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

@@ -0,0 +1,170 @@
# 异步任务系统 + 并发拉取批量写入
## Context
当前数据入库通过 CLI `cmd/ingest` 串行执行,全量 1035 只转债约需 1 小时。用户要求:
1. Web 控制页增加全量/增量更新按钮,显示数据库数据时间
2. 后端引入 Redis,异步执行入库任务,任务状态实时写 Redis
3. 前端轮询后端接口实时刷新任务状态
4. 任务和结果同时持久化到 SQLite,Web 可查询任务列表
5. **核心优化**:并发拉取(多 worker 同时从 API 取数据)+ 批量写入(20 只一批统一写 SQLite),替代串行模式
目标:全量入库从 1 小时降至约 5 分钟,且支持 Web 端操作。
## 架构概览
```
前端(轮询2s) → POST /api/tasks → TaskController → JobManager.Start()
↓ (异步 goroutine)
Runner: N workers 并发拉取
↓ resultCh
Collector: 单 goroutine 批量写 SQLite
↓ 每20只
Store.SaveKlinesBatch (单事务)
↓
Redis(实时进度) + SQLite(持久化)
```
**关键约束与对策**:
- `sqlite.Store.SetMaxOpenConns(1)` → 单一 collector goroutine 串行写入(worker 不直接写 DB)
- `eastmoney.Client.throttle()` 非线程安全(`lastReq` 无锁)→ 加 sync.Mutex + 可配置间隔,job 用 `NewClientWithThrottle(0)` 关闭内部节流,由全局限速器统一控制
- 预加载优化:`ListCodes()` + `LastKlineDates()`(新增 GROUP BY 一次查询)替代 600 次逐只查询,避免占用唯一连接堵塞写入
- `MarketController` 每请求 `sqlite.Open` → 改为注入共享 `*sqlite.Store`,消除连接池争用
## 实现步骤
### 阶段 A:基础设施
1. **修改 `internal/datasource/eastmoney/client.go`**:`Client` 加 `sync.Mutex` + `interval time.Duration` 字段;`throttle()` 加锁;新增 `NewClientWithThrottle(interval)` 构造函数(`NewClient()` 行为不变,默认 2s)
2. **新增依赖**:`go get github.com/redis/go-redis/v9`,提升 `github.com/google/uuid` 为直接依赖
3. **新建 `internal/cache/cache.go`**:`Task` 结构体(ID/Type/Status/Total/Done/Success/Failed/FailedCodes/Error/时间戳)、`Cache` 接口(GetTask/SetTask/DeleteTask/ListActiveTaskIDs/AddActiveTask/RemoveActiveTask/Ping)、`NoopCache` 空实现(Redis 降级用)
4. **新建 `internal/cache/redis.go`**:`RedisCache` 实现,键设计 `stock-analysis:task:{id}` → Task JSON(TTL 24h)、`stock-analysis:tasks:active` → SET
### 阶段 B:存储层扩展
5. **修改 `internal/storage/sqlite/store.go`**:
- `schemaSQL` 追加 `ingest_task` 表(id/type/status/total/done/success/failed/failed_codes/params/error/started_at/finished_at)
- 新增 `SaveKlinesBatch(ctx, batch map[string][]model.Kline)`:单事务批量写入多只转债K线,带 3 次重试
- 新增 `LastKlineDates(ctx) map[string]string`:`SELECT code, MAX(date) FROM convertible_kline GROUP BY code` 一次查询
6. **新建 `internal/storage/sqlite/task.go`**:任务 CRUD(SaveTask/UpdateTaskProgress/FinishTask/GetTask/ListTasks),`ListTasks` 按 `started_at DESC` 排序
### 阶段 C:任务执行核心
7. **新建 `internal/job/store.go`**:定义 `Store` 接口(含 K线批量写 + 任务 CRUD),编译期断言 `*sqlite.Store` 实现它
8. **新建 `internal/job/ingest.go`**:`resolveCandidates`(full 走 eastmoney datacenter、incremental 走 store.ListCodes)、`yearOf`、`resolveIncrementalBeg`(从 cmd/ingest 提取)
9. **新建 `internal/job/runner.go`**:核心并发逻辑
- `run()`:预加载 skipSet/lastDateMap → 启动 collector goroutine → N worker 并发拉取 → 等待完成 → flush 残余 batch
- `collect()`:单 goroutine,从 resultCh 收集,每 20 只调 `SaveKlinesBatch`,调 `reportProgress`
- `fetchOne()`:单只转债拉取(全量用 FetchFullKline,增量用 FetchIncrementalKline)
- `reportProgress()`:每次写 Redis,每 5 只写 SQLite(减少写压力)
10. **新建 `internal/job/job.go`**:`Manager` 管理任务注册表 + 全局限速器 `gate`(令牌桶:400ms 间隔,突发 4)
- `Start(ctx, type, since, limit)`:创建 Task → 存 SQLite+Redis → 异步启动 Runner
- `Cancel(id)`:调用 context.CancelFunc
- `Get(ctx, id)`:Redis 优先,回退 SQLite
- `List(ctx, limit)`:SQLite 历史 + Redis 实时覆盖运行中任务
- `StopAll()`:服务关闭时优雅停止
### 阶段 D:HTTP 层
11. **新建 `internal/controller/task.go`**:`TaskController`,4 个 handler
- `POST /api/tasks`:创建任务 `{"type":"full"|"incremental","since":"","limit":0}`
- `GET /api/tasks`:任务列表
- `GET /api/tasks/{id}`:任务状态
- `POST /api/tasks/{id}/cancel`:取消任务
12. **修改 `internal/controller/api.go`**:`MarketController` 字段 `dbPath string` → `store *sqlite.Store`;`NewMarketController(svc, store)`;移除 handler 中临时 `sqlite.Open`
13. **修改 `internal/router/router.go`**:`New` 签名增加 `tc *TaskController`;注册 4 条 task 路由(均 `requireAuth`)
14. **修改 `cmd/server/main.go`**:
- 共享 `sqlite.Open(dbPath)`(替代 controller 临时 open)
- `initCache()`:读 `REDIS_ADDR`(默认 localhost:6379),连不上降级 `NoopCache`
- `initJobManager()`:创建无节流 eastmoney client + fallback + Manager
- `defer mgr.StopAll()`
- Docker 启动 Redis:`docker run -d --name stock-redis -p 6379:6379 redis:7-alpine`
### 阶段 E:前端
15. **修改 `web/style.css`**:新增 `.progress-bar`/`.progress-fill`/`.badge-*`(running/success/failed/cancelled)样式
16. **修改 `web/index.html` 数据管理 Tab**:
- 保留现有状态卡片
- 新增工具栏:全量更新、增量更新按钮 + since 日期输入(增量可选)
- 新增任务列表表格:ID(截断)、类型、状态徽章、进度条、完成/总数、时间、取消按钮
- JS:`createTask(type)` → POST /api/tasks → 启动轮询 `pollTasks()`(2s 间隔,有 running 任务时持续,无则停止)
- `renderTaskTable(tasks)`:渲染表格 + 进度条
- `cancelTask(id)` → POST cancel
## 关键文件
| 文件 | 操作 | 说明 |
|---|---|---|
| `internal/datasource/eastmoney/client.go` | 修改 | throttle 加锁 + 可配置间隔 |
| `internal/cache/cache.go` | 新建 | Cache 接口 + Task 模型 + NoopCache |
| `internal/cache/redis.go` | 新建 | Redis 实现 |
| `internal/storage/sqlite/store.go` | 修改 | 新增 ingest_task 表 + SaveKlinesBatch + LastKlineDates |
| `internal/storage/sqlite/task.go` | 新建 | 任务 CRUD |
| `internal/job/store.go` | 新建 | Store 接口 |
| `internal/job/ingest.go` | 新建 | 候选列表解析 + 增量起点 |
| `internal/job/runner.go` | 新建 | worker 池 + collector |
| `internal/job/job.go` | 新建 | Manager + 限速器 |
| `internal/controller/task.go` | 新建 | TaskController |
| `internal/controller/api.go` | 修改 | MarketController 注入共享 Store |
| `internal/router/router.go` | 修改 | 注册 task 路由 |
| `cmd/server/main.go` | 修改 | 装配 Redis/Manager/共享Store |
| `web/index.html` | 修改 | 数据管理 Tab 增强 |
| `web/style.css` | 修改 | 进度条 + 徽章样式 |
## 并发拉取 + 批量写入流程
```
Manager.Start(full)
→ 创建 Task,存 SQLite + Redis
→ go runner.run(ctx, task)
runner.run:
1. resolveCandidates() → 全量列表或已入库 codes
2. 预加载:skipSet = ListCodes(),lastDateMap = LastKlineDates()
3. resultCh = make(chan fetchResult, workers*2)
4. go collect(resultCh) // 单一 SQLite 写者
5. for code in candidates:
sem <- {} // worker 槽
go fetchOne(code): // 并发拉取
<-gate // 全局限速令牌
klines = ds.FetchKline(code)
resultCh <- {code, klines, err}
<-sem
6. wg.Wait(); close(resultCh)
7. flush 残余 batch
8. 更新 Task 状态 → SQLite + Redis
collect: // 单 goroutine
batch = {}
for res in resultCh:
task.Done++
if err: task.Failed++; failedCodes.append(code)
else: task.Success++; batch[code] = klines
if len(batch) >= 20: store.SaveKlinesBatch(batch); batch = {}
reportProgress(task) // Redis 每次 + SQLite 每5只
```
## 验证方法
1. **编译**:`go build ./...` 每阶段验证
2. **竞态检测**:`go test -race ./internal/...`
3. **端到端**:
- Docker 启动 Redis
- 启动 server,admin 登录
- 数据管理 Tab → 点"增量更新" → 任务表出现 running 行 → 进度条实时增长 → 完成
- 点"全量更新" → 两个任务并行
- 点"取消" → 状态变 cancelled
- 重启 server → 任务列表仍在(SQLite 持久化)
- 停 Redis → 任务仍能跑(NoopCache 降级)
4. **性能**:50 只转债(limit=50)应在 1 分钟内完成

