性能优化
This commit is contained in:
@@ -16,6 +16,8 @@ require (
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
require github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible // indirect
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
|
||||
@@ -2,6 +2,8 @@ github.com/ArtisanCloud/PowerLibs/v3 v3.3.2 h1:IInr1YWwkhwOykxDqux1Goym0uFhrYwBj
|
||||
github.com/ArtisanCloud/PowerLibs/v3 v3.3.2/go.mod h1:xFGsskCnzAu+6rFEJbGVAlwhrwZPXAny6m7j71S/B5k=
|
||||
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38 h1:yu4A7WhPXfs/RSYFL2UdHFRQYAXbrpiBOT3kJ5hjepU=
|
||||
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38/go.mod h1:boWl2cwbgXt1AbrYTWMXs9Ebby6ecbJ1CyNVRaNVqUY=
|
||||
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g=
|
||||
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
|
||||
171
soul-api/internal/cache/cache.go
vendored
Normal file
171
soul-api/internal/cache/cache.go
vendored
Normal file
@@ -0,0 +1,171 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
"soul-api/internal/redis"
|
||||
)
|
||||
|
||||
const defaultTimeout = 2 * time.Second
|
||||
|
||||
// KeyBookParts 目录接口缓存 key,后台更新章节/内容时需 Del
|
||||
const KeyBookParts = "soul:book:parts"
|
||||
|
||||
// KeyBookHot 热门章节,格式 soul:book:hot:{limit}
|
||||
func KeyBookHot(limit int) string { return "soul:book:hot:" + fmt.Sprint(limit) }
|
||||
const KeyBookRecommended = "soul:book:recommended"
|
||||
const KeyBookStats = "soul:book:stats"
|
||||
const KeyConfigMiniprogram = "soul:config:miniprogram"
|
||||
|
||||
// Get 从 Redis 读取,未配置或失败返回 nil(调用方回退 DB)
|
||||
func Get(ctx context.Context, key string, dest interface{}) bool {
|
||||
client := redis.Client()
|
||||
if client == nil {
|
||||
return false
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
|
||||
defer cancel()
|
||||
val, err := client.Get(ctx, key).Bytes()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if dest != nil && len(val) > 0 {
|
||||
_ = json.Unmarshal(val, dest)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Set 写入 Redis,失败仅打日志不阻塞
|
||||
func Set(ctx context.Context, key string, val interface{}, ttl time.Duration) {
|
||||
client := redis.Client()
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
|
||||
defer cancel()
|
||||
data, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
log.Printf("cache.Set marshal %s: %v", key, err)
|
||||
return
|
||||
}
|
||||
if err := client.Set(ctx, key, data, ttl).Err(); err != nil {
|
||||
log.Printf("cache.Set %s: %v (非致命)", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Del 删除 key,失败仅打日志
|
||||
func Del(ctx context.Context, key string) {
|
||||
client := redis.Client()
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
|
||||
defer cancel()
|
||||
if err := client.Del(ctx, key).Err(); err != nil {
|
||||
log.Printf("cache.Del %s: %v (非致命)", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// BookPartsTTL 目录接口缓存 TTL,后台更新时主动 Del,此为兜底时长
|
||||
const BookPartsTTL = 10 * time.Minute
|
||||
|
||||
// InvalidateBookParts 后台更新章节/内容时调用,使目录接口缓存失效
|
||||
func InvalidateBookParts() {
|
||||
Del(context.Background(), KeyBookParts)
|
||||
}
|
||||
|
||||
// InvalidateBookCache 使热门、推荐、统计等书籍相关缓存失效(与 InvalidateBookParts 同时调用)
|
||||
func InvalidateBookCache() {
|
||||
ctx := context.Background()
|
||||
Del(ctx, KeyBookRecommended)
|
||||
Del(ctx, KeyBookStats)
|
||||
for _, limit := range []int{3, 10, 20, 50} {
|
||||
Del(ctx, KeyBookHot(limit))
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidateConfig 配置变更时调用,使小程序 config 缓存失效
|
||||
func InvalidateConfig() {
|
||||
Del(context.Background(), KeyConfigMiniprogram)
|
||||
}
|
||||
|
||||
// BookRelatedTTL 书籍相关接口 TTL(hot/recommended/stats)
|
||||
const BookRelatedTTL = 5 * time.Minute
|
||||
|
||||
// ConfigTTL 配置接口 TTL
|
||||
const ConfigTTL = 10 * time.Minute
|
||||
|
||||
// KeyChapterContent 章节正文缓存,格式 soul:chapter:content:{mid},存原始 HTML 字符串
|
||||
func KeyChapterContent(mid int) string { return "soul:chapter:content:" + fmt.Sprint(mid) }
|
||||
|
||||
// ChapterContentTTL 章节正文 TTL,后台更新时主动 Del
|
||||
const ChapterContentTTL = 30 * time.Minute
|
||||
|
||||
// GetString 读取字符串(不经过 JSON,适合大文本 content)
|
||||
func GetString(ctx context.Context, key string) (string, bool) {
|
||||
client := redis.Client()
|
||||
if client == nil {
|
||||
return "", false
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
|
||||
defer cancel()
|
||||
val, err := client.Get(ctx, key).Result()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return val, true
|
||||
}
|
||||
|
||||
// SetString 写入字符串(不经过 JSON,适合大文本 content)
|
||||
func SetString(ctx context.Context, key string, val string, ttl time.Duration) {
|
||||
client := redis.Client()
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
|
||||
defer cancel()
|
||||
if err := client.Set(ctx, key, val, ttl).Err(); err != nil {
|
||||
log.Printf("cache.SetString %s: %v (非致命)", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidateChapterContent 章节内容更新时调用,mid<=0 时忽略
|
||||
func InvalidateChapterContent(mid int) {
|
||||
if mid <= 0 {
|
||||
return
|
||||
}
|
||||
Del(context.Background(), KeyChapterContent(mid))
|
||||
}
|
||||
|
||||
// InvalidateChapterContentByID 按业务 id 使章节内容缓存失效(内部查 mid 后调用 InvalidateChapterContent)
|
||||
func InvalidateChapterContentByID(id string) {
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
var mid int
|
||||
if err := database.DB().Model(&model.Chapter{}).Where("id = ?", id).Pluck("mid", &mid).Error; err != nil || mid <= 0 {
|
||||
return
|
||||
}
|
||||
InvalidateChapterContent(mid)
|
||||
}
|
||||
@@ -256,7 +256,7 @@ func Load() (*Config, error) {
|
||||
// Redis:REDIS_URL 配置后启用;不配置则跳过。本地开发可设 REDIS_URL=redis://localhost:6379/0
|
||||
redisURL := strings.TrimSpace(os.Getenv("REDIS_URL"))
|
||||
|
||||
return &Config{
|
||||
cfg := &Config{
|
||||
Port: port,
|
||||
Mode: mode,
|
||||
DBDSN: dsn,
|
||||
@@ -284,7 +284,31 @@ func Load() (*Config, error) {
|
||||
SyncOrdersIntervalMinutes: syncOrdersInterval,
|
||||
UploadDir: uploadDir,
|
||||
RedisURL: redisURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 生产环境(GIN_MODE=release)强制校验敏感配置,禁止使用默认值
|
||||
if cfg.Mode == "release" {
|
||||
sensitive := []struct {
|
||||
name string
|
||||
val string
|
||||
}{
|
||||
{"WECHAT_APPSECRET", cfg.WechatAppSecret},
|
||||
{"WECHAT_MCH_KEY", cfg.WechatMchKey},
|
||||
{"WECHAT_APIV3_KEY", cfg.WechatAPIv3Key},
|
||||
{"ADMIN_PASSWORD", cfg.AdminPassword},
|
||||
{"ADMIN_SESSION_SECRET", cfg.AdminSessionSecret},
|
||||
}
|
||||
for _, s := range sensitive {
|
||||
if s.val == "" ||
|
||||
strings.HasPrefix(s.val, "wx3e31b068") ||
|
||||
s.val == "admin123" ||
|
||||
s.val == "soul-admin-secret-change-in-prod" {
|
||||
log.Fatalf("生产环境必须配置 %s,禁止使用默认值", s.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// resolveUploadDir 解析上传目录绝对路径(air 运行时 exe 在 tmp/,需用项目根)
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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、fixedSections(id, mid, title 供序言/尾声/附录跳转用 mid)
|
||||
// 带 30 秒内存缓存,固定模块合并为 1 次查询,三路并行执行
|
||||
// 缓存优先级:Redis(10min,后台更新时失效)> 内存(30s)> DB;Redis 不可用时回退内存+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. 内存缓存(30s,Redis 不可用时的容灾)
|
||||
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}})
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "配置保存成功"})
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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,优先上传到 OSS;OSS 失败或未配置时回退本地磁盘(容灾)
|
||||
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/xxx(OSS)
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
|
||||
124
soul-api/internal/oss/oss.go
Normal file
124
soul-api/internal/oss/oss.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package oss
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
alioss "github.com/aliyun/aliyun-oss-go-sdk/oss"
|
||||
)
|
||||
|
||||
// Config 阿里云 OSS 配置,与管理端 ossConfig 字段对应
|
||||
type Config struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Bucket string `json:"bucket"`
|
||||
Region string `json:"region"`
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
AccessKeySecret string `json:"accessKeySecret"`
|
||||
}
|
||||
|
||||
// LoadConfig 从 system_config 读取 oss_config,配置不完整时返回 nil
|
||||
func LoadConfig() *Config {
|
||||
var row model.SystemConfig
|
||||
if err := database.DB().Where("config_key = ?", "oss_config").First(&row).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(row.ConfigValue, &m); err != nil {
|
||||
return nil
|
||||
}
|
||||
var cfg Config
|
||||
if v, ok := m["endpoint"].(string); ok && v != "" {
|
||||
cfg.Endpoint = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := m["bucket"].(string); ok && v != "" {
|
||||
cfg.Bucket = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := m["accessKeyId"].(string); ok && v != "" {
|
||||
cfg.AccessKeyID = v
|
||||
}
|
||||
if v, ok := m["accessKeySecret"].(string); ok && v != "" {
|
||||
cfg.AccessKeySecret = v
|
||||
}
|
||||
if cfg.Endpoint == "" || cfg.Bucket == "" || cfg.AccessKeyID == "" || cfg.AccessKeySecret == "" {
|
||||
return nil
|
||||
}
|
||||
// endpoint 去掉 scheme,SDK 需要
|
||||
cfg.Endpoint = strings.TrimPrefix(strings.TrimPrefix(cfg.Endpoint, "https://"), "http://")
|
||||
return &cfg
|
||||
}
|
||||
|
||||
// IsEnabled 是否已配置 OSS 且可用
|
||||
func IsEnabled() bool {
|
||||
return LoadConfig() != nil
|
||||
}
|
||||
|
||||
// Upload 上传文件到 OSS,objectKey 如 "uploads/avatars/xxx.jpg"
|
||||
// 返回公网访问 URL,如 https://bucket.oss-cn-hangzhou.aliyuncs.com/uploads/avatars/xxx.jpg
|
||||
func Upload(objectKey string, reader io.Reader, options ...alioss.Option) (string, error) {
|
||||
cfg := LoadConfig()
|
||||
if cfg == nil {
|
||||
return "", nil // 未配置,调用方需回退本地
|
||||
}
|
||||
client, err := alioss.New(cfg.Endpoint, cfg.AccessKeyID, cfg.AccessKeySecret)
|
||||
if err != nil {
|
||||
log.Printf("oss: client init failed: %v", err)
|
||||
return "", err
|
||||
}
|
||||
bucket, err := client.Bucket(cfg.Bucket)
|
||||
if err != nil {
|
||||
log.Printf("oss: bucket %s failed: %v", cfg.Bucket, err)
|
||||
return "", err
|
||||
}
|
||||
if err := bucket.PutObject(objectKey, reader, options...); err != nil {
|
||||
log.Printf("oss: PutObject %s failed: %v", objectKey, err)
|
||||
return "", err
|
||||
}
|
||||
// 公网 URL:https://{bucket}.{endpoint}/{objectKey}
|
||||
u := "https://" + cfg.Bucket + "." + cfg.Endpoint + "/" + objectKey
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Delete 从 OSS 删除对象,objectKey 如 "uploads/avatars/xxx.jpg"
|
||||
func Delete(objectKey string) error {
|
||||
cfg := LoadConfig()
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
client, err := alioss.New(cfg.Endpoint, cfg.AccessKeyID, cfg.AccessKeySecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bucket, err := client.Bucket(cfg.Bucket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return bucket.DeleteObject(objectKey)
|
||||
}
|
||||
|
||||
// ParseObjectKeyFromURL 从 OSS 公网 URL 解析出 objectKey
|
||||
// 格式: https://bucket.oss-cn-xxx.aliyuncs.com/uploads/avatars/xxx.jpg
|
||||
func ParseObjectKeyFromURL(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
path := strings.TrimPrefix(u.Path, "/")
|
||||
return path
|
||||
}
|
||||
|
||||
// IsOSSURL 判断是否为 OSS 公网 URL(用于删除时区分本地/OSS)
|
||||
func IsOSSURL(rawURL string) bool {
|
||||
cfg := LoadConfig()
|
||||
if cfg == nil {
|
||||
return false
|
||||
}
|
||||
// 格式: https://{bucket}.{endpoint}/...
|
||||
prefix := "https://" + cfg.Bucket + "." + cfg.Endpoint + "/"
|
||||
return strings.HasPrefix(rawURL, prefix)
|
||||
}
|
||||
@@ -95,6 +95,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
admin.DELETE("/users", handler.AdminUsersAction)
|
||||
admin.GET("/orders", handler.OrdersList)
|
||||
admin.GET("/gift-pay-requests", handler.AdminGiftPayRequestsList)
|
||||
admin.GET("/user/track", handler.UserTrackGet)
|
||||
}
|
||||
|
||||
// ----- 鉴权 -----
|
||||
|
||||
Reference in New Issue
Block a user