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

82
internal/job/ingest.go Normal file
View File

@@ -0,0 +1,82 @@
package job
import (
"context"
"fmt"
"time"
"stock-analysis/internal/cache"
"stock-analysis/internal/model"
)
// resolveCandidates 根据任务类型解析要处理的转债候选列表。
//
// full: 从 eastmoney datacenter 获取全量转债列表,过滤 ListDate 为空的
// incremental: 从 SQLite 获取已入库的转债代码列表
func resolveCandidates(ctx context.Context, store Store, lister BondLister, typ cache.TaskType, limit int) ([]model.ConvertibleBond, error) {
switch typ {
case cache.TaskTypeFull:
bonds, err := lister.FetchAllBondCodes(ctx)
if err != nil {
return nil, fmt.Errorf("获取转债列表: %w", err)
}
var candidates []model.ConvertibleBond
for _, b := range bonds {
if b.ListDate == "" {
continue // 过滤未上市
}
candidates = append(candidates, b)
}
if limit > 0 && limit < len(candidates) {
candidates = candidates[:limit]
}
return candidates, nil
case cache.TaskTypeIncremental:
codes, err := store.ListCodes(ctx)
if err != nil {
return nil, fmt.Errorf("加载已入库转债: %w", err)
}
candidates := make([]model.ConvertibleBond, 0, len(codes))
for i, c := range codes {
if limit > 0 && i >= limit {
break
}
candidates = append(candidates, model.ConvertibleBond{Code: c})
}
return candidates, nil
default:
return nil, fmt.Errorf("未知任务类型: %s", typ)
}
}
// yearOf 从上市日期(2006-01-02)解析年份。
func yearOf(listDate string) int {
if len(listDate) >= 4 {
var y int
if _, err := fmt.Sscanf(listDate[:4], "%d", &y); err == nil && y > 2000 {
return y
}
}
return time.Now().Year()
}
// resolveIncrementalBeg 确定增量拉取的起始日期(YYYYMMDD)。
//
// since 非空 → 直接使用(覆盖库中最后日期)
// 否则 → lastDateMap[code] 次日;无记录返回空串(表示跳过)
func resolveIncrementalBeg(code, since string, lastDateMap map[string]string) (string, bool) {
if since != "" {
return since, true
}
lastDate, ok := lastDateMap[code]
if !ok || lastDate == "" {
return "", false
}
t, err := time.Parse("2006-01-02", lastDate)
if err != nil {
return "", false
}
return t.AddDate(0, 0, 1).Format("20060102"), true
}

187
internal/job/job.go Normal file
View File