View File

@@ -12,10 +12,12 @@ import (
"syscall" "syscall"
"time" "time"
"stock-analysis/internal/cache"
"stock-analysis/internal/controller" "stock-analysis/internal/controller"
"stock-analysis/internal/datasource/eastmoney" "stock-analysis/internal/datasource/eastmoney"
"stock-analysis/internal/datasource/fallback" "stock-analysis/internal/datasource/fallback"
"stock-analysis/internal/datasource/tencent" "stock-analysis/internal/datasource/tencent"
"stock-analysis/internal/job"
"stock-analysis/internal/router" "stock-analysis/internal/router"
"stock-analysis/internal/service" "stock-analysis/internal/service"
"stock-analysis/internal/storage/sqlite" "stock-analysis/internal/storage/sqlite"
@@ -29,23 +31,43 @@ func main() {
webDir := "web" webDir := "web"
dbPath := defaultDBPath() dbPath := defaultDBPath()
// 东财为主数据源(实时行情+历史K线),腾讯为历史K线备用源(东财失败时自动切换) // 1. 共享 SQLite 存储(MarketController / AuthController / job.Manager 共用)。
ds := fallback.New(eastmoney.NewClient(), tencent.NewClient())
svc := service.NewMarketService(ds)
mc := controller.NewMarketController(svc, dbPath)
store, err := sqlite.Open(dbPath) store, err := sqlite.Open(dbPath)
if err != nil { if err != nil {
log.Fatalf("open sqlite: %v", err) log.Fatalf("open sqlite: %v", err)
} }
defer store.Close() 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) authSvc := service.NewAuthService(store)
if err := authSvc.EnsureAdmin(context.Background()); err != nil { if err := authSvc.EnsureAdmin(context.Background()); err != nil {
log.Printf("ensure admin: %v", err) log.Printf("ensure admin: %v", err)
} }
ac := controller.NewAuthController(authSvc) 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{ server := &http.Server{
Addr: addr, 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 返回默认数据库路径。 // defaultDBPath 返回默认数据库路径。
// 默认使用项目目录下的 stock.db,避免 OneDrive/AppData 的 I/O 限制。 // 默认使用项目目录下的 stock.db,避免 OneDrive/AppData 的 I/O 限制。
func defaultDBPath() string { func defaultDBPath() string {

18
docker-compose.yml Normal file
View File

@@ -0,0 +1,18 @@
# docker-compose.yml
# 启动 Redis 服务,供任务状态实时缓存使用。
# 使用方法:docker compose up -d
# 停止:docker compose down
version: "3.8"
services:
redis:
image: redis:7-alpine
container_name: stock-analysis-redis
ports:
- "6379:6379"
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis-data:/data
restart: unless-stopped
volumes:
redis-data:

3
go.mod
View File

@@ -9,11 +9,14 @@ require (
) )
require ( require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/redis/go-redis/v9 v9.22.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.55.3 // indirect modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect modernc.org/mathutil v1.6.0 // indirect

6
go.sum
View File

@@ -1,3 +1,5 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
@@ -8,8 +10,12 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=

74
internal/cache/cache.go vendored Normal file
View File

@@ -0,0 +1,74 @@
// Package cache 定义任务状态缓存接口,支持 Redis 实时状态 + 内存降级。
// Redis 不可用时降级到 NoopCache,任务系统仍可运行(仅失去实时状态能力,回退到 SQLite)。
package cache
import (
"context"
"time"
)
// TaskType 任务类型。
type TaskType string
const (
TaskTypeFull TaskType = "full" // 全量入库
TaskTypeIncremental TaskType = "incremental" // 增量同步
)
// TaskStatus 任务状态。
type TaskStatus string
const (
TaskStatusPending TaskStatus = "pending" // 已创建未启动
TaskStatusRunning TaskStatus = "running" // 执行中
TaskStatusSuccess TaskStatus = "success" // 成功
TaskStatusFailed TaskStatus = "failed" // 失败
TaskStatusCancelled TaskStatus = "cancelled" // 已取消
)
// Task 任务运行态快照,Redis 实时状态与 SQLite 持久化共用同一结构。
type Task struct {
ID string `json:"id"`
Type TaskType `json:"type"`
Status TaskStatus `json:"status"`
Total int `json:"total"`
Done int `json:"done"`
Success int `json:"success"`
Failed int `json:"failed"`
FailedCodes []string `json:"failedCodes,omitempty"`
Params string `json:"params"`
Error string `json:"error,omitempty"`
StartedAt string `json:"startedAt"`
FinishedAt string `json:"finishedAt,omitempty"`
}
// Cache 任务状态缓存接口。
// Redis 实现用于实时状态查询;NoopCache 用于 Redis 不可用时降级。
type Cache interface {
// GetTask 读取任务状态,不存在返回 nil,nil。
GetTask(ctx context.Context, id string) (*Task, error)
// SetTask 写入任务状态,ttl 为过期时间。
SetTask(ctx context.Context, t *Task, ttl time.Duration) error
// DeleteTask 删除任务状态。
DeleteTask(ctx context.Context, id string) error
// ListActiveTaskIDs 返回运行中任务的 ID 列表。
ListActiveTaskIDs(ctx context.Context) ([]string, error)
// AddActiveTask 将任务 ID 加入活跃集合。
AddActiveTask(ctx context.Context, id string) error
// RemoveActiveTask 从活跃集合移除任务 ID。
RemoveActiveTask(ctx context.Context, id string) error
// Ping 探测缓存可用性。
Ping(ctx context.Context) error
}
// NoopCache Cache 的空实现,Redis 不可用时降级使用。
// 所有操作均为空操作,任务状态仅依赖 SQLite 持久化。
type NoopCache struct{}
func (NoopCache) GetTask(ctx context.Context, id string) (*Task, error) { return nil, nil }
func (NoopCache) SetTask(ctx context.Context, t *Task, ttl time.Duration) error { return nil }
func (NoopCache) DeleteTask(ctx context.Context, id string) error { return nil }
func (NoopCache) ListActiveTaskIDs(ctx context.Context) ([]string, error) { return nil, nil }
func (NoopCache) AddActiveTask(ctx context.Context, id string) error { return nil }
func (NoopCache) RemoveActiveTask(ctx context.Context, id string) error { return nil }
func (NoopCache) Ping(ctx context.Context) error { return nil }

107
internal/cache/redis.go vendored Normal file
View File

@@ -0,0 +1,107 @@
package cache
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
// keyPrefix Redis 键前缀,避免与其他应用冲突。
const keyPrefix = "stock-analysis:"
// taskKey 构造任务状态键。
func taskKey(id string) string { return keyPrefix + "task:" + id }
// activeSetKey 构造活跃任务集合键。
const activeSetKey = keyPrefix + "tasks:active"
// RedisCache 基于 go-redis/v9 的 Cache 实现。
type RedisCache struct {
client *redis.Client
}
// NewRedisCache 创建 Redis 缓存实例。
// addr 格式 "host:port",password 为空表示无密码,db 为 Redis 数据库编号。
// 配置较短的超时和重试次数,Redis 不可用时快速降级到 NoopCache。
func NewRedisCache(addr, password string, db int) (*RedisCache, error) {
client := redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: db,
DialTimeout: 2 * time.Second,
ReadTimeout: 2 * time.Second,
WriteTimeout: 2 * time.Second,
MaxRetries: 1,
PoolSize: 10,
})
return &RedisCache{client: client}, nil
}
// GetTask 从 Redis 读取任务状态(JSON 反序列化)。
func (c *RedisCache) GetTask(ctx context.Context, id string) (*Task, error) {
data, err := c.client.Get(ctx, taskKey(id)).Bytes()
if err != nil {
if err == redis.Nil {
return nil, nil // 不存在
}
return nil, fmt.Errorf("redis get task %s: %w", id, err)
}
var t Task
if err := json.Unmarshal(data, &t); err != nil {
return nil, fmt.Errorf("unmarshal task %s: %w", id, err)
}
return &t, nil
}
// SetTask 写入任务状态(JSON 序列化),设置 TTL 防止过期数据残留。
func (c *RedisCache) SetTask(ctx context.Context, t *Task, ttl time.Duration) error {
data, err := json.Marshal(t)
if err != nil {
return fmt.Errorf("marshal task %s: %w", t.ID, err)
}
if err := c.client.Set(ctx, taskKey(t.ID), data, ttl).Err(); err != nil {
return fmt.Errorf("redis set task %s: %w", t.ID, err)
}
return nil
}
// DeleteTask 删除任务状态。
func (c *RedisCache) DeleteTask(ctx context.Context, id string) error {
if err := c.client.Del(ctx, taskKey(id)).Err(); err != nil {
return fmt.Errorf("redis del task %s: %w", id, err)
}
return nil
}
// ListActiveTaskIDs 返回活跃任务集合中的所有 ID。
func (c *RedisCache) ListActiveTaskIDs(ctx context.Context) ([]string, error) {
ids, err := c.client.SMembers(ctx, activeSetKey).Result()
if err != nil {
return nil, fmt.Errorf("redis smembers: %w", err)
}
return ids, nil
}
// AddActiveTask 将任务 ID 加入活跃集合。
func (c *RedisCache) AddActiveTask(ctx context.Context, id string) error {
if err := c.client.SAdd(ctx, activeSetKey, id).Err(); err != nil {
return fmt.Errorf("redis sadd %s: %w", id, err)
}
return nil
}
// RemoveActiveTask 从活跃集合移除任务 ID。
func (c *RedisCache) RemoveActiveTask(ctx context.Context, id string) error {
if err := c.client.SRem(ctx, activeSetKey, id).Err(); err != nil {
return fmt.Errorf("redis srem %s: %w", id, err)
}
return nil
}
// Ping 探测 Redis 连通性。
func (c *RedisCache) Ping(ctx context.Context) error {
return c.client.Ping(ctx).Err()
}

View File

@@ -15,14 +15,15 @@ import (
) )
// MarketController 提供行情与回测相关的 HTTP 接口。 // MarketController 提供行情与回测相关的 HTTP 接口。
// store 为共享 SQLite 存储,与 job.Manager 共用同一连接池,避免每次请求重复开关连接。
type MarketController struct { type MarketController struct {
svc *service.MarketService svc *service.MarketService
dbPath string store *sqlite.Store
} }
// NewMarketController 创建行情控制器。 // NewMarketController 创建行情控制器。
func NewMarketController(svc *service.MarketService, dbPath string) *MarketController { func NewMarketController(svc *service.MarketService, store *sqlite.Store) *MarketController {
return &MarketController{svc: svc, dbPath: dbPath} return &MarketController{svc: svc, store: store}
} }
// ListBonds 处理 GET /api/bonds,返回全量可转债实时行情。 // ListBonds 处理 GET /api/bonds,返回全量可转债实时行情。
@@ -93,13 +94,7 @@ func (c *MarketController) RunBacktest(w http.ResponseWriter, r *http.Request) {
} }
ctx := r.Context() ctx := r.Context()
store := c.store
store, err := sqlite.Open(c.dbPath)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
defer store.Close()
feed, err := backtest.NewDataFeed(ctx, store) feed, err := backtest.NewDataFeed(ctx, store)
if err != nil { if err != nil {
@@ -251,12 +246,7 @@ type DBBond struct {
// ListDBBonds 处理 GET /api/db/bonds,返回数据库中已有的转债列表。 // ListDBBonds 处理 GET /api/db/bonds,返回数据库中已有的转债列表。
func (c *MarketController) ListDBBonds(w http.ResponseWriter, r *http.Request) { func (c *MarketController) ListDBBonds(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
store, err := sqlite.Open(c.dbPath) store := c.store
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
defer store.Close()
codes, err := store.ListCodes(ctx) codes, err := store.ListCodes(ctx)
if err != nil { if err != nil {
@@ -282,14 +272,8 @@ func (c *MarketController) ListDBBonds(w http.ResponseWriter, r *http.Request) {
// GetDataStatus 处理 GET /api/status,返回数据库状态信息。 // GetDataStatus 处理 GET /api/status,返回数据库状态信息。
func (c *MarketController) GetDataStatus(w http.ResponseWriter, r *http.Request) { func (c *MarketController) GetDataStatus(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
status := DataStatus{DBPath: c.dbPath} store := c.store
status := DataStatus{DBPath: c.store.Path()}
store, err := sqlite.Open(c.dbPath)
if err != nil {
writeJSON(w, http.StatusOK, status) // 返回部分信息
return
}
defer store.Close()
codes, err := store.ListCodes(ctx) codes, err := store.ListCodes(ctx)
if err == nil { if err == nil {

122
internal/controller/task.go Normal file
View File

@@ -0,0 +1,122 @@
package controller
import (
"encoding/json"
"net/http"
"strconv"
"stock-analysis/internal/cache"
"stock-analysis/internal/job"
)
// 任务接口错误(包级哨兵错误,便于测试断言)。
var (
errBadTaskType = errString("无效的任务类型,支持 full / incremental")
errMissingTaskID = errString("缺少任务 ID")
errTaskNotFound = errString("任务不存在")
errTaskNotRunning = errString("任务不存在或已结束,无法取消")
)
// errString 将字符串转为 error,避免重复 fmt.Errorf 调用。
type errString string
func (e errString) Error() string { return string(e) }
// TaskController 提供数据入库任务管理的 HTTP 接口。
// 任务由 job.Manager 异步执行,状态实时写入 Redis,持久化到 SQLite。
type TaskController struct {
mgr *job.Manager
}
// NewTaskController 创建任务控制器。
func NewTaskController(mgr *job.Manager) *TaskController {
return &TaskController{mgr: mgr}
}
// taskRequest 创建任务请求参数。
type taskRequest struct {
Type string `json:"type"` // full | incremental
Since string `json:"since"` // 增量起点(YYYY-MM-DD),可选,留空自动取最后K线日期
Limit int `json:"limit"` // 限制处理数量,0 表示全部
}
// CreateTask 处理 POST /api/tasks,创建数据入库任务(全量/增量)。
// 任务异步执行,立即返回 pending 状态的任务快照,前端通过轮询 GetTask 获取进度。
func (c *TaskController) CreateTask(w http.ResponseWriter, r *http.Request) {
var req taskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
var typ cache.TaskType
switch req.Type {
case "full", "":
typ = cache.TaskTypeFull
case "incremental":
typ = cache.TaskTypeIncremental
default:
writeError(w, http.StatusBadRequest, errBadTaskType)
return
}
task, err := c.mgr.Start(r.Context(), typ, req.Since, req.Limit)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, task)
}
// ListTasks 处理 GET /api/tasks,返回任务列表(按开始时间降序)。
// 默认返回最近 50 条,可通过 ?limit=N 调整。
// 运行中任务会合并 Redis 实时进度。
func (c *TaskController) ListTasks(w http.ResponseWriter, r *http.Request) {
limit := 50
if q := r.URL.Query().Get("limit"); q != "" {
if n, err := strconv.Atoi(q); err == nil && n > 0 {
limit = n
}
}
tasks, err := c.mgr.List(r.Context(), limit)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, tasks)
}
// GetTask 处理 GET /api/tasks/{id},返回单个任务状态。
// 优先从 Redis 读取实时状态,回退 SQLite 持久化记录。
func (c *TaskController) GetTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
writeError(w, http.StatusBadRequest, errMissingTaskID)
return
}
task, err := c.mgr.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, err)
return
}
if task == nil {
writeError(w, http.StatusNotFound, errTaskNotFound)
return
}
writeJSON(w, http.StatusOK, task)
}
// CancelTask 处理 POST /api/tasks/{id}/cancel,取消运行中任务。
// 仅运行中任务可取消,已完成任务忽略。
func (c *TaskController) CancelTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
writeError(w, http.StatusBadRequest, errMissingTaskID)
return
}
if ok := c.mgr.Cancel(id); !ok {
writeError(w, http.StatusNotFound, errTaskNotRunning)
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "任务已取消"})
}

View File

@@ -10,6 +10,7 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"sync"
"time" "time"
) )
@@ -48,12 +49,24 @@ var historyHosts = []string{
type Client struct { type Client struct {
http *http.Client http *http.Client
lastReq time.Time lastReq time.Time
mu sync.Mutex // 保护 lastReq,支持多 goroutine 并发调用
interval time.Duration
} }
// NewClient 创建东财行情客户端。 // NewClient 创建东财行情客户端(默认 2s 节流)。
func NewClient() *Client { func NewClient() *Client {
return &Client{ return &Client{
http: &http.Client{Timeout: 15 * time.Second}, http: &http.Client{Timeout: 15 * time.Second},
interval: requestInterval,
}
}
// NewClientWithThrottle 创建可配置节流间隔的客户端。
// interval=0 关闭内部节流,由外部全局限速器统一控制(job runner 场景)。
func NewClientWithThrottle(interval time.Duration) *Client {
return &Client{
http: &http.Client{Timeout: 15 * time.Second},
interval: interval,
} }
} }
@@ -104,10 +117,16 @@ func (c *Client) fetch(ctx context.Context, reqURL string, out any) error {
return json.Unmarshal(body, out) return json.Unmarshal(body, out)
} }
// throttle 保证请求间隔不小于 requestInterval。 // throttle 保证请求间隔不小于 interval,线程安全。
// interval=0 时为空操作,由外部全局限速器统一控制速率。
func (c *Client) throttle() { func (c *Client) throttle() {
if c.interval == 0 {
return
}
c.mu.Lock()
defer c.mu.Unlock()
elapsed := time.Since(c.lastReq) elapsed := time.Since(c.lastReq)
if wait := requestInterval - elapsed; wait > 0 { if wait := c.interval - elapsed; wait > 0 {
time.Sleep(wait) time.Sleep(wait)
} }
c.lastReq = time.Now() c.lastReq = time.Now()

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

View File

@@ -12,7 +12,7 @@ import (
// New 构建应用路由处理器。 // New 构建应用路由处理器。
// webDir 为前端静态资源目录(View 层)。 // webDir 为前端静态资源目录(View 层)。
func New(mc *controller.MarketController, ac *controller.AuthController, webDir string) http.Handler { func New(mc *controller.MarketController, ac *controller.AuthController, tc *controller.TaskController, webDir string) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
// 公开接口:无需登录。 // 公开接口:无需登录。
@@ -29,6 +29,12 @@ func New(mc *controller.MarketController, ac *controller.AuthController, webDir
mux.HandleFunc("GET /api/status", requireAuth(ac, mc.GetDataStatus)) mux.HandleFunc("GET /api/status", requireAuth(ac, mc.GetDataStatus))
mux.HandleFunc("GET /api/db/bonds", requireAuth(ac, mc.ListDBBonds)) mux.HandleFunc("GET /api/db/bonds", requireAuth(ac, mc.ListDBBonds))
// 数据入库任务接口:创建/查询/取消(需要登录)。
mux.HandleFunc("POST /api/tasks", requireAuth(ac, tc.CreateTask))
mux.HandleFunc("GET /api/tasks", requireAuth(ac, tc.ListTasks))
mux.HandleFunc("GET /api/tasks/{id}", requireAuth(ac, tc.GetTask))
mux.HandleFunc("POST /api/tasks/{id}/cancel", requireAuth(ac, tc.CancelTask))
// 静态资源:登录页及样式脚本公开,首页受保护。 // 静态资源:登录页及样式脚本公开,首页受保护。
fs := noCache(http.FileServer(http.Dir(webDir))) fs := noCache(http.FileServer(http.Dir(webDir)))
mux.Handle("/auth.html", fs) mux.Handle("/auth.html", fs)

View File

@@ -80,6 +80,23 @@ CREATE TABLE IF NOT EXISTS convert_price_history (
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
CREATE INDEX IF NOT EXISTS idx_verify_code_email ON verify_code (email, purpose); CREATE INDEX IF NOT EXISTS idx_verify_code_email ON verify_code (email, purpose);
CREATE TABLE IF NOT EXISTS ingest_task (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
status TEXT NOT NULL,
total INTEGER NOT NULL DEFAULT 0,
done INTEGER NOT NULL DEFAULT 0,
success INTEGER NOT NULL DEFAULT 0,
failed INTEGER NOT NULL DEFAULT 0,
failed_codes TEXT NOT NULL DEFAULT '',
params TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
started_at TEXT NOT NULL,
finished_at TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_ingest_task_status ON ingest_task (status);
CREATE INDEX IF NOT EXISTS idx_ingest_task_started ON ingest_task (started_at);
` `
// 通用 SQL:幂等写入(存在则跳过)。 // 通用 SQL:幂等写入(存在则跳过)。
@@ -149,8 +166,12 @@ type PremiumRow struct {
// Store 是 SQLite 存储实现,实现 storage.KlineStore。 // Store 是 SQLite 存储实现,实现 storage.KlineStore。
type Store struct { type Store struct {
db *sql.DB db *sql.DB
path string
} }
// Path 返回数据库文件路径。
func (s *Store) Path() string { return s.path }
// Open 打开(或创建)SQLite 数据库文件并初始化表结构。 // Open 打开(或创建)SQLite 数据库文件并初始化表结构。
func Open(path string) (*Store, error) { func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite", path) db, err := sql.Open("sqlite", path)
@@ -187,7 +208,7 @@ func Open(path string) (*Store, error) {
db.Close() db.Close()
return nil, fmt.Errorf("set busy timeout: %w", err) return nil, fmt.Errorf("set busy timeout: %w", err)
} }
return &Store{db: db}, nil return &Store{db: db, path: path}, nil
} }
// initSchema 执行建表语句。 // initSchema 执行建表语句。
@@ -354,6 +375,74 @@ func (s *Store) ListCodes(ctx context.Context) ([]string, error) {
return codes, rows.Err() return codes, rows.Err()
} }
// LastKlineDates 一次性返回所有已入库 code 的最新K线日期(YYYY-MM-DD)。
// 用于增量同步预加载,替代逐只 LastKlineDate 查询,避免占用唯一写连接。
func (s *Store) LastKlineDates(ctx context.Context) (map[string]string, error) {
rows, err := s.db.QueryContext(ctx, "SELECT code, MAX(date) FROM convertible_kline GROUP BY code")
if err != nil {
return nil, fmt.Errorf("last kline dates: %w", err)
}
defer rows.Close()
out := make(map[string]string)
for rows.Next() {
var code, date string
if err := rows.Scan(&code, &date); err != nil {
return nil, fmt.Errorf("scan last date: %w", err)
}
out[code] = date
}
return out, rows.Err()
}
// SaveKlinesBatch 单事务批量写入多只转债K线(collector 调用)。
// 接收 map[转债代码]K线切片,在一个事务内遍历写入,带 3 次重试。
func (s *Store) SaveKlinesBatch(ctx context.Context, batch map[string][]model.Kline) error {
if len(batch) == 0 {
return nil
}
const maxAttempt = 3
var lastErr error
for attempt := 0; attempt < maxAttempt; attempt++ {
if attempt > 0 {
time.Sleep(2 * time.Second)
}
if err := s.saveBatchOnce(ctx, batch); err != nil {
lastErr = err
continue
}
return nil
}
return lastErr
}
// saveBatchOnce 单次执行批量写入事务。
func (s *Store) saveBatchOnce(ctx context.Context, batch map[string][]model.Kline) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
stmt, err := tx.PrepareContext(ctx, upsertSQL)
if err != nil {
return fmt.Errorf("prepare upsert: %w", err)
}
defer stmt.Close()
for code, klines := range batch {
for _, k := range klines {
if _, err := stmt.ExecContext(ctx, code, k.Date, k.Open, k.Close, k.High, k.Low, k.Volume, k.Amount, k.ConvertPrice, k.ConvertValue, k.PremiumRate); err != nil {
return fmt.Errorf("insert kline %s %s: %w", code, k.Date, err)
}
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit tx: %w", err)
}
return nil
}
// LoadAllKlines 一次性加载全部标的的K线(按代码、日期升序)。 // LoadAllKlines 一次性加载全部标的的K线(按代码、日期升序)。
// 用于回测等需要全量数据在内存的场景。 // 用于回测等需要全量数据在内存的场景。
func (s *Store) LoadAllKlines(ctx context.Context) (map[string][]model.Kline, error) { func (s *Store) LoadAllKlines(ctx context.Context) (map[string][]model.Kline, error) {

View File

@@ -0,0 +1,126 @@
package sqlite
import (
"context"
"encoding/json"
"fmt"
"stock-analysis/internal/cache"
)
// 任务相关 SQL。
const (
saveTaskSQL = `
INSERT INTO ingest_task (id, type, status, total, done, success, failed, failed_codes, params, error, started_at, finished_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
type = excluded.type,
status = excluded.status,
total = excluded.total,
done = excluded.done,
success = excluded.success,
failed = excluded.failed,
failed_codes = excluded.failed_codes,
params = excluded.params,
error = excluded.error,
started_at = excluded.started_at,
finished_at = excluded.finished_at;`
updateTaskProgressSQL = `UPDATE ingest_task SET total = ?, done = ?, success = ?, failed = ?, failed_codes = ? WHERE id = ?;`
finishTaskSQL = `UPDATE ingest_task SET status = ?, error = ?, finished_at = ? WHERE id = ?;`
getTaskSQL = `SELECT id, type, status, total, done, success, failed, failed_codes, params, error, started_at, finished_at FROM ingest_task WHERE id = ?;`
listTasksSQL = `SELECT id, type, status, total, done, success, failed, failed_codes, params, error, started_at, finished_at FROM ingest_task ORDER BY started_at DESC LIMIT ?;`
)
// SaveTask 保存或更新任务(INSERT OR REPLACE 语义)。
func (s *Store) SaveTask(ctx context.Context, t *cache.Task) error {
failedCodes, err := json.Marshal(t.FailedCodes)
if err != nil {
return fmt.Errorf("marshal failed codes: %w", err)
}
_, err = s.db.ExecContext(ctx, saveTaskSQL,
t.ID, t.Type, t.Status, t.Total, t.Done, t.Success, t.Failed,
string(failedCodes), t.Params, t.Error, t.StartedAt, t.FinishedAt)
if err != nil {
return fmt.Errorf("save task %s: %w", t.ID, err)
}
return nil
}
// UpdateTaskProgress 更新任务进度(total/done/success/failed/failed_codes)。
func (s *Store) UpdateTaskProgress(ctx context.Context, id string, total, done, success, failed int, failedCodes []string) error {
data, err := json.Marshal(failedCodes)
if err != nil {
return fmt.Errorf("marshal failed codes: %w", err)
}
_, err = s.db.ExecContext(ctx, updateTaskProgressSQL, total, done, success, failed, string(data), id)
if err != nil {
return fmt.Errorf("update task progress %s: %w", id, err)
}
return nil
}
// FinishTask 标记任务终态(status/error/finished_at)。
func (s *Store) FinishTask(ctx context.Context, id string, status cache.TaskStatus, errMsg, finishedAt string) error {
_, err := s.db.ExecContext(ctx, finishTaskSQL, status, errMsg, finishedAt, id)
if err != nil {
return fmt.Errorf("finish task %s: %w", id, err)
}
return nil
}
// GetTask 按 ID 查询任务。
func (s *Store) GetTask(ctx context.Context, id string) (*cache.Task, error) {
row := s.db.QueryRowContext(ctx, getTaskSQL, id)
t, err := scanTask(row)
if err != nil {
return nil, fmt.Errorf("get task %s: %w", id, err)
}
return t, nil
}
// ListTasks 返回最近的任务列表(按开始时间降序)。
func (s *Store) ListTasks(ctx context.Context, limit int) ([]*cache.Task, error) {
if limit <= 0 {
limit = 50
}
rows, err := s.db.QueryContext(ctx, listTasksSQL, limit)
if err != nil {
return nil, fmt.Errorf("list tasks: %w", err)
}
defer rows.Close()
var tasks []*cache.Task
for rows.Next() {
t, err := scanTask(rows)
if err != nil {
return nil, err
}
tasks = append(tasks, t)
}
return tasks, rows.Err()
}
// rowScanner 抽象 *sql.Row 和 *sql.Rows 的 Scan 方法。
type rowScanner interface {
Scan(dest ...any) error
}
// scanTask 从查询行扫描一条任务记录。
func scanTask(row rowScanner) (*cache.Task, error) {
var (
t cache.Task
failedCodes string
)
if err := row.Scan(&t.ID, &t.Type, &t.Status, &t.Total, &t.Done, &t.Success, &t.Failed,
&failedCodes, &t.Params, &t.Error, &t.StartedAt, &t.FinishedAt); err != nil {
return nil, err
}
if failedCodes != "" && failedCodes != "null" {
_ = json.Unmarshal([]byte(failedCodes), &t.FailedCodes)
}
return &t, nil
}

View File

@@ -308,6 +308,64 @@
font-size: 13px; font-size: 13px;
} }
/* ============ Task Status Badges ============ */
.task-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.3px;
white-space: nowrap;
}
.task-badge.pending { background: #FFF3CD; color: #856404; }
.task-badge.running { background: #D6E4FF; color: #1E5FBB; }
.task-badge.success { background: #D4EDDA; color: #155724; }
.task-badge.failed { background: #F8D7DA; color: #721C24; }
.task-badge.cancelled { background: #E2E3E5; color: #383D41; }
.task-badge.running::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: #1E5FBB;
margin-right: 5px;
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.progress-bar {
display: inline-block;
width: 80px;
height: 6px;
background: var(--gray-200);
border-radius: 3px;
vertical-align: middle;
margin: 0 6px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: var(--blue);
border-radius: 3px;
transition: width 0.3s ease;
}
.btn-cancel {
padding: 4px 10px;
border: 1px solid var(--red);
background: transparent;
color: var(--red);
border-radius: var(--radius-sm);
font-size: 12px;
cursor: pointer;
transition: all var(--transition);
}
.btn-cancel:hover { background: var(--red); color: var(--white); }
.btn-cancel:disabled { opacity: 0.4; cursor: not-allowed; }
/* ============ Responsive ============ */ /* ============ Responsive ============ */
@media (max-width: 768px) { @media (max-width: 768px) {
.header { padding: 16px 20px; } .header { padding: 16px 20px; }
@@ -484,6 +542,59 @@
</div> </div>
<div id="data-error" style="display:none"></div> <div id="data-error" style="display:none"></div>
<div class="status-grid" id="data-cards"></div> <div class="status-grid" id="data-cards"></div>
<!-- 任务控制 -->
<div class="section-card" style="margin-top:16px" data-section="task-control">
<div class="section-header">
<span class="section-title">数据更新任务</span>
<button type="button" class="section-toggle" aria-label="折叠">−</button>
</div>
<div class="section-body">
<div class="toolbar" style="margin-top:0">
<button class="btn-primary" id="task-full">全量更新</button>
<button class="btn-primary" id="task-incremental">增量更新</button>
<input type="date" id="task-since" title="增量起始日期(留空自动取最后K线日期)">
<input type="number" id="task-limit" placeholder="限制数量" min="0" style="width:120px" title="限制处理数量(0=全部)">
<span id="task-create-status"></span>
</div>
<div style="font-size:12px;color:var(--gray-500);margin-top:4px;line-height:1.6">
<b>全量更新</b>:拉取所有转债的历史K线(已入库的自动跳过)<br>
<b>增量更新</b>:仅同步已入库转债的最新数据(自上次入库日期起)
</div>
</div>
</div>
<!-- 任务列表 -->
<div class="section-card" data-section="task-list">
<div class="section-header">
<span class="section-title">任务列表</span>
<button type="button" class="section-toggle" aria-label="折叠">−</button>
</div>
<div class="section-body">
<div class="toolbar" style="margin-top:0">
<button class="btn-default" id="task-refresh">刷新任务</button>
<span id="task-list-status" style="font-size:13px;color:var(--gray-500)"></span>
</div>
<div class="table-wrap" style="max-height:480px;overflow-y:auto;margin-bottom:0">
<table id="task-table">
<thead>
<tr>
<th>任务ID</th>
<th>类型</th>
<th>状态</th>
<th>进度</th>
<th>成功</th>
<th>失败</th>
<th>开始时间</th>
<th>完成时间</th>
<th>操作</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div> </div>
</div> </div>
@@ -559,7 +670,7 @@
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active'); tab.classList.add('active');
document.getElementById('tab-' + tab.dataset.tab).classList.add('active'); document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
if (tab.dataset.tab === 'data') loadDataStatus(); if (tab.dataset.tab === 'data') { loadDataStatus(); loadTasks(); }
}); });
}); });
@@ -956,6 +1067,170 @@
document.getElementById('data-refresh').addEventListener('click', loadDataStatus); document.getElementById('data-refresh').addEventListener('click', loadDataStatus);
// ============ 数据更新任务 ============
let taskPollTimer = null;
// 任务类型中文映射
const taskTypeLabel = { full: '全量更新', incremental: '增量更新' };
// 任务状态中文映射
const taskStatusLabel = {
pending: '等待中', running: '运行中', success: '成功', failed: '失败', cancelled: '已取消'
};
// 创建任务
async function createTask(type) {
const statusEl = document.getElementById('task-create-status');
const btnId = type === 'full' ? 'task-full' : 'task-incremental';
const btn = document.getElementById(btnId);
const since = document.getElementById('task-since').value;
const limit = +document.getElementById('task-limit').value || 0;
btn.disabled = true;
statusEl.textContent = '提交中...';
statusEl.style.color = 'var(--blue)';
try {
const body = { type, since, limit };
const resp = await apiFetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || 'HTTP ' + resp.status);
}
const task = await resp.json();
showToast(`${taskTypeLabel[type]}任务已创建`, 'success');
statusEl.textContent = '';
// 立即刷新任务列表并启动轮询
await loadTasks();
startTaskPolling();
// 切换到任务列表区域
document.querySelector('[data-section="task-list"] .section-header').scrollIntoView({ behavior: 'smooth', block: 'start' });
} catch (e) {
showToast('创建任务失败: ' + e.message, 'error');
statusEl.textContent = '× ' + e.message;
statusEl.style.color = 'var(--red)';
} finally {
btn.disabled = false;
}
}
// 加载任务列表
async function loadTasks() {
const statusEl = document.getElementById('task-list-status');
const tbody = document.querySelector('#task-table tbody');
statusEl.textContent = '加载中...';
try {
const resp = await apiFetch('/api/tasks?limit=50');
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const tasks = await resp.json();
renderTasks(tasks || []);
const running = (tasks || []).filter(t => t.status === 'running' || t.status === 'pending').length;
statusEl.textContent = `共 ${tasks.length} 条${running > 0 ? `(${running} 个运行中)` : ''}`;
// 有运行中任务时继续轮询,否则停止
if (running > 0) {
startTaskPolling();
} else {
stopTaskPolling();
}
} catch (e) {
tbody.innerHTML = `<tr><td colspan="9" style="text-align:center;padding:20px;color:var(--red)">加载失败: ${e.message}</td></tr>`;
statusEl.textContent = '加载失败';
}
}
// 渲染任务列表
function renderTasks(tasks) {
const tbody = document.querySelector('#task-table tbody');
if (tasks.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:24px;color:#888">暂无任务记录</td></tr>';
return;
}
tbody.innerHTML = tasks.map(t => {
const pct = t.total > 0 ? Math.round(t.done / t.total * 100) : 0;
const isActive = t.status === 'running' || t.status === 'pending';
const statusText = taskStatusLabel[t.status] || t.status;
const shortId = t.id.substring(0, 8);
const started = formatTime(t.startedAt);
const finished = t.finishedAt ? formatTime(t.finishedAt) : '-';
const failInfo = t.failed > 0 && t.failedCodes && t.failedCodes.length > 0
? ` title="失败代码: ${t.failedCodes.slice(0, 10).join(', ')}${t.failedCodes.length > 10 ? '...' : ''}"`
: '';
const errorInfo = t.error ? ` title="${t.error.replace(/"/g, '&quot;')}"` : '';
return `<tr>
<td style="font-family:monospace;font-size:12px" title="${t.id}">${shortId}</td>
<td>${taskTypeLabel[t.type] || t.type}</td>
<td><span class="task-badge ${t.status}"${errorInfo}>${statusText}</span></td>
<td>
<span style="font-size:12px">${t.done}/${t.total}</span>
<span class="progress-bar"><span class="progress-bar-fill" style="width:${pct}%"></span></span>
<span style="font-size:11px;color:var(--gray-500)">${pct}%</span>
</td>
<td style="color:var(--green);font-weight:600">${t.success}</td>
<td style="color:var(--red);font-weight:600"${failInfo}>${t.failed}</td>
<td style="font-size:12px">${started}</td>
<td style="font-size:12px">${finished}</td>
<td>${isActive
? `<button class="btn-cancel" onclick="cancelTask('${t.id}')">取消</button>`
: '<span style="color:var(--gray-500);font-size:12px">-</span>'}</td>
</tr>`;
}).join('');
}
// 取消任务
async function cancelTask(id) {
if (!confirm('确定取消该任务?')) return;
try {
const resp = await apiFetch(`/api/tasks/${id}/cancel`, { method: 'POST' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || 'HTTP ' + resp.status);
}
showToast('任务取消请求已发送', 'info');
await loadTasks();
} catch (e) {
showToast('取消失败: ' + e.message, 'error');
}
}
// 暴露到全局供 inline onclick 调用
window.cancelTask = cancelTask;
// 格式化时间:RFC3339 → 可读格式
function formatTime(s) {
if (!s) return '-';
const d = new Date(s);
if (isNaN(d)) return s;
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
// 轮询:运行中任务时每 3 秒刷新一次
function startTaskPolling() {
if (taskPollTimer) return; // 已在轮询
taskPollTimer = setInterval(loadTasks, 3000);
}
function stopTaskPolling() {
if (taskPollTimer) {
clearInterval(taskPollTimer);
taskPollTimer = null;
}
}
document.getElementById('task-full').addEventListener('click', () => createTask('full'));
document.getElementById('task-incremental').addEventListener('click', () => createTask('incremental'));
document.getElementById('task-refresh').addEventListener('click', loadTasks);
// 数据管理Tab的折叠/展开
document.querySelectorAll('#tab-data .section-header').forEach(header => {
header.addEventListener('click', e => {
if (e.target.closest('.section-toggle')) e.stopPropagation();
const card = header.closest('.section-card');
const collapsed = card.classList.toggle('collapsed');
header.querySelector('.section-toggle').textContent = collapsed ? '+' : '−';
});
});
// 初始化 // 初始化
loadBonds(); loadBonds();
</script> </script>