diff --git a/controllers/file/picUpload.go b/controllers/file/picUpload.go new file mode 100644 index 0000000..2473ac2 --- /dev/null +++ b/controllers/file/picUpload.go @@ -0,0 +1,203 @@ +package file + +import ( + "errors" + "fmt" + "io" + "mime/multipart" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/aliyun/aliyun-oss-go-sdk/oss" + "github.com/gin-gonic/gin" +) + +// 阿里云OSS配置 - 请替换为你的实际配置 +const ( + ossEndpoint = "oss-cn-shanghai.aliyuncs.com" // OSS地域节点 + ossAccessKeyID = "LTAI5tQDJnnajQS8uCXJLKfP" // 你的AccessKeyID + ossAccessKeySecret = "4wmJNcnYg5wKgBWePTQ9x4CPfUqgzn" // 你的AccessKeySecret + ossBucketName = "ttk-userdata" // 你的Bucket名称 + ossBasePath = "/" // 图片存储基础路径 +) + +// 允许的图片文件扩展名 +var allowedImageExts = map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".png": true, + ".gif": true, + ".webp": true, +} + +// 上传响应结构 +type UploadResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + URL string `json:"url"` // 图片访问URL + Filename string `json:"filename"` // OSS中的文件名 + Size int64 `json:"size"` // 图片大小(字节) + } `json:"data"` +} + +// PicUpload 处理图片上传请求 +func PicUpload(c *gin.Context) { + // 1. 获取上传的文件 + file, err := c.FormFile("image") + if err != nil { + c.JSON(400, UploadResponse{ + Code: 400, + Message: "获取上传文件失败: " + err.Error(), + }) + return + } + + // 2. 验证文件 + if err := validateImageFile(file); err != nil { + c.JSON(400, UploadResponse{ + Code: 400, + Message: err.Error(), + }) + return + } + + // 3. 打开文件 + srcFile, err := file.Open() + if err != nil { + c.JSON(500, UploadResponse{ + Code: 500, + Message: "打开文件失败: " + err.Error(), + }) + return + } + defer srcFile.Close() + + // 4. 生成OSS中的文件名 + objectName, err := generateObjectName(file.Filename) + if err != nil { + c.JSON(500, UploadResponse{ + Code: 500, + Message: "生成文件名失败: " + err.Error(), + }) + return + } + + // 5. 上传到OSS + ossURL, err := uploadToOSS(srcFile, objectName, file.Size) + if err != nil { + c.JSON(500, UploadResponse{ + Code: 500, + Message: "上传到OSS失败: " + err.Error(), + }) + return + } + + // 6. 返回成功响应 + c.JSON(200, UploadResponse{ + Code: 200, + Message: "上传成功", + Data: struct { + URL string `json:"url"` + Filename string `json:"filename"` + Size int64 `json:"size"` + }{ + URL: ossURL, + Filename: objectName, + Size: file.Size, + }, + }) +} + +// 验证图片文件 +func validateImageFile(file *multipart.FileHeader) error { + // 验证大小(10MB) + if file.Size > 10*1024*1024 { + return errors.New("文件大小不能超过10MB") + } + + // 验证扩展名 + ext := strings.ToLower(filepath.Ext(file.Filename)) + if !allowedImageExts[ext] { + return errors.New("不支持的图片格式,仅支持jpg、jpeg、png、gif、webp") + } + + return nil +} + +// 生成唯一文件名 +func generateObjectName(originalName string) (string, error) { + // 步骤1:提取原始文件的扩展名(如 .jpg .png) + ext := strings.ToLower(filepath.Ext(originalName)) + if ext == "" { + return "", errors.New("文件没有扩展名,无法识别文件类型") + } + // 验证扩展名是否为允许的图片格式(避免非法扩展名) + if !allowedImageExts[ext] { + return "", errors.New("不支持的图片格式,仅支持jpg、jpeg、png、gif、webp") + } + + // 步骤2:过滤原始文件名中的非法字符(仅保留 a-z A-Z 0-9 - _ .) + // 先移除扩展名,只处理文件名部分 + rawFileName := strings.TrimSuffix(originalName, ext) + // 正则表达式:保留合法字符,其他替换为空 + reg := regexp.MustCompile(`[^a-zA-Z0-9_\-.]`) + filteredFileName := reg.ReplaceAllString(rawFileName, "") + // 若过滤后文件名为空,用"unknown"代替(避免生成空文件名) + if filteredFileName == "" { + filteredFileName = "unknown" + } + + // 步骤3:生成唯一文件名(时间戳 + 过滤后的文件名 + 扩展名) + // 时间戳用秒级+毫秒级,确保唯一性(避免同一秒上传相同文件名) + timestamp := time.Now().Format("20060102150405.000") // 格式:年月日时分秒.毫秒 + uniqueFileName := fmt.Sprintf("%s-%s%s", timestamp, filteredFileName, ext) + + // 步骤4:拼接最终 ObjectName(确保路径规范:无首尾/、无连续/) + // 清理 ossBasePath(如 "images/" 转为 "images",避免结尾/) + cleanBasePath := strings.TrimSuffix(ossBasePath, "/") + // 拼接路径(如 "images" + "20240520143000.123-unknown.jpg" → "images/20240520143000.123-unknown.jpg") + objectName := fmt.Sprintf("%s/%s", cleanBasePath, uniqueFileName) + + // 最终验证:确保 ObjectName 无首尾/、无连续/ + objectName = strings.Trim(objectName, "/") + objectName = regexp.MustCompile(`/+`).ReplaceAllString(objectName, "/") // 多/替换为单/ + + return objectName, nil +} + +// 上传到OSS +func uploadToOSS(reader io.Reader, objectName string, fileSize int64) (string, error) { + // 1. 创建OSS客户端(旧版SDK的创建方式) + client, err := oss.New(ossEndpoint, ossAccessKeyID, ossAccessKeySecret) + if err != nil { + return "", fmt.Errorf("创建OSS客户端失败: %w", err) + } + + // 2. 获取Bucket + bucket, err := client.Bucket(ossBucketName) + if err != nil { + return "", fmt.Errorf("获取Bucket失败: %w", err) + } + + // 3. 上传文件(旧版SDK的PutObject方法) + err = bucket.PutObject(objectName, reader) + if err != nil { + return "", fmt.Errorf("上传文件失败: %w", err) + } + + // 4. 生成访问URL + // 公开Bucket可以直接使用此URL + url := fmt.Sprintf("https://%s.%s/%s", ossBucketName, ossEndpoint, objectName) + + // 如果是私有Bucket,需要生成带签名的URL(有效期1小时) + // signedURL, err := bucket.SignURL(objectName, oss.HTTPGet, 3600) + // if err != nil { + // return "", fmt.Errorf("生成签名URL失败: %w", err) + // } + // return signedURL, nil + + return url, nil +} diff --git a/go.mod b/go.mod index ca0157b..ac5bbf2 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 // indirect github.com/RoaringBitmap/roaring/v2 v2.10.0 // indirect github.com/acomagu/bufpipe v1.0.3 // indirect + github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible // indirect github.com/benbjohnson/clock v1.3.5 // indirect github.com/bitly/go-simplejson v0.5.0 // indirect github.com/bits-and-blooms/bitset v1.24.0 // indirect @@ -127,6 +128,7 @@ require ( golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.29.0 // indirect + golang.org/x/time v0.13.0 // indirect golang.org/x/tools v0.37.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090 // indirect diff --git a/router/setupRouter.go b/router/setupRouter.go index ebafbe3..61939b7 100644 --- a/router/setupRouter.go +++ b/router/setupRouter.go @@ -1,18 +1,21 @@ package router import ( - "github.com/gin-gonic/gin" "toutoukan/controllers/article" "toutoukan/controllers/article/getArticleNum" "toutoukan/controllers/comments/getcomments" "toutoukan/controllers/comments/publishComments" + "toutoukan/controllers/file" "toutoukan/controllers/goods" "toutoukan/controllers/kills" "toutoukan/controllers/notifications/getnotifications" "toutoukan/controllers/search" "toutoukan/controllers/system" "toutoukan/controllers/user" + "toutoukan/controllers/user/setting" "toutoukan/init/ratelimit" + + "github.com/gin-gonic/gin" ) func SetupRouter() *gin.Engine { @@ -24,6 +27,7 @@ func SetupRouter() *gin.Engine { apiGroup.POST("/kill", ratelimit.RateLimitMiddleware(), kills.Userkill) apiGroup.POST("/getscore", user.GetScore) apiGroup.POST("/getInfo", user.GetUserInfo) + apiGroup.POST("/change", setting.ChangeUserSetting) } //r.GET("/socket", jwt.JWTAuthMiddleware(), func(c *gin.Context) { @@ -60,6 +64,10 @@ func SetupRouter() *gin.Engine { { notificationGroup.POST("/get", getnotifications.GetNotifications) } + fileGroup := r.Group("/file") + { + fileGroup.POST("/upload", file.PicUpload) + } return r }