fix: submit verification code

This commit is contained in:
王性驊 2025-11-12 14:50:35 +08:00
parent 432343fa6f
commit 0d13e54be5
22 changed files with 1131 additions and 14 deletions

View File

@ -7,13 +7,11 @@ Cache:
type: node
CacheExpireTime: 1s
CacheWithNotFoundExpiry: 1s
RedisConf:
Host: 127.0.0.1:6379
Type: node
Pass: ""
Tls: false
Mongo:
Schema: mongodb
Host: "127.0.0.1:27017"
@ -30,19 +28,15 @@ Mongo:
- f
EnableStandardReadWriteSplitMode: true
ConnectTimeoutMs : 300
Bcrypt:
Cost: 10
GoogleAuth:
ClientID: xxx.apps.googleusercontent.com
AuthURL: x
LineAuth:
ClientID : "200000000"
ClientSecret : xxxxx
RedirectURI : http://localhost:8080/line.html
Token:
AccessSecret : "1qaz@WSX3edc$RFV"
RefreshSecret : "1qaz@WSX3edc$RFV"
@ -51,16 +45,12 @@ Token:
OneTimeTokenExpiry : 600s
MaxTokensPerUser : 2
MaxTokensPerDevice : 2
RoleConfig:
UIDPrefix: "AM"
UIDLength: 6
AdminRoleUID: "AM000000"
AdminUserUID: "B000000"
DefaultRoleName: "USER"
SMTPConfig:
Enable: true
GoroutinePoolNum: 1000
@ -70,8 +60,6 @@ SMTPConfig:
Password: 595da25c2a44ef2629ba92bf88ae94f1-02300200-af1d3b04
Sender: daniel.wang@code.30cm.net
SenderName: "Digimon 平台"
DeliveryConfig:
max_retries : 5
initial_delay : 500ms
@ -79,3 +67,12 @@ DeliveryConfig:
max_delay : 5000ms
Timeout: 1000ms
enable_history: false
AmazonS3Settings:
Region: ap-northeast-3
Bucket:
CloudFrontDomain:
CloudFrontURI:
BucketURI:
AccessKey:
SecretKey:
CloudFrontID:

View File

@ -0,0 +1,52 @@
syntax = "v1"
// 圖片上傳請求(使用 base64
type UploadImgReq {
Authorization
Content string `json:"content" validate:"required"` // base64 編碼的圖片內容
}
// 影片上傳請求(使用 multipart/form-data 文件上傳)
// 注意:文件字段需要在 handler 中通過 r.FormFile("file") 獲取
type UploadVideoReq {
Authorization
}
// 統一的上傳響應
type UploadResp {
FileUrl string `json:"file_url"` // 文件訪問 URL
FileSize int64 `json:"file_size,optional"` // 文件大小bytes
MimeType string `json:"mime_type,optional"` // MIME 類型
}
@server(
group: fileStorage
prefix: /api/v1/fileStorage
schemes: https
timeout: 300s // 影片上傳可能需要更長時間
middleware: AuthMiddleware
)
service gateway {
/* @respdoc-400 (BaseResponse) // 輸入的參數錯誤 */
/* @respdoc-403 (BaseResponse) // 無效的Token */
/* @respdoc-413 (BaseResponse) // 文件大小超過限制 */
/* @respdoc-500 (BaseResponse) // 伺服器出錯 */
@doc(
summary: "create - 上傳圖片檔案"
description: "上傳轉成 base64 過後的圖片,建議圖片大小不超過 10MB"
)
@handler UploadImgHandler
post /fileStorage/img/upload (UploadImgReq) returns (UploadResp)
/* @respdoc-400 (BaseResponse) // 輸入的參數錯誤 */
/* @respdoc-403 (BaseResponse) // 無效的Token */
/* @respdoc-413 (BaseResponse) // 文件大小超過限制 */
/* @respdoc-500 (BaseResponse) // 伺服器出錯 */
@doc(
summary: "create - 上傳影片檔案"
description: "使用 multipart/form-data 上傳影片檔案form field 名稱為 'file',注意:大檔案(>50MB建議使用分片上傳機制"
)
@handler UploadVideoHandler
post /fileStorage/video/upload (UploadVideoReq) returns (UploadResp)
}

View File

@ -16,5 +16,6 @@ import (
"common.api"
"ping.api"
"member.api"
"file_storage.api"
)

2
go.mod
View File

