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