性能优化

This commit is contained in:
Alex-larget
2026-03-17 14:02:09 +08:00
parent 2f35520670
commit c24caf63c5
46 changed files with 1387 additions and 1879 deletions

View File

@@ -3,6 +3,7 @@ package handler
import (
"net/http"
"soul-api/internal/cache"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -131,6 +132,7 @@ func AdminChaptersAction(c *gin.Context) {
if body.Action == "delete" {
id := resolveID()
if id != "" {
cache.InvalidateChapterContentByID(id)
db.Where("id = ?", id).Delete(&model.Chapter{})
}
}
@@ -156,5 +158,7 @@ func AdminChaptersAction(c *gin.Context) {
}
}
}
cache.InvalidateBookParts()
cache.InvalidateBookCache()
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -7,6 +7,7 @@ import (
"net/http"
"strings"
"soul-api/internal/cache"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -94,6 +95,7 @@ func AdminLinkedMpCreate(c *gin.Context) {
return
}
}
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true, "data": item})
}
@@ -148,6 +150,7 @@ func AdminLinkedMpUpdate(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存失败: " + err.Error()})
return
}
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true})
}
@@ -181,6 +184,7 @@ func AdminLinkedMpDelete(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "删除失败: " + err.Error()})
return
}
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -1,6 +1,7 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"sort"
@@ -10,6 +11,7 @@ import (
"time"
"unicode/utf8"
"soul-api/internal/cache"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -44,6 +46,13 @@ var allChaptersSelectCols = []string{
"is_new", "edition_standard", "edition_premium", "hot_score", "created_at", "updated_at",
}
// chapterMetaCols 章节详情元数据(不含 content用于 content 缓存命中时的轻量查询
var chapterMetaCols = []string{
"mid", "id", "part_id", "part_title", "chapter_id", "chapter_title",
"section_title", "word_count", "is_free", "price", "sort_order", "status",
"is_new", "edition_standard", "edition_premium", "hot_score", "created_at", "updated_at",
}
// allChaptersCache 内存缓存,减轻 DB 压力30 秒 TTL
var allChaptersCache struct {
mu sync.RWMutex
@@ -68,6 +77,13 @@ type cachedFixedItem struct {
SectionTitle string `json:"title"`
}
// bookPartsRedisPayload Redis 缓存结构,与 BookParts 响应一致
type bookPartsRedisPayload struct {
Parts []cachedPartRow `json:"parts"`
TotalSections int64 `json:"totalSections"`
FixedSections []cachedFixedItem `json:"fixedSections"`
}
var bookPartsCache struct {
mu sync.RWMutex
parts []cachedPartRow
@@ -178,9 +194,11 @@ func fetchAndCacheBookParts() (parts []cachedPartRow, total int64, fixed []cache
return parts, total, fixed
}
// WarmBookPartsCache 启动时预热目录缓存,避免首请求慢
// WarmBookPartsCache 启动时预热目录缓存(内存+Redis,避免首请求慢
func WarmBookPartsCache() {
fetchAndCacheBookParts()
parts, total, fixed := fetchAndCacheBookParts()
payload := bookPartsRedisPayload{Parts: parts, TotalSections: total, FixedSections: fixed}
cache.Set(context.Background(), cache.KeyBookParts, payload, cache.BookPartsTTL)
}
// BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表)
@@ -248,8 +266,21 @@ func BookChapterByID(c *gin.Context) {
// BookParts GET /api/miniprogram/book/parts 目录懒加载:仅返回篇章列表,不含章节详情
// 返回 parts排除序言/尾声/附录、totalSections、fixedSectionsid, mid, title 供序言/尾声/附录跳转用 mid
// 带 30 秒内存缓存,固定模块合并为 1 次查询,三路并行执行
// 缓存优先级Redis10min后台更新时失效> 内存30s> DBRedis 不可用时回退内存+DB
func BookParts(c *gin.Context) {
// 1. 优先 Redis后台无更新时长期有效
var redisPayload bookPartsRedisPayload
if cache.Get(context.Background(), cache.KeyBookParts, &redisPayload) && len(redisPayload.Parts) > 0 {
c.JSON(http.StatusOK, gin.H{
"success": true,
"parts": redisPayload.Parts,
"totalSections": redisPayload.TotalSections,
"fixedSections": redisPayload.FixedSections,
})
return
}
// 2. 内存缓存30sRedis 不可用时的容灾)
bookPartsCache.mu.RLock()
if time.Now().Before(bookPartsCache.expires) {
parts := bookPartsCache.parts
@@ -266,7 +297,11 @@ func BookParts(c *gin.Context) {
}
bookPartsCache.mu.RUnlock()
// 3. DB 查询并更新 Redis + 内存
parts, total, fixed := fetchAndCacheBookParts()
payload := bookPartsRedisPayload{Parts: parts, TotalSections: total, FixedSections: fixed}
cache.Set(context.Background(), cache.KeyBookParts, payload, cache.BookPartsTTL)
c.JSON(http.StatusOK, gin.H{
"success": true,
"parts": parts,
@@ -453,10 +488,12 @@ func previewContent(content string, percent int) string {
// findChapterAndRespond 按条件查章节并返回统一格式
// 免费判断优先级system_config.free_chapters / chapter_config.freeChapters > chapters.is_free/price
// 付费章节:若请求携带 userId 且有购买权限则返回完整 content否则返回 previewContent
// content 缓存:优先 Redis命中时仅查元数据不含 LONGTEXT未命中时查全量并回填缓存
func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
var ch model.Chapter
db := database.DB()
if err := whereFn(db).First(&ch).Error; err != nil {
// 1. 先查元数据(不含 content轻量
if err := whereFn(db).Select(chapterMetaCols).First(&ch).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "章节不存在"})
return
@@ -465,6 +502,17 @@ func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
return
}
// 2. 取 content优先 Redis未命中再查 DB
if content, ok := cache.GetString(context.Background(), cache.KeyChapterContent(ch.MID)); ok && content != "" {
ch.Content = content
} else {
if err := db.Model(&model.Chapter{}).Where("mid = ?", ch.MID).Pluck("content", &ch.Content).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
cache.SetString(context.Background(), cache.KeyChapterContent(ch.MID), ch.Content, cache.ChapterContentTTL)
}
isFreeFromConfig := getFreeChapterIDs(db)[ch.ID]
isFree := isFreeFromConfig
if !isFree && ch.IsFree != nil && *ch.IsFree {
@@ -656,6 +704,7 @@ func bookHotChaptersSorted(db *gorm.DB, limit int) []model.Chapter {
}
// BookHot GET /api/book/hot 热门章节(按阅读量排序,排除序言/尾声/附录;支持 ?limit=,最大 50
// Redis 缓存 5min章节更新时失效
func BookHot(c *gin.Context) {
limit := 10
if l := c.Query("limit"); l != "" {
@@ -663,9 +712,14 @@ func BookHot(c *gin.Context) {
limit = n
}
}
// 优先 Redis
var cached []model.Chapter
if cache.Get(context.Background(), cache.KeyBookHot(limit), &cached) && len(cached) > 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "data": cached})
return
}
list := bookHotChaptersSorted(database.DB(), limit)
if len(list) == 0 {
// 兜底:按 sort_order 取前 10同样排除序言/尾声/附录
q := database.DB().Model(&model.Chapter{})
for _, p := range excludeParts {
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
@@ -673,12 +727,18 @@ func BookHot(c *gin.Context) {
q.Order("sort_order ASC, id ASC").Limit(10).Find(&list)
sortChaptersByNaturalID(list)
}
cache.Set(context.Background(), cache.KeyBookHot(limit), list, cache.BookRelatedTTL)
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
// BookRecommended GET /api/book/recommended 精选推荐(首页「为你推荐」前 3 章)
// 与内容排行榜完全同源:使用 computeArticleRankingSections取前 3 条,保证顺序一致
// Redis 缓存 5min章节更新时失效
func BookRecommended(c *gin.Context) {
var cached []gin.H
if cache.Get(context.Background(), cache.KeyBookRecommended, &cached) && len(cached) > 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "data": cached})
return
}
sections, err := computeArticleRankingSections(database.DB())
if err != nil || len(sections) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []gin.H{}})
@@ -708,6 +768,7 @@ func BookRecommended(c *gin.Context) {
"isNew": s.IsNew,
})
}
cache.Set(context.Background(), cache.KeyBookRecommended, out, cache.BookRelatedTTL)
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
}
@@ -783,9 +844,18 @@ func BookSearch(c *gin.Context) {
}
// BookStats GET /api/book/stats
// Redis 缓存 5min章节更新时失效
func BookStats(c *gin.Context) {
var cached struct {
TotalChapters int64 `json:"totalChapters"`
}
if cache.Get(context.Background(), cache.KeyBookStats, &cached) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"totalChapters": cached.TotalChapters}})
return
}
var total int64
database.DB().Model(&model.Chapter{}).Count(&total)
cache.Set(context.Background(), cache.KeyBookStats, gin.H{"totalChapters": total}, cache.BookRelatedTTL)
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"totalChapters": total}})
}