@@ -0,0 +1,187 @@
package job
import (
"context"
"encoding/json"
"log"
"sync"
"time"
"github.com/google/uuid"
"stock-analysis/internal/cache"
"stock-analysis/internal/datasource"
)
// Config Manager 配置。
type Config struct {
Workers int // 并发 worker 数,默认 8
BatchSize int // 批量写入阈值,默认 20
RateInterval time.Duration // 全局限速间隔,默认 400ms(≈2.5 req/s)
RateBurst int // 限速突发额度,默认 4
}
// runningTask 记录运行中任务的取消句柄和状态快照。
type runningTask struct {
cancel context.CancelFunc
task *cache.Task
}
// Manager 管理任务注册表和全局限速器,允许多任务并发运行。
type Manager struct {
store Store
cache cache.Cache
ds datasource.MarketDataSource
lister BondLister
gate <-chan struct{} // 全局限速令牌(跨任务共享)
workers int
batchSize int
mu sync.RWMutex
running map[string]*runningTask
}
// NewManager 创建任务管理器,内部启动全局限速器。
func NewManager(store Store, c cache.Cache, ds datasource.MarketDataSource, lister BondLister, cfg Config) *Manager {
if cfg.Workers <= 0 {
cfg.Workers = 8
}
if cfg.BatchSize <= 0 {
cfg.BatchSize = 20
}
if cfg.RateInterval <= 0 {
cfg.RateInterval = 400 * time.Millisecond
}
if cfg.RateBurst <= 0 {
cfg.RateBurst = 4
}
return &Manager{
store: store,
cache: c,
ds: ds,
lister: lister,
gate: newRateLimiter(cfg.RateInterval, cfg.RateBurst),
workers: cfg.Workers,
batchSize: cfg.BatchSize,
running: make(map[string]*runningTask),
}
}
// Start 创建并异步启动一个任务,立即返回任务快照(pending 状态)。
func (m *Manager) Start(ctx context.Context, typ cache.TaskType, since string, limit int) (*cache.Task, error) {
params, _ := json.Marshal(map[string]any{"since": since, "limit": limit})
task := &cache.Task{
ID: uuid.New().String(),
Type: typ,
Status: cache.TaskStatusPending,
StartedAt: time.Now().Format(time.RFC3339),
Params: string(params),
}
// 持久化到 SQLite + Redis
if err := m.store.SaveTask(ctx, task); err != nil {
return nil, err
}
_ = m.cache.SetTask(ctx, task, 24*time.Hour)
_ = m.cache.AddActiveTask(ctx, task.ID)
// 创建可取消 context
taskCtx, cancel := context.WithCancel(context.Background())
m.mu.Lock()
m.running[task.ID] = &runningTask{cancel: cancel, task: task}
m.mu.Unlock()
// 异步执行
runner := NewRunner(m.store, m.cache, m.ds, m.gate, m.workers, m.batchSize)
go func() {
defer cancel()
runner.run(taskCtx, task, m.lister, since, limit)
// 任务结束后从 running map 移除
m.mu.Lock()
delete(m.running, task.ID)
m.mu.Unlock()
}()
log.Printf("[job] task %s created: type=%s, since=%s, limit=%d", task.ID, typ, since, limit)
return task, nil
}
// Cancel 取消运行中任务,返回是否成功取消。
func (m *Manager) Cancel(id string) bool {
m.mu.RLock()
rt, ok := m.running[id]
m.mu.RUnlock()
if !ok {
return false
}
rt.cancel()
log.Printf("[job] task %s cancelled", id)
return true
}
// Get 获取任务状态:Redis 优先(实时),回退 SQLite。
func (m *Manager) Get(ctx context.Context, id string) (*cache.Task, error) {
// 1. 先查 Redis(实时状态)
t, err := m.cache.GetTask(ctx, id)
if err == nil && t != nil {
return t, nil
}
// 2. 回退 SQLite
return m.store.GetTask(ctx, id)
}
// List 返回任务列表:SQLite 历史记录 + Redis 实时覆盖运行中任务。
func (m *Manager) List(ctx context.Context, limit int) ([]*cache.Task, error) {
tasks, err := m.store.ListTasks(ctx, limit)
if err != nil {
return nil, err
}
// 对运行中任务用 Redis 实时数据覆盖
for _, t := range tasks {
if t.Status == cache.TaskStatusRunning || t.Status == cache.TaskStatusPending {
if rt, _ := m.cache.GetTask(ctx, t.ID); rt != nil {
t.Done = rt.Done
t.Success = rt.Success
t.Failed = rt.Failed
t.Status = rt.Status
t.FailedCodes = rt.FailedCodes
}
}
}
return tasks, nil
}
// StopAll 优雅停止所有运行中任务(服务关闭时调用)。
func (m *Manager) StopAll() {
m.mu.Lock()
defer m.mu.Unlock()
for id, rt := range m.running {
rt.cancel()
log.Printf("[job] stopping task %s on shutdown", id)
}
}
// newRateLimiter 创建全局限速器(令牌桶模式)。
// 返回一个只读 channel,每次接收表示获得一个请求令牌。
// 稳态速率 = 1 token / interval,初始突发 = burst。
func newRateLimiter(interval time.Duration, burst int) <-chan struct{} {
ch := make(chan struct{}, burst)
// 预填突发额度
for i := 0; i < burst; i++ {
ch <- struct{}{}
}
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
select {
case ch <- struct{}{}:
default: // 满了丢弃,保持稳态速率
}
}
}()
return ch
}

229
internal/job/runner.go Normal file
View File

