- 数据概览:去掉代付统计独立卡片,总收入中以小标签显示代付金额 - 数据概览:移除余额统计区块(余额改在用户管理中展示) - 数据概览:恢复转化率卡片(唯一付费用户/总用户) - 用户管理:用户列表新增「余额/提现」列,显示钱包余额和已提现金额 - 后端:DBUsersList 增加 user_balances 查询,返回 walletBalance 字段 - 后端:User model 添加 WalletBalance 非数据库字段 - 包含之前的小程序埋点和管理后台点击统计面板 Made-with: Cursor
110 lines
3.4 KiB
Go
110 lines
3.4 KiB
Go
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},
|
||
})
|
||
}
|