压缩历史至 95081aff

This commit is contained in:
ryan
2026-06-08 20:34:27 +08:00
commit 8a782525de
435 changed files with 71146 additions and 0 deletions
+28
View File
@@ -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
)
+32
View File
@@ -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 = "查询历史封面失败"
)
+75
View File
@@ -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)
}
+233
View File
@@ -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))
}
+105
View File
@@ -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, "没有需要清理的上传文件")
}
}
+45
View File
@@ -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
}