删除股票相关功能,仅保留用户体系

This commit is contained in:
2026-07-04 14:29:15 +08:00
parent b1776a1c2f
commit 024320e082
16 changed files with 7 additions and 1837 deletions
+1 -10
View File
@@ -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)
-24
View File
@@ -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
}
-594
View File
@@ -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_rateTushare 当前未单独获取,可能为 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()
}
-336
View File
@@ -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
}
-44
View File
@@ -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{})
}
+1 -13
View File
@@ -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
-327
View File
@@ -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
}