@@ -0,0 +1,229 @@
package job
import (
"context"
"fmt"
"log"
"sync"
"time"
"stock-analysis/internal/cache"
"stock-analysis/internal/datasource"
"stock-analysis/internal/model"
)
// fetchResult 单只转债拉取结果,通过 channel 发往 collector。
type fetchResult struct {
code string
klines []model.Kline
err error
}
// Runner 单任务执行器:N worker 并发拉取 + 单 collector 串行批量写入。
//
// 并发模型:
// - 主 goroutine 遍历候选列表,为每只转债启动一个 worker goroutine
// - worker 从 gate 获取全局限速令牌后拉取数据,结果发往 resultCh
// - collector 单 goroutine 从 resultCh 收集,每 batchSize 只批量写 SQLite
// - SQLite SetMaxOpenConns(1) 下,只有 collector 写入,避免锁竞争
type Runner struct {
store Store
cache cache.Cache
ds datasource.MarketDataSource
gate <-chan struct{} // 全局限速令牌
workers int
batchSize int
}
// NewRunner 创建执行器。
func NewRunner(store Store, c cache.Cache, ds datasource.MarketDataSource, gate <-chan struct{}, workers, batchSize int) *Runner {
return &Runner{
store: store,
cache: c,
ds: ds,
gate: gate,
workers: workers,
batchSize: batchSize,
}
}
// run 执行任务(异步调用,不阻塞调用方)。
func (r *Runner) run(ctx context.Context, task *cache.Task, lister BondLister, since string, limit int) {
// 1. 解析候选列表
candidates, err := resolveCandidates(ctx, r.store, lister, task.Type, limit)
if err != nil {
r.failTask(ctx, task, err)
return
}
task.Total = len(candidates)
// 2. 预加载:全量模式加载已入库集合(跳过断点续跑);增量模式加载最后日期映射
var skipSet map[string]bool
var lastDateMap map[string]string
if task.Type == cache.TaskTypeFull {
codes, err := r.store.ListCodes(ctx)
if err == nil {
skipSet = make(map[string]bool, len(codes))
for _, c := range codes {
skipSet[c] = true
}
}
} else {
lastDateMap, _ = r.store.LastKlineDates(ctx)
}
// 3. 更新状态为 running
task.Status = cache.TaskStatusRunning
r.reportProgress(ctx, task, true)
log.Printf("[job] task %s started: type=%s, total=%d", task.ID, task.Type, task.Total)
// 4. 启动 collector(唯一 SQLite 写者)
resultCh := make(chan fetchResult, r.workers*2)
collectorDone := make(chan struct{})
go r.collect(ctx, task, resultCh, collectorDone)
// 5. 并发拉取:主 goroutine 遍历候选,每只启动一个 worker
sem := make(chan struct{}, r.workers)
var wg sync.WaitGroup
for _, b := range candidates {
if ctx.Err() != nil {
break
}
// 全量模式跳过已入库转债
if task.Type == cache.TaskTypeFull && skipSet[b.Code] {
task.Done++
task.Success++
r.reportProgress(ctx, task, false)
continue
}
sem <- struct{}{}
wg.Add(1)
go func(bond model.ConvertibleBond) {
defer wg.Done()
defer func() { <-sem }()
// 全局限速:获取令牌后才允许发请求
select {
case <-r.gate:
case <-ctx.Done():
return
}
klines, err := r.fetchOne(ctx, task.Type, bond, since, lastDateMap)
select {
case resultCh <- fetchResult{code: bond.Code, klines: klines, err: err}:
case <-ctx.Done():
}
}(b)
}
// 6. 等待所有 worker 完成
wg.Wait()
close(resultCh)
<-collectorDone
// 7. 确定终态
r.finishTask(ctx, task)
}
// fetchOne 拉取单只转债K线。
func (r *Runner) fetchOne(ctx context.Context, typ cache.TaskType, bond model.ConvertibleBond, since string, lastDateMap map[string]string) ([]model.Kline, error) {
switch typ {
case cache.TaskTypeFull:
beg := fmt.Sprintf("%04d0101", yearOf(bond.ListDate))
return r.ds.FetchKline(ctx, bond.Code, "101", beg, "20500101")
case cache.TaskTypeIncremental:
beg, ok := resolveIncrementalBeg(bond.Code, since, lastDateMap)
if !ok {
return nil, nil // 无存档,跳过
}
return r.ds.FetchKline(ctx, bond.Code, "101", beg, "20500101")
default:
return nil, fmt.Errorf("未知任务类型: %s", typ)
}
}
// collect 单 goroutine 收集结果并批量写入 SQLite(唯一写者)。
func (r *Runner) collect(ctx context.Context, task *cache.Task, resultCh <-chan fetchResult, done chan<- struct{}) {
defer close(done)
batch := make(map[string][]model.Kline)
for res := range resultCh {
task.Done++
if res.err != nil || len(res.klines) == 0 {
task.Failed++
if res.err != nil {
task.FailedCodes = append(task.FailedCodes, res.code)
}
} else {
task.Success++
batch[res.code] = res.klines
if len(batch) >= r.batchSize {
if err := r.store.SaveKlinesBatch(ctx, batch); err != nil {
log.Printf("[job] batch save error: %v", err)
}
batch = make(map[string][]model.Kline)
}
}
r.reportProgress(ctx, task, false)
}
// flush 残余 batch
if len(batch) > 0 {
if err := r.store.SaveKlinesBatch(ctx, batch); err != nil {
log.Printf("[job] final batch save error: %v", err)
}
}
}
// reportProgress 上报进度:Redis 每次(廉价),SQLite 按策略写(减少写压力)。
// force=true(状态变更)时用 SaveTask 写入完整状态(含 status/total),
// 确保 Redis 不可用时 SQLite 也能反映运行状态。
func (r *Runner) reportProgress(ctx context.Context, task *cache.Task, force bool) {
_ = r.cache.SetTask(ctx, task, 24*time.Hour)
if force {
_ = r.store.SaveTask(ctx, task)
} else if task.Done%5 == 0 {
_ = r.store.UpdateTaskProgress(ctx, task.ID, task.Total, task.Done, task.Success, task.Failed, task.FailedCodes)
}
}
// finishTask 确定任务终态并持久化。
// 使用 SaveTask 写入完整任务状态(含 total/done/success/failed),
// 确保 SQLite 中最终记录与内存一致(Redis 不可用时尤为重要)。
func (r *Runner) finishTask(ctx context.Context, task *cache.Task) {
var errMsg string
switch {
case ctx.Err() != nil:
task.Status = cache.TaskStatusCancelled
case task.Success == 0 && task.Failed > 0:
task.Status = cache.TaskStatusFailed
errMsg = fmt.Sprintf("全部 %d 只失败", task.Failed)
default:
task.Status = cache.TaskStatusSuccess
if task.Failed > 0 {
errMsg = fmt.Sprintf("成功 %d,失败 %d", task.Success, task.Failed)
}
}
task.Error = errMsg
task.FinishedAt = time.Now().Format(time.RFC3339)
_ = r.store.SaveTask(ctx, task)
_ = r.cache.SetTask(ctx, task, 24*time.Hour)
_ = r.cache.RemoveActiveTask(ctx, task.ID)
log.Printf("[job] task %s finished: status=%s, total=%d, success=%d, failed=%d",
task.ID, task.Status, task.Total, task.Success, task.Failed)
}
// failTask 标记任务失败(启动阶段错误)。
func (r *Runner) failTask(ctx context.Context, task *cache.Task, err error) {
task.Status = cache.TaskStatusFailed
task.Error = err.Error()
task.FinishedAt = time.Now().Format(time.RFC3339)
_ = r.store.SaveTask(ctx, task)
_ = r.cache.SetTask(ctx, task, 24*time.Hour)
_ = r.cache.RemoveActiveTask(ctx, task.ID)
log.Printf("[job] task %s failed: %v", task.ID, err)
}