View File

@@ -1,6 +1,7 @@
package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -8,6 +9,7 @@ import (
"strings"
"time"
"soul-api/internal/cache"
"soul-api/internal/config"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -16,8 +18,13 @@ import (
)
// GetPublicDBConfig GET /api/miniprogram/config 公开接口,供小程序获取完整配置(与 next-project 对齐)
// 从 system_config 读取 chapter_config、feature_config、mp_config合并后返回免费以章节 is_free/price 为准)
// Redis 缓存 10min配置变更时失效
func GetPublicDBConfig(c *gin.Context) {
var cached map[string]interface{}
if cache.Get(context.Background(), cache.KeyConfigMiniprogram, &cached) && len(cached) > 0 {
c.JSON(http.StatusOK, cached)
return
}
defaultPrices := gin.H{"section": float64(1), "fullbook": 9.9}
defaultFeatures := gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true}
apiDomain := "https://soulapi.quwanzhi.com"
@@ -127,6 +134,7 @@ func GetPublicDBConfig(c *gin.Context) {
if _, has := out["linkedMiniprograms"]; !has {
out["linkedMiniprograms"] = []gin.H{}
}
cache.Set(context.Background(), cache.KeyConfigMiniprogram, out, cache.ConfigTTL)
c.JSON(http.StatusOK, out)
}
@@ -272,6 +280,7 @@ func AdminSettingsPost(c *gin.Context) {
return
}
}
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "设置已保存"})
}
@@ -355,6 +364,7 @@ func AdminReferralSettingsPost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "推广设置已保存"})
}
@@ -526,6 +536,7 @@ func DBConfigPost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "配置保存成功"})
}

