diff --git a/.env.example b/.env.example index 7245da7..d2a71b3 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,12 @@ RUST_LOG=info # 端口 BACKEND_PORT=3019 FRONTEND_PORT=3018 + +# TickFlow 数据源配置(付费 key 必填,空 key 将无法启动同步) +TICKFLOW_API_KEY= +TICKFLOW_BASE_URL=https://api.tickflow.org + +# 盘后数据同步配置 +DATA_SYNC_ENABLED=true +DATA_SYNC_TIME=16:30 +DATA_SYNC_WEEKDAYS_ONLY=true diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index e1cc03f..83b5748 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -7,8 +7,10 @@ import ( "stock-user-system/internal/config" "stock-user-system/internal/db" + "stock-user-system/internal/jobs" "stock-user-system/internal/models" "stock-user-system/internal/routes" + "stock-user-system/internal/services" ) func main() { @@ -34,7 +36,14 @@ func main() { log.Fatalf("seed system admin: %v", err) } - r := routes.Setup(cfg, database) + if err := models.AutoMigrateStock(database); err != nil { + log.Fatalf("migrate stock tables: %v", err) + } + + stockSyncSvc := services.NewStockSyncService(cfg, database) + jobs.StartStockSyncScheduler(cfg, stockSyncSvc) + + r := routes.Setup(cfg, database, stockSyncSvc) addr := fmt.Sprintf("0.0.0.0:%s", cfg.Port) log.Printf("backend listening on %s", addr) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 87915aa..f52a7e2 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -16,6 +16,13 @@ type Config struct { JWTExpirationHours int Port string AllowedOrigins []string + + // 股票数据源 + TickFlowAPIKey string + TickFlowBaseURL string + DataSyncEnabled bool + DataSyncTime string // HH:MM + DataSyncWeekdays bool } func Load() (*Config, error) { @@ -50,12 +57,29 @@ func Load() (*Config, error) { } } + tickFlowBaseURL := os.Getenv("TICKFLOW_BASE_URL") + if tickFlowBaseURL == "" { + tickFlowBaseURL = "https://api.tickflow.org" + } + + dataSyncEnabled := strings.ToLower(os.Getenv("DATA_SYNC_ENABLED")) != "false" + dataSyncTime := os.Getenv("DATA_SYNC_TIME") + if dataSyncTime == "" { + dataSyncTime = "16:30" + } + dataSyncWeekdays := strings.ToLower(os.Getenv("DATA_SYNC_WEEKDAYS_ONLY")) != "false" + return &Config{ DatabaseURL: databaseURL, JWTSecret: jwtSecret, JWTExpirationHours: expHours, Port: port, AllowedOrigins: allowedOrigins, + TickFlowAPIKey: os.Getenv("TICKFLOW_API_KEY"), + TickFlowBaseURL: tickFlowBaseURL, + DataSyncEnabled: dataSyncEnabled, + DataSyncTime: dataSyncTime, + DataSyncWeekdays: dataSyncWeekdays, }, nil } diff --git a/backend/internal/datasource/tickflow.go b/backend/internal/datasource/tickflow.go new file mode 100644 index 0000000..834b44d --- /dev/null +++ b/backend/internal/datasource/tickflow.go @@ -0,0 +1,199 @@ +package datasource + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "stock-user-system/internal/config" +) + +const ( + defaultTimeout = 60 * time.Second + maxRetries = 3 + retryBaseDelay = 1 * time.Second +) + +// Quote 对应 tickflow /v1/quotes 返回的单条行情数据。 +type Quote struct { + Symbol string `json:"symbol"` + Name string `json:"name"` + Open float64 `json:"open"` + High float64 `json:"high"` + Low float64 `json:"low"` + Close float64 `json:"last_price"` + PrevClose float64 `json:"prev_close"` + Volume int64 `json:"volume"` + Amount float64 `json:"amount"` + Timestamp int64 `json:"timestamp"` + Region string `json:"region"` + Ext struct { + ChangePct float64 `json:"change_pct"` + TurnoverRate float64 `json:"turnover_rate"` + Name string `json:"name"` + } `json:"ext"` +} + +// ChangePct 优先使用 ext.change_pct,否则根据 close/prev_close 计算。 +func (q *Quote) ChangePct() float64 { + if q.Ext.ChangePct != 0 { + return q.Ext.ChangePct + } + if q.PrevClose != 0 { + return (q.Close - q.PrevClose) / q.PrevClose * 100 + } + return 0 +} + +// TurnoverRate 返回 ext.turnover_rate(仅 A 股有效)。 +func (q *Quote) TurnoverRate() float64 { + return q.Ext.TurnoverRate +} + +// DisplayName 优先使用 ext.name,否则使用 symbol。 +func (q *Quote) DisplayName() string { + if q.Ext.Name != "" { + return q.Ext.Name + } + if q.Name != "" { + return q.Name + } + return q.Symbol +} + +// UniverseDetail 对应 /v1/universes/:id 返回的标的池详情。 +type UniverseDetail struct { + ID string `json:"id"` + Name string `json:"name"` + SymbolCount int `json:"symbol_count"` + Symbols []string `json:"symbols"` +} + +// Client 封装 tickflow HTTP API 调用。 +type Client struct { + baseURL string + apiKey string + client *http.Client +} + +// NewClient 从配置创建 tickflow 客户端。 +func NewClient(cfg *config.Config) *Client { + return &Client{ + baseURL: strings.TrimRight(cfg.TickFlowBaseURL, "/"), + apiKey: cfg.TickFlowAPIKey, + client: &http.Client{Timeout: defaultTimeout}, + } +} + +// GetUniverse 获取指定标的池的完整代码列表。 +func (c *Client) GetUniverse(id string) (*UniverseDetail, error) { + path := fmt.Sprintf("/v1/universes/%s", id) + var detail UniverseDetail + if err := c.request("GET", path, nil, nil, &detail); err != nil { + return nil, err + } + return &detail, nil +} + +// GetQuotesByUniverses 按标的池批量获取行情快照。 +func (c *Client) GetQuotesByUniverses(universes []string) ([]Quote, error) { + body := map[string]any{"universes": universes} + var quotes []Quote + if err := c.request("POST", "/v1/quotes", nil, body, "es); err != nil { + return nil, err + } + return quotes, nil +} + +// GetQuotesBySymbols 按代码列表批量获取行情快照。 +func (c *Client) GetQuotesBySymbols(symbols []string) ([]Quote, error) { + body := map[string]any{"symbols": symbols} + var quotes []Quote + if err := c.request("POST", "/v1/quotes", nil, body, "es); err != nil { + return nil, err + } + return quotes, nil +} + +func (c *Client) request(method, path string, params, body map[string]any, result any) error { + url := c.baseURL + path + + var lastErr error + for attempt := 0; attempt <= maxRetries; attempt++ { + if attempt > 0 { + time.Sleep(retryBaseDelay * time.Duration(1<<(attempt-1))) + } + + var bodyReader io.Reader + if len(body) > 0 && (method == "POST" || method == "PUT") { + data, err := json.Marshal(body) + if err != nil { + return err + } + bodyReader = bytes.NewReader(data) + } + + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + return err + } + + if c.apiKey != "" { + req.Header.Set("x-api-key", c.apiKey) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + + if len(params) > 0 { + q := req.URL.Query() + for k, v := range params { + q.Set(k, fmt.Sprintf("%v", v)) + } + req.URL.RawQuery = q.Encode() + } + + resp, err := c.client.Do(req) + if err != nil { + lastErr = err + continue + } + + respBody, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + lastErr = err + continue + } + + if resp.StatusCode >= 500 || resp.StatusCode == 429 { + lastErr = fmt.Errorf("tickflow %s %s returned %d: %s", method, path, resp.StatusCode, string(respBody)) + continue + } + + if resp.StatusCode >= 400 { + return fmt.Errorf("tickflow %s %s returned %d: %s", method, path, resp.StatusCode, string(respBody)) + } + + var wrapper struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(respBody, &wrapper); err != nil { + return fmt.Errorf("decode tickflow response: %w", err) + } + if result != nil { + if err := json.Unmarshal(wrapper.Data, result); err != nil { + return fmt.Errorf("decode tickflow data: %w", err) + } + } + return nil + } + + if lastErr != nil { + return fmt.Errorf("tickflow request failed after %d retries: %w", maxRetries, lastErr) + } + return fmt.Errorf("tickflow request failed") +} diff --git a/backend/internal/handlers/stock.go b/backend/internal/handlers/stock.go new file mode 100644 index 0000000..2be4e1a --- /dev/null +++ b/backend/internal/handlers/stock.go @@ -0,0 +1,161 @@ +package handlers + +import ( + "context" + "net/http" + "time" + + "stock-user-system/internal/models" + "stock-user-system/internal/services" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +var coreIndexSymbols = map[string]bool{ + "000001.SH": true, + "399001.SZ": true, + "399006.SZ": true, + "000688.SH": true, +} + +// StockHandler 处理股票看板与同步接口。 +type StockHandler struct { + DB *gorm.DB + Svc *services.StockSyncService +} + +type stockRow struct { + Symbol string `json:"symbol"` + Name string `json:"name"` + Close float64 `json:"close"` + ChangePct float64 `json:"change_pct"` + Amount float64 `json:"amount,omitempty"` +} + +type overviewResponse struct { + LatestTradeDate string `json:"latest_trade_date"` + Counts marketCounts `json:"counts"` + AvgChangePct float64 `json:"avg_change_pct"` + Indices []stockRow `json:"indices"` + TopGainers []stockRow `json:"top_gainers"` + TopLosers []stockRow `json:"top_losers"` + TurnoverLeaders []stockRow `json:"turnover_leaders"` +} + +type marketCounts struct { + Up int64 `json:"up"` + Down int64 `json:"down"` + Flat int64 `json:"flat"` + Total int64 `json:"total"` +} + +// Overview 返回最新交易日的市场概览。 +func (h *StockHandler) Overview(c *gin.Context) { + ctx := context.Background() + + var latestDate time.Time + if err := h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}). + Select("MAX(trade_date)").Scan(&latestDate).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "查询最新交易日失败"}) + return + } + if latestDate.IsZero() { + c.JSON(http.StatusOK, overviewResponse{}) + return + } + + var counts marketCounts + if err := h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}). + Select(` + COUNT(*) AS total, + SUM(CASE WHEN change_pct > 0 THEN 1 ELSE 0 END) AS up, + SUM(CASE WHEN change_pct < 0 THEN 1 ELSE 0 END) AS down, + SUM(CASE WHEN change_pct = 0 THEN 1 ELSE 0 END) AS flat + `). + Where("trade_date = ? AND symbol NOT IN ?", latestDate, indexSymbolsList()). + Scan(&counts).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "统计涨跌失败"}) + return + } + + var avgChange float64 + h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}). + Select("COALESCE(AVG(change_pct), 0)"). + Where("trade_date = ? AND symbol NOT IN ?", latestDate, indexSymbolsList()). + Scan(&avgChange) + + indices := h.topRows(ctx, latestDate, "symbol IN ?", indexSymbolsList(), 10, false) + gainers := h.topRows(ctx, latestDate, "symbol NOT IN ?", indexSymbolsList(), 10, true) + losers := h.topRows(ctx, latestDate, "symbol NOT IN ?", indexSymbolsList(), 10, false) + turnover := h.topRowsByAmount(ctx, latestDate) + + c.JSON(http.StatusOK, overviewResponse{ + LatestTradeDate: latestDate.Format("2006-01-02"), + Counts: counts, + AvgChangePct: avgChange, + Indices: indices, + TopGainers: gainers, + TopLosers: losers, + TurnoverLeaders: turnover, + }) +} + +func (h *StockHandler) topRows(ctx context.Context, tradeDate time.Time, condition string, args any, limit int, desc bool) []stockRow { + var rows []stockRow + order := "change_pct ASC" + if desc { + order = "change_pct DESC" + } + h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}). + Select("symbol, name, close, change_pct"). + Where("trade_date = ?", tradeDate). + Where(condition, args). + Order(order). + Limit(limit). + Scan(&rows) + return rows +} + +func (h *StockHandler) topRowsByAmount(ctx context.Context, tradeDate time.Time) []stockRow { + var rows []stockRow + h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}). + Select("symbol, name, close, change_pct, amount"). + Where("trade_date = ? AND symbol NOT IN ?", tradeDate, indexSymbolsList()). + Order("amount DESC"). + Limit(10). + Scan(&rows) + return rows +} + +// SyncStatus 返回最近一次同步任务状态。 +func (h *StockHandler) SyncStatus(c *gin.Context) { + job, err := h.Svc.LatestJob(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "查询同步状态失败"}) + return + } + if job == nil { + c.JSON(http.StatusOK, gin.H{"success": true, "data": nil}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": job}) +} + +// TriggerSync 手动触发同步(管理员)。 +func (h *StockHandler) TriggerSync(c *gin.Context) { + count, err := h.Svc.Sync(c.Request.Context(), "manual") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "records_count": count}) +} + +func indexSymbolsList() []string { + list := make([]string, 0, len(coreIndexSymbols)) + for s := range coreIndexSymbols { + list = append(list, s) + } + return list +} diff --git a/backend/internal/jobs/stock_sync_scheduler.go b/backend/internal/jobs/stock_sync_scheduler.go new file mode 100644 index 0000000..4a1277d --- /dev/null +++ b/backend/internal/jobs/stock_sync_scheduler.go @@ -0,0 +1,81 @@ +package jobs + +import ( + "context" + "fmt" + "log" + "strconv" + "strings" + "time" + + "stock-user-system/internal/config" + "stock-user-system/internal/services" +) + +var chinaLoc, _ = time.LoadLocation("Asia/Shanghai") + +// StartStockSyncScheduler 启动盘后数据同步调度器。 +func StartStockSyncScheduler(cfg *config.Config, svc *services.StockSyncService) { + if !cfg.DataSyncEnabled { + log.Println("stock sync scheduler disabled") + return + } + + hour, minute, err := parseSyncTime(cfg.DataSyncTime) + if err != nil { + log.Printf("invalid DATA_SYNC_TIME %q, scheduler not started: %v", cfg.DataSyncTime, err) + return + } + + go func() { + for { + next := nextSyncTime(hour, minute, cfg.DataSyncWeekdays) + wait := time.Until(next) + log.Printf("next stock sync scheduled at %s (in %v)", next.Format(time.RFC3339), wait) + time.Sleep(wait) + + if _, err := svc.Sync(context.Background(), "schedule"); err != nil { + log.Printf("scheduled stock sync failed: %v", err) + } else { + log.Println("scheduled stock sync completed") + } + } + }() +} + +func parseSyncTime(s string) (int, int, error) { + parts := strings.Split(s, ":") + if len(parts) != 2 { + return 0, 0, fmt.Errorf("expected HH:MM") + } + h, err := strconv.Atoi(parts[0]) + if err != nil { + return 0, 0, err + } + m, err := strconv.Atoi(parts[1]) + if err != nil { + return 0, 0, err + } + if h < 0 || h > 23 || m < 0 || m > 59 { + return 0, 0, fmt.Errorf("invalid time") + } + return h, m, nil +} + +func nextSyncTime(hour, minute int, weekdaysOnly bool) time.Time { + now := time.Now().In(chinaLoc) + today := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, chinaLoc) + + candidate := today + if now.Equal(candidate) || now.After(candidate) { + candidate = candidate.Add(24 * time.Hour) + } + + if weekdaysOnly { + for candidate.Weekday() == time.Saturday || candidate.Weekday() == time.Sunday { + candidate = candidate.Add(24 * time.Hour) + } + } + + return candidate +} diff --git a/backend/internal/models/stock.go b/backend/internal/models/stock.go new file mode 100644 index 0000000..5987b7f --- /dev/null +++ b/backend/internal/models/stock.go @@ -0,0 +1,44 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// StockDailyQuote 存储每日行情快照(盘后同步)。 +type StockDailyQuote struct { + ID string `json:"id" gorm:"type:uuid;primaryKey;default:gen_random_uuid()"` + Symbol string `json:"symbol" gorm:"size:32;not null;uniqueIndex:idx_stock_daily_symbol_date"` + Name string `json:"name" gorm:"size:128"` + TradeDate time.Time `json:"trade_date" gorm:"type:date;not null;uniqueIndex:idx_stock_daily_symbol_date"` + Open float64 `json:"open"` + High float64 `json:"high"` + Low float64 `json:"low"` + Close float64 `json:"close"` + PrevClose float64 `json:"prev_close"` + Volume int64 `json:"volume"` + Amount float64 `json:"amount"` + ChangePct float64 `json:"change_pct"` + TurnoverRate float64 `json:"turnover_rate"` + Region string `json:"region" gorm:"size:16"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// StockSyncJob 记录每次盘后同步任务的状态。 +type StockSyncJob struct { + ID string `json:"id" gorm:"type:uuid;primaryKey;default:gen_random_uuid()"` + JobDate time.Time `json:"job_date" gorm:"type:date;not null"` + Status string `json:"status" gorm:"size:16;not null"` // running / success / failed + RecordsCount int `json:"records_count"` + StartedAt time.Time `json:"started_at"` + FinishedAt *time.Time `json:"finished_at"` + ErrorMessage string `json:"error_message"` + TriggerBy string `json:"trigger_by" gorm:"size:16"` // schedule / manual +} + +// AutoMigrateStock 迁移股票数据表。 +func AutoMigrateStock(db *gorm.DB) error { + return db.AutoMigrate(&StockDailyQuote{}, &StockSyncJob{}) +} diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go index ad2358a..8435729 100644 --- a/backend/internal/routes/routes.go +++ b/backend/internal/routes/routes.go @@ -5,14 +5,16 @@ import ( "stock-user-system/internal/handlers" "stock-user-system/internal/middleware" "stock-user-system/internal/models" + "stock-user-system/internal/services" "github.com/gin-gonic/gin" "gorm.io/gorm" ) -func Setup(cfg *config.Config, db *gorm.DB) *gin.Engine { +func Setup(cfg *config.Config, db *gorm.DB, stockSyncSvc *services.StockSyncService) *gin.Engine { authHandler := &handlers.AuthHandler{DB: db, CFG: cfg} adminHandler := &handlers.AdminHandler{DB: db, CFG: cfg} + stockHandler := &handlers.StockHandler{DB: db, Svc: stockSyncSvc} r := gin.Default() @@ -41,6 +43,14 @@ func Setup(cfg *config.Config, db *gorm.DB) *gin.Engine { auth.POST("/logout", authHandler.Logout) } + // 受保护的股票看板接口 + stocks := r.Group("/api/stocks") + stocks.Use(middleware.RequireAuth()) + { + stocks.GET("/overview", stockHandler.Overview) + stocks.GET("/sync/status", stockHandler.SyncStatus) + } + // 管理员接口 admin := r.Group("/api/admin") admin.Use(middleware.RequireAuth(), middleware.RequireRoles(models.RoleAdmin, models.RoleSystemAdmin)) @@ -50,6 +60,7 @@ func Setup(cfg *config.Config, db *gorm.DB) *gin.Engine { admin.PUT("/users/:id", adminHandler.UpdateUser) admin.DELETE("/users/:id", adminHandler.DeleteUser) admin.GET("/roles", adminHandler.ListRoles) + admin.POST("/stocks/sync", stockHandler.TriggerSync) } return r diff --git a/backend/internal/services/stock_sync.go b/backend/internal/services/stock_sync.go new file mode 100644 index 0000000..e6002c1 --- /dev/null +++ b/backend/internal/services/stock_sync.go @@ -0,0 +1,138 @@ +package services + +import ( + "context" + "fmt" + "sync" + "time" + + "stock-user-system/internal/config" + "stock-user-system/internal/datasource" + "stock-user-system/internal/models" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ( + coreIndexSymbols = []string{"000001.SH", "399001.SZ", "399006.SZ", "000688.SH"} + chinaLoc, _ = time.LoadLocation("Asia/Shanghai") +) + +// StockSyncService 负责盘后行情同步。 +type StockSyncService struct { + cfg *config.Config + db *gorm.DB + client *datasource.Client + mu sync.Mutex +} + +// NewStockSyncService 创建同步服务。 +func NewStockSyncService(cfg *config.Config, db *gorm.DB) *StockSyncService { + return &StockSyncService{ + cfg: cfg, + db: db, + client: datasource.NewClient(cfg), + } +} + +// Sync 执行一次盘后同步,返回写入的记录数。 +func (s *StockSyncService) Sync(ctx context.Context, triggerBy string) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + + job := models.StockSyncJob{ + JobDate: today(), + Status: "running", + StartedAt: time.Now().UTC(), + TriggerBy: triggerBy, + } + if err := s.db.WithContext(ctx).Create(&job).Error; err != nil { + return 0, fmt.Errorf("create sync job: %w", err) + } + + recordsCount, err := s.doSync(ctx) + + now := time.Now().UTC() + updates := map[string]any{ + "records_count": recordsCount, + "finished_at": &now, + } + if err != nil { + updates["status"] = "failed" + updates["error_message"] = err.Error() + } else { + updates["status"] = "success" + } + s.db.WithContext(ctx).Model(&job).Updates(updates) + + return recordsCount, err +} + +func (s *StockSyncService) doSync(ctx context.Context) (int, error) { + // 1. 拉取全 A 股行情快照 + stockQuotes, err := s.client.GetQuotesByUniverses([]string{"CN_Equity_A"}) + if err != nil { + return 0, fmt.Errorf("fetch stock quotes: %w", err) + } + + // 2. 拉取核心指数行情 + indexQuotes, err := s.client.GetQuotesBySymbols(coreIndexSymbols) + if err != nil { + return 0, fmt.Errorf("fetch index quotes: %w", err) + } + + quotes := append(stockQuotes, indexQuotes...) + if len(quotes) == 0 { + return 0, fmt.Errorf("no quotes returned") + } + + // 3. 归一化并写入 DB + records := make([]models.StockDailyQuote, 0, len(quotes)) + for _, q := range quotes { + tradeDate := msToDate(q.Timestamp) + records = append(records, models.StockDailyQuote{ + Symbol: q.Symbol, + Name: q.DisplayName(), + TradeDate: tradeDate, + Open: q.Open, + High: q.High, + Low: q.Low, + Close: q.Close, + PrevClose: q.PrevClose, + Volume: q.Volume, + Amount: q.Amount, + ChangePct: q.ChangePct(), + TurnoverRate: q.TurnoverRate(), + Region: q.Region, + }) + } + + if err := s.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "symbol"}, {Name: "trade_date"}}, + UpdateAll: true, + }).CreateInBatches(records, 500).Error; err != nil { + return 0, fmt.Errorf("upsert daily quotes: %w", err) + } + + return len(records), nil +} + +func (s *StockSyncService) LatestJob(ctx context.Context) (*models.StockSyncJob, error) { + var job models.StockSyncJob + if err := s.db.WithContext(ctx).Order("started_at DESC").First(&job).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return &job, nil +} + +func today() time.Time { + return time.Now().In(chinaLoc).Truncate(24 * time.Hour) +} + +func msToDate(ms int64) time.Time { + return time.UnixMilli(ms).In(chinaLoc).Truncate(24 * time.Hour) +} diff --git a/docker-compose.yml b/docker-compose.yml index 1158f63..9ee4225 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,11 @@ services: JWT_EXPIRATION_HOURS: ${JWT_EXPIRATION_HOURS:-168} GIN_MODE: ${GIN_MODE:-release} PORT: 3019 + TICKFLOW_API_KEY: ${TICKFLOW_API_KEY:-} + TICKFLOW_BASE_URL: ${TICKFLOW_BASE_URL:-https://api.tickflow.org} + DATA_SYNC_ENABLED: ${DATA_SYNC_ENABLED:-true} + DATA_SYNC_TIME: ${DATA_SYNC_TIME:-16:30} + DATA_SYNC_WEEKDAYS_ONLY: ${DATA_SYNC_WEEKDAYS_ONLY:-true} ports: - "3019:3019" depends_on: diff --git a/frontend/src/components/StockOverview.tsx b/frontend/src/components/StockOverview.tsx new file mode 100644 index 0000000..c117146 --- /dev/null +++ b/frontend/src/components/StockOverview.tsx @@ -0,0 +1,241 @@ +import { useEffect, useState } from 'react' +import { + BarChart3, + TrendingUp, + TrendingDown, + Minus, + RefreshCw, + Loader2, + AlertCircle, + Calendar, + Activity, +} from 'lucide-react' +import { api, MarketOverview, SyncJob, StockRow } from '@/lib/api' +import { canManageUsers } from '@/lib/auth' +import { cn } from '@/lib/cn' + +interface StockOverviewProps { + role: 'system_admin' | 'admin' | 'user' +} + +export function StockOverview({ role }: StockOverviewProps) { + const [overview, setOverview] = useState(null) + const [job, setJob] = useState(null) + const [loading, setLoading] = useState(true) + const [syncing, setSyncing] = useState(false) + const [error, setError] = useState('') + + const canSync = canManageUsers(role) + + const fetchAll = async () => { + setLoading(true) + setError('') + try { + const [o, j] = await Promise.all([api.stockOverview(), api.stockSyncStatus()]) + setOverview(o) + setJob(j) + } catch (err: any) { + setError(err?.message || '加载市场数据失败') + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchAll() + }, []) + + const handleSync = async () => { + setSyncing(true) + setError('') + try { + const res = await api.triggerStockSync() + await fetchAll() + // eslint-disable-next-line no-console + console.log('synced', res.records_count, 'records') + } catch (err: any) { + setError(err?.message || '同步失败') + } finally { + setSyncing(false) + } + } + + if (loading) { + return ( +
+ + 加载市场数据… +
+ ) + } + + if (!overview?.latest_trade_date) { + return ( +
+
暂无市场数据
+ {canSync && ( + + )} +
+ ) + } + + const { counts, avg_change_pct, indices, top_gainers, top_losers, turnover_leaders } = overview + + return ( +
+
+
+ +

市场概览

+ + + {overview.latest_trade_date} + +
+
+ {job && ( + + 上次同步:{formatSyncStatus(job)} + + )} + {canSync && ( + + )} +
+
+ + {error && ( +
+ + {error} +
+ )} + +
+ } label="上涨" value={counts.up} /> + } label="下跌" value={counts.down} /> + } label="平盘" value={counts.flat} /> + } + label="平均涨跌" + value={`${avg_change_pct >= 0 ? '+' : ''}${avg_change_pct.toFixed(2)}%`} + valueClass={avg_change_pct >= 0 ? 'text-bull' : 'text-bear'} + /> +
+ + {indices.length > 0 && ( +
+ {indices.map((item) => ( + + ))} +
+ )} + +
+ + + +
+
+ ) +} + +function KpiCard({ + icon, + label, + value, + valueClass, +}: { + icon: React.ReactNode + label: string + value: React.ReactNode + valueClass?: string +}) { + return ( +
+
{icon}
+
+
{value}
+
{label}
+
+
+ ) +} + +function IndexCard({ item }: { item: StockRow }) { + const positive = item.change_pct >= 0 + return ( +
+
{item.name || item.symbol}
+
+ {item.close.toFixed(2)} +
+
+ {positive ? '+' : ''}{item.change_pct.toFixed(2)}% +
+
+ ) +} + +function StockList({ + title, + rows, + valueKey, +}: { + title: string + rows: StockRow[] + valueKey: 'change_pct' | 'amount' +}) { + return ( +
+

{title}

+
+ {rows.length === 0 ? ( +
暂无数据
+ ) : ( + rows.map((item, idx) => { + const val = valueKey === 'amount' ? (item.amount ?? 0) / 1e8 : item.change_pct + const isPositive = valueKey === 'amount' ? true : item.change_pct >= 0 + return ( +
+
+ {idx + 1} + {item.name || item.symbol} +
+
+ {valueKey === 'amount' + ? `${val.toFixed(2)}亿` + : `${isPositive ? '+' : ''}${val.toFixed(2)}%`} +
+
+ ) + }) + )} +
+
+ ) +} + +function formatSyncStatus(job: SyncJob): string { + if (job.status === 'running') return '同步中…' + const timeStr = job.finished_at + ? new Date(job.finished_at).toLocaleString() + : new Date(job.started_at).toLocaleString() + const statusText = job.status === 'success' ? '成功' : '失败' + return `${statusText} · ${timeStr}` +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index cf026da..11bc79e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -28,6 +28,40 @@ export interface AuthResponse { user: UserInfo } +export interface StockRow { + symbol: string + name: string + close: number + change_pct: number + amount?: number +} + +export interface MarketOverview { + latest_trade_date: string + counts: { + up: number + down: number + flat: number + total: number + } + avg_change_pct: number + indices: StockRow[] + top_gainers: StockRow[] + top_losers: StockRow[] + turnover_leaders: StockRow[] +} + +export interface SyncJob { + id: string + job_date: string + status: 'running' | 'success' | 'failed' + records_count: number + started_at: string + finished_at?: string + error_message: string + trigger_by: string +} + export function getToken(): string | null { try { return localStorage.getItem('access_token') @@ -107,4 +141,11 @@ export const api = { request<{ message: string }>(`/api/admin/users/${id}`, { method: 'DELETE' }), listRoles: () => request('/api/admin/roles'), + + stockOverview: () => request('/api/stocks/overview'), + + stockSyncStatus: () => request('/api/stocks/sync/status'), + + triggerStockSync: () => + request<{ records_count: number }>('/api/admin/stocks/sync', { method: 'POST' }), } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 0a41e7e..ee202a0 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,5 +1,6 @@ import { LayoutDashboard, ShieldCheck } from 'lucide-react' import { getStoredUser, roleLabel } from '@/lib/auth' +import { StockOverview } from '@/components/StockOverview' export function Dashboard() { const user = getStoredUser() @@ -16,6 +17,8 @@ export function Dashboard() { + +
}