删除股票相关功能,仅保留用户体系
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# A股工具 · 用户体系
|
||||
# 用户体系
|
||||
|
||||
基于 **Go + PostgreSQL + Docker Compose** 的用户权限管理模块,角色覆盖:系统管理员、管理员、用户。前端界面复用 `refer/` 目录下的暗色主题设计语言。
|
||||
基于 **Go + PostgreSQL + Docker Compose** 的用户权限管理模块,角色覆盖:系统管理员、管理员、用户。
|
||||
|
||||
## 技术栈
|
||||
|
||||
|
||||
+1
-10
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
<div className="font-mono font-bold text-[13px] tracking-[0.06em] text-foreground leading-tight">
|
||||
<div>A股</div>
|
||||
<div>用户体系</div>
|
||||
<div>工作台</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,7 +131,7 @@ export function Layout() {
|
||||
<div className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-border bg-surface shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="h-6 w-6 rounded-md flex items-center justify-center text-white text-xs font-bold" style={{ background: BRAND }}>A</div>
|
||||
<span className="text-sm font-semibold">A股工作台</span>
|
||||
<span className="text-sm font-semibold">用户体系工作台</span>
|
||||
</div>
|
||||
<button onClick={() => setMobileOpen(!mobileOpen)} className="p-2 rounded-btn hover:bg-elevated">
|
||||
<Menu className="h-5 w-5" />
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
import { useEffect, useRef, 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<MarketOverview | null>(null)
|
||||
const [job, setJob] = useState<SyncJob | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const pollRef = useRef<number | null>(null)
|
||||
|
||||
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()
|
||||
return () => {
|
||||
if (pollRef.current) {
|
||||
window.clearInterval(pollRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startPolling = () => {
|
||||
if (pollRef.current) {
|
||||
window.clearInterval(pollRef.current)
|
||||
}
|
||||
pollRef.current = window.setInterval(async () => {
|
||||
try {
|
||||
const j = await api.stockSyncStatus()
|
||||
setJob(j)
|
||||
if (!j || j.status !== 'running') {
|
||||
if (pollRef.current) {
|
||||
window.clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
setSyncing(false)
|
||||
await fetchAll()
|
||||
if (j?.status === 'failed') {
|
||||
setError(j.error_message || '同步失败')
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (pollRef.current) {
|
||||
window.clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
setSyncing(false)
|
||||
setError(err?.message || '轮询同步状态失败')
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.triggerStockSync()
|
||||
startPolling()
|
||||
} catch (err: any) {
|
||||
setSyncing(false)
|
||||
setError(err?.message || '同步失败')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-card border border-border bg-surface p-6 flex items-center justify-center gap-2 text-sm text-muted">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
加载市场数据…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!overview?.latest_trade_date) {
|
||||
return (
|
||||
<div className="rounded-card border border-border bg-surface p-6 text-center">
|
||||
<div className="text-sm text-secondary">暂无市场数据</div>
|
||||
{canSync && (
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
className="mt-3 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent text-white text-xs font-medium hover:bg-accent/90 disabled:opacity-60"
|
||||
>
|
||||
{syncing ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
|
||||
立即同步
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { counts, avg_change_pct, indices, top_gainers, top_losers, turnover_leaders } = overview
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-card border border-border bg-surface/80 px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4 text-accent" />
|
||||
<h2 className="text-base font-semibold text-foreground">市场概览</h2>
|
||||
<span className="text-xs text-muted flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{overview.latest_trade_date}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{job && (
|
||||
<span className="text-xs text-muted">
|
||||
上次同步:{formatSyncStatus(job)}
|
||||
</span>
|
||||
)}
|
||||
{canSync && (
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent text-white text-xs font-medium hover:bg-accent/90 disabled:opacity-60 transition-colors"
|
||||
>
|
||||
{syncing ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
|
||||
立即同步
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2.5 text-xs text-danger">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<KpiCard icon={<TrendingUp className="h-4 w-4 text-bull" />} label="上涨" value={counts.up} />
|
||||
<KpiCard icon={<TrendingDown className="h-4 w-4 text-bear" />} label="下跌" value={counts.down} />
|
||||
<KpiCard icon={<Minus className="h-4 w-4 text-muted" />} label="平盘" value={counts.flat} />
|
||||
<KpiCard
|
||||
icon={<Activity className="h-4 w-4 text-accent" />}
|
||||
label="平均涨跌"
|
||||
value={`${avg_change_pct >= 0 ? '+' : ''}${(avg_change_pct * 100).toFixed(2)}%`}
|
||||
valueClass={avg_change_pct >= 0 ? 'text-bull' : 'text-bear'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{indices.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{indices.map((item) => (
|
||||
<IndexCard key={item.symbol} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<StockList title="涨幅榜" rows={top_gainers} valueKey="change_pct" />
|
||||
<StockList title="跌幅榜" rows={top_losers} valueKey="change_pct" />
|
||||
<StockList title="成交额榜" rows={turnover_leaders} valueKey="amount" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KpiCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
valueClass,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
valueClass?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-card border border-border bg-surface p-3 flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-btn bg-elevated">{icon}</div>
|
||||
<div>
|
||||
<div className={cn('text-lg font-bold text-foreground', valueClass)}>{value}</div>
|
||||
<div className="text-xs text-secondary">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IndexCard({ item }: { item: StockRow }) {
|
||||
const positive = item.change_pct >= 0
|
||||
return (
|
||||
<div className="rounded-card border border-border bg-surface p-3">
|
||||
<div className="text-xs text-secondary truncate">{item.name || item.symbol}</div>
|
||||
<div className={cn('text-base font-semibold mt-1', positive ? 'text-bull' : 'text-bear')}>
|
||||
{item.close.toFixed(2)}
|
||||
</div>
|
||||
<div className={cn('text-xs font-medium', positive ? 'text-bull' : 'text-bear')}>
|
||||
{positive ? '+' : ''}{(item.change_pct * 100).toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StockList({
|
||||
title,
|
||||
rows,
|
||||
valueKey,
|
||||
}: {
|
||||
title: string
|
||||
rows: StockRow[]
|
||||
valueKey: 'change_pct' | 'amount'
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-card border border-border bg-surface p-3">
|
||||
<h3 className="text-xs font-semibold text-secondary uppercase tracking-wider mb-2">{title}</h3>
|
||||
<div className="space-y-1">
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-xs text-muted py-2">暂无数据</div>
|
||||
) : (
|
||||
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 (
|
||||
<div key={item.symbol} className="flex items-center justify-between text-sm py-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-[10px] text-muted w-4">{idx + 1}</span>
|
||||
<span className="text-foreground font-medium truncate">{item.name || item.symbol}</span>
|
||||
</div>
|
||||
<div className={cn('text-xs font-mono shrink-0', valueKey === 'change_pct' ? (isPositive ? 'text-bull' : 'text-bear') : 'text-foreground')}>
|
||||
{valueKey === 'amount'
|
||||
? `${val.toFixed(2)}亿`
|
||||
: `${isPositive ? '+' : ''}${val.toFixed(2)}%`}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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)}`
|
||||
}
|
||||
@@ -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<Role[]>('/api/admin/roles'),
|
||||
|
||||
stockOverview: () => request<MarketOverview>('/api/stocks/overview'),
|
||||
|
||||
stockSyncStatus: () => request<SyncJob | null>('/api/stocks/sync/status'),
|
||||
|
||||
triggerStockSync: () =>
|
||||
request<{ job: SyncJob }>('/api/admin/stocks/sync', { method: 'POST' }),
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-btn bg-accent/10 text-accent">
|
||||
<LayoutDashboard className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-foreground">看板</h1>
|
||||
<p className="text-sm text-secondary">欢迎回来,{user?.username || '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StockOverview role={user?.role || 'user'} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Card
|
||||
icon={<ShieldCheck className="h-5 w-5 text-accent" />}
|
||||
title="当前身份"
|
||||
value={user ? roleLabel(user.role) : '—'}
|
||||
desc={user ? `用户:${user.username}` : '加载中…'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-card border border-border bg-surface p-5">
|
||||
<h2 className="text-sm font-semibold text-foreground mb-3">权限说明</h2>
|
||||
<ul className="space-y-2 text-sm text-secondary">
|
||||
<li><span className="text-accent font-medium">系统管理员</span>:可管理管理员、用户、系统配置</li>
|
||||
<li><span className="text-accent font-medium">管理员</span>:可管理普通用户</li>
|
||||
<li><span className="text-accent font-medium">用户</span>:可访问业务功能、修改个人资料</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Card({
|
||||
icon,
|
||||
title,
|
||||
value,
|
||||
desc,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
value: string
|
||||
desc: string
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-card border border-border bg-surface p-4 transition hover:border-accent/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{icon}
|
||||
<span className="text-xs font-medium text-secondary uppercase tracking-wider">{title}</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-foreground">{value}</div>
|
||||
<div className="mt-1 text-xs text-muted">{desc}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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([
|
||||
</AuthGuard>
|
||||
),
|
||||
children: [
|
||||
{ index: true, element: <Dashboard /> },
|
||||
{ index: true, element: <Navigate to="/profile" replace /> },
|
||||
{
|
||||
path: 'users',
|
||||
element: (
|
||||
|
||||
Reference in New Issue
Block a user