fix: submit verification code
This commit is contained in:
parent
432343fa6f
commit
0d13e54be5
|
|
@ -7,13 +7,11 @@ Cache:
|
||||||
type: node
|
type: node
|
||||||
CacheExpireTime: 1s
|
CacheExpireTime: 1s
|
||||||
CacheWithNotFoundExpiry: 1s
|
CacheWithNotFoundExpiry: 1s
|
||||||
|
|
||||||
RedisConf:
|
RedisConf:
|
||||||
Host: 127.0.0.1:6379
|
Host: 127.0.0.1:6379
|
||||||
Type: node
|
Type: node
|
||||||
Pass: ""
|
Pass: ""
|
||||||
Tls: false
|
Tls: false
|
||||||
|
|
||||||
Mongo:
|
Mongo:
|
||||||
Schema: mongodb
|
Schema: mongodb
|
||||||
Host: "127.0.0.1:27017"
|
Host: "127.0.0.1:27017"
|
||||||
|
|
@ -30,19 +28,15 @@ Mongo:
|
||||||
- f
|
- f
|
||||||
EnableStandardReadWriteSplitMode: true
|
EnableStandardReadWriteSplitMode: true
|
||||||
ConnectTimeoutMs : 300
|
ConnectTimeoutMs : 300
|
||||||
|
|
||||||
Bcrypt:
|
Bcrypt:
|
||||||
Cost: 10
|
Cost: 10
|
||||||
|
|
||||||
GoogleAuth:
|
GoogleAuth:
|
||||||
ClientID: xxx.apps.googleusercontent.com
|
ClientID: xxx.apps.googleusercontent.com
|
||||||
AuthURL: x
|
AuthURL: x
|
||||||
|
|
||||||
LineAuth:
|
LineAuth:
|
||||||
ClientID : "200000000"
|
ClientID : "200000000"
|
||||||
ClientSecret : xxxxx
|
ClientSecret : xxxxx
|
||||||
RedirectURI : http://localhost:8080/line.html
|
RedirectURI : http://localhost:8080/line.html
|
||||||
|
|
||||||
Token:
|
Token:
|
||||||
AccessSecret : "1qaz@WSX3edc$RFV"
|
AccessSecret : "1qaz@WSX3edc$RFV"
|
||||||
RefreshSecret : "1qaz@WSX3edc$RFV"
|
RefreshSecret : "1qaz@WSX3edc$RFV"
|
||||||
|
|
@ -51,16 +45,12 @@ Token:
|
||||||
OneTimeTokenExpiry : 600s
|
OneTimeTokenExpiry : 600s
|
||||||
MaxTokensPerUser : 2
|
MaxTokensPerUser : 2
|
||||||
MaxTokensPerDevice : 2
|
MaxTokensPerDevice : 2
|
||||||
|
|
||||||
|
|
||||||
RoleConfig:
|
RoleConfig:
|
||||||
UIDPrefix: "AM"
|
UIDPrefix: "AM"
|
||||||
UIDLength: 6
|
UIDLength: 6
|
||||||
AdminRoleUID: "AM000000"
|
AdminRoleUID: "AM000000"
|
||||||
AdminUserUID: "B000000"
|
AdminUserUID: "B000000"
|
||||||
DefaultRoleName: "USER"
|
DefaultRoleName: "USER"
|
||||||
|
|
||||||
|
|
||||||
SMTPConfig:
|
SMTPConfig:
|
||||||
Enable: true
|
Enable: true
|
||||||
GoroutinePoolNum: 1000
|
GoroutinePoolNum: 1000
|
||||||
|
|
@ -70,8 +60,6 @@ SMTPConfig:
|
||||||
Password: 595da25c2a44ef2629ba92bf88ae94f1-02300200-af1d3b04
|
Password: 595da25c2a44ef2629ba92bf88ae94f1-02300200-af1d3b04
|
||||||
Sender: daniel.wang@code.30cm.net
|
Sender: daniel.wang@code.30cm.net
|
||||||
SenderName: "Digimon 平台"
|
SenderName: "Digimon 平台"
|
||||||
|
|
||||||
|
|
||||||
DeliveryConfig:
|
DeliveryConfig:
|
||||||
max_retries : 5
|
max_retries : 5
|
||||||
initial_delay : 500ms
|
initial_delay : 500ms
|
||||||
|
|
@ -79,3 +67,12 @@ DeliveryConfig:
|
||||||
max_delay : 5000ms
|
max_delay : 5000ms
|
||||||
Timeout: 1000ms
|
Timeout: 1000ms
|
||||||
enable_history: false
|
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"
|
"common.api"
|
||||||
"ping.api"
|
"ping.api"
|
||||||
"member.api"
|
"member.api"
|
||||||
|
"file_storage.api"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
2
go.mod
2
go.mod
|
|
@ -5,6 +5,7 @@ go 1.25.1
|
||||||
require (
|
require (
|
||||||
code.30cm.net/digimon/library-go/utils/invited_code v1.2.5
|
code.30cm.net/digimon/library-go/utils/invited_code v1.2.5
|
||||||
github.com/alicebob/miniredis/v2 v2.35.0
|
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 v1.39.6
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.21
|
github.com/aws/aws-sdk-go-v2/credentials v1.18.21
|
||||||
github.com/aws/aws-sdk-go-v2/service/ses v1.34.9
|
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/huandu/xstrings v1.2.0 // indirect
|
||||||
github.com/imdario/mergo v0.3.6 // indirect
|
github.com/imdario/mergo v0.3.6 // indirect
|
||||||
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0 // 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/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // 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/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 h1:7fpzNGoJ3VA8qcrm++XEE1QUe0mIwNeLa02Nwq7RDkg=
|
||||||
github.com/aokoli/goutils v1.0.1/go.mod h1:SijmP0QR8LtwsmDs8Yii5Z/S4trXFGFC2oO5g9DP+DQ=
|
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 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 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=
|
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/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 h1:xqgexXAGQgY3HAjNPSaCqn5Aahbo5TKsmhp8VRfr1iQ=
|
||||||
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
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 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
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=
|
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 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
|
||||||
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
|
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.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 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
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=
|
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"` // 單次發送超時時間
|
Timeout time.Duration `json:"timeout"` // 單次發送超時時間
|
||||||
EnableHistory bool `json:"enable_history"` // 是否啟用歷史記錄
|
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.
|
// Code generated by goctl. DO NOT EDIT.
|
||||||
// goctl 1.8.1
|
// goctl 1.9.0
|
||||||
|
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
auth "backend/internal/handler/auth"
|
auth "backend/internal/handler/auth"
|
||||||
|
fileStorage "backend/internal/handler/fileStorage"
|
||||||
ping "backend/internal/handler/ping"
|
ping "backend/internal/handler/ping"
|
||||||
user "backend/internal/handler/user"
|
user "backend/internal/handler/user"
|
||||||
"backend/internal/svc"
|
"backend/internal/svc"
|
||||||
|
|
@ -59,6 +60,28 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||||
rest.WithTimeout(10000*time.Millisecond),
|
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(
|
server.AddRoutes(
|
||||||
[]rest.Route{
|
[]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"
|
"context"
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
|
|
||||||
|
fileStorageUC "backend/pkg/fileStorage/domain/usecase"
|
||||||
vi "backend/pkg/library/validator"
|
vi "backend/pkg/library/validator"
|
||||||
memberUC "backend/pkg/member/domain/usecase"
|
memberUC "backend/pkg/member/domain/usecase"
|
||||||
deliveryUC "backend/pkg/notification/domain/usecase"
|
deliveryUC "backend/pkg/notification/domain/usecase"
|
||||||
|
|
@ -28,6 +29,7 @@ type ServiceContext struct {
|
||||||
RolePermission tokenUC.RolePermissionUseCase
|
RolePermission tokenUC.RolePermissionUseCase
|
||||||
UserRoleUC tokenUC.UserRoleUseCase
|
UserRoleUC tokenUC.UserRoleUseCase
|
||||||
DeliveryUC deliveryUC.DeliveryUseCase
|
DeliveryUC deliveryUC.DeliveryUseCase
|
||||||
|
FileStorageUC fileStorageUC.FileStorageUseCase
|
||||||
Redis *redis.Redis
|
Redis *redis.Redis
|
||||||
Logger errs.Logger
|
Logger errs.Logger
|
||||||
}
|
}
|
||||||
|
|
@ -58,6 +60,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
||||||
UserRoleUC: rp.UserRole,
|
UserRoleUC: rp.UserRole,
|
||||||
Redis: rds,
|
Redis: rds,
|
||||||
DeliveryUC: MustDeliveryUseCase(&c, lgr),
|
DeliveryUC: MustDeliveryUseCase(&c, lgr),
|
||||||
|
FileStorageUC: MustS3Storage(&c, lgr),
|
||||||
Logger: lgr,
|
Logger: lgr,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// Code generated by goctl. DO NOT EDIT.
|
// Code generated by goctl. DO NOT EDIT.
|
||||||
// goctl 1.8.1
|
// goctl 1.9.0
|
||||||
|
|
||||||
package types
|
package types
|
||||||
|
|
||||||
|
|
@ -132,6 +132,21 @@ type UpdateUserInfoReq struct {
|
||||||
Carrier *string `json:"carrier,optional"` // 載具
|
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 {
|
type UserInfoResp struct {
|
||||||
Platform string `json:"platform"` // 註冊平台
|
Platform string `json:"platform"` // 註冊平台
|
||||||
UID string `json:"uid"` // 用戶 UID
|
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