diff --git a/.env.example b/.env.example index 387c38b..7245da7 100644 --- a/.env.example +++ b/.env.example @@ -13,11 +13,3 @@ RUST_LOG=info # 端口 BACKEND_PORT=3019 FRONTEND_PORT=3018 - -# Tushare 数据源配置(token 必填,空 token 将无法启动同步) -TUSHARE_TOKEN= - -# 盘后数据同步配置 -DATA_SYNC_ENABLED=true -DATA_SYNC_TIME=16:30 -DATA_SYNC_WEEKDAYS_ONLY=true diff --git a/README.md b/README.md index 1727020..4b9e38a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# A股工具 · 用户体系 +# 用户体系 -基于 **Go + PostgreSQL + Docker Compose** 的用户权限管理模块,角色覆盖:系统管理员、管理员、用户。前端界面复用 `refer/` 目录下的暗色主题设计语言。 +基于 **Go + PostgreSQL + Docker Compose** 的用户权限管理模块,角色覆盖:系统管理员、管理员、用户。 ## 技术栈 diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 83b5748..e1cc03f 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -7,10 +7,8 @@ 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() { @@ -36,14 +34,7 @@ func main() { log.Fatalf("seed system admin: %v", err) } - 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) + r := routes.Setup(cfg, database) 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 d0486f0..87915aa 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -10,20 +10,12 @@ import ( "github.com/joho/godotenv" ) -const defaultTushareToken = "76efd8465f9f2591aa42a385268e06acf6b80b7a15be2267ad2281b7" - type Config struct { DatabaseURL string JWTSecret string JWTExpirationHours int Port string AllowedOrigins []string - - // 股票数据源 - TushareToken string - DataSyncEnabled bool - DataSyncTime string // HH:MM - DataSyncWeekdays bool } func Load() (*Config, error) { @@ -58,28 +50,12 @@ func Load() (*Config, error) { } } - tushareToken := os.Getenv("TUSHARE_TOKEN") - if tushareToken == "" { - tushareToken = defaultTushareToken - } - - 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, - TushareToken: tushareToken, - DataSyncEnabled: dataSyncEnabled, - DataSyncTime: dataSyncTime, - DataSyncWeekdays: dataSyncWeekdays, }, nil } diff --git a/backend/internal/datasource/tushare.go b/backend/internal/datasource/tushare.go deleted file mode 100644 index d8a68ce..0000000 --- a/backend/internal/datasource/tushare.go +++ /dev/null @@ -1,594 +0,0 @@ -package datasource - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "sort" - "strconv" - "strings" - "sync" - "time" - - "stock-user-system/internal/config" -) - -const ( - tushareBaseURL = "http://api.tushare.pro" - chinaTZ = "Asia/Shanghai" - defaultTimeout = 60 * time.Second - maxRetries = 3 - retryBaseDelay = 1 * time.Second -) - -var chinaLoc, _ = time.LoadLocation(chinaTZ) - -// Quote 对应行情接口返回的单条行情数据。 -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(统一返回小数形式)。 -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 - } - return 0 -} - -// TurnoverRate 返回 ext.turnover_rate(Tushare 当前未单独获取,可能为 0)。 -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 对应标的池详情。 -type UniverseDetail struct { - ID string `json:"id"` - Name string `json:"name"` - SymbolCount int `json:"symbol_count"` - Symbols []string `json:"symbols"` -} - -// KlineData 对应单标的历史日 K 线(紧凑列式)。 -type KlineData struct { - Symbol string `json:"-"` - Name string `json:"-"` - Timestamp []int64 `json:"timestamp"` - Open []float64 `json:"open"` - High []float64 `json:"high"` - Low []float64 `json:"low"` - Close []float64 `json:"close"` - Volume []int64 `json:"volume"` - Amount []float64 `json:"amount"` -} - -// Tushare 股票代码后缀到 region 的映射。 -var regionBySuffix = map[string]string{ - ".SH": "CN_SH", - ".SZ": "CN_SZ", - ".BJ": "CN_BJ", -} - -// knownIndexSymbols 已知的核心指数代码,历史 K 线同步时需要使用 index_daily 接口。 -var knownIndexSymbols = map[string]bool{ - "000001.SH": true, - "399001.SZ": true, - "399006.SZ": true, - "000688.SH": true, -} - -// indexNames 已知核心指数的中文名称。 -var indexNames = map[string]string{ - "000001.SH": "上证指数", - "399001.SZ": "深证成指", - "399006.SZ": "创业板指", - "000688.SH": "科创50", -} - -// Client 封装 Tushare Pro HTTP API 调用,接口语义与旧 tickflow 客户端保持一致, -// 方便 stock_sync 服务直接切换数据源。 -type Client struct { - token string - baseURL string - client *http.Client - nameCache map[string]string - cacheMu sync.RWMutex -} - -// NewClient 从配置创建 Tushare 客户端。 -func NewClient(cfg *config.Config) *Client { - return &Client{ - token: cfg.TushareToken, - baseURL: tushareBaseURL, - client: &http.Client{Timeout: defaultTimeout}, - nameCache: make(map[string]string), - } -} - -// GetUniverse 获取指定标的池的完整代码列表。Tushare 没有标的池概念, -// 这里固定返回全部 A 股(list_status=L),忽略传入的 id。 -func (c *Client) GetUniverse(id string) (*UniverseDetail, error) { - params := map[string]any{ - "list_status": "L", - "fields": "ts_code,name", - } - fields, items, err := c.call("stock_basic", params) - if err != nil { - return nil, fmt.Errorf("stock_basic: %w", err) - } - - col := buildColumnMap(fields) - idxCode := col["ts_code"] - idxName := col["name"] - - symbols := make([]string, 0, len(items)) - c.cacheMu.Lock() - for _, row := range items { - code := stringAt(row, idxCode) - name := stringAt(row, idxName) - if code == "" { - continue - } - symbols = append(symbols, code) - if name != "" { - c.nameCache[code] = name - } - } - c.cacheMu.Unlock() - - return &UniverseDetail{ - ID: id, - Name: "A股全市场", - SymbolCount: len(symbols), - Symbols: symbols, - }, nil -} - -// GetQuotesByUniverses 按标的池批量获取行情快照。Tushare 不支持按标的池查询, -// 这里返回最新交易日的全部 A 股日 K 数据。 -func (c *Client) GetQuotesByUniverses(universes []string) ([]Quote, error) { - _ = universes - // 名称缓存失败不影响行情同步,忽略错误 - _ = c.ensureNameCache() - - latestDate, err := c.latestTradeDate() - if err != nil { - return nil, fmt.Errorf("get latest trade date: %w", err) - } - - params := map[string]any{ - "trade_date": latestDate, - } - fields, items, err := c.call("daily", params) - if err != nil { - return nil, fmt.Errorf("daily: %w", err) - } - - return c.rowsToQuotes(fields, items, latestDate, false) -} - -// GetQuotesBySymbols 按代码列表批量获取行情快照。用于获取核心指数日 K。 -func (c *Client) GetQuotesBySymbols(symbols []string) ([]Quote, error) { - if len(symbols) == 0 { - return nil, nil - } - - latestDate, err := c.latestTradeDate() - if err != nil { - return nil, fmt.Errorf("get latest trade date: %w", err) - } - - params := map[string]any{ - "ts_code": strings.Join(symbols, ","), - "trade_date": latestDate, - } - fields, items, err := c.call("index_daily", params) - if err != nil { - return nil, fmt.Errorf("index_daily: %w", err) - } - - return c.rowsToQuotes(fields, items, latestDate, true) -} - -// GetKlinesBatch 批量获取多只股票的历史日 K 线。 -// Tushare 的 daily 接口按单代码查询历史数据效率更高,因此内部串行/并发调用。 -func (c *Client) GetKlinesBatch(symbols []string, period string, startMs, endMs int64) (map[string]KlineData, error) { - if len(symbols) == 0 { - return nil, nil - } - if period != "" && period != "1d" { - return nil, fmt.Errorf("tushare only supports 1d period, got %q", period) - } - - startDate := msToTushareDate(startMs) - endDate := msToTushareDate(endMs) - - result := make(map[string]KlineData, len(symbols)) - var mu sync.Mutex - var wg sync.WaitGroup - - // Tushare 免费/付费账号有频率限制,默认 4 并发,避免触发限流。 - sem := make(chan struct{}, 4) - var errMu sync.Mutex - var firstErr error - - for _, sym := range symbols { - wg.Add(1) - sem <- struct{}{} - go func(symbol string) { - defer wg.Done() - defer func() { <-sem }() - - data, err := c.getKlines(symbol, startDate, endDate) - if err != nil { - errMu.Lock() - if firstErr == nil { - firstErr = err - } - errMu.Unlock() - return - } - mu.Lock() - result[symbol] = data - mu.Unlock() - }(sym) - } - wg.Wait() - - if firstErr != nil { - return result, firstErr - } - return result, nil -} - -func (c *Client) getKlines(symbol, startDate, endDate string) (KlineData, error) { - apiName := "daily" - if knownIndexSymbols[symbol] { - apiName = "index_daily" - } - - params := map[string]any{ - "ts_code": symbol, - "start_date": startDate, - "end_date": endDate, - } - fields, items, err := c.call(apiName, params) - if err != nil { - return KlineData{}, err - } - - col := buildColumnMap(fields) - data := KlineData{ - Symbol: symbol, - Timestamp: make([]int64, 0, len(items)), - Open: make([]float64, 0, len(items)), - High: make([]float64, 0, len(items)), - Low: make([]float64, 0, len(items)), - Close: make([]float64, 0, len(items)), - Volume: make([]int64, 0, len(items)), - Amount: make([]float64, 0, len(items)), - } - - for _, row := range items { - tradeDate := stringAt(row, col["trade_date"]) - ts := tushareDateToMs(tradeDate) - if ts == 0 { - continue - } - data.Timestamp = append(data.Timestamp, ts) - data.Open = append(data.Open, floatAt(row, col["open"])) - data.High = append(data.High, floatAt(row, col["high"])) - data.Low = append(data.Low, floatAt(row, col["low"])) - data.Close = append(data.Close, floatAt(row, col["close"])) - // Tushare 成交量单位为"手",成交金额单位为"千元" - data.Volume = append(data.Volume, int64(floatAt(row, col["vol"])*100)) - data.Amount = append(data.Amount, floatAt(row, col["amount"])*1000) - } - - // 按时间升序排列所有列 - indices := make([]int, len(data.Timestamp)) - for i := range indices { - indices[i] = i - } - sort.SliceStable(indices, func(i, j int) bool { - return data.Timestamp[indices[i]] < data.Timestamp[indices[j]] - }) - - reorder := func(src []int64) []int64 { - dst := make([]int64, len(src)) - for i, idx := range indices { - dst[i] = src[idx] - } - return dst - } - reorderF := func(src []float64) []float64 { - dst := make([]float64, len(src)) - for i, idx := range indices { - dst[i] = src[idx] - } - return dst - } - - data.Timestamp = reorder(data.Timestamp) - data.Open = reorderF(data.Open) - data.High = reorderF(data.High) - data.Low = reorderF(data.Low) - data.Close = reorderF(data.Close) - data.Volume = reorder(data.Volume) - data.Amount = reorderF(data.Amount) - - return data, nil -} - -// ensureNameCache 如果名称缓存为空,则通过 stock_basic 加载一次。 -func (c *Client) ensureNameCache() error { - c.cacheMu.RLock() - hasData := len(c.nameCache) > 0 - c.cacheMu.RUnlock() - if hasData { - return nil - } - - _, err := c.GetUniverse("CN_Equity_A") - return err -} - -// displayName 返回股票名称;索引使用内置名称,股票从缓存读取,缺失则返回 symbol。 -func (c *Client) displayName(symbol string) string { - if name, ok := indexNames[symbol]; ok { - return name - } - c.cacheMu.RLock() - name, ok := c.nameCache[symbol] - c.cacheMu.RUnlock() - if ok && name != "" { - return name - } - return symbol -} - -func (c *Client) rowsToQuotes(fields []string, items [][]any, tradeDate string, isIndex bool) ([]Quote, error) { - col := buildColumnMap(fields) - quotes := make([]Quote, 0, len(items)) - - for _, row := range items { - symbol := stringAt(row, col["ts_code"]) - if symbol == "" { - continue - } - - vol := floatAt(row, col["vol"]) - amount := floatAt(row, col["amount"]) - changePct := floatAt(row, col["pct_chg"]) / 100 - - quote := Quote{ - Symbol: symbol, - Name: c.displayName(symbol), - Open: floatAt(row, col["open"]), - High: floatAt(row, col["high"]), - Low: floatAt(row, col["low"]), - Close: floatAt(row, col["close"]), - PrevClose: floatAt(row, col["pre_close"]), - Volume: int64(vol * 100), - Amount: amount * 1000, - Timestamp: tushareDateToMs(tradeDate), - Region: regionFromSymbol(symbol), - Ext: struct { - ChangePct float64 `json:"change_pct"` - TurnoverRate float64 `json:"turnover_rate"` - Name string `json:"name"` - }{ - ChangePct: changePct, - Name: c.displayName(symbol), - }, - } - quotes = append(quotes, quote) - } - - return quotes, nil -} - -// latestTradeDate 返回最近一个交易日的日期字符串(YYYYMMDD)。 -func (c *Client) latestTradeDate() (string, error) { - today := time.Now().In(chinaLoc).Format("20060102") - params := map[string]any{ - "exchange": "SSE", - "start_date": "20200101", - "end_date": today, - "is_open": "1", - } - fields, items, err := c.call("trade_cal", params) - if err != nil { - return "", err - } - - col := buildColumnMap(fields) - idxDate, ok := col["cal_date"] - if !ok { - return "", fmt.Errorf("trade_cal response missing cal_date field") - } - - latest := "" - for _, row := range items { - date := stringAt(row, idxDate) - if date > latest { - latest = date - } - } - if latest == "" { - return "", fmt.Errorf("no trade date found") - } - return latest, nil -} - -// call 调用 Tushare Pro API,返回字段名与数据行。 -func (c *Client) call(apiName string, params map[string]any) ([]string, [][]any, error) { - reqBody := map[string]any{ - "api_name": apiName, - "token": c.token, - "params": params, - "fields": "", - } - data, err := json.Marshal(reqBody) - if err != nil { - return nil, nil, err - } - - var lastErr error - for attempt := 0; attempt <= maxRetries; attempt++ { - if attempt > 0 { - time.Sleep(retryBaseDelay * time.Duration(1<<(attempt-1))) - } - - req, err := http.NewRequest("POST", c.baseURL, bytes.NewReader(data)) - if err != nil { - return nil, nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - - 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("tushare %s returned %d: %s", apiName, resp.StatusCode, string(respBody)) - continue - } - if resp.StatusCode >= 400 { - return nil, nil, fmt.Errorf("tushare %s returned %d: %s", apiName, resp.StatusCode, string(respBody)) - } - - var wrapper struct { - Code int `json:"code"` - Msg string `json:"msg"` - Data *struct { - Fields []string `json:"fields"` - Items [][]any `json:"items"` - } `json:"data"` - } - if err := json.Unmarshal(respBody, &wrapper); err != nil { - return nil, nil, fmt.Errorf("decode tushare response: %w", err) - } - if wrapper.Code != 0 { - return nil, nil, fmt.Errorf("tushare %s error %d: %s", apiName, wrapper.Code, wrapper.Msg) - } - if wrapper.Data == nil { - return nil, nil, nil - } - return wrapper.Data.Fields, wrapper.Data.Items, nil - } - - if lastErr != nil { - return nil, nil, fmt.Errorf("tushare %s failed after %d retries: %w", apiName, maxRetries, lastErr) - } - return nil, nil, fmt.Errorf("tushare %s request failed", apiName) -} - -func buildColumnMap(fields []string) map[string]int { - m := make(map[string]int, len(fields)) - for i, f := range fields { - m[f] = i - } - return m -} - -func stringAt(row []any, idx int) string { - if idx < 0 || idx >= len(row) || row[idx] == nil { - return "" - } - switch v := row[idx].(type) { - case string: - return v - case []byte: - return string(v) - default: - return fmt.Sprintf("%v", v) - } -} - -func floatAt(row []any, idx int) float64 { - if idx < 0 || idx >= len(row) || row[idx] == nil { - return 0 - } - switch v := row[idx].(type) { - case float64: - return v - case float32: - return float64(v) - case int: - return float64(v) - case int64: - return float64(v) - case string: - f, _ := strconv.ParseFloat(v, 64) - return f - default: - f, _ := strconv.ParseFloat(fmt.Sprintf("%v", v), 64) - return f - } -} - -func regionFromSymbol(symbol string) string { - for suffix, region := range regionBySuffix { - if strings.HasSuffix(symbol, suffix) { - return region - } - } - return "CN" -} - -func msToTushareDate(ms int64) string { - return time.UnixMilli(ms).In(chinaLoc).Format("20060102") -} - -func tushareDateToMs(date string) int64 { - if len(date) != 8 { - return 0 - } - t, err := time.ParseInLocation("20060102", date, chinaLoc) - if err != nil { - return 0 - } - return t.UnixMilli() -} diff --git a/backend/internal/handlers/stock.go b/backend/internal/handlers/stock.go deleted file mode 100644 index c403111..0000000 --- a/backend/internal/handlers/stock.go +++ /dev/null @@ -1,336 +0,0 @@ -package handlers - -import ( - "context" - "net/http" - "time" - - "stock-user-system/internal/middleware" - "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"` - PrevClose float64 `json:"prev_close,omitempty"` - 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 - } - - baseWhere := "trade_date = ? AND symbol NOT IN ? AND NOT (volume = 0 AND change_pct = 0)" - - var counts marketCounts - if err := h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}). - Select(` - COUNT(*) AS total, - SUM(CASE WHEN close > prev_close THEN 1 ELSE 0 END) AS up, - SUM(CASE WHEN close < prev_close THEN 1 ELSE 0 END) AS down, - SUM(CASE WHEN close = prev_close THEN 1 ELSE 0 END) AS flat - `). - Where(baseWhere, 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(baseWhere, latestDate, indexSymbolsList()). - Scan(&avgChange) - - indices := h.topRows(ctx, latestDate, "symbol IN ?", indexSymbolsList(), 10, false) - gainers := h.topRows(ctx, latestDate, "symbol NOT IN ? AND NOT (volume = 0 AND change_pct = 0)", indexSymbolsList(), 10, true) - losers := h.topRows(ctx, latestDate, "symbol NOT IN ? AND NOT (volume = 0 AND change_pct = 0)", 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 ? AND NOT (volume = 0 AND change_pct = 0)", 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 - } - c.JSON(http.StatusOK, job) -} - -// TriggerSync 手动触发同步(管理员)。 -// 系统管理员点击时异步同步近半年历史数据,其他管理员只同步当天。 -func (h *StockHandler) TriggerSync(c *gin.Context) { - user, ok := middleware.GetCurrentUser(c) - if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "未登录"}) - return - } - - var job *models.StockSyncJob - var err error - if user.Role == models.RoleSystemAdmin { - job, err = h.Svc.StartSyncHistory(c.Request.Context(), "manual", 6) - } else { - job, err = h.Svc.StartSync(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, "job": job}) -} - -type distributionRow struct { - Label string `json:"label"` - Count int64 `json:"count"` -} - -type distributionScan struct { - SH int64 `gorm:"column:sh"` - SZ int64 `gorm:"column:sz"` - BJ int64 `gorm:"column:bj"` - Up0_3 int64 `gorm:"column:up_0_3"` - Up3_5 int64 `gorm:"column:up_3_5"` - Up5_7 int64 `gorm:"column:up_5_7"` - Up7_10 int64 `gorm:"column:up_7_10"` - Up10 int64 `gorm:"column:up_10"` - Down0_3 int64 `gorm:"column:down_0_3"` - Down3_5 int64 `gorm:"column:down_3_5"` - Down5_7 int64 `gorm:"column:down_5_7"` - Down7_10 int64 `gorm:"column:down_7_10"` - Down10 int64 `gorm:"column:down_10"` -} - -type debugStatsResponse struct { - TradeDate string `json:"trade_date"` - Total int64 `json:"total"` - IndexCount int64 `json:"index_count"` - SuspendedCount int64 `json:"suspended_count"` - UpByPrice int64 `json:"up_by_price"` - DownByPrice int64 `json:"down_by_price"` - FlatByPrice int64 `json:"flat_by_price"` - UpByPct int64 `json:"up_by_pct"` - DownByPct int64 `json:"down_by_pct"` - FlatByPct int64 `json:"flat_by_pct"` - ExchangeCounts map[string]int64 `json:"exchange_counts"` - UpDistribution []distributionRow `json:"up_distribution"` - DownDistribution []distributionRow `json:"down_distribution"` - FlatSamples []stockRow `json:"flat_samples"` - BoundarySamples []stockRow `json:"boundary_samples"` - UpBoundarySamples []stockRow `json:"up_boundary_samples"` - DownBoundarySamples []stockRow `json:"down_boundary_samples"` -} - -// DebugStats 返回最新交易日的详细统计与边界样本,便于对齐第三方口径。 -func (h *StockHandler) DebugStats(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, gin.H{"success": true, "data": debugStatsResponse{}}) - return - } - - idxList := indexSymbolsList() - baseWhere := "trade_date = ? AND symbol NOT IN ? AND NOT (volume = 0 AND change_pct = 0)" - - var stats debugStatsResponse - h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}). - Select(` - COUNT(*) AS total, - SUM(CASE WHEN close > prev_close THEN 1 ELSE 0 END) AS up_by_price, - SUM(CASE WHEN close < prev_close THEN 1 ELSE 0 END) AS down_by_price, - SUM(CASE WHEN close = prev_close THEN 1 ELSE 0 END) AS flat_by_price, - SUM(CASE WHEN change_pct > 0 THEN 1 ELSE 0 END) AS up_by_pct, - SUM(CASE WHEN change_pct < 0 THEN 1 ELSE 0 END) AS down_by_pct, - SUM(CASE WHEN change_pct = 0 THEN 1 ELSE 0 END) AS flat_by_pct - `). - Where(baseWhere, latestDate, idxList). - Scan(&stats) - - h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}). - Where("trade_date = ? AND symbol IN ?", latestDate, idxList). - Count(&stats.IndexCount) - - h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}). - Where("trade_date = ? AND symbol NOT IN ? AND volume = 0 AND change_pct = 0", latestDate, idxList). - Count(&stats.SuspendedCount) - - stats.TradeDate = latestDate.Format("2006-01-02") - - var dist distributionScan - h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}). - Select(` - SUM(CASE WHEN symbol LIKE '%.SH' THEN 1 ELSE 0 END) AS sh, - SUM(CASE WHEN symbol LIKE '%.SZ' THEN 1 ELSE 0 END) AS sz, - SUM(CASE WHEN symbol LIKE '%.BJ' THEN 1 ELSE 0 END) AS bj, - SUM(CASE WHEN change_pct > 0 AND change_pct <= 0.03 THEN 1 ELSE 0 END) AS up_0_3, - SUM(CASE WHEN change_pct > 0.03 AND change_pct <= 0.05 THEN 1 ELSE 0 END) AS up_3_5, - SUM(CASE WHEN change_pct > 0.05 AND change_pct <= 0.07 THEN 1 ELSE 0 END) AS up_5_7, - SUM(CASE WHEN change_pct > 0.07 AND change_pct <= 0.10 THEN 1 ELSE 0 END) AS up_7_10, - SUM(CASE WHEN change_pct > 0.10 THEN 1 ELSE 0 END) AS up_10, - SUM(CASE WHEN change_pct < 0 AND change_pct >= -0.03 THEN 1 ELSE 0 END) AS down_0_3, - SUM(CASE WHEN change_pct < -0.03 AND change_pct >= -0.05 THEN 1 ELSE 0 END) AS down_3_5, - SUM(CASE WHEN change_pct < -0.05 AND change_pct >= -0.07 THEN 1 ELSE 0 END) AS down_5_7, - SUM(CASE WHEN change_pct < -0.07 AND change_pct >= -0.10 THEN 1 ELSE 0 END) AS down_7_10, - SUM(CASE WHEN change_pct < -0.10 THEN 1 ELSE 0 END) AS down_10 - `). - Where(baseWhere, latestDate, idxList). - Scan(&dist) - - stats.ExchangeCounts = map[string]int64{ - "sh": dist.SH, - "sz": dist.SZ, - "bj": dist.BJ, - } - stats.UpDistribution = []distributionRow{ - {Label: "0~3%", Count: dist.Up0_3}, - {Label: "3~5%", Count: dist.Up3_5}, - {Label: "5~7%", Count: dist.Up5_7}, - {Label: "7~10%", Count: dist.Up7_10}, - {Label: ">10%", Count: dist.Up10}, - } - stats.DownDistribution = []distributionRow{ - {Label: "3~0%", Count: dist.Down0_3}, - {Label: "5~3%", Count: dist.Down3_5}, - {Label: "7~5%", Count: dist.Down5_7}, - {Label: "10~7%", Count: dist.Down7_10}, - {Label: ">10%", Count: dist.Down10}, - } - - var flatSamples []stockRow - h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}). - Select("symbol, name, close, prev_close, change_pct"). - Where("trade_date = ? AND symbol NOT IN ? AND close = prev_close", latestDate, idxList). - Order("amount DESC"). - Limit(20). - Scan(&flatSamples) - stats.FlatSamples = flatSamples - - var boundarySamples []stockRow - h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}). - Select("symbol, name, close, prev_close, change_pct"). - Where("trade_date = ? AND symbol NOT IN ?", latestDate, idxList). - Order("ABS(change_pct) ASC"). - Limit(20). - Scan(&boundarySamples) - stats.BoundarySamples = boundarySamples - - var upBoundarySamples []stockRow - h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}). - Select("symbol, name, close, prev_close, change_pct"). - Where("trade_date = ? AND symbol NOT IN ? AND close > prev_close", latestDate, idxList). - Order("change_pct ASC"). - Limit(50). - Scan(&upBoundarySamples) - stats.UpBoundarySamples = upBoundarySamples - - var downBoundarySamples []stockRow - h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}). - Select("symbol, name, close, prev_close, change_pct"). - Where("trade_date = ? AND symbol NOT IN ? AND close < prev_close", latestDate, idxList). - Order("change_pct DESC"). - Limit(50). - Scan(&downBoundarySamples) - stats.DownBoundarySamples = downBoundarySamples - - c.JSON(http.StatusOK, gin.H{"success": true, "data": stats}) -} - -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 deleted file mode 100644 index 4a1277d..0000000 --- a/backend/internal/jobs/stock_sync_scheduler.go +++ /dev/null @@ -1,81 +0,0 @@ -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 deleted file mode 100644 index 5987b7f..0000000 --- a/backend/internal/models/stock.go +++ /dev/null @@ -1,44 +0,0 @@ -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 c1c02f3..ad2358a 100644 --- a/backend/internal/routes/routes.go +++ b/backend/internal/routes/routes.go @@ -5,16 +5,14 @@ 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, stockSyncSvc *services.StockSyncService) *gin.Engine { +func Setup(cfg *config.Config, db *gorm.DB) *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() @@ -43,14 +41,6 @@ func Setup(cfg *config.Config, db *gorm.DB, stockSyncSvc *services.StockSyncServ 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)) @@ -60,8 +50,6 @@ func Setup(cfg *config.Config, db *gorm.DB, stockSyncSvc *services.StockSyncServ admin.PUT("/users/:id", adminHandler.UpdateUser) admin.DELETE("/users/:id", adminHandler.DeleteUser) admin.GET("/roles", adminHandler.ListRoles) - admin.POST("/stocks/sync", stockHandler.TriggerSync) - admin.GET("/stocks/debug-stats", stockHandler.DebugStats) } return r diff --git a/backend/internal/services/stock_sync.go b/backend/internal/services/stock_sync.go deleted file mode 100644 index 9e732de..0000000 --- a/backend/internal/services/stock_sync.go +++ /dev/null @@ -1,327 +0,0 @@ -package services - -import ( - "context" - "fmt" - "log" - "math" - "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) { - return s.runSyncJob(ctx, triggerBy, func() (int, error) { - return s.doSync(ctx) - }) -} - -// SyncHistory 同步近 N 个月的历史日 K 数据,返回写入的记录数。 -func (s *StockSyncService) SyncHistory(ctx context.Context, triggerBy string, months int) (int, error) { - return s.runSyncJob(ctx, triggerBy, func() (int, error) { - return s.doSyncHistory(ctx, months) - }) -} - -// StartSync 异步启动一次盘后同步,返回已创建的 job。 -func (s *StockSyncService) StartSync(ctx context.Context, triggerBy string) (*models.StockSyncJob, error) { - job, err := s.createJob(ctx, triggerBy) - if err != nil { - return nil, err - } - go s.runSyncJobByID(context.Background(), job.ID, func(ctx context.Context) (int, error) { - return s.doSync(ctx) - }) - return job, nil -} - -// StartSyncHistory 异步启动近 N 个月历史同步,返回已创建的 job。 -func (s *StockSyncService) StartSyncHistory(ctx context.Context, triggerBy string, months int) (*models.StockSyncJob, error) { - job, err := s.createJob(ctx, triggerBy) - if err != nil { - return nil, err - } - go s.runSyncJobByID(context.Background(), job.ID, func(ctx context.Context) (int, error) { - return s.doSyncHistory(ctx, months) - }) - return job, nil -} - -func (s *StockSyncService) createJob(ctx context.Context, triggerBy string) (*models.StockSyncJob, error) { - 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 nil, fmt.Errorf("create sync job: %w", err) - } - return &job, nil -} - -func (s *StockSyncService) runSyncJob(ctx context.Context, triggerBy string, fn func() (int, error)) (int, error) { - job, err := s.createJob(ctx, triggerBy) - if err != nil { - return 0, err - } - recordsCount, err := fn() - s.finishJob(ctx, job.ID, recordsCount, err) - return recordsCount, err -} - -func (s *StockSyncService) runSyncJobByID(ctx context.Context, jobID string, fn func(context.Context) (int, error)) { - s.mu.Lock() - defer s.mu.Unlock() - - recordsCount, err := fn(ctx) - s.finishJob(ctx, jobID, recordsCount, err) -} - -func (s *StockSyncService) finishJob(ctx context.Context, jobID string, recordsCount int, jobErr error) { - now := time.Now().UTC() - updates := map[string]any{ - "records_count": recordsCount, - "finished_at": &now, - } - if jobErr != nil { - updates["status"] = "failed" - updates["error_message"] = jobErr.Error() - log.Printf("[stock sync] job %s failed: %v", jobID, jobErr) - } else { - updates["status"] = "success" - log.Printf("[stock sync] job %s success, records=%d", jobID, recordsCount) - } - if err := s.db.WithContext(ctx).Model(&models.StockSyncJob{}).Where("id = ?", jobID).Updates(updates).Error; err != nil { - log.Printf("[stock sync] update job %s failed: %v", jobID, err) - return - } - - var job models.StockSyncJob - if err := s.db.WithContext(ctx).Where("id = ?", jobID).First(&job).Error; err == nil { - log.Printf("[stock sync] job %s final state: status=%s, started_at=%v, finished_at=%v", - jobID, job.Status, job.StartedAt, job.FinishedAt) - } -} - -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") - } - - log.Printf("[stock sync] fetched %d stock quotes from CN_Equity_A, %d index quotes, total %d", - len(stockQuotes), len(indexQuotes), len(quotes)) - - // 3. 归一化并写入 DB - records := make([]models.StockDailyQuote, 0, len(quotes)) - for _, q := range quotes { - tradeDate := msToDate(q.Timestamp) - open := round2(q.Open) - high := round2(q.High) - low := round2(q.Low) - close := round2(q.Close) - prevClose := round2(q.PrevClose) - var changePct float64 - if prevClose != 0 { - changePct = (close - prevClose) / prevClose - } - records = append(records, models.StockDailyQuote{ - Symbol: q.Symbol, - Name: q.DisplayName(), - TradeDate: tradeDate, - Open: open, - High: high, - Low: low, - Close: close, - PrevClose: prevClose, - Volume: q.Volume, - Amount: q.Amount, - ChangePct: 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). - Where("started_at > ?", time.Time{}). - Order("started_at DESC, id DESC"). - First(&job).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil, nil - } - return nil, err - } - return &job, nil -} - -func (s *StockSyncService) doSyncHistory(ctx context.Context, months int) (int, error) { - // 1. 获取全 A 股代码列表 - universe, err := s.client.GetUniverse("CN_Equity_A") - if err != nil { - return 0, fmt.Errorf("fetch universe: %w", err) - } - if universe == nil || len(universe.Symbols) == 0 { - return 0, fmt.Errorf("empty universe") - } - - // 2. 获取核心指数列表 - allSymbols := append([]string{}, universe.Symbols...) - allSymbols = append(allSymbols, coreIndexSymbols...) - - // 3. 计算时间范围(近 N 个月) - endMs := time.Now().In(chinaLoc).UnixMilli() - startMs := time.Now().In(chinaLoc).AddDate(0, -months, 0).UnixMilli() - - log.Printf("[stock sync] start history sync for %d months, symbols=%d, range=%d-%d", - months, len(allSymbols), startMs, endMs) - - // 4. 分批拉取并写入 - const batchSize = 50 - totalRecords := 0 - for i := 0; i < len(allSymbols); i += batchSize { - batch := allSymbols[i:min(i+batchSize, len(allSymbols))] - klinesMap, err := s.client.GetKlinesBatch(batch, "1d", startMs, endMs) - if err != nil { - log.Printf("[stock sync] klines batch %d-%d failed: %v", i+1, min(i+batchSize, len(allSymbols)), err) - continue - } - - records := make([]models.StockDailyQuote, 0) - for sym, data := range klinesMap { - name := sym - records = append(records, klineDataToRecords(sym, name, data)...) - } - - if len(records) == 0 { - continue - } - - 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 totalRecords, fmt.Errorf("upsert history batch %d: %w", i/batchSize, err) - } - totalRecords += len(records) - log.Printf("[stock sync] history batch %d/%d done, records=%d, total=%d", - (i/batchSize)+1, (len(allSymbols)+batchSize-1)/batchSize, len(records), totalRecords) - } - - return totalRecords, nil -} - -func klineDataToRecords(symbol, name string, data datasource.KlineData) []models.StockDailyQuote { - n := len(data.Timestamp) - if n == 0 { - return nil - } - if len(data.Open) < n { n = len(data.Open) } - if len(data.High) < n { n = len(data.High) } - if len(data.Low) < n { n = len(data.Low) } - if len(data.Close) < n { n = len(data.Close) } - if len(data.Volume) < n { n = len(data.Volume) } - if len(data.Amount) < n { n = len(data.Amount) } - - records := make([]models.StockDailyQuote, 0, n) - for i := 0; i < n; i++ { - open := round2(data.Open[i]) - high := round2(data.High[i]) - low := round2(data.Low[i]) - close := round2(data.Close[i]) - var prevClose float64 - if i > 0 { - prevClose = round2(data.Close[i-1]) - } - var changePct float64 - if prevClose != 0 { - changePct = (close - prevClose) / prevClose - } - records = append(records, models.StockDailyQuote{ - Symbol: symbol, - Name: name, - TradeDate: msToDate(data.Timestamp[i]), - Open: open, - High: high, - Low: low, - Close: close, - PrevClose: prevClose, - Volume: data.Volume[i], - Amount: data.Amount[i], - ChangePct: changePct, - }) - } - return records -} - -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) -} - -func round2(v float64) float64 { - return math.Round(v*100) / 100 -} - -func min(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/docker-compose.yml b/docker-compose.yml index 4641eda..3963942 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,10 +30,6 @@ services: JWT_EXPIRATION_HOURS: ${JWT_EXPIRATION_HOURS:-168} GIN_MODE: ${GIN_MODE:-release} PORT: 3019 - TUSHARE_TOKEN: ${TUSHARE_TOKEN:-} - DATA_SYNC_ENABLED: ${DATA_SYNC_ENABLED:-true} - DATA_SYNC_TIME: ${DATA_SYNC_TIME:-16:30} - DATA_SYNC_WEEKDAYS_ONLY: ${DATA_SYNC_WEEKDAYS_ONLY:-true} TZ: Asia/Shanghai ports: - "3019:3019" diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index a94e6ea..afb6d75 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,7 +1,6 @@ import { useEffect, useState } from 'react' import { NavLink, Outlet, useNavigate } from 'react-router-dom' import { - LayoutDashboard, Users, User, LogOut, @@ -46,7 +45,6 @@ export function Layout() { } const navItems = [ - { to: '/', label: '看板', icon: LayoutDashboard }, ...(user && canManageUsers(user.role) ? [{ to: '/users', label: '用户管理', icon: Users }] : []), { to: '/profile', label: '个人中心', icon: User }, ] @@ -70,7 +68,7 @@ export function Layout() { A
-
A股
+
用户体系
工作台
@@ -133,7 +131,7 @@ export function Layout() {
A
- A股工作台 + 用户体系工作台
- )} -
- ) - } - - 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 * 100).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 * 100).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 * 100 - 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 || !job.id) return '状态未知' - if (job.status === 'running') return '同步中…' - const ts = job.finished_at || job.started_at - let timeStr = '时间未知' - if (ts && ts !== '0001-01-01T00:00:00Z') { - const d = new Date(ts) - if (!isNaN(d.getTime())) { - timeStr = d.toLocaleString() - } - } - const statusText = job.status === 'success' ? '成功' : '失败' - if (job.status === 'failed' && job.error_message) { - return `${statusText} · ${timeStr} · ${job.error_message} · ${job.id.slice(0, 8)}` - } - return `${statusText} · ${timeStr} · ${job.id.slice(0, 8)}` -} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 99cee31..cf026da 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -28,40 +28,6 @@ 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') @@ -141,11 +107,4 @@ 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<{ job: SyncJob }>('/api/admin/stocks/sync', { method: 'POST' }), } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx deleted file mode 100644 index ee202a0..0000000 --- a/frontend/src/pages/Dashboard.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { LayoutDashboard, ShieldCheck } from 'lucide-react' -import { getStoredUser, roleLabel } from '@/lib/auth' -import { StockOverview } from '@/components/StockOverview' - -export function Dashboard() { - const user = getStoredUser() - - return ( -
-
-
- -
-
-

看板

-

欢迎回来,{user?.username || '—'}

-
-
- - - -
- } - title="当前身份" - value={user ? roleLabel(user.role) : '—'} - desc={user ? `用户:${user.username}` : '加载中…'} - /> -
- -
-

权限说明

-
    -
  • 系统管理员:可管理管理员、用户、系统配置
  • -
  • 管理员:可管理普通用户
  • -
  • 用户:可访问业务功能、修改个人资料
  • -
-
-
- ) -} - -function Card({ - icon, - title, - value, - desc, -}: { - icon: React.ReactNode - title: string - value: string - desc: string -}) { - return ( -
-
- {icon} - {title} -
-
{value}
-
{desc}
-
- ) -} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 470663c..910676c 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -2,7 +2,6 @@ import { createBrowserRouter, Navigate } from 'react-router-dom' import { Layout } from './components/Layout' import { AuthGuard } from './components/AuthGuard' import { Login } from './pages/Login' -import { Dashboard } from './pages/Dashboard' import { Users } from './pages/Users' import { Profile } from './pages/Profile' @@ -16,7 +15,7 @@ export const router = createBrowserRouter([ ), children: [ - { index: true, element: }, + { index: true, element: }, { path: 'users', element: (