fix: submit verification code
This commit is contained in:
parent
432343fa6f
commit
0d13e54be5
|
|
@ -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:
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -16,5 +16,6 @@ import (
|
|||
"common.api"
|
||||
"ping.api"
|
||||
"member.api"
|
||||
"file_storage.api"
|
||||
)
|
||||
|
||||
|
|
|
|||
2
go.mod
2
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
|
||||
|
|
|
|||
7
go.sum
7
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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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{
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package domain
|
||||
|
||||
const (
|
||||
S3AclPublic = "public-read"
|
||||
S3AclPrivate = "private"
|
||||
)
|
||||
|
||||
var S3AclSetting = S3AclPublic
|
||||
|
||||
func SetACLIsPublic() {
|
||||
S3AclSetting = S3AclPublic
|
||||
}
|
||||
|
||||
func SetACLIsPrivate() {
|
||||
S3AclSetting = S3AclPrivate
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue