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}, }) }