diff --git a/etc/gateway.yaml b/etc/gateway.yaml index 5020996..e125572 100644 --- a/etc/gateway.yaml +++ b/etc/gateway.yaml @@ -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: \ No newline at end of file diff --git a/generate/api/file_storage.api b/generate/api/file_storage.api new file mode 100644 index 0000000..fce2613 --- /dev/null +++ b/generate/api/file_storage.api @@ -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) +} \ No newline at end of file diff --git a/generate/api/gateway.api b/generate/api/gateway.api index f06716c..12663fc 100755 --- a/generate/api/gateway.api +++ b/generate/api/gateway.api @@ -16,5 +16,6 @@ import ( "common.api" "ping.api" "member.api" + "file_storage.api" ) diff --git a/go.mod b/go.mod index c7f15c6..5300f0e 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 4d1a028..3c2c7e1 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go index 55708db..639607f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 + } } diff --git a/internal/handler/fileStorage/upload_img_handler.go b/internal/handler/fileStorage/upload_img_handler.go new file mode 100644 index 0000000..467314c --- /dev/null +++ b/internal/handler/fileStorage/upload_img_handler.go @@ -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) + } + } +} diff --git a/internal/handler/fileStorage/upload_video_handler.go b/internal/handler/fileStorage/upload_video_handler.go new file mode 100644 index 0000000..c631746 --- /dev/null +++ b/internal/handler/fileStorage/upload_video_handler.go @@ -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) + } + } +} diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 23cfd99..f7d60b8 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -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{ { diff --git a/internal/logic/fileStorage/upload_img_logic.go b/internal/logic/fileStorage/upload_img_logic.go new file mode 100644 index 0000000..2317a53 --- /dev/null +++ b/internal/logic/fileStorage/upload_img_logic.go @@ -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" + } +} diff --git a/internal/logic/fileStorage/upload_video_logic.go b/internal/logic/fileStorage/upload_video_logic.go new file mode 100644 index 0000000..7077d05 --- /dev/null +++ b/internal/logic/fileStorage/upload_video_logic.go @@ -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" + } +} diff --git a/internal/svc/file_storage.go b/internal/svc/file_storage.go new file mode 100644 index 0000000..7bb2460 --- /dev/null +++ b/internal/svc/file_storage.go @@ -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 +} diff --git a/internal/svc/service_context.go b/internal/svc/service_context.go index ff8d0dd..36ae88c 100644 --- a/internal/svc/service_context.go +++ b/internal/svc/service_context.go @@ -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, } } diff --git a/internal/types/types.go b/internal/types/types.go index c74ede7..7e7c471 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -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 diff --git a/pkg/fileStorage/config/config.go b/pkg/fileStorage/config/config.go new file mode 100644 index 0000000..780e11f --- /dev/null +++ b/pkg/fileStorage/config/config.go @@ -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 + } +} diff --git a/pkg/fileStorage/domain/aws/payload.go b/pkg/fileStorage/domain/aws/payload.go new file mode 100755 index 0000000..b4bb919 --- /dev/null +++ b/pkg/fileStorage/domain/aws/payload.go @@ -0,0 +1,16 @@ +package domain + +const ( + S3AclPublic = "public-read" + S3AclPrivate = "private" +) + +var S3AclSetting = S3AclPublic + +func SetACLIsPublic() { + S3AclSetting = S3AclPublic +} + +func SetACLIsPrivate() { + S3AclSetting = S3AclPrivate +} diff --git a/pkg/fileStorage/domain/repository/file_storage.go b/pkg/fileStorage/domain/repository/file_storage.go new file mode 100644 index 0000000..57da670 --- /dev/null +++ b/pkg/fileStorage/domain/repository/file_storage.go @@ -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 +} diff --git a/pkg/fileStorage/domain/usecase/file_storage.go b/pkg/fileStorage/domain/usecase/file_storage.go new file mode 100644 index 0000000..1e0f67f --- /dev/null +++ b/pkg/fileStorage/domain/usecase/file_storage.go @@ -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 +} diff --git a/pkg/fileStorage/repository/aws_s3.go b/pkg/fileStorage/repository/aws_s3.go new file mode 100644 index 0000000..dc528cf --- /dev/null +++ b/pkg/fileStorage/repository/aws_s3.go @@ -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 +} diff --git a/pkg/fileStorage/usecase/aws_s3.go b/pkg/fileStorage/usecase/aws_s3.go new file mode 100644 index 0000000..9bbfec4 --- /dev/null +++ b/pkg/fileStorage/usecase/aws_s3.go @@ -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) +} diff --git a/pkg/fileStorage/utils/utils.go b/pkg/fileStorage/utils/utils.go new file mode 100755 index 0000000..cacf707 --- /dev/null +++ b/pkg/fileStorage/utils/utils.go @@ -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() +} diff --git a/pkg/fileStorage/utils/utils_test.go b/pkg/fileStorage/utils/utils_test.go new file mode 100755 index 0000000..03ad52b --- /dev/null +++ b/pkg/fileStorage/utils/utils_test.go @@ -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") + }) + } +}