2026-03-07 22:58:43 +08:00
|
|
|
|
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,参数化)
|
2026-03-15 09:20:27 +08:00
|
|
|
|
// 优化:先搜标题(快),再搜内容(慢),不加载完整 content 到内存
|
2026-03-07 22:58:43 +08:00
|
|
|
|
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) + "%"
|
2026-03-15 09:20:27 +08:00
|
|
|
|
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).
|
2026-03-07 22:58:43 +08:00
|
|
|
|
Order("sort_order ASC, id ASC").
|
2026-03-15 09:20:27 +08:00
|
|
|
|
Limit(30).
|
|
|
|
|
|
Find(&titleMatches)
|
|
|
|
|
|
|
|
|
|
|
|
titleIDs := make(map[string]bool, len(titleMatches))
|
|
|
|
|
|
for _, m := range titleMatches {
|
|
|
|
|
|
titleIDs[m.ID] = true
|
2026-03-07 22:58:43 +08:00
|
|
|
|
}
|
2026-03-15 09:20:27 +08:00
|
|
|
|
|
|
|
|
|
|
// 第二步:内容匹配(排除已命中标题的,用 SQL 提取摘要避免加载完整 content)
|
|
|
|
|
|
remaining := 50 - 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)
|
2026-03-07 22:58:43 +08:00
|
|
|
|
}
|
2026-03-15 09:20:27 +08:00
|
|
|
|
contentQ = contentQ.Where("id NOT IN ?", ids)
|
2026-03-07 22:58:43 +08:00
|
|
|
|
}
|
2026-03-15 09:20:27 +08:00
|
|
|
|
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 {
|
2026-03-07 22:58:43 +08:00
|
|
|
|
price := 1.0
|
|
|
|
|
|
if ch.Price != nil {
|
|
|
|
|
|
price = *ch.Price
|
|
|
|
|
|
}
|
2026-03-15 09:20:27 +08:00
|
|
|
|
snippet := ch.Snippet
|
|
|
|
|
|
if len([]rune(snippet)) > 200 {
|
|
|
|
|
|
snippet = string([]rune(snippet)[:200]) + "..."
|
|
|
|
|
|
}
|
2026-03-07 22:58:43 +08:00
|
|
|
|
results = append(results, gin.H{
|
|
|
|
|
|
"id": ch.ID, "title": ch.SectionTitle, "partTitle": ch.PartTitle, "chapterTitle": ch.ChapterTitle,
|
2026-03-15 09:20:27 +08:00
|
|
|
|
"price": price, "isFree": ch.IsFree, "matchType": "content", "score": 5, "snippet": snippet,
|
2026-03-07 22:58:43 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
|
|
|
|
"success": true,
|
|
|
|
|
|
"data": gin.H{"keyword": q, "total": len(results), "results": results},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|