@ -5,6 +5,7 @@ go 1.25.1
require (
code.30cm.net/digimon/library-go/utils/invited_code v1.2.5
github.com/alicebob/miniredis/v2 v2.35.0
github.com/aws/aws-sdk-go v1.55.8
github.com/aws/aws-sdk-go-v2 v1.39.6
github.com/aws/aws-sdk-go-v2/credentials v1.18.21
github.com/aws/aws-sdk-go-v2/service/ses v1.34.9
@ -70,6 +71,7 @@ require (
github.com/huandu/xstrings v1.2.0 // indirect
github.com/imdario/mergo v0.3.6 // indirect
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect

7
go.sum
View File

@ -20,6 +20,8 @@ github.com/andybalholm/cascadia v1.0.0 h1:hOCXnnZ5A+3eVDX8pvgl4kofXv2ELss0bKcqRy
github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/aokoli/goutils v1.0.1 h1:7fpzNGoJ3VA8qcrm++XEE1QUe0mIwNeLa02Nwq7RDkg=
github.com/aokoli/goutils v1.0.1/go.mod h1:SijmP0QR8LtwsmDs8Yii5Z/S4trXFGFC2oO5g9DP+DQ=
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
github.com/aws/aws-sdk-go-v2 v1.39.6 h1:2JrPCVgWJm7bm83BDwY5z8ietmeJUbh3O2ACnn+Xsqk=
github.com/aws/aws-sdk-go-v2 v1.39.6/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
github.com/aws/aws-sdk-go-v2/credentials v1.18.21 h1:56HGpsgnmD+2/KpG0ikvvR8+3v3COCwaF4r+oWwOeNA=
@ -119,6 +121,10 @@ github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28=
github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0 h1:xqgexXAGQgY3HAjNPSaCqn5Aahbo5TKsmhp8VRfr1iQ=
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@ -364,6 +370,7 @@ gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkp
gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -97,4 +97,15 @@ type Config struct {
Timeout time.Duration `json:"timeout"` // 單次發送超時時間
EnableHistory bool `json:"enable_history"` // 是否啟用歷史記錄
}
AmazonS3Settings struct {
Region string
Bucket string
CloudFrontDomain string
CloudFrontURI string
BucketURI string
AccessKey string
SecretKey string
CloudFrontID string
}
}

View File

@ -0,0 +1,39 @@
package fileStorage
import (
errs "backend/pkg/library/errors"
"net/http"
"backend/internal/logic/fileStorage"
"backend/internal/svc"
"backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 上傳圖片檔案
func UploadImgHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UploadImgReq
if err := httpx.Parse(r, &req); err != nil {
e := errs.InputInvalidFormatError(err.Error())
httpx.WriteJsonCtx(r.Context(), w, e.HTTPStatus(), types.Resp{
Code: e.DisplayCode(),
Message: err.Error(),
})
return
}
l := fileStorage.NewUploadImgLogic(r.Context(), svcCtx)
resp, err := l.UploadImg(&req)
if err != nil {
e := errs.FromError(err)
httpx.WriteJsonCtx(r.Context(), w, e.HTTPStatus(), types.Resp{
Code: e.DisplayCode(),
Message: e.Error(),
Error: e.Unwrap(),
})
} else {
httpx.WriteJsonCtx(r.Context(), w, http.StatusOK, resp)
}
}
}

View File

@ -0,0 +1,61 @@
package fileStorage
import (
errs "backend/pkg/library/errors"
"net/http"
"backend/internal/logic/fileStorage"
"backend/internal/svc"
"backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 上傳影片檔案
func UploadVideoHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UploadVideoReq
if err := httpx.Parse(r, &req); err != nil {
e := errs.InputInvalidFormatError(err.Error())
httpx.WriteJsonCtx(r.Context(), w, e.HTTPStatus(), types.Resp{
Code: e.DisplayCode(),
Message: err.Error(),
})
return
}
// 解析 multipart form限制 100MB
if err := r.ParseMultipartForm(100 << 20); err != nil {
e := errs.InputInvalidFormatError("failed to parse multipart form: " + err.Error())
httpx.WriteJsonCtx(r.Context(), w, e.HTTPStatus(), types.Resp{
Code: e.DisplayCode(),
Message: err.Error(),
})
return
}
// 獲取文件
file, header, err := r.FormFile("file")
if err != nil {
e := errs.InputInvalidFormatError("failed to get file from form: " + err.Error())
httpx.WriteJsonCtx(r.Context(), w, e.HTTPStatus(), types.Resp{
Code: e.DisplayCode(),
Message: err.Error(),
})
return
}
defer file.Close()
l := fileStorage.NewUploadVideoLogic(r.Context(), svcCtx)
resp, err := l.UploadVideo(&req, file, header)
if err != nil {
e := errs.FromError(err)
httpx.WriteJsonCtx(r.Context(), w, e.HTTPStatus(), types.Resp{
Code: e.DisplayCode(),
Message: e.Error(),
Error: e.Unwrap(),
})
} else {
httpx.WriteJsonCtx(r.Context(), w, http.StatusOK, resp)
}
}
}

View File

@ -1,5 +1,5 @@
// Code generated by goctl. DO NOT EDIT.
// goctl 1.8.1
// goctl 1.9.0
package handler
@ -8,6 +8,7 @@ import (
"time"
auth "backend/internal/handler/auth"
fileStorage "backend/internal/handler/fileStorage"
ping "backend/internal/handler/ping"
user "backend/internal/handler/user"
"backend/internal/svc"
@ -59,6 +60,28 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithTimeout(10000*time.Millisecond),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthMiddleware},
[]rest.Route{
{
// create - 上傳圖片檔案
Method: http.MethodPost,
Path: "/fileStorage/img/upload",
Handler: fileStorage.UploadImgHandler(serverCtx),
},
{
// create - 上傳影片檔案
Method: http.MethodPost,
Path: "/fileStorage/video/upload",
Handler: fileStorage.UploadVideoHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1/fileStorage"),
rest.WithTimeout(300000*time.Millisecond),
)
server.AddRoutes(
[]rest.Route{
{

View File

@ -0,0 +1,120 @@
package fileStorage
import (
"context"
"encoding/base64"
"fmt"
"strings"
"time"
"backend/internal/svc"
"backend/internal/types"
"github.com/google/uuid"
"github.com/zeromicro/go-zero/core/logx"
)
type UploadImgLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// 上傳圖片檔案
func NewUploadImgLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UploadImgLogic {
return &UploadImgLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UploadImgLogic) UploadImg(req *types.UploadImgReq) (resp *types.UploadResp, err error) {
// 驗證 base64 格式
content := req.Content
if strings.HasPrefix(content, "data:image") {
// 移除 data URL 前綴 (例如: data:image/png;base64,)
parts := strings.SplitN(content, ",", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid base64 image format")
}
content = parts[1]
}
// 解碼 base64
imgData, err := base64.StdEncoding.DecodeString(content)
if err != nil {
return nil, fmt.Errorf("failed to decode base64: %w", err)
}
// 驗證文件大小10MB 限制)
const maxImgSize = 10 << 20 // 10MB
if len(imgData) > maxImgSize {
return nil, fmt.Errorf("image size exceeds 10MB limit")
}
// 驗證圖片格式(檢查 magic bytes
mimeType, err := l.detectImageType(imgData)
if err != nil {
return nil, fmt.Errorf("invalid image format: %w", err)
}
// 生成唯一文件名
fileExt := l.getExtensionFromMimeType(mimeType)
fileName := fmt.Sprintf("%s%s", uuid.New().String(), fileExt)
objectPath := fmt.Sprintf("images/%d/%s", time.Now().Year(), fileName)
// 上傳到 S3
fileStorageUC := l.svcCtx.FileStorageUC
if err := fileStorageUC.UploadFromData(l.ctx, imgData, objectPath, mimeType); err != nil {
return nil, fmt.Errorf("failed to upload image: %w", err)
}
// 獲取公開 URL
fileUrl := fileStorageUC.GetPublicURL(l.ctx, objectPath)
return &types.UploadResp{
FileUrl: fileUrl,
FileSize: int64(len(imgData)),
MimeType: mimeType,
}, nil
}
// detectImageType 檢測圖片類型
func (l *UploadImgLogic) detectImageType(data []byte) (string, error) {
if len(data) < 4 {
return "", fmt.Errorf("file too small")
}
// 檢查常見圖片格式的 magic bytes
if len(data) >= 2 && data[0] == 0xFF && data[1] == 0xD8 {
return "image/jpeg", nil
}
if len(data) >= 8 && string(data[0:8]) == "\x89PNG\r\n\x1a\n" {
return "image/png", nil
}
if len(data) >= 6 && (string(data[0:6]) == "GIF87a" || string(data[0:6]) == "GIF89a") {
return "image/gif", nil
}
if len(data) >= 12 && string(data[8:12]) == "WEBP" {
return "image/webp", nil
}
return "", fmt.Errorf("unsupported image format")
}
// getExtensionFromMimeType 根據 MIME 類型獲取文件擴展名
func (l *UploadImgLogic) getExtensionFromMimeType(mimeType string) string {
switch mimeType {
case "image/jpeg":
return ".jpg"
case "image/png":
return ".png"
case "image/gif":
return ".gif"
case "image/webp":
return ".webp"
default:
return ".jpg"
}
}

View File

@ -0,0 +1,170 @@
package fileStorage
import (
"context"
"fmt"
"io"
"mime/multipart"
"path/filepath"
"strings"
"time"
"backend/internal/svc"
"backend/internal/types"
"github.com/google/uuid"
"github.com/zeromicro/go-zero/core/logx"
)
type UploadVideoLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewUploadVideoLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UploadVideoLogic {
return &UploadVideoLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UploadVideoLogic) UploadVideo(req *types.UploadVideoReq, file multipart.File, header *multipart.FileHeader) (resp *types.UploadResp, err error) {
// 驗證文件大小100MB 限制)
const maxVideoSize = 100 << 20 // 100MB
if header.Size > maxVideoSize {
return nil, fmt.Errorf("video size exceeds 100MB limit")
}
// 讀取文件內容
videoData, err := io.ReadAll(file)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
// 驗證實際文件大小
if int64(len(videoData)) > maxVideoSize {
return nil, fmt.Errorf("video size exceeds 100MB limit")
}
// 檢測視頻 MIME 類型
mimeType := l.detectVideoType(videoData, header)
if mimeType == "" {
// 從文件名推斷
ext := strings.ToLower(filepath.Ext(header.Filename))
mimeType = l.getMimeTypeFromExtension(ext)
if mimeType == "" {
return nil, fmt.Errorf("unsupported video format")
}
}
// 生成唯一文件名
fileExt := filepath.Ext(header.Filename)
if fileExt == "" {
fileExt = l.getExtensionFromMimeType(mimeType)
}
fileName := fmt.Sprintf("%s%s", uuid.New().String(), fileExt)
objectPath := fmt.Sprintf("videos/%d/%s", time.Now().Year(), fileName)
// 上傳到 S3
fileStorageUC := l.svcCtx.FileStorageUC
if err := fileStorageUC.UploadFromData(l.ctx, videoData, objectPath, mimeType); err != nil {
return nil, fmt.Errorf("failed to upload video: %w", err)
}
// 獲取公開 URL
fileUrl := fileStorageUC.GetPublicURL(l.ctx, objectPath)
return &types.UploadResp{
FileUrl: fileUrl,
FileSize: int64(len(videoData)),
MimeType: mimeType,
}, nil
}
// detectVideoType 檢測視頻類型(通過 magic bytes
func (l *UploadVideoLogic) detectVideoType(data []byte, header *multipart.FileHeader) string {
if len(data) < 12 {
return ""
}
// 檢查常見視頻格式的 magic bytes
// MP4: ftyp box 通常在開頭
if len(data) >= 12 {
// MP4/MOV: 通常以 ftyp 開頭
if string(data[4:8]) == "ftyp" {
if strings.Contains(string(data[8:12]), "mp4") || strings.Contains(string(data[8:12]), "isom") {
return "video/mp4"
}
if strings.Contains(string(data[8:12]), "qt") {
return "video/quicktime"
}
}
}
// AVI: RIFF...AVI
if len(data) >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "AVI " {
return "video/x-msvideo"
}
// WebM: webm
if len(data) >= 4 && string(data[0:4]) == "\x1a\x45\xdf\xa3" {
return "video/webm"
}
// 從 Content-Type header 獲取
if header != nil && len(header.Header) > 0 {
contentType := header.Header.Get("Content-Type")
if contentType != "" && strings.HasPrefix(contentType, "video/") {
return contentType
}
}
return ""
}
// getMimeTypeFromExtension 根據文件擴展名獲取 MIME 類型
func (l *UploadVideoLogic) getMimeTypeFromExtension(ext string) string {
ext = strings.ToLower(ext)
switch ext {
case ".mp4":
return "video/mp4"
case ".mov":
return "video/quicktime"
case ".avi":
return "video/x-msvideo"
case ".webm":
return "video/webm"
case ".mkv":
return "video/x-matroska"
case ".flv":
return "video/x-flv"
case ".wmv":
return "video/x-ms-wmv"
default:
return ""
}
}
// getExtensionFromMimeType 根據 MIME 類型獲取文件擴展名
func (l *UploadVideoLogic) getExtensionFromMimeType(mimeType string) string {
switch mimeType {
case "video/mp4":
return ".mp4"
case "video/quicktime":
return ".mov"
case "video/x-msvideo":
return ".avi"
case "video/webm":
return ".webm"
case "video/x-matroska":
return ".mkv"
case "video/x-flv":
return ".flv"
case "video/x-ms-wmv":
return ".wmv"
default:
return ".mp4"
}
}

View File

@ -0,0 +1,48 @@
package svc
import (
"backend/internal/config"
fileStorageConfig "backend/pkg/fileStorage/config"
fileStorageUC "backend/pkg/fileStorage/domain/usecase"
fileStorageRepo "backend/pkg/fileStorage/repository"
fileStorageUseCase "backend/pkg/fileStorage/usecase"
errs "backend/pkg/library/errors"
)
func MustS3Storage(c *config.Config, logger errs.Logger) fileStorageUC.FileStorageUseCase {
// 初始化 FileStorage 配置
fileStorageConf := &fileStorageConfig.Config{
AmazonS3Settings: struct {
Region string
Bucket string
CloudFrontDomain string
CloudFrontURI string
BucketURI string
AccessKey string
SecretKey string
CloudFrontID string
}{
Region: c.AmazonS3Settings.Region,
Bucket: c.AmazonS3Settings.Bucket,
CloudFrontDomain: c.AmazonS3Settings.CloudFrontDomain,
CloudFrontURI: c.AmazonS3Settings.CloudFrontURI,
BucketURI: c.AmazonS3Settings.BucketURI,
AccessKey: c.AmazonS3Settings.AccessKey,
SecretKey: c.AmazonS3Settings.SecretKey,
CloudFrontID: c.AmazonS3Settings.CloudFrontID,
},
}
// 初始化 FileStorage Repository 和 UseCase
fileStorageRepoInstance := fileStorageRepo.MustAwsS3FileStorageRepo(fileStorageRepo.AwsS3FileStorageRepositoryParam{
Conf: fileStorageConf,
Logger: logger,
})
fileStorageUCInstance := fileStorageUseCase.MustAwsS3FileStorageUseCase(fileStorageUseCase.AwsS3FileStorageUseCaseParam{
Conf: fileStorageConf,
Logger: logger,
Repo: fileStorageRepoInstance,
})
return fileStorageUCInstance
}

View File

@ -8,6 +8,7 @@ import (
"context"
"github.com/zeromicro/go-zero/core/logx"
fileStorageUC "backend/pkg/fileStorage/domain/usecase"
vi "backend/pkg/library/validator"
memberUC "backend/pkg/member/domain/usecase"
deliveryUC "backend/pkg/notification/domain/usecase"
@ -28,6 +29,7 @@ type ServiceContext struct {
RolePermission tokenUC.RolePermissionUseCase
UserRoleUC tokenUC.UserRoleUseCase
DeliveryUC deliveryUC.DeliveryUseCase
FileStorageUC fileStorageUC.FileStorageUseCase
Redis *redis.Redis
Logger errs.Logger
}
@ -58,6 +60,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
UserRoleUC: rp.UserRole,
Redis: rds,
DeliveryUC: MustDeliveryUseCase(&c, lgr),
FileStorageUC: MustS3Storage(&c, lgr),
Logger: lgr,
}
}

View File

@ -1,5 +1,5 @@
// Code generated by goctl. DO NOT EDIT.
// goctl 1.8.1
// goctl 1.9.0
package types
@ -132,6 +132,21 @@ type UpdateUserInfoReq struct {
Carrier *string `json:"carrier,optional"` // 載具
}
type UploadImgReq struct {
Authorization
Content string `json:"content" validate:"required"` // base64 編碼的圖片內容
}
type UploadResp struct {
FileUrl string `json:"file_url"` // 文件訪問 URL
FileSize int64 `json:"file_size,optional"` // 文件大小bytes
MimeType string `json:"mime_type,optional"` // MIME 類型
}
type UploadVideoReq struct {
Authorization
}
type UserInfoResp struct {
Platform string `json:"platform"` // 註冊平台
UID string `json:"uid"` // 用戶 UID

View File

@ -0,0 +1,14 @@
package config
type Config struct {
AmazonS3Settings struct {
Region string
Bucket string
CloudFrontDomain string
CloudFrontURI string
BucketURI string
AccessKey string
SecretKey string
CloudFrontID string
}
}

View File

@ -0,0 +1,16 @@
package domain
const (
S3AclPublic = "public-read"
S3AclPrivate = "private"
)
var S3AclSetting = S3AclPublic
func SetACLIsPublic() {
S3AclSetting = S3AclPublic
}
func SetACLIsPrivate() {
S3AclSetting = S3AclPrivate
}

View File

@ -0,0 +1,29 @@
package repository
import (
"context"
"io"
"time"
)
// FileStorageRepository 是一個通用的文件儲存操作接口,用於管理雲存儲或本地存儲中的對象文件。
type FileStorageRepository interface {
// Download 下載指定路徑的對象並返回其內容
Download(ctx context.Context, objectPath string) ([]byte, error)
// Move 將對象從一個路徑移動到另一個指定路徑
Move(ctx context.Context, objectPath string, destinationPath string) error
// Delete 刪除指定路徑的對象
Delete(ctx context.Context, objectPath string) error
// Exists 檢查指定路徑的對象是否存在
Exists(ctx context.Context, objectPath string) (bool, error)
// DeleteDirectory 刪除指定路徑的文件夾及其內容
DeleteDirectory(ctx context.Context, directoryPath string) error
// UploadWithTTL 從 io.Reader 上傳文件到指定路徑,並設置自訂過期時間
UploadWithTTL(ctx context.Context, content io.Reader, objectPath string, expires *time.Time) error
// UploadFromData 直接從數據上傳文件到指定路徑,可指定 MIME 類型
UploadFromData(ctx context.Context, data []byte, objectPath string, contentType string) error
// UploadFromPath 將本地文件從指定路徑上傳到對象存儲路徑
UploadFromPath(ctx context.Context, localFilePath string, objectPath string) error
// GetPublicURL 獲取對象的完整公共 URL
GetPublicURL(ctx context.Context, objectPath string) string
}

View File

@ -0,0 +1,29 @@
package usecase
import (
"context"
"io"
"time"
)
// FileStorageUseCase ...
type FileStorageUseCase interface {
// Download 下載指定路徑的對象並返回其內容
Download(ctx context.Context, objectPath string) ([]byte, error)
// Move 將對象從一個路徑移動到另一個指定路徑
Move(ctx context.Context, objectPath string, destinationPath string) error
// Delete 刪除指定路徑的對象
Delete(ctx context.Context, objectPath string) error
// Exists 檢查指定路徑的對象是否存在
Exists(ctx context.Context, objectPath string) (bool, error)
// DeleteDirectory 刪除指定路徑的文件夾及其內容
DeleteDirectory(ctx context.Context, directoryPath string) error
// UploadWithTTL 從 io.Reader 上傳文件到指定路徑,並設置自訂過期時間
UploadWithTTL(ctx context.Context, content io.Reader, objectPath string, expires *time.Time) error
// UploadFromData 直接從數據上傳文件到指定路徑,可指定 MIME 類型
UploadFromData(ctx context.Context, data []byte, objectPath string, contentType string) error
// UploadFromPath 將本地文件從指定路徑上傳到對象存儲路徑
UploadFromPath(ctx context.Context, localFilePath string, objectPath string) error
// GetPublicURL 獲取對象的完整公共 URL
GetPublicURL(ctx context.Context, objectPath string) string
}

View File

@ -0,0 +1,247 @@
package repository
import (
"backend/pkg/fileStorage/config"
s3Storage "backend/pkg/fileStorage/domain/aws"
"backend/pkg/fileStorage/domain/repository"
"backend/pkg/fileStorage/utils"
errs "backend/pkg/library/errors"
"bytes"
"context"
"fmt"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/aws/aws-sdk-go/service/s3/s3manager"
"io"
"net/http"
"os"
"path"
"strings"
"time"
)
type AwsS3FileStorageRepositoryParam struct {
Conf *config.Config
Logger errs.Logger
}
type AwsS3FileStorageRepository struct {
AwsS3FileStorageRepositoryParam
}
func MustAwsS3FileStorageRepo(param AwsS3FileStorageRepositoryParam) repository.FileStorageRepository {
return &AwsS3FileStorageRepository{
param,
}
}
// 每次上傳都用一個新的 Session
func (repo *AwsS3FileStorageRepository) getAwsSession() (*session.Session, error) {
conf := &aws.Config{
Region: aws.String(repo.Conf.AmazonS3Settings.Region),
Credentials: credentials.NewStaticCredentials(
repo.Conf.AmazonS3Settings.AccessKey,
repo.Conf.AmazonS3Settings.SecretKey,
"",
),
}
awsSession, err := session.NewSession(conf)
if err != nil {
return nil, err
}
return awsSession, nil
}
func (repo *AwsS3FileStorageRepository) Move(_ context.Context, objectPath string, destinationPath string) error {
awsSession, _ := repo.getAwsSession()
svc := s3.New(awsSession)
copyObjectInput := &s3.CopyObjectInput{
Key: aws.String(destinationPath),
Bucket: aws.String(repo.Conf.AmazonS3Settings.Bucket),
CopySource: aws.String(path.Join(repo.Conf.AmazonS3Settings.Bucket, objectPath)),
}
deleteObjectInput := &s3.DeleteObjectInput{
Bucket: aws.String(repo.Conf.AmazonS3Settings.Bucket),
Key: aws.String(objectPath),
}
if _, err := svc.CopyObject(copyObjectInput); err != nil {
return err
}
if _, err := svc.DeleteObject(deleteObjectInput); err != nil {
return err
}
return nil
}
func (repo *AwsS3FileStorageRepository) Delete(_ context.Context, objectPath string) error {
awsSession, _ := repo.getAwsSession()
svc := s3.New(awsSession)
deleteObjectInput := &s3.DeleteObjectInput{
Bucket: aws.String(repo.Conf.AmazonS3Settings.Bucket),
Key: aws.String(objectPath),
}
deleteObjOutput, err := svc.DeleteObject(deleteObjectInput)
if err != nil {
return err
}
if deleteObjOutput.DeleteMarker != nil && deleteObjOutput.VersionId != nil {
repo.Logger.Info(fmt.Sprintf("s3 - delete object, delete marker: %t, VersionId: %s", *deleteObjOutput.DeleteMarker, *deleteObjOutput.VersionId))
}
return nil
}
func (repo *AwsS3FileStorageRepository) Exists(_ context.Context, objectPath string) (bool, error) {
awsSession, _ := repo.getAwsSession()
downloader := s3.New(awsSession)
_, err := downloader.HeadObject(&s3.HeadObjectInput{
Bucket: aws.String(repo.Conf.AmazonS3Settings.Bucket),
Key: aws.String(objectPath),
})
if err != nil {
return false, err
}
return true, nil
}
func (repo *AwsS3FileStorageRepository) DeleteDirectory(_ context.Context, directoryPath string) error {
if strings.HasPrefix(directoryPath, "/") {
directoryPath = directoryPath[1:]
}
awsSession, _ := repo.getAwsSession()
svc := s3.New(awsSession)
listObjectsInput := &s3.ListObjectsV2Input{
Bucket: aws.String(repo.Conf.AmazonS3Settings.Bucket),
Prefix: aws.String(directoryPath),
}
listObjectsOutput, err := svc.ListObjectsV2(listObjectsInput)
if err != nil {
return err
}
for _, object := range listObjectsOutput.Contents {
deleteObjectInput := &s3.DeleteObjectInput{
Bucket: aws.String(repo.Conf.AmazonS3Settings.Bucket),
Key: object.Key,
}
go func(inp *s3.DeleteObjectInput) {
_, _ = svc.DeleteObject(inp)
}(deleteObjectInput)
}
return nil
}
func (repo *AwsS3FileStorageRepository) UploadWithTTL(_ context.Context, content io.Reader, objectPath string, expires *time.Time) error {
awsSession, _ := repo.getAwsSession()
uploader := s3manager.NewUploader(awsSession)
_, err := uploader.Upload(&s3manager.UploadInput{
Bucket: aws.String(repo.Conf.AmazonS3Settings.Bucket),
Key: aws.String(objectPath),
Body: content,
ACL: aws.String(s3Storage.S3AclSetting),
ContentDisposition: aws.String("attachment"),
Expires: expires,
})
if err != nil {
return err
}
return nil
}
func (repo *AwsS3FileStorageRepository) UploadFromData(_ context.Context, data []byte, objectPath string, contentType string) error {
reader := bytes.NewReader(data)
awsSession, _ := repo.getAwsSession()
uploader := s3manager.NewUploader(awsSession)
_, err := uploader.Upload(&s3manager.UploadInput{
Bucket: aws.String(repo.Conf.AmazonS3Settings.Bucket),
Key: aws.String(objectPath),
Body: reader,
ACL: aws.String(s3Storage.S3AclSetting),
ContentType: aws.String(contentType),
})
if err != nil {
return err
}
return nil
}
func (repo *AwsS3FileStorageRepository) UploadFromPath(_ context.Context, localFilePath string, objectPath string) error {
file, err := os.Open(localFilePath)
if err != nil {
return err
}
defer file.Close()
fileInfo, _ := file.Stat()
buffer := make([]byte, fileInfo.Size())
_, _ = file.Read(buffer)
awsSession, err := repo.getAwsSession()
if err != nil {
return err
}
uploader := s3manager.NewUploader(awsSession)
_, err = uploader.Upload(&s3manager.UploadInput{
Bucket: aws.String(repo.Conf.AmazonS3Settings.Bucket),
Key: aws.String(objectPath),
Body: bytes.NewReader(buffer),
ContentType: aws.String(http.DetectContentType(buffer)),
ACL: aws.String(s3Storage.S3AclSetting),
ContentDisposition: aws.String("attachment"),
})
if err != nil {
return err
}
return nil
}
func (repo *AwsS3FileStorageRepository) GetPublicURL(_ context.Context, objectPath string) string {
dirPart := strings.Split(objectPath, string(os.PathSeparator))
return utils.URLJoin(repo.Conf.AmazonS3Settings.CloudFrontURI, dirPart...)
}
func (repo *AwsS3FileStorageRepository) Download(_ context.Context, objectPath string) ([]byte, error) {
awsSession, _ := repo.getAwsSession()
downloader := s3manager.NewDownloader(awsSession, func(d *s3manager.Downloader) {
d.PartSize = 64 * 1024 * 1024 // 每部分 64MB
d.Concurrency = 4
d.BufferProvider = s3manager.NewPooledBufferedWriterReadFromProvider(1024 * 1024 * 8)
})
buff := &aws.WriteAtBuffer{}
_, err := downloader.Download(buff, &s3.GetObjectInput{
Bucket: aws.String(repo.Conf.AmazonS3Settings.Bucket),
Key: aws.String(objectPath),
})
data := buff.Bytes()
if err != nil {
return nil, err
}
return data, nil
}

View File

@ -0,0 +1,158 @@
package usecase
import (
"backend/pkg/fileStorage/config"
"backend/pkg/fileStorage/domain/repository"
"backend/pkg/fileStorage/domain/usecase"
errs "backend/pkg/library/errors"
"context"
"io"
"time"
)
type AwsS3FileStorageUseCaseParam struct {
Conf *config.Config
Logger errs.Logger
Repo repository.FileStorageRepository
}
type AwsS3FileStorageUseCase struct {
AwsS3FileStorageUseCaseParam
}
func MustAwsS3FileStorageUseCase(param AwsS3FileStorageUseCaseParam) usecase.FileStorageUseCase {
return &AwsS3FileStorageUseCase{
param,
}
}
func (use *AwsS3FileStorageUseCase) Download(ctx context.Context, objectPath string) ([]byte, error) {
download, err := use.Repo.Download(ctx, objectPath)
if err != nil {
e := errs.SvcThirdPartyErrorL(use.Logger, []errs.LogField{
{Key: "path", Val: objectPath},
{Key: "action", Val: "AwsS3FileStorageUseCase.Download"},
{Key: "error", Val: err.Error()},
}, "s3 - download object failed").Wrap(err)
return nil, e
}
return download, nil
}
func (use *AwsS3FileStorageUseCase) Move(ctx context.Context, objectPath string, destinationPath string) error {
err := use.Repo.Move(ctx, objectPath, destinationPath)
if err != nil {
e := errs.SvcThirdPartyErrorL(use.Logger, []errs.LogField{
{Key: "source", Val: objectPath},
{Key: "destination", Val: destinationPath},
{Key: "action", Val: "AwsS3FileStorageUseCase.Move"},
{Key: "error", Val: err.Error()},
}, "s3 - s3 - move object failed").Wrap(err)
return e
}
return nil
}
func (use *AwsS3FileStorageUseCase) Delete(ctx context.Context, objectPath string) error {
err := use.Repo.Delete(ctx, objectPath)
if err != nil {
e := errs.SvcThirdPartyErrorL(use.Logger, []errs.LogField{
{Key: "path", Val: objectPath},
{Key: "action", Val: "AwsS3FileStorageUseCase.Delete"},
{Key: "error", Val: err.Error()},
}, "s3 - delete object failed").Wrap(err)
return e
}
return nil
}
func (use *AwsS3FileStorageUseCase) Exists(ctx context.Context, objectPath string) (bool, error) {
ex, err := use.Repo.Exists(ctx, objectPath)
if err != nil {
e := errs.SvcThirdPartyErrorL(use.Logger, []errs.LogField{
{Key: "path", Val: objectPath},
{Key: "error", Val: err.Error()},
}, "s3 - check exists object failed").Wrap(err)
return false, e
}
return ex, nil
}
func (use *AwsS3FileStorageUseCase) DeleteDirectory(ctx context.Context, directoryPath string) error {
err := use.Repo.DeleteDirectory(ctx, directoryPath)
return errs.SvcThirdPartyErrorL(
use.Logger,
[]errs.LogField{
{Key: "directory", Val: directoryPath},
{Key: "action", Val: "AwsS3FileStorageUseCase.DeleteDirectory"},
{Key: "error", Val: err.Error()},
},
"s3 - failed to list objects in folder",
).Wrap(err)
}
func (use *AwsS3FileStorageUseCase) UploadWithTTL(ctx context.Context, content io.Reader, objectPath string, expires *time.Time) error {
err := use.Repo.UploadWithTTL(ctx, content, objectPath, expires)
if err != nil {
e := errs.SvcThirdPartyErrorL(
use.Logger,
[]errs.LogField{
{Key: "action", Val: "AwsS3FileStorageUseCase.DeleteDirectory"},
{Key: "error", Val: err.Error()},
},
"s3 - failed to list objects in folder")
return e
}
return nil
}
func (use *AwsS3FileStorageUseCase) UploadFromData(ctx context.Context, data []byte, objectPath string, contentType string) error {
err := use.Repo.UploadFromData(ctx, data, objectPath, contentType)
if err != nil {
e := errs.SvcThirdPartyErrorL(
use.Logger,
[]errs.LogField{
{Key: "path", Val: objectPath},
{Key: "action", Val: "AwsS3FileStorageUseCase.UploadFromData"},
{Key: "error", Val: err.Error()},
},
"s3 - upload object failed")
return e
}
return nil
}
func (use *AwsS3FileStorageUseCase) UploadFromPath(ctx context.Context, localFilePath string, objectPath string) error {
err := use.Repo.UploadFromPath(ctx, localFilePath, objectPath)
if err != nil {
e := errs.SvcThirdPartyErrorL(
use.Logger,
[]errs.LogField{
{Key: "localPath", Val: localFilePath},
{Key: "action", Val: "AwsS3FileStorageUseCase.UploadFromPath"},
{Key: "error", Val: err.Error()},
},
"s3 - upload object failed")
return e
}
return nil
}
func (use *AwsS3FileStorageUseCase) GetPublicURL(ctx context.Context, objectPath string) string {
return use.Repo.GetPublicURL(ctx, objectPath)
}

14
pkg/fileStorage/utils/utils.go Executable file
View File

@ -0,0 +1,14 @@
package utils
import (
"net/url"
"path"
)
func URLJoin(baseURL string, paths ...string) string {
u, _ := url.Parse(baseURL)
pathElements := append([]string{u.Path}, paths...)
u.Path = path.Join(pathElements...)
return u.String()
}

View File

@ -0,0 +1,61 @@
package utils
import (
"testing"
"github.com/stretchr/testify/assert"
)
// 測試 URLJoin 函數
func TestURLJoin(t *testing.T) {
tests := []struct {
name string
baseURL string
paths []string
expected string
}{
{
name: "Base URL without trailing slash and single path",
baseURL: "https://example.com",
paths: []string{"path1"},
expected: "https://example.com/path1",
},
{
name: "Base URL with trailing slash and single path",
baseURL: "https://example.com/",
paths: []string{"path1"},
expected: "https://example.com/path1",
},
{
name: "Base URL without trailing slash and multiple paths",
baseURL: "https://example.com",
paths: []string{"path1", "path2", "path3"},
expected: "https://example.com/path1/path2/path3",
},
{
name: "Base URL with trailing slash and multiple paths",
baseURL: "https://example.com/",
paths: []string{"path1", "path2", "path3"},
expected: "https://example.com/path1/path2/path3",
},
{
name: "Empty path elements",
baseURL: "https://example.com",
paths: []string{},
expected: "https://example.com",
},
{
name: "Path elements with leading slashes",
baseURL: "https://example.com",
paths: []string{"/path1/", "/path2", "path3"},
expected: "https://example.com/path1/path2/path3",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := URLJoin(tt.baseURL, tt.paths...)
assert.Equal(t, tt.expected, result, "Expected URL to match")
})
}
}