Files
soul-yongping/soul-api/internal/handler/search.go
卡若 708547d0dd feat: 数据概览简化 + 用户管理增加余额/提现列
- 数据概览:去掉代付统计独立卡片,总收入中以小标签显示代付金额
- 数据概览:移除余额统计区块(余额改在用户管理中展示)
- 数据概览:恢复转化率卡片(唯一付费用户/总用户)
- 用户管理:用户列表新增「余额/提现」列,显示钱包余额和已提现金额
- 后端:DBUsersList 增加 user_balances 查询,返回 walletBalance 字段
- 后端:User model 添加 WalletBalance 非数据库字段
- 包含之前的小程序埋点和管理后台点击统计面板

Made-with: Cursor
2026-03-15 15:57:09 +08:00

110 lines
3.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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