View File

@@ -9,6 +9,7 @@ import (
"strings"
"time"
"soul-api/internal/cache"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -440,6 +441,8 @@ func DBBookAction(c *gin.Context) {
}
switch body.Action {
case "sync":
cache.InvalidateBookParts()
cache.InvalidateBookCache()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "同步完成Gin 无文件源时可从 DB 已存在数据视为已同步)"})
return
case "import":
@@ -488,8 +491,11 @@ func DBBookAction(c *gin.Context) {
failed++
continue
}
cache.InvalidateChapterContentByID(item.ID)
imported++
}
cache.InvalidateBookParts()
cache.InvalidateBookCache()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "导入完成", "imported": imported, "failed": failed})
return
default:
@@ -553,6 +559,8 @@ func DBBookAction(c *gin.Context) {
}
_ = db.WithContext(context.Background()).Model(&model.Chapter{}).Where("id = ?", it.ID).Updates(up).Error
}
cache.InvalidateBookParts()
cache.InvalidateBookCache()
}()
return
}
@@ -567,6 +575,8 @@ func DBBookAction(c *gin.Context) {
_ = db.WithContext(context.Background()).Model(&model.Chapter{}).Where("id = ?", id).Update("sort_order", i).Error
}
}
cache.InvalidateBookParts()
cache.InvalidateBookCache()
}()
return
}
@@ -590,6 +600,8 @@ func DBBookAction(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
cache.InvalidateBookParts()
cache.InvalidateBookCache()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已移动", "count": len(body.SectionIds)})
return
}
@@ -696,6 +708,9 @@ func DBBookAction(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
cache.InvalidateChapterContent(ch.MID)
cache.InvalidateBookParts()
cache.InvalidateBookCache()
c.JSON(http.StatusOK, gin.H{"success": true})
return
}
@@ -708,6 +723,9 @@ func DBBookAction(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
cache.InvalidateChapterContentByID(body.ID)
cache.InvalidateBookParts()
cache.InvalidateBookCache()
c.JSON(http.StatusOK, gin.H{"success": true})
return
}
@@ -748,9 +766,12 @@ func DBBookDelete(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
return
}
cache.InvalidateChapterContentByID(id)
if err := database.DB().Where("id = ?", id).Delete(&model.Chapter{}).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
cache.InvalidateBookParts()
cache.InvalidateBookCache()
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -4,6 +4,7 @@ import (
"net/http"
"strings"
"soul-api/internal/cache"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -66,6 +67,7 @@ func DBLinkTagSave(c *gin.Context) {
existing.AppID = body.AppID
existing.PagePath = body.PagePath
db.Save(&existing)
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": existing})
return
}
@@ -75,6 +77,7 @@ func DBLinkTagSave(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": t})
}
@@ -89,5 +92,6 @@ func DBLinkTagDelete(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -10,6 +10,7 @@ import (
"time"
"soul-api/internal/config"
"soul-api/internal/oss"
"github.com/gin-gonic/gin"
)
@@ -17,6 +18,7 @@ const maxUploadBytes = 5 * 1024 * 1024 // 5MB
var allowedTypes = map[string]bool{"image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true}
// UploadPost POST /api/upload 上传图片(表单 file
// 若管理端已配置 OSS优先上传到 OSSOSS 失败或未配置时回退本地磁盘(容灾)
func UploadPost(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
@@ -40,13 +42,30 @@ func UploadPost(c *gin.Context) {
if folder == "" {
folder = "avatars"
}
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrUpload(6), ext)
objectKey := filepath.ToSlash(filepath.Join("uploads", folder, name))
// 优先尝试 OSS已配置时
if oss.IsEnabled() {
f, err := file.Open()
if err == nil {
url, uploadErr := oss.Upload(objectKey, f)
_ = f.Close()
if uploadErr == nil && url != "" {
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": file.Size, "type": ct}})
return
}
// OSS 失败,回退本地(容灾)
}
}
// 本地磁盘存储OSS 未配置或失败时)
uploadDir := config.Get().UploadDir
if uploadDir == "" {
uploadDir = "uploads"
}
dir := filepath.Join(uploadDir, folder)
_ = os.MkdirAll(dir, 0755)
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrUpload(6), ext)
dst := filepath.Join(dir, name)
if err := c.SaveUploadedFile(file, dst); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存失败"})
@@ -70,18 +89,35 @@ func randomStrUpload(n int) string {
}
// UploadDelete DELETE /api/upload
// path 支持:/uploads/xxx本地或 https://bucket.oss-xxx.aliyuncs.com/uploads/xxxOSS
func UploadDelete(c *gin.Context) {
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请指定 path"})
return
}
if !strings.HasPrefix(path, "/uploads/") && !strings.HasPrefix(path, "uploads/") {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "无权限删除此文件"})
return
// OSS 公网 URL从 OSS 删除
if oss.IsOSSURL(path) {
objectKey := oss.ParseObjectKeyFromURL(path)
if objectKey != "" {
if err := oss.Delete(objectKey); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "OSS 删除失败"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "删除成功"})
return
}
}
// 本地路径:支持 /uploads/xxx、uploads/xxx 或含 /uploads/ 的完整 URL
if idx := strings.Index(path, "/uploads/"); idx >= 0 {
path = path[idx+1:] // 从 uploads/ 开始
}
rel := strings.TrimPrefix(path, "/uploads/")
rel = strings.TrimPrefix(rel, "uploads/")
if rel == "" || strings.Contains(rel, "..") {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "无权限删除此文件"})
return
}
uploadDir := config.Get().UploadDir
if uploadDir == "" {
uploadDir = "uploads"

View File

@@ -8,6 +8,7 @@ import (
"os"
"time"
"soul-api/internal/config"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -133,7 +134,12 @@ func WithdrawPost(c *gin.Context) {
// AdminWithdrawTest GET/POST /api/admin/withdraw-test 提现测试接口,供 curl 等调试用
// 参数userId默认 ogpTW5fmXRGNpoUbXB3UEqnVe5Tg、amount默认 1
// 测试时忽略最低提现额限制,仅校验可提现余额与用户存在
// 生产环境禁用,避免误用
func AdminWithdrawTest(c *gin.Context) {
if cfg := config.Get(); cfg != nil && cfg.Mode == "release" {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "生产环境禁用"})
return
}
userID := c.DefaultQuery("userId", "ogpTW5fmXRGNpoUbXB3UEqnVe5Tg")
amountStr := c.DefaultQuery("amount", "1")
var amount float64