diff --git a/cmd/server/main.go b/cmd/server/main.go index 56a4e11..032a9f0 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,6 +1,7 @@ package main import ( + "github.com/gin-gonic/gin" "gorm.io/driver/mysql" "gorm.io/gorm" "level_go/internal/api" @@ -12,6 +13,8 @@ import ( // main 是服务启动入口,负责完成配置加载、数据库连接、依赖组装和 HTTP 路由启动。 func main() { + gin.SetMode(gin.ReleaseMode) + // 初始化配置文件,后续数据库 DSN 和服务端口都从全局配置中读取。 config.InitConfig() @@ -21,6 +24,12 @@ func main() { if err != nil { log.Fatal("数据库连接失败:", err) } + sqlDB, err := db.DB() + if err != nil { + log.Fatal("获取数据库连接池失败:", err) + } + sqlDB.SetMaxIdleConns(20) + sqlDB.SetMaxOpenConns(100) // 依赖注入:Service 持有数据库连接,Handler 持有对应 Service。 commonSrv := &service.CommonService{DB: db} diff --git a/database/ddl/20260514_perf_indexes.sql b/database/ddl/20260514_perf_indexes.sql new file mode 100644 index 0000000..b44b782 --- /dev/null +++ b/database/ddl/20260514_perf_indexes.sql @@ -0,0 +1,3 @@ +ALTER TABLE `stock_limit_combined_data` + ADD INDEX `idx_ts_code_date_status` (`ts_code`, `trade_date`, `final_status`), + ADD INDEX `idx_trade_status_code` (`trade_date`, `final_status`, `ts_code`); diff --git a/database/ddl/current_tables.sql b/database/ddl/current_tables.sql index 636f56a..ae9011a 100644 --- a/database/ddl/current_tables.sql +++ b/database/ddl/current_tables.sql @@ -110,5 +110,7 @@ CREATE TABLE `stock_limit_combined_data` ( UNIQUE KEY `uniq_date_code` (`trade_date`,`ts_code`), KEY `idx_trade_date` (`trade_date`), KEY `idx_symbol` (`symbol`), - KEY `idx_trade_date_status` (`trade_date`,`final_status`) + KEY `idx_trade_date_status` (`trade_date`,`final_status`), + KEY `idx_ts_code_date_status` (`ts_code`,`trade_date`,`final_status`), + KEY `idx_trade_status_code` (`trade_date`,`final_status`,`ts_code`) ) ENGINE=InnoDB AUTO_INCREMENT=167035 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/docs/second-cal.md b/docs/second-cal.md index c335315..fce1a4c 100644 --- a/docs/second-cal.md +++ b/docs/second-cal.md @@ -63,6 +63,10 @@ GET /api/v1/limit-board/second-cal?tradeStartDate=2026-05-07&tradeEndDate=2026-0 ### 4.2 list[].stocks 明细字段 +`list[].stocks` 保留当日全部二板明细。 + +`list[].total_stocks` 为当日涨停和炸板合并后的明细列表,字段结构与 `stocks` 相同;该列表会剔除 `first_limit_time` 为空的数据,并按 `first_limit_time` 升序排序,时间相同时按 `ts_code` 升序排序。 + | 字段 | 类型 | 说明 | | --- | --- | --- | | `trade_date` | `string` | T 日 | @@ -121,6 +125,31 @@ GET /api/v1/limit-board/second-cal?tradeStartDate=2026-05-07&tradeEndDate=2026-0 "break_next_data_count": 3, "break_next_profit_count": 3, "break_next_profit_rate": 100, + "total_stocks": [ + { + "trade_date": "2026-05-07", + "previous_trade_date": "2026-05-06", + "previous_two_trade_date": "2026-04-30", + "next_trade_date": "2026-05-08", + "symbol": "000001", + "ts_code": "000001.SZ", + "name": "示例股份", + "market": "主板", + "industry": "银行", + "previous_close": 10, + "limit_price": 11, + "t_high": 11, + "t_close": 11, + "t_pct_chg": 10, + "status": "limit", + "first_limit_time": "09:35:12", + "open_times": 0, + "next_high": 11.2, + "next_close": 11.1, + "next_intraday_profitable": true, + "next_high_profit_pct": 1.82 + } + ], "stocks": [ { "trade_date": "2026-05-07", diff --git a/internal/model/limit_board.go b/internal/model/limit_board.go index 46a1766..d3afe49 100644 --- a/internal/model/limit_board.go +++ b/internal/model/limit_board.go @@ -261,6 +261,7 @@ type SecondCalDay struct { BreakNextProfitCount int `json:"break_next_profit_count"` // 当日炸板股 T+1 盘中盈利数量。 BreakNextProfitRate float64 `json:"break_next_profit_rate"` // 当日炸板股 T+1 盘中盈利概率,百分比。 Stocks []SecondCalStock `json:"stocks"` // 当日二板股票明细。 + TotalStocks []SecondCalStock `json:"total_stocks"` // 涨停和炸板合并后按首次涨停时间排序的明细。 } // SecondCalResponse 是二板日历接口响应。 diff --git a/internal/router/cache.go b/internal/router/cache.go new file mode 100644 index 0000000..5b7057e --- /dev/null +++ b/internal/router/cache.go @@ -0,0 +1,115 @@ +package router + +import ( + "bytes" + "net/http" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +const ( + responseCacheTTL = 5 * time.Minute + responseCacheMaxBody = 4 << 20 + responseCacheMaxKeys = 512 +) + +type responseCacheEntry struct { + status int + contentType string + body []byte + expiresAt time.Time +} + +type responseCacheStore struct { + mu sync.RWMutex + items map[string]responseCacheEntry +} + +func newResponseCacheStore() *responseCacheStore { + return &responseCacheStore{items: make(map[string]responseCacheEntry)} +} + +func (s *responseCacheStore) get(key string) (responseCacheEntry, bool) { + now := time.Now() + + s.mu.RLock() + entry, ok := s.items[key] + s.mu.RUnlock() + if !ok { + return responseCacheEntry{}, false + } + if now.After(entry.expiresAt) { + s.mu.Lock() + if current, exists := s.items[key]; exists && now.After(current.expiresAt) { + delete(s.items, key) + } + s.mu.Unlock() + return responseCacheEntry{}, false + } + return entry, true +} + +func (s *responseCacheStore) set(key string, entry responseCacheEntry) { + s.mu.Lock() + if len(s.items) >= responseCacheMaxKeys { + now := time.Now() + for cacheKey, current := range s.items { + if now.After(current.expiresAt) || len(s.items) >= responseCacheMaxKeys { + delete(s.items, cacheKey) + } + } + } + s.items[key] = entry + s.mu.Unlock() +} + +type cachedResponseWriter struct { + gin.ResponseWriter + body *bytes.Buffer +} + +func (w cachedResponseWriter) Write(data []byte) (int, error) { + w.body.Write(data) + return w.ResponseWriter.Write(data) +} + +func cacheableGETResponseMiddleware(store *responseCacheStore) gin.HandlerFunc { + return func(c *gin.Context) { + if c.Request.Method != http.MethodGet || strings.HasPrefix(c.Request.URL.Path, "/api/v1/download/") { + c.Next() + return + } + + key := c.Request.URL.RequestURI() + if entry, ok := store.get(key); ok { + c.Header("Content-Type", entry.contentType) + c.Data(entry.status, entry.contentType, entry.body) + return + } + + body := bytes.NewBuffer(nil) + writer := cachedResponseWriter{ResponseWriter: c.Writer, body: body} + c.Writer = writer + c.Next() + + status := c.Writer.Status() + if status != http.StatusOK || body.Len() == 0 || body.Len() > responseCacheMaxBody { + return + } + + contentType := c.Writer.Header().Get("Content-Type") + if !strings.Contains(contentType, "application/json") { + return + } + + store.set(key, responseCacheEntry{ + status: status, + contentType: contentType, + body: append([]byte(nil), body.Bytes()...), + expiresAt: time.Now().Add(responseCacheTTL), + }) + } +} diff --git a/internal/router/router.go b/internal/router/router.go index e1c03d7..1e51d25 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -9,7 +9,8 @@ import ( // // 函数集中注册跨域中间件和 v1 版本接口,调用方只需要注入各业务 Handler。 func NewRouter(commonApi *api.CommonHandler, stockApi *api.StockHandler, limitBoardApi *api.LimitBoardHandler) *gin.Engine { - r := gin.Default() + r := gin.New() + r.Use(gin.Recovery()) // 跨域中间件允许前端从任意来源访问接口,并暴露 CSV 下载需要的 Content-Disposition。 r.Use(func(c *gin.Context) { @@ -23,6 +24,7 @@ func NewRouter(commonApi *api.CommonHandler, stockApi *api.StockHandler, limitBo } c.Next() }) + r.Use(cacheableGETResponseMiddleware(newResponseCacheStore())) v1 := r.Group("/api/v1") { diff --git a/internal/service/second_cal_service.go b/internal/service/second_cal_service.go index ab2cd48..01aa014 100644 --- a/internal/service/second_cal_service.go +++ b/internal/service/second_cal_service.go @@ -2,6 +2,8 @@ package service import ( "math" + "sort" + "strings" "level_go/internal/model" ) @@ -57,6 +59,7 @@ func (s *LimitBoardService) GetSecondCal(req model.SecondCalRequest) (model.Seco PreviousTwoTradeDate: day.PreviousTwoTradeDate, NextTradeDate: day.NextTradeDate, Stocks: []model.SecondCalStock{}, + TotalStocks: []model.SecondCalStock{}, }) } @@ -298,10 +301,19 @@ func accumulateSecondCalDay(day *model.SecondCalDay, item model.SecondCalStock) } } day.Stocks = append(day.Stocks, item) + if strings.TrimSpace(item.FirstLimitTime) != "" { + day.TotalStocks = append(day.TotalStocks, item) + } } // finalizeSecondCalDay 计算单个交易日的概率字段。 func finalizeSecondCalDay(day *model.SecondCalDay) { + sort.SliceStable(day.TotalStocks, func(i, j int) bool { + if day.TotalStocks[i].FirstLimitTime == day.TotalStocks[j].FirstLimitTime { + return day.TotalStocks[i].TsCode < day.TotalStocks[j].TsCode + } + return day.TotalStocks[i].FirstLimitTime < day.TotalStocks[j].FirstLimitTime + }) day.NextIntradayProfitRate = secondCalRate(day.NextIntradayProfitCount, day.NextTradeDataCount) day.LimitNextProfitRate = secondCalRate(day.LimitNextProfitCount, day.LimitNextDataCount) day.BreakNextProfitRate = secondCalRate(day.BreakNextProfitCount, day.BreakNextDataCount)