Files
soul-yongping/soul-api/internal/handler/search.go

110 lines
3.4 KiB
Go
Raw Normal View History

package handler
import (
"net/http"
"strings"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
// escapeLike 转义 LIKE 中的 % _ \,防止注入与通配符滥用
func escapeLike(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "%", "\\%")
s = strings.ReplaceAll(s, "_", "\\_")
return s
}
// SearchGet GET /api/search?q= 从 chapters 表搜索GORM参数化
// 优化:先搜标题(快),再搜内容(慢),不加载完整 content 到内存
func SearchGet(c *gin.Context) {
q := strings.TrimSpace(c.Query("q"))
if q == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请输入搜索关键词"})
return
}
pattern := "%" + escapeLike(q) + "%"
db := database.DB()
// 第一步:标题匹配(快速,不加载 content
type searchRow struct {
ID string `gorm:"column:id"`
MID uint `gorm:"column:mid"`
SectionTitle string `gorm:"column:section_title"`
PartTitle string `gorm:"column:part_title"`
ChapterTitle string `gorm:"column:chapter_title"`
Price *float64 `gorm:"column:price"`
IsFree *bool `gorm:"column:is_free"`
Snippet string `gorm:"column:snippet"`
}
var titleMatches []searchRow
db.Model(&model.Chapter{}).
Select("id, mid, section_title, part_title, chapter_title, price, is_free, '' as snippet").
Where("section_title LIKE ?", pattern).
Order("sort_order ASC, id ASC").
Limit(3).
Find(&titleMatches)
titleIDs := make(map[string]bool, len(titleMatches))
for _, m := range titleMatches {
titleIDs[m.ID] = true
}
// 第二步:内容匹配(排除已命中标题的,用 SQL 提取摘要避免加载完整 content
remaining := 20 - len(titleMatches)
var contentMatches []searchRow
if remaining > 0 {
contentQ := db.Model(&model.Chapter{}).
Select("id, mid, section_title, part_title, chapter_title, price, is_free, "+
"CONCAT(CASE WHEN LOCATE(?, content) > 60 THEN '...' ELSE '' END, "+
"SUBSTRING(content, GREATEST(1, LOCATE(?, content) - 50), 200), "+
"CASE WHEN LENGTH(content) > LOCATE(?, content) + 150 THEN '...' ELSE '' END) as snippet",
q, q, q).
Where("content LIKE ?", pattern)
if len(titleIDs) > 0 {
ids := make([]string, 0, len(titleIDs))
for id := range titleIDs {
ids = append(ids, id)
}
contentQ = contentQ.Where("id NOT IN ?", ids)
}
contentQ.Order("sort_order ASC, id ASC").
Limit(remaining).
Find(&contentMatches)
}
results := make([]gin.H, 0, len(titleMatches)+len(contentMatches))
for _, ch := range titleMatches {
price := 1.0
if ch.Price != nil {
price = *ch.Price
}
results = append(results, gin.H{
"id": ch.ID, "title": ch.SectionTitle, "partTitle": ch.PartTitle, "chapterTitle": ch.ChapterTitle,
"price": price, "isFree": ch.IsFree, "matchType": "title", "score": 10, "snippet": "",
})
}
for _, ch := range contentMatches {
price := 1.0
if ch.Price != nil {
price = *ch.Price
}
snippet := ch.Snippet
if len([]rune(snippet)) > 200 {
snippet = string([]rune(snippet)[:200]) + "..."
}
results = append(results, gin.H{
"id": ch.ID, "title": ch.SectionTitle, "partTitle": ch.PartTitle, "chapterTitle": ch.ChapterTitle,
"price": price, "isFree": ch.IsFree, "matchType": "content", "score": 5, "snippet": snippet,
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{"keyword": q, "total": len(results), "results": results},
})
}