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 }