1、新增首页合计详情页;

2、新增索引和缓存;
This commit is contained in:
2026-05-14 11:19:36 +08:00
parent 49cd4f85df
commit 507b79f257
8 changed files with 175 additions and 2 deletions
+9
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
"gorm.io/gorm" "gorm.io/gorm"
"level_go/internal/api" "level_go/internal/api"
@@ -12,6 +13,8 @@ import (
// main 是服务启动入口,负责完成配置加载、数据库连接、依赖组装和 HTTP 路由启动。 // main 是服务启动入口,负责完成配置加载、数据库连接、依赖组装和 HTTP 路由启动。
func main() { func main() {
gin.SetMode(gin.ReleaseMode)
// 初始化配置文件,后续数据库 DSN 和服务端口都从全局配置中读取。 // 初始化配置文件,后续数据库 DSN 和服务端口都从全局配置中读取。
config.InitConfig() config.InitConfig()
@@ -21,6 +24,12 @@ func main() {
if err != nil { if err != nil {
log.Fatal("数据库连接失败:", err) log.Fatal("数据库连接失败:", err)
} }
sqlDB, err := db.DB()
if err != nil {
log.Fatal("获取数据库连接池失败:", err)
}
sqlDB.SetMaxIdleConns(20)
sqlDB.SetMaxOpenConns(100)
// 依赖注入:Service 持有数据库连接,Handler 持有对应 Service。 // 依赖注入:Service 持有数据库连接,Handler 持有对应 Service。
commonSrv := &service.CommonService{DB: db} commonSrv := &service.CommonService{DB: db}
+3
View File
@@ -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`);
+3 -1
View File
@@ -110,5 +110,7 @@ CREATE TABLE `stock_limit_combined_data` (
UNIQUE KEY `uniq_date_code` (`trade_date`,`ts_code`), UNIQUE KEY `uniq_date_code` (`trade_date`,`ts_code`),
KEY `idx_trade_date` (`trade_date`), KEY `idx_trade_date` (`trade_date`),
KEY `idx_symbol` (`symbol`), 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; ) ENGINE=InnoDB AUTO_INCREMENT=167035 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+29
View File
@@ -63,6 +63,10 @@ GET /api/v1/limit-board/second-cal?tradeStartDate=2026-05-07&tradeEndDate=2026-0
### 4.2 list[].stocks 明细字段 ### 4.2 list[].stocks 明细字段
`list[].stocks` 保留当日全部二板明细。
`list[].total_stocks` 为当日涨停和炸板合并后的明细列表,字段结构与 `stocks` 相同;该列表会剔除 `first_limit_time` 为空的数据,并按 `first_limit_time` 升序排序,时间相同时按 `ts_code` 升序排序。
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `trade_date` | `string` | T 日 | | `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_data_count": 3,
"break_next_profit_count": 3, "break_next_profit_count": 3,
"break_next_profit_rate": 100, "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": [ "stocks": [
{ {
"trade_date": "2026-05-07", "trade_date": "2026-05-07",
+1
View File
@@ -261,6 +261,7 @@ type SecondCalDay struct {
BreakNextProfitCount int `json:"break_next_profit_count"` // 当日炸板股 T+1 盘中盈利数量。 BreakNextProfitCount int `json:"break_next_profit_count"` // 当日炸板股 T+1 盘中盈利数量。
BreakNextProfitRate float64 `json:"break_next_profit_rate"` // 当日炸板股 T+1 盘中盈利概率,百分比。 BreakNextProfitRate float64 `json:"break_next_profit_rate"` // 当日炸板股 T+1 盘中盈利概率,百分比。
Stocks []SecondCalStock `json:"stocks"` // 当日二板股票明细。 Stocks []SecondCalStock `json:"stocks"` // 当日二板股票明细。
TotalStocks []SecondCalStock `json:"total_stocks"` // 涨停和炸板合并后按首次涨停时间排序的明细。
} }
// SecondCalResponse 是二板日历接口响应。 // SecondCalResponse 是二板日历接口响应。
+115
View File
@@ -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),
})
}
}
+3 -1
View File
@@ -9,7 +9,8 @@ import (
// //
// 函数集中注册跨域中间件和 v1 版本接口,调用方只需要注入各业务 Handler。 // 函数集中注册跨域中间件和 v1 版本接口,调用方只需要注入各业务 Handler。
func NewRouter(commonApi *api.CommonHandler, stockApi *api.StockHandler, limitBoardApi *api.LimitBoardHandler) *gin.Engine { 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。 // 跨域中间件允许前端从任意来源访问接口,并暴露 CSV 下载需要的 Content-Disposition。
r.Use(func(c *gin.Context) { r.Use(func(c *gin.Context) {
@@ -23,6 +24,7 @@ func NewRouter(commonApi *api.CommonHandler, stockApi *api.StockHandler, limitBo
} }
c.Next() c.Next()
}) })
r.Use(cacheableGETResponseMiddleware(newResponseCacheStore()))
v1 := r.Group("/api/v1") v1 := r.Group("/api/v1")
{ {
+12
View File
@@ -2,6 +2,8 @@ package service
import ( import (
"math" "math"
"sort"
"strings"
"level_go/internal/model" "level_go/internal/model"
) )
@@ -57,6 +59,7 @@ func (s *LimitBoardService) GetSecondCal(req model.SecondCalRequest) (model.Seco
PreviousTwoTradeDate: day.PreviousTwoTradeDate, PreviousTwoTradeDate: day.PreviousTwoTradeDate,
NextTradeDate: day.NextTradeDate, NextTradeDate: day.NextTradeDate,
Stocks: []model.SecondCalStock{}, Stocks: []model.SecondCalStock{},
TotalStocks: []model.SecondCalStock{},
}) })
} }
@@ -298,10 +301,19 @@ func accumulateSecondCalDay(day *model.SecondCalDay, item model.SecondCalStock)
} }
} }
day.Stocks = append(day.Stocks, item) day.Stocks = append(day.Stocks, item)
if strings.TrimSpace(item.FirstLimitTime) != "" {
day.TotalStocks = append(day.TotalStocks, item)
}
} }
// finalizeSecondCalDay 计算单个交易日的概率字段。 // finalizeSecondCalDay 计算单个交易日的概率字段。
func finalizeSecondCalDay(day *model.SecondCalDay) { 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.NextIntradayProfitRate = secondCalRate(day.NextIntradayProfitCount, day.NextTradeDataCount)
day.LimitNextProfitRate = secondCalRate(day.LimitNextProfitCount, day.LimitNextDataCount) day.LimitNextProfitRate = secondCalRate(day.LimitNextProfitCount, day.LimitNextDataCount)
day.BreakNextProfitRate = secondCalRate(day.BreakNextProfitCount, day.BreakNextDataCount) day.BreakNextProfitRate = secondCalRate(day.BreakNextProfitCount, day.BreakNextDataCount)