性能优化
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user