From b7748996556b50a086611e692416573bd6838c00 Mon Sep 17 00:00:00 2001 From: fish Date: Sat, 4 Jul 2026 14:36:51 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=91=98=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=90=8C=E6=AD=A5=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=88=9D=E5=A7=8B=E5=8C=96=E8=82=A1=E7=A5=A8?= =?UTF-8?q?=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/api/main.go | 4 + backend/internal/config/config.go | 4 + backend/internal/datasource/tushare.go | 206 +++++++++++++++++++++++++ backend/internal/handlers/data_sync.go | 82 ++++++++++ backend/internal/models/stock.go | 27 ++++ backend/internal/routes/routes.go | 2 + frontend/src/components/Layout.tsx | 2 + frontend/src/lib/api.ts | 11 ++ frontend/src/pages/DataSync.tsx | 84 ++++++++++ frontend/src/router.tsx | 9 ++ 10 files changed, 431 insertions(+) create mode 100644 backend/internal/datasource/tushare.go create mode 100644 backend/internal/handlers/data_sync.go create mode 100644 backend/internal/models/stock.go create mode 100644 frontend/src/pages/DataSync.tsx diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index e1cc03f..dd72f96 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -26,6 +26,10 @@ func main() { log.Fatalf("migrate database: %v", err) } + if err := models.AutoMigrateStocks(database); err != nil { + log.Fatalf("migrate stocks table: %v", err) + } + if err := models.SeedRoles(database); err != nil { log.Fatalf("seed roles: %v", err) } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 87915aa..d0da761 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -16,6 +16,7 @@ type Config struct { JWTExpirationHours int Port string AllowedOrigins []string + TushareToken string } func Load() (*Config, error) { @@ -50,12 +51,15 @@ func Load() (*Config, error) { } } + tushareToken := os.Getenv("TUSHARE_TOKEN") + return &Config{ DatabaseURL: databaseURL, JWTSecret: jwtSecret, JWTExpirationHours: expHours, Port: port, AllowedOrigins: allowedOrigins, + TushareToken: tushareToken, }, nil } diff --git a/backend/internal/datasource/tushare.go b/backend/internal/datasource/tushare.go new file mode 100644 index 0000000..ea144f1 --- /dev/null +++ b/backend/internal/datasource/tushare.go @@ -0,0 +1,206 @@ +package datasource + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" + + "stock-user-system/internal/config" +) + +const ( + tushareBaseURL = "http://api.tushare.pro" + defaultTimeout = 60 * time.Second + maxRetries = 3 + retryBaseDelay = 1 * time.Second +) + +// StockBasic 表示股票基础信息。 +type StockBasic struct { + TsCode string + Symbol string + Name string + Area string + Industry string + Market string + Exchange string + ListStatus string +} + +// Client 封装 Tushare Pro HTTP API 调用。 +type Client struct { + token string + baseURL string + client *http.Client +} + +// NewClient 从配置创建 Tushare 客户端。 +func NewClient(cfg *config.Config) *Client { + return &Client{ + token: cfg.TushareToken, + baseURL: tushareBaseURL, + client: &http.Client{Timeout: defaultTimeout}, + } +} + +// ListStocks 通过 stock_basic 接口获取全部上市股票基础信息。 +func (c *Client) ListStocks() ([]StockBasic, error) { + params := map[string]any{ + "list_status": "L", + "fields": "ts_code,symbol,name,area,industry,market,exchange,list_status", + } + fields, items, err := c.call("stock_basic", params) + if err != nil { + return nil, fmt.Errorf("stock_basic: %w", err) + } + + col := buildColumnMap(fields) + idxTsCode := col["ts_code"] + idxSymbol := col["symbol"] + idxName := col["name"] + idxArea := col["area"] + idxIndustry := col["industry"] + idxMarket := col["market"] + idxExchange := col["exchange"] + idxListStatus := col["list_status"] + + stocks := make([]StockBasic, 0, len(items)) + for _, row := range items { + code := stringAt(row, idxTsCode) + if code == "" { + continue + } + stocks = append(stocks, StockBasic{ + TsCode: code, + Symbol: stringAt(row, idxSymbol), + Name: stringAt(row, idxName), + Area: stringAt(row, idxArea), + Industry: stringAt(row, idxIndustry), + Market: stringAt(row, idxMarket), + Exchange: stringAt(row, idxExchange), + ListStatus: stringAt(row, idxListStatus), + }) + } + return stocks, 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 + } +} diff --git a/backend/internal/handlers/data_sync.go b/backend/internal/handlers/data_sync.go new file mode 100644 index 0000000..bb6d592 --- /dev/null +++ b/backend/internal/handlers/data_sync.go @@ -0,0 +1,82 @@ +package handlers + +import ( + "net/http" + "strings" + + "stock-user-system/internal/config" + "stock-user-system/internal/datasource" + "stock-user-system/internal/models" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// DataSyncHandler 处理数据同步相关接口。 +type DataSyncHandler struct { + DB *gorm.DB + CFG *config.Config + client *datasource.Client +} + +// NewDataSyncHandler 创建数据同步处理器。 +func NewDataSyncHandler(db *gorm.DB, cfg *config.Config) *DataSyncHandler { + return &DataSyncHandler{ + DB: db, + CFG: cfg, + client: datasource.NewClient(cfg), + } +} + +type initStocksResponse struct { + Count int `json:"count"` + Message string `json:"message"` +} + +// InitStocks 从 Tushare 拉取全部上市股票列表并写入数据库。 +func (h *DataSyncHandler) InitStocks(c *gin.Context) { + if h.CFG.TushareToken == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "未配置 TUSHARE_TOKEN"}) + return + } + + stocks, err := h.client.ListStocks() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) + return + } + + records := make([]models.Stock, 0, len(stocks)) + for _, s := range stocks { + records = append(records, models.Stock{ + TsCode: s.TsCode, + Symbol: s.Symbol, + Name: s.Name, + Area: s.Area, + Industry: s.Industry, + Market: s.Market, + Exchange: s.Exchange, + ListStatus: s.ListStatus, + }) + } + + if err := h.DB.Exec("TRUNCATE TABLE stocks RESTART IDENTITY").Error; err != nil { + if !strings.Contains(err.Error(), "does not exist") { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "清空旧数据失败: " + err.Error()}) + return + } + } + + if err := h.DB.CreateInBatches(records, 500).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "写入股票列表失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": initStocksResponse{ + Count: len(records), + Message: "股票列表初始化完成", + }, + }) +} diff --git a/backend/internal/models/stock.go b/backend/internal/models/stock.go new file mode 100644 index 0000000..b143740 --- /dev/null +++ b/backend/internal/models/stock.go @@ -0,0 +1,27 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// Stock 存储股票基础信息。 +type Stock struct { + ID string `json:"id" gorm:"type:uuid;primaryKey;default:gen_random_uuid()"` + TsCode string `json:"ts_code" gorm:"size:32;not null;uniqueIndex"` + Symbol string `json:"symbol" gorm:"size:32;not null;index"` + Name string `json:"name" gorm:"size:128"` + Area string `json:"area" gorm:"size:64"` + Industry string `json:"industry" gorm:"size:64"` + Market string `json:"market" gorm:"size:16"` + Exchange string `json:"exchange" gorm:"size:16"` + ListStatus string `json:"list_status" gorm:"size:8"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// AutoMigrateStocks 迁移股票基础信息表。 +func AutoMigrateStocks(db *gorm.DB) error { + return db.AutoMigrate(&Stock{}) +} diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go index ad2358a..727e1a5 100644 --- a/backend/internal/routes/routes.go +++ b/backend/internal/routes/routes.go @@ -13,6 +13,7 @@ import ( func Setup(cfg *config.Config, db *gorm.DB) *gin.Engine { authHandler := &handlers.AuthHandler{DB: db, CFG: cfg} adminHandler := &handlers.AdminHandler{DB: db, CFG: cfg} + dataSyncHandler := handlers.NewDataSyncHandler(db, cfg) r := gin.Default() @@ -50,6 +51,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("/data-sync/init-stocks", dataSyncHandler.InitStocks) } return r diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index afb6d75..80b4e19 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -3,6 +3,7 @@ import { NavLink, Outlet, useNavigate } from 'react-router-dom' import { Users, User, + Database, LogOut, Moon, Sun, @@ -46,6 +47,7 @@ export function Layout() { const navItems = [ ...(user && canManageUsers(user.role) ? [{ to: '/users', label: '用户管理', icon: Users }] : []), + ...(user && canManageUsers(user.role) ? [{ to: '/data-sync', label: '数据同步', icon: Database }] : []), { to: '/profile', label: '个人中心', icon: User }, ] diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index cf026da..b59e96c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -28,6 +28,14 @@ export interface AuthResponse { user: UserInfo } +export interface InitStocksResponse { + success: boolean + data: { + count: number + message: string + } +} + export function getToken(): string | null { try { return localStorage.getItem('access_token') @@ -107,4 +115,7 @@ export const api = { request<{ message: string }>(`/api/admin/users/${id}`, { method: 'DELETE' }), listRoles: () => request('/api/admin/roles'), + + initStocks: () => + request('/api/admin/data-sync/init-stocks', { method: 'POST' }), } diff --git a/frontend/src/pages/DataSync.tsx b/frontend/src/pages/DataSync.tsx new file mode 100644 index 0000000..ddeac76 --- /dev/null +++ b/frontend/src/pages/DataSync.tsx @@ -0,0 +1,84 @@ +import { useState } from 'react' +import { Database, RefreshCw, CheckCircle, AlertCircle } from 'lucide-react' +import { api } from '@/lib/api' + +export function DataSync() { + const [loading, setLoading] = useState(false) + const [result, setResult] = useState<{ count: number; message: string } | null>(null) + const [error, setError] = useState('') + + const handleInitStocks = async () => { + if (!confirm('确定要从 Tushare 初始化股票列表?这会清空现有股票数据。')) { + return + } + setLoading(true) + setError('') + setResult(null) + try { + const res = await api.initStocks() + setResult(res.data) + } catch (err: any) { + setError(err?.message || '初始化失败') + } finally { + setLoading(false) + } + } + + return ( +
+
+
+ +
+
+

数据同步

+

从 Tushare 拉取基础数据

+
+
+ + {error && ( +
+ + {error} +
+ )} + + {result && ( +
+ + {result.message},共 {result.count} 条记录 +
+ )} + +
+
+
+

初始化股票列表

+

调用 Tushare stock_basic 接口,获取全部 A 股基础信息

+
+ +
+
+ +
+

说明

+
    +
  • • 需要配置 TUSHARE_TOKEN 环境变量
  • +
  • • 每次同步会清空 stocks 表并重新写入
  • +
  • • 仅获取基础字段:代码、名称、交易所、行业等
  • +
+
+
+ ) +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 910676c..806774c 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -4,6 +4,7 @@ import { AuthGuard } from './components/AuthGuard' import { Login } from './pages/Login' import { Users } from './pages/Users' import { Profile } from './pages/Profile' +import { DataSync } from './pages/DataSync' export const router = createBrowserRouter([ { path: '/login', element: }, @@ -24,6 +25,14 @@ export const router = createBrowserRouter([ ), }, + { + path: 'data-sync', + element: ( + + + + ), + }, { path: 'profile', element: ,