mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 08:06:37 +08:00
压缩历史至 95081aff
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
Copyright 2025 linux.do
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package upload
|
||||
|
||||
// 封面类型
|
||||
const (
|
||||
CoverTypeCover = "cover"
|
||||
CoverTypeHeterotypic = "heterotypic"
|
||||
)
|
||||
|
||||
// 最大文件大小
|
||||
const (
|
||||
MaxFileSize = 2 * 1024 * 1024 // 2MB
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
Copyright 2025 linux.do
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package upload
|
||||
|
||||
const (
|
||||
ErrNoFileSelected = "请选择要上传的文件"
|
||||
ErrInvalidCoverType = "无效的封面类型"
|
||||
ErrFileTooLarge = "图片大小不能超过 2MB"
|
||||
ErrUnsupportedFormat = "只支持 JPG、PNG、WEBP 格式的图片"
|
||||
ErrInvalidImage = "无效的图片文件"
|
||||
ErrUploadExtensionsNotConfigured = "上传扩展名未配置"
|
||||
ErrProcessFileFailed = "处理文件失败"
|
||||
ErrSaveFileFailed = "保存文件失败"
|
||||
ErrOpenFileFailed = "打开文件失败"
|
||||
ErrInvalidFilePath = "非法文件路径"
|
||||
ErrSaveUploadRecordFailed = "保存上传记录失败"
|
||||
ErrQueryHistoryCoverFailed = "查询历史封面失败"
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
Copyright 2025 linux.do
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package upload
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/linux-do/credit/internal/db"
|
||||
"github.com/linux-do/credit/internal/model"
|
||||
"github.com/linux-do/credit/internal/storage"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ServeFileByID serves an uploaded file by its ID
|
||||
// @Tags upload
|
||||
// @Produce octet-stream
|
||||
// @Param id path string true "Upload ID"
|
||||
// @Success 200
|
||||
// @Router /f/{id} [get]
|
||||
func ServeFileByID(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
uploadID, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid upload ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var upload model.Upload
|
||||
if err := db.DB(c.Request.Context()).
|
||||
Where("id = ? AND status IN (?, ?)", uploadID, model.UploadStatusPending, model.UploadStatusUsed).
|
||||
First(&upload).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Retrieve file from S3 (via CDN if configured)
|
||||
obj, err := storage.GetObjectViaCache(c.Request.Context(), upload.FilePath)
|
||||
if err != nil {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Cachefile
|
||||
if obj.CachePath != "" {
|
||||
c.File(obj.CachePath)
|
||||
return
|
||||
}
|
||||
|
||||
// Stream from CDN/S3
|
||||
defer obj.Body.Close()
|
||||
|
||||
// Respond with the file content
|
||||
c.DataFromReader(http.StatusOK, obj.ContentLength, obj.ContentType, obj.Body, nil)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
Copyright 2025 linux.do
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package upload
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/linux-do/credit/internal/apps/oauth"
|
||||
"github.com/linux-do/credit/internal/db"
|
||||
"github.com/linux-do/credit/internal/db/idgen"
|
||||
"github.com/linux-do/credit/internal/model"
|
||||
"github.com/linux-do/credit/internal/storage"
|
||||
"github.com/linux-do/credit/internal/util"
|
||||
_ "golang.org/x/image/webp"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UploadResponse 上传响应
|
||||
type UploadResponse struct {
|
||||
ID uint64 `json:"id,string"`
|
||||
}
|
||||
|
||||
// UploadRedEnvelopeCover 上传红包封面
|
||||
// @Tags upload
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param file formData file true "图片文件"
|
||||
// @Param type formData string true "封面类型 (cover/heterotypic)"
|
||||
// @Success 200 {object} util.ResponseAny
|
||||
// @Router /api/v1/upload/redenvelope/cover [post]
|
||||
func UploadRedEnvelopeCover(c *gin.Context) {
|
||||
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
|
||||
|
||||
// 获取上传的文件
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(ErrNoFileSelected))
|
||||
return
|
||||
}
|
||||
|
||||
// 获取封面类型
|
||||
coverType := c.PostForm("type")
|
||||
if coverType != CoverTypeCover && coverType != CoverTypeHeterotypic {
|
||||
c.JSON(http.StatusBadRequest, util.Err(ErrInvalidCoverType))
|
||||
return
|
||||
}
|
||||
|
||||
// 验证文件大小
|
||||
if file.Size > int64(MaxFileSize) {
|
||||
c.JSON(http.StatusBadRequest, util.Err(ErrFileTooLarge))
|
||||
return
|
||||
}
|
||||
|
||||
// 打开上传的文件
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(ErrOpenFileFailed))
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
// 验证文件确实是图片并获取尺寸
|
||||
_, format, err := image.DecodeConfig(src)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(ErrInvalidImage))
|
||||
return
|
||||
}
|
||||
|
||||
// 验证图片类型
|
||||
norm := strings.ToLower(format)
|
||||
if norm == "jpeg" {
|
||||
norm = "jpg"
|
||||
}
|
||||
var sc model.SystemConfig
|
||||
if err := sc.GetByKey(c.Request.Context(), model.ConfigKeyUploadAllowedExtensions); err != nil || strings.TrimSpace(sc.Value) == "" {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(ErrUploadExtensionsNotConfigured))
|
||||
return
|
||||
}
|
||||
v := strings.ToLower(strings.ReplaceAll(sc.Value, " ", ""))
|
||||
v = strings.ReplaceAll(v, "jpeg", "jpg")
|
||||
if !strings.Contains(","+v+",", ","+norm+",") {
|
||||
c.JSON(http.StatusBadRequest, util.Err(ErrUnsupportedFormat))
|
||||
return
|
||||
}
|
||||
|
||||
// 重置文件指针
|
||||
src.Close()
|
||||
src, _ = file.Open()
|
||||
defer src.Close()
|
||||
|
||||
// 计算文件 MD5 以避免重复上传
|
||||
hash := md5.New()
|
||||
if _, err := io.Copy(hash, src); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(ErrProcessFileFailed))
|
||||
return
|
||||
}
|
||||
md5Sum := hex.EncodeToString(hash.Sum(nil))
|
||||
|
||||
// 重置文件指针
|
||||
src.Close()
|
||||
src, _ = file.Open()
|
||||
defer src.Close()
|
||||
|
||||
// 生成安全的文件名: 用户ID_类型_MD5.扩展名
|
||||
// 使用完整 MD5 实现去重,同一用户上传相同图片会命中已有文件
|
||||
safeExt := "." + format
|
||||
if format == "jpeg" {
|
||||
safeExt = ".jpg"
|
||||
}
|
||||
filename := fmt.Sprintf("%s%s", md5Sum, safeExt)
|
||||
|
||||
// 构建 S3 object key: {prefix}{type}/{date}/{userID}/{filename}
|
||||
now := time.Now()
|
||||
objectPath := fmt.Sprintf("%s/%s/%d/%s", coverType, now.Format("2006/01/02"), currentUser.ID, filename)
|
||||
s3Key := storage.BuildKey(objectPath)
|
||||
|
||||
// validate S3 key
|
||||
if err := ValidateS3Key(s3Key); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(ErrInvalidFilePath))
|
||||
return
|
||||
}
|
||||
|
||||
// Content type for S3
|
||||
contentType := "image/" + format
|
||||
|
||||
var recordID uint64
|
||||
|
||||
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
var existing model.Upload
|
||||
if err := tx.Where("file_path = ?", s3Key).First(&existing).Error; err == nil {
|
||||
recordID = existing.ID
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Upload to S3
|
||||
if err := storage.PutObject(c.Request.Context(), s3Key, src, file.Size, contentType); err != nil {
|
||||
return errors.New(ErrSaveFileFailed)
|
||||
}
|
||||
|
||||
upload := model.Upload{
|
||||
ID: idgen.NextUint64ID(),
|
||||
UserID: currentUser.ID,
|
||||
FilePath: s3Key,
|
||||
FileSize: file.Size,
|
||||
Type: coverType,
|
||||
Status: model.UploadStatusPending,
|
||||
}
|
||||
|
||||
if err := tx.Create(&upload).Error; err != nil {
|
||||
// 如果数据库保存失败,尝试删除已上传的文件以避免垃圾数据
|
||||
_ = storage.DeleteObject(c.Request.Context(), s3Key)
|
||||
return errors.New(ErrSaveUploadRecordFailed)
|
||||
}
|
||||
|
||||
recordID = upload.ID
|
||||
return nil
|
||||
}); err != nil {
|
||||
if err.Error() == ErrSaveFileFailed {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(ErrSaveFileFailed))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, util.Err(ErrSaveUploadRecordFailed))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(UploadResponse{
|
||||
ID: recordID,
|
||||
}))
|
||||
}
|
||||
|
||||
// ListRedEnvelopeCovers 获取用户历史红包封面
|
||||
// @Tags redenvelope
|
||||
// @Produce json
|
||||
// @Param type query string true "封面类型 (cover/heterotypic)"
|
||||
// @Success 200 {object} util.ResponseAny
|
||||
// @Router /api/v1/redenvelope/covers [get]
|
||||
func ListRedEnvelopeCovers(c *gin.Context) {
|
||||
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
|
||||
|
||||
coverType := c.Query("type")
|
||||
if coverType != CoverTypeCover && coverType != CoverTypeHeterotypic {
|
||||
c.JSON(http.StatusBadRequest, util.Err(ErrInvalidCoverType))
|
||||
return
|
||||
}
|
||||
|
||||
var uploads []model.Upload
|
||||
if err := db.DB(c.Request.Context()).
|
||||
Where("user_id = ? AND status = ? AND type = ?",
|
||||
currentUser.ID, model.UploadStatusUsed, coverType).
|
||||
Order("created_at DESC").
|
||||
Limit(20).
|
||||
Find(&uploads).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(ErrQueryHistoryCoverFailed))
|
||||
return
|
||||
}
|
||||
|
||||
var results []UploadResponse
|
||||
for _, u := range uploads {
|
||||
results = append(results, UploadResponse{
|
||||
ID: u.ID,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(results))
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
Copyright 2025 linux.do
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package upload
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/linux-do/credit/internal/db"
|
||||
"github.com/linux-do/credit/internal/logger"
|
||||
"github.com/linux-do/credit/internal/model"
|
||||
"github.com/linux-do/credit/internal/storage"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// HandleCleanupUnusedUploads 处理清理未使用上传文件的定时任务
|
||||
func HandleCleanupUnusedUploads(ctx context.Context, t *asynq.Task) error {
|
||||
logger.InfoF(ctx, "开始清理未使用的上传文件任务")
|
||||
cleanupUnusedUploads(ctx)
|
||||
logger.InfoF(ctx, "未使用上传文件清理任务完成")
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupUnusedUploads 清理超过1小时未使用的上传文件
|
||||
func cleanupUnusedUploads(ctx context.Context) {
|
||||
const batchSize = 100 // 每批处理100个文件
|
||||
var lastID uint64 = 0
|
||||
var totalProcessed int = 0
|
||||
var totalDeleted int = 0
|
||||
|
||||
// 计算1小时前的时间
|
||||
oneHourAgo := time.Now().Add(-1 * time.Hour)
|
||||
|
||||
for {
|
||||
// 使用游标分页查询未使用且超过1小时的上传记录
|
||||
var unusedUploads []model.Upload
|
||||
if err := db.DB(ctx).
|
||||
Where("id > ? AND status = ? AND created_at < ?", lastID, model.UploadStatusPending, oneHourAgo).
|
||||
Order("id ASC").
|
||||
Limit(batchSize).
|
||||
Find(&unusedUploads).Error; err != nil {
|
||||
logger.ErrorF(ctx, "查询未使用的上传文件失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 没有更多数据,退出循环
|
||||
if len(unusedUploads) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
logger.InfoF(ctx, "本批次找到 %d 个需要清理的上传文件", len(unusedUploads))
|
||||
|
||||
// 处理每个未使用的上传文件
|
||||
for _, upload := range unusedUploads {
|
||||
totalProcessed++
|
||||
|
||||
if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 更新上传记录状态
|
||||
if err := tx.Model(&model.Upload{}).
|
||||
Where("id = ? AND status = ?", upload.ID, model.UploadStatusPending).
|
||||
Update("status", model.UploadStatusDeleted).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete from S3
|
||||
if err := storage.DeleteObject(ctx, upload.FilePath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
logger.ErrorF(ctx, "清理上传文件失败 [ID:%d]: %v", upload.ID, err)
|
||||
lastID = upload.ID
|
||||
continue
|
||||
}
|
||||
|
||||
totalDeleted++
|
||||
logger.InfoF(ctx, "成功清理上传文件 [ID:%d, Path:%s, Size:%d bytes]", upload.ID, upload.FilePath, upload.FileSize)
|
||||
|
||||
// 更新游标
|
||||
lastID = upload.ID
|
||||
}
|
||||
}
|
||||
|
||||
if totalDeleted > 0 {
|
||||
logger.InfoF(ctx, "清理任务完成,共处理 %d 个文件,成功删除 %d 个", totalProcessed, totalDeleted)
|
||||
} else {
|
||||
logger.InfoF(ctx, "没有需要清理的上传文件")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
Copyright 2025 linux.do
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package upload
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxS3KeyLength = 1024
|
||||
|
||||
// ValidateS3Key validates an S3 object key for safety.
|
||||
func ValidateS3Key(key string) error {
|
||||
if key == "" {
|
||||
return fmt.Errorf("s3 key must not be empty")
|
||||
}
|
||||
|
||||
if len(key) > maxS3KeyLength {
|
||||
return fmt.Errorf("s3 key exceeds maximum length of %d", maxS3KeyLength)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(key, "/") {
|
||||
return fmt.Errorf("s3 key must not start with /")
|
||||
}
|
||||
|
||||
if strings.Contains(key, "\x00") {
|
||||
return fmt.Errorf("s3 key must not contain null bytes")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user