37
internal/job/store.go Normal file
View File

@@ -0,0 +1,37 @@
// Package job 实现异步任务执行系统:并发拉取 + 批量写入 + 进度上报。
//
// 核心设计:
// - Manager 管理任务注册表和全局限速器,允许多任务并发
// - Runner 是单任务执行器:N worker 并发拉取,单 collector 串行写 SQLite
// - Redis 存实时进度,SQLite 存持久化任务记录
package job
import (
"context"
"stock-analysis/internal/cache"
"stock-analysis/internal/model"
)
// Store 定义 job 运行期需要的存储能力(K线 + 任务 CRUD)。
// *sqlite.Store 实现此接口(编译期断言)。
type Store interface {
// K线批量写入(单事务,collector 调用)
SaveKlinesBatch(ctx context.Context, batch map[string][]model.Kline) error
// 返回所有已入库 code(全量断点续跑跳过判断)
ListCodes(ctx context.Context) ([]string, error)
// 一次性返回 code -> 最后K线日期 映射(增量起点预加载)
LastKlineDates(ctx context.Context) (map[string]string, error)
// 任务持久化
SaveTask(ctx context.Context, t *cache.Task) error
UpdateTaskProgress(ctx context.Context, id string, total, done, success, failed int, failedCodes []string) error
FinishTask(ctx context.Context, id string, status cache.TaskStatus, errMsg, finishedAt string) error
GetTask(ctx context.Context, id string) (*cache.Task, error)
ListTasks(ctx context.Context, limit int) ([]*cache.Task, error)
}
// BondLister 全量转债列表获取接口(eastmoney.Client 实现)。
type BondLister interface {
FetchAllBondCodes(ctx context.Context) ([]model.ConvertibleBond, error)
}