1、新增首页合计详情页;
2、新增索引和缓存;
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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`);
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 是二板日历接口响应。
|
||||||
|
|||||||
@@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user