Compare commits
8 Commits
b1a8926532
...
3a3b3c99f0
| Author | SHA1 | Date |
|---|---|---|
|
|
3a3b3c99f0 | |
|
|
28b232159f | |
|
|
0d13e54be5 | |
|
|
432343fa6f | |
|
|
20a05ea195 | |
|
|
4c8121bfdf | |
|
|
da62b8f230 | |
|
|
3d9d05f4f6 |
|
|
@ -8,7 +8,6 @@ services:
|
||||||
environment:
|
environment:
|
||||||
MONGO_INITDB_ROOT_USERNAME: root
|
MONGO_INITDB_ROOT_USERNAME: root
|
||||||
MONGO_INITDB_ROOT_PASSWORD: example
|
MONGO_INITDB_ROOT_PASSWORD: example
|
||||||
|
|
||||||
etcd:
|
etcd:
|
||||||
image: quay.io/coreos/etcd:v3.5.5
|
image: quay.io/coreos/etcd:v3.5.5
|
||||||
container_name: etcd
|
container_name: etcd
|
||||||
|
|
@ -22,10 +21,21 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- "2379:2379"
|
- "2379:2379"
|
||||||
- "2380:2380"
|
- "2380:2380"
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7.0
|
image: redis:7.0
|
||||||
container_name: redis
|
container_name: redis
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio
|
||||||
|
container_name: minio
|
||||||
|
ports:
|
||||||
|
- "9000:9000" # MinIO S3 API port
|
||||||
|
- "9001:9001" # MinIO Console port
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: minioadmin # Replace with your desired root username
|
||||||
|
MINIO_ROOT_PASSWORD: minioadmin # Replace with your desired root password
|
||||||
|
# MINIO_DEFAULT_BUCKETS: mybucket # Optional: Create a default bucket on startup
|
||||||
|
command: server /data --console-address ":9001" # Start MinIO server and specify console address
|
||||||
|
|
|
||||||
|
|
@ -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,11 +45,34 @@ 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:
|
||||||
|
Enable: true
|
||||||
|
GoroutinePoolNum: 1000
|
||||||
|
Host: smtp.mailgun.org
|
||||||
|
Port: 465
|
||||||
|
Username: postmaster@code.30cm.net
|
||||||
|
Password: 595da25c2a44ef2629ba92bf88ae94f1-02300200-af1d3b04
|
||||||
|
Sender: daniel.wang@code.30cm.net
|
||||||
|
SenderName: "Digimon 平台"
|
||||||
|
DeliveryConfig:
|
||||||
|
max_retries : 5
|
||||||
|
initial_delay : 500ms
|
||||||
|
backoff_factor: 2.0
|
||||||
|
max_delay : 5000ms
|
||||||
|
Timeout: 1000ms
|
||||||
|
enable_history: false
|
||||||
|
AmazonS3Settings:
|
||||||
|
Region: ap-northeast-3
|
||||||
|
Bucket: gutenbergtw-prod
|
||||||
|
CloudFrontDomain: d2gk3kpttfhhhf.cloudfront.net
|
||||||
|
CloudFrontURI: https://d2gk3kpttfhhhf.cloudfront.net
|
||||||
|
BucketURI: https://gutenbergtw-prod.s3.ap-northeast-3.amazonaws.com
|
||||||
|
AccessKey: AKIAVRUVVY4IJOBFOY42
|
||||||
|
SecretKey: sSpml0h3k0y2hU5A+Fxlhcv+QGt4ddobttvvlxm+
|
||||||
|
CloudFrontID: E3UMOQ0CGBOBAE
|
||||||
389
gateway.json
389
gateway.json
|
|
@ -29,27 +29,6 @@
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"ErrorResp": {
|
|
||||||
"properties": {
|
|
||||||
"code": {
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"details": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"description": "可選的錯誤信息"
|
|
||||||
},
|
|
||||||
"msg": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"code",
|
|
||||||
"msg"
|
|
||||||
],
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"LoginReq": {
|
"LoginReq": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"auth_method": {
|
"auth_method": {
|
||||||
|
|
@ -97,6 +76,100 @@
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"MyInfo": {
|
||||||
|
"properties": {
|
||||||
|
"address": {
|
||||||
|
"description": "地址",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"alarm_category": {
|
||||||
|
"description": "告警狀態",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"avatar_url": {
|
||||||
|
"description": "頭像 URL",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"birthdate": {
|
||||||
|
"description": "生日 (格式: 1993-04-17)",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"carrier": {
|
||||||
|
"description": "載具",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"create_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"currency": {
|
||||||
|
"description": "偏好幣種",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"description": "信箱",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"full_name": {
|
||||||
|
"description": "用戶全名",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"gender_code": {
|
||||||
|
"description": "性別代碼",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"is_email_verified": {
|
||||||
|
"description": "信箱是否已驗證",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"is_phone_verified": {
|
||||||
|
"description": "手機是否已驗證",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"nickname": {
|
||||||
|
"description": "暱稱",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"phone_number": {
|
||||||
|
"description": "電話",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"platform": {
|
||||||
|
"description": "註冊平台",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"post_code": {
|
||||||
|
"description": "郵遞區號",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"preferred_language": {
|
||||||
|
"description": "偏好語言",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"description": "角色",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"uid": {
|
||||||
|
"description": "用戶 UID",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"update_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"user_status": {
|
||||||
|
"description": "用戶狀態",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"platform",
|
||||||
|
"uid",
|
||||||
|
"role",
|
||||||
|
"update_at",
|
||||||
|
"create_at"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"PagerResp": {
|
"PagerResp": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"index": {
|
"index": {
|
||||||
|
|
@ -135,11 +208,15 @@
|
||||||
},
|
},
|
||||||
"RefreshTokenReq": {
|
"RefreshTokenReq": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"access_token": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"refresh_token": {
|
"refresh_token": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
"access_token",
|
||||||
"refresh_token"
|
"refresh_token"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
|
|
@ -182,6 +259,9 @@
|
||||||
},
|
},
|
||||||
"RequestVerificationCodeReq": {
|
"RequestVerificationCodeReq": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"Account": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"purpose": {
|
"purpose": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
|
@ -217,24 +297,33 @@
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"RespOK": {
|
"Resp": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"code": {
|
"code": {
|
||||||
"type": "integer"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"data": {},
|
"data": {},
|
||||||
"msg": {
|
"error": {
|
||||||
|
"description": "可選的錯誤信息"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"code",
|
"code",
|
||||||
"msg"
|
"message"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"RespOK": {
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"SubmitVerificationCodeReq": {
|
"SubmitVerificationCodeReq": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"Account": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"purpose": {
|
"purpose": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
@ -316,6 +405,41 @@
|
||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"UploadImgReq": {
|
||||||
|
"properties": {
|
||||||
|
"content": {
|
||||||
|
"description": "base64 編碼的圖片內容",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"content"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"UploadResp": {
|
||||||
|
"properties": {
|
||||||
|
"file_size": {
|
||||||
|
"description": "文件大小(bytes)",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"file_url": {
|
||||||
|
"description": "文件訪問 URL",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"mime_type": {
|
||||||
|
"description": "MIME 類型",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"file_url"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"UploadVideoReq": {
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"UserInfoResp": {
|
"UserInfoResp": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"address": {
|
"address": {
|
||||||
|
|
@ -330,10 +454,6 @@
|
||||||
"description": "生日 (格式: 1993-04-17)",
|
"description": "生日 (格式: 1993-04-17)",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"carrier": {
|
|
||||||
"description": "載具",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"create_at": {
|
"create_at": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
@ -361,10 +481,6 @@
|
||||||
"description": "手機是否已驗證",
|
"description": "手機是否已驗證",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"national": {
|
|
||||||
"description": "國家",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"nickname": {
|
"nickname": {
|
||||||
"description": "暱稱",
|
"description": "暱稱",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|
@ -377,10 +493,6 @@
|
||||||
"description": "註冊平台",
|
"description": "註冊平台",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"post_code": {
|
|
||||||
"description": "郵遞區號",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"preferred_language": {
|
"preferred_language": {
|
||||||
"description": "偏好語言",
|
"description": "偏好語言",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|
@ -417,9 +529,6 @@
|
||||||
"user_status",
|
"user_status",
|
||||||
"preferred_language",
|
"preferred_language",
|
||||||
"currency",
|
"currency",
|
||||||
"national",
|
|
||||||
"post_code",
|
|
||||||
"carrier",
|
|
||||||
"role",
|
"role",
|
||||||
"update_at",
|
"update_at",
|
||||||
"create_at"
|
"create_at"
|
||||||
|
|
@ -482,9 +591,7 @@
|
||||||
"400": {
|
"400": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "\"驗證碼無效或請求參數錯誤\""
|
"description": "\"驗證碼無效或請求參數錯誤\""
|
||||||
|
|
@ -492,9 +599,7 @@
|
||||||
"500": {
|
"500": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "// 伺服器內部錯誤"
|
"description": "// 伺服器內部錯誤"
|
||||||
|
|
@ -531,9 +636,7 @@
|
||||||
"400": {
|
"400": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "\"請求參數格式錯誤\""
|
"description": "\"請求參數格式錯誤\""
|
||||||
|
|
@ -541,9 +644,7 @@
|
||||||
"429": {
|
"429": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "\"請求過於頻繁\" // 429 Too Many Requests"
|
"description": "\"請求過於頻繁\" // 429 Too Many Requests"
|
||||||
|
|
@ -551,9 +652,7 @@
|
||||||
"500": {
|
"500": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "// 伺服器內部錯誤"
|
"description": "// 伺服器內部錯誤"
|
||||||
|
|
@ -590,9 +689,7 @@
|
||||||
"400": {
|
"400": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "\"驗證碼無效或已過期\""
|
"description": "\"驗證碼無效或已過期\""
|
||||||
|
|
@ -600,9 +697,7 @@
|
||||||
"500": {
|
"500": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "// 伺服器內部錯誤"
|
"description": "// 伺服器內部錯誤"
|
||||||
|
|
@ -639,9 +734,7 @@
|
||||||
"400": {
|
"400": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "\"請求參數格式錯誤\""
|
"description": "\"請求參數格式錯誤\""
|
||||||
|
|
@ -649,9 +742,7 @@
|
||||||
"409": {
|
"409": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "\"帳號已被註冊\" // 409 Conflict: 資源衝突"
|
"description": "\"帳號已被註冊\" // 409 Conflict: 資源衝突"
|
||||||
|
|
@ -659,9 +750,7 @@
|
||||||
"500": {
|
"500": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "// 伺服器內部錯誤"
|
"description": "// 伺服器內部錯誤"
|
||||||
|
|
@ -698,9 +787,7 @@
|
||||||
"400": {
|
"400": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "\"請求參數格式錯誤\""
|
"description": "\"請求參數格式錯誤\""
|
||||||
|
|
@ -708,9 +795,7 @@
|
||||||
"401": {
|
"401": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "\"帳號或密碼錯誤 / 無效的平台 Token\" // 401 Unauthorized"
|
"description": "\"帳號或密碼錯誤 / 無效的平台 Token\" // 401 Unauthorized"
|
||||||
|
|
@ -718,9 +803,7 @@
|
||||||
"500": {
|
"500": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "// 伺服器內部錯誤"
|
"description": "// 伺服器內部錯誤"
|
||||||
|
|
@ -757,9 +840,7 @@
|
||||||
"400": {
|
"400": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "\"請求參數格式錯誤\""
|
"description": "\"請求參數格式錯誤\""
|
||||||
|
|
@ -767,9 +848,7 @@
|
||||||
"401": {
|
"401": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "\"無效或已過期的 Refresh Token\" // 401 Unauthorized"
|
"description": "\"無效或已過期的 Refresh Token\" // 401 Unauthorized"
|
||||||
|
|
@ -777,9 +856,7 @@
|
||||||
"500": {
|
"500": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "// 伺服器內部錯誤"
|
"description": "// 伺服器內部錯誤"
|
||||||
|
|
@ -788,6 +865,72 @@
|
||||||
"summary": "刷新 Access Token"
|
"summary": "刷新 Access Token"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/fileStorage/fileStorage/img/upload": {
|
||||||
|
"post": {
|
||||||
|
"description": "上傳轉成 base64 過後的圖片,建議圖片大小不超過 10MB",
|
||||||
|
"operationId": "fileStorageUploadImgHandler",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "header",
|
||||||
|
"name": "Authorization",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UploadImgReq"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UploadResp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "create - 上傳圖片檔案"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/fileStorage/fileStorage/video/upload": {
|
||||||
|
"post": {
|
||||||
|
"description": "使用 multipart/form-data 上傳影片檔案,form field 名稱為 'file',注意:大檔案(\u003e50MB)建議使用分片上傳機制",
|
||||||
|
"operationId": "fileStorageUploadVideoHandler",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "header",
|
||||||
|
"name": "Authorization",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UploadResp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "create - 上傳影片檔案"
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/health": {
|
"/api/v1/health": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "檢查系統服務狀態,用於監控和負載均衡器健康檢查。返回系統運行狀態信息。",
|
"description": "檢查系統服務狀態,用於監控和負載均衡器健康檢查。返回系統運行狀態信息。",
|
||||||
|
|
@ -831,9 +974,7 @@
|
||||||
"401": {
|
"401": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "\"未授權或 Token 無效\""
|
"description": "\"未授權或 Token 無效\""
|
||||||
|
|
@ -841,9 +982,7 @@
|
||||||
"404": {
|
"404": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "\"找不到使用者\""
|
"description": "\"找不到使用者\""
|
||||||
|
|
@ -851,9 +990,7 @@
|
||||||
"500": {
|
"500": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "// 伺服器內部錯誤"
|
"description": "// 伺服器內部錯誤"
|
||||||
|
|
@ -888,9 +1025,7 @@
|
||||||
"400": {
|
"400": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "\"請求參數格式錯誤\""
|
"description": "\"請求參數格式錯誤\""
|
||||||
|
|
@ -898,9 +1033,7 @@
|
||||||
"401": {
|
"401": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "\"未授權或 Token 無效\""
|
"description": "\"未授權或 Token 無效\""
|
||||||
|
|
@ -908,9 +1041,7 @@
|
||||||
"500": {
|
"500": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "// 伺服器內部錯誤"
|
"description": "// 伺服器內部錯誤"
|
||||||
|
|
@ -956,9 +1087,7 @@
|
||||||
"400": {
|
"400": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "\"請求參數格式錯誤或新舊密碼不符\""
|
"description": "\"請求參數格式錯誤或新舊密碼不符\""
|
||||||
|
|
@ -966,9 +1095,7 @@
|
||||||
"401": {
|
"401": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "\"未授權或 Token 無效\""
|
"description": "\"未授權或 Token 無效\""
|
||||||
|
|
@ -976,9 +1103,7 @@
|
||||||
"403": {
|
"403": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "\"當前密碼不正確\" // 403 Forbidden"
|
"description": "\"當前密碼不正確\" // 403 Forbidden"
|
||||||
|
|
@ -986,9 +1111,7 @@
|
||||||
"500": {
|
"500": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "// 伺服器內部錯誤"
|
"description": "// 伺服器內部錯誤"
|
||||||
|
|
@ -1034,9 +1157,7 @@
|
||||||
"400": {
|
"400": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "\"請求參數格式錯誤\""
|
"description": "\"請求參數格式錯誤\""
|
||||||
|
|
@ -1044,9 +1165,7 @@
|
||||||
"401": {
|
"401": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "\"未授權或 Token 無效\""
|
"description": "\"未授權或 Token 無效\""
|
||||||
|
|
@ -1054,9 +1173,7 @@
|
||||||
"429": {
|
"429": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "\"請求過於頻繁\" // 429 Too Many Requests"
|
"description": "\"請求過於頻繁\" // 429 Too Many Requests"
|
||||||
|
|
@ -1064,9 +1181,7 @@
|
||||||
"500": {
|
"500": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "// 伺服器內部錯誤"
|
"description": "// 伺服器內部錯誤"
|
||||||
|
|
@ -1110,9 +1225,7 @@
|
||||||
"400": {
|
"400": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "\"驗證碼無效或已過期\""
|
"description": "\"驗證碼無效或已過期\""
|
||||||
|
|
@ -1120,9 +1233,7 @@
|
||||||
"401": {
|
"401": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "\"未授權或 Token 無效\""
|
"description": "\"未授權或 Token 無效\""
|
||||||
|
|
@ -1130,9 +1241,7 @@
|
||||||
"500": {
|
"500": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {}
|
||||||
"$ref": "#/components/schemas/ErrorResp"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "// 伺服器內部錯誤"
|
"description": "// 伺服器內部錯誤"
|
||||||
|
|
@ -1150,7 +1259,7 @@
|
||||||
"url": "https://localhost:8888"
|
"url": "https://localhost:8888"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"x-date": "2025-10-02 23:03:53",
|
"x-date": "2025-11-12 14:59:58",
|
||||||
"x-description": "This is a go-doc generated swagger file.",
|
"x-description": "This is a go-doc generated swagger file.",
|
||||||
"x-generator": "go-doc",
|
"x-generator": "go-doc",
|
||||||
"x-github": "https://github.com/danielchan-25/go-doc",
|
"x-github": "https://github.com/danielchan-25/go-doc",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,13 +94,9 @@ type (
|
||||||
UserStatus string `json:"user_status"` // 用戶狀態
|
UserStatus string `json:"user_status"` // 用戶狀態
|
||||||
PreferredLanguage string `json:"preferred_language"` // 偏好語言
|
PreferredLanguage string `json:"preferred_language"` // 偏好語言
|
||||||
Currency string `json:"currency"` // 偏好幣種
|
Currency string `json:"currency"` // 偏好幣種
|
||||||
National string `json:"national"` // 國家
|
|
||||||
PostCode string `json:"post_code"` // 郵遞區號
|
|
||||||
Carrier string `json:"carrier"` // 載具
|
|
||||||
Role string `json:"role"` // 角色
|
Role string `json:"role"` // 角色
|
||||||
UpdateAt string `json:"update_at"`
|
UpdateAt string `json:"update_at"`
|
||||||
CreateAt string `json:"create_at"`
|
CreateAt string `json:"create_at"`
|
||||||
Authorization
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateUserInfoReq 更新會員資訊的請求結構
|
// UpdateUserInfoReq 更新會員資訊的請求結構
|
||||||
|
|
@ -131,12 +127,14 @@ type (
|
||||||
// RequestVerificationCodeReq 請求發送驗證碼
|
// RequestVerificationCodeReq 請求發送驗證碼
|
||||||
RequestVerificationCodeReq {
|
RequestVerificationCodeReq {
|
||||||
// 驗證目的:'email_verification' 或 'phone_verification'
|
// 驗證目的:'email_verification' 或 'phone_verification'
|
||||||
|
Account string `json:"account" validate:"required`
|
||||||
Purpose string `json:"purpose" validate:"required,oneof=email_verification phone_verification"`
|
Purpose string `json:"purpose" validate:"required,oneof=email_verification phone_verification"`
|
||||||
Authorization
|
Authorization
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubmitVerificationCodeReq 提交驗證碼以完成驗證
|
// SubmitVerificationCodeReq 提交驗證碼以完成驗證
|
||||||
SubmitVerificationCodeReq {
|
SubmitVerificationCodeReq {
|
||||||
|
Account string `json:"account" validate:"required`
|
||||||
Purpose string `json:"purpose" validate:"required,oneof=email_verification phone_verification"`
|
Purpose string `json:"purpose" validate:"required,oneof=email_verification phone_verification"`
|
||||||
VerifyCode string `json:"verify_code" validate:"required,len=6"`
|
VerifyCode string `json:"verify_code" validate:"required,len=6"`
|
||||||
Authorization
|
Authorization
|
||||||
|
|
|
||||||
43
go.mod
43
go.mod
|
|
@ -5,10 +5,11 @@ 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-v2 v1.39.2
|
github.com/aws/aws-sdk-go v1.55.8
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.16
|
github.com/aws/aws-sdk-go-v2 v1.39.6
|
||||||
github.com/aws/aws-sdk-go-v2/service/ses v1.34.5
|
github.com/aws/aws-sdk-go-v2/credentials v1.18.21
|
||||||
github.com/go-playground/validator/v10 v10.27.0
|
github.com/aws/aws-sdk-go-v2/service/ses v1.34.9
|
||||||
|
github.com/go-playground/validator/v10 v10.28.0
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||||
github.com/matcornic/hermes/v2 v2.1.0
|
github.com/matcornic/hermes/v2 v2.1.0
|
||||||
github.com/minchao/go-mitake v1.0.0
|
github.com/minchao/go-mitake v1.0.0
|
||||||
|
|
@ -16,12 +17,12 @@ require (
|
||||||
github.com/segmentio/ksuid v1.0.4
|
github.com/segmentio/ksuid v1.0.4
|
||||||
github.com/shopspring/decimal v1.4.0
|
github.com/shopspring/decimal v1.4.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/testcontainers/testcontainers-go v0.39.0
|
github.com/testcontainers/testcontainers-go v0.40.0
|
||||||
github.com/zeromicro/go-zero v1.9.1
|
github.com/zeromicro/go-zero v1.9.2
|
||||||
go.mongodb.org/mongo-driver/v2 v2.3.0
|
go.mongodb.org/mongo-driver/v2 v2.4.0
|
||||||
go.uber.org/mock v0.6.0
|
go.uber.org/mock v0.6.0
|
||||||
golang.org/x/crypto v0.42.0
|
golang.org/x/crypto v0.43.0
|
||||||
google.golang.org/grpc v1.75.1
|
google.golang.org/grpc v1.76.0
|
||||||
google.golang.org/protobuf v1.36.10
|
google.golang.org/protobuf v1.36.10
|
||||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||||
)
|
)
|
||||||
|
|
@ -35,9 +36,9 @@ require (
|
||||||
github.com/PuerkitoBio/goquery v1.5.0 // indirect
|
github.com/PuerkitoBio/goquery v1.5.0 // indirect
|
||||||
github.com/andybalholm/cascadia v1.0.0 // indirect
|
github.com/andybalholm/cascadia v1.0.0 // indirect
|
||||||
github.com/aokoli/goutils v1.0.1 // indirect
|
github.com/aokoli/goutils v1.0.1 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 // indirect
|
||||||
github.com/aws/smithy-go v1.23.0 // indirect
|
github.com/aws/smithy-go v1.23.2 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
|
@ -49,19 +50,18 @@ require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/distribution/reference v0.6.0 // indirect
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
github.com/docker/docker v28.3.3+incompatible // indirect
|
github.com/docker/docker v28.5.1+incompatible // indirect
|
||||||
github.com/docker/go-connections v0.6.0 // indirect
|
github.com/docker/go-connections v0.6.0 // indirect
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
github.com/ebitengine/purego v0.8.4 // indirect
|
github.com/ebitengine/purego v0.8.4 // indirect
|
||||||
github.com/fatih/color v1.18.0 // indirect
|
github.com/fatih/color v1.18.0 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
|
||||||
github.com/golang/snappy v1.0.0 // indirect
|
github.com/golang/snappy v1.0.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/css v1.0.0 // indirect
|
github.com/gorilla/css v1.0.0 // indirect
|
||||||
|
|
@ -71,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
|
||||||
|
|
@ -99,7 +100,7 @@ require (
|
||||||
github.com/prometheus/client_model v0.6.1 // indirect
|
github.com/prometheus/client_model v0.6.1 // indirect
|
||||||
github.com/prometheus/common v0.62.0 // indirect
|
github.com/prometheus/common v0.62.0 // indirect
|
||||||
github.com/prometheus/procfs v0.15.1 // indirect
|
github.com/prometheus/procfs v0.15.1 // indirect
|
||||||
github.com/redis/go-redis/v9 v9.15.0 // indirect
|
github.com/redis/go-redis/v9 v9.14.0 // indirect
|
||||||
github.com/rivo/uniseg v0.2.0 // indirect
|
github.com/rivo/uniseg v0.2.0 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.0.1 // indirect
|
github.com/russross/blackfriday/v2 v2.0.1 // indirect
|
||||||
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
|
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
|
||||||
|
|
@ -131,12 +132,12 @@ require (
|
||||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
|
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
|
||||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||||
golang.org/x/net v0.43.0 // indirect
|
golang.org/x/net v0.45.0 // indirect
|
||||||
golang.org/x/sync v0.17.0 // indirect
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
golang.org/x/sys v0.36.0 // indirect
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
golang.org/x/text v0.29.0 // indirect
|
golang.org/x/text v0.30.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect
|
||||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|
|
||||||
112
go.sum
112
go.sum
|
|
@ -20,18 +20,20 @@ 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-v2 v1.39.2 h1:EJLg8IdbzgeD7xgvZ+I8M1e0fL0ptn/M47lianzth0I=
|
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.39.2/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY=
|
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.16 h1:4JHirI4zp958zC026Sm+V4pSDwW4pwLefKrc0bF2lwI=
|
github.com/aws/aws-sdk-go-v2 v1.39.6 h1:2JrPCVgWJm7bm83BDwY5z8ietmeJUbh3O2ACnn+Xsqk=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.16/go.mod h1:qQMtGx9OSw7ty1yLclzLxXCRbrkjWAM7JnObZjmCB7I=
|
github.com/aws/aws-sdk-go-v2 v1.39.6/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9 h1:se2vOWGD3dWQUtfn4wEjRQJb1HK1XsNIt825gskZ970=
|
github.com/aws/aws-sdk-go-v2/credentials v1.18.21 h1:56HGpsgnmD+2/KpG0ikvvR8+3v3COCwaF4r+oWwOeNA=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9/go.mod h1:hijCGH2VfbZQxqCDN7bwz/4dzxV+hkyhjawAtdPWKZA=
|
github.com/aws/aws-sdk-go-v2/credentials v1.18.21/go.mod h1:3YELwedmQbw7cXNaII2Wywd+YY58AmLPwX4LzARgmmA=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9 h1:6RBnKZLkJM4hQ+kN6E7yWFveOTg8NLPHAkqrs4ZPlTU=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 h1:a+8/MLcWlIxo1lF9xaGt3J/u3yOZx+CdSveSNwjhD40=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9/go.mod h1:V9rQKRmK7AWuEsOMnHzKj8WyrIir1yUJbZxDuZLFvXI=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13/go.mod h1:oGnKwIYZ4XttyU2JWxFrwvhF6YKiK/9/wmE3v3Iu9K8=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ses v1.34.5 h1:NwOeuOFrWoh4xWKINrmaAK4Vh75jmmY0RAuNjQ6W5Es=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 h1:HBSI2kDkMdWz4ZM7FjwE7e/pWDEZ+nR95x8Ztet1ooY=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ses v1.34.5/go.mod h1:m3BsMJZD0eqjGIniBzwrNUqG9ZUPquC4hY9FyE2qNFo=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13/go.mod h1:YE94ZoDArI7awZqJzBAZ3PDD2zSfuP7w6P2knOzIn8M=
|
||||||
github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE=
|
github.com/aws/aws-sdk-go-v2/service/ses v1.34.9 h1:hrUBTmbCLLQ+X21wdcoK78sjRW3HGspp/vkAL3TkMx4=
|
||||||
github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
|
github.com/aws/aws-sdk-go-v2/service/ses v1.34.9/go.mod h1:CeGX4LAFCsrBp24qazKmO/dwxghNCGbAoTbi64dGSEM=
|
||||||
|
github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
|
||||||
|
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
|
@ -61,8 +63,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
|
github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM=
|
||||||
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
|
@ -73,8 +75,8 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df/go.mod h1:GJr+FCSXshIwgHBtLglIg9M2l2kQSi6QjVAngtzI08Y=
|
github.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df/go.mod h1:GJr+FCSXshIwgHBtLglIg9M2l2kQSi6QjVAngtzI08Y=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
|
@ -89,10 +91,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
|
@ -121,8 +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/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
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=
|
||||||
|
|
@ -200,8 +202,8 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ
|
||||||
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
||||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||||
github.com/redis/go-redis/v9 v9.15.0 h1:2jdes0xJxer4h3NUZrZ4OGSntGlXp4WbXju2nOTRXto=
|
github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE=
|
||||||
github.com/redis/go-redis/v9 v9.15.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
||||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
|
|
@ -235,8 +237,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/testcontainers/testcontainers-go v0.39.0 h1:uCUJ5tA+fcxbFAB0uP3pIK3EJ2IjjDUHFSZ1H1UxAts=
|
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
|
||||||
github.com/testcontainers/testcontainers-go v0.39.0/go.mod h1:qmHpkG7H5uPf/EvOORKvS6EuDkBUPE3zpVGaH9NL7f8=
|
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
|
||||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||||
|
|
@ -253,17 +255,15 @@ github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6
|
||||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
github.com/zeromicro/go-zero v1.9.1 h1:GZCl4jun/ZgZHnSvX3SSNDHf+tEGmEQ8x2Z23xjHa9g=
|
github.com/zeromicro/go-zero v1.9.2 h1:ZXOXBIcazZ1pWAMiHyVnDQ3Sxwy7DYPzjE89Qtj9vqM=
|
||||||
github.com/zeromicro/go-zero v1.9.1/go.mod h1:bHOl7Xr7EV/iHZWEqsUNJwFc/9WgAMrPpPagYvOaMtY=
|
github.com/zeromicro/go-zero v1.9.2/go.mod h1:k8YBMEFZKjTd4q/qO5RCW+zDgUlNyAs5vue3P4/Kmn0=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.3.0 h1:sh55yOXA2vUjW1QYw/2tRlHSQViwDyPnW61AwpZ4rtU=
|
go.mongodb.org/mongo-driver/v2 v2.4.0 h1:Oq6BmUAAFTzMeh6AonuDlgZMuAuEiUxoAD1koK5MuFo=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.3.0/go.mod h1:jHeEDJHJq7tm6ZF45Issun9dbogjfnPySb1vXA7EeAI=
|
go.mongodb.org/mongo-driver/v2 v2.4.0/go.mod h1:jHeEDJHJq7tm6ZF45Issun9dbogjfnPySb1vXA7EeAI=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
||||||
|
|
@ -300,35 +300,24 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
golang.org/x/crypto v0.0.0-20181029175232-7e6ffbd03851/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
golang.org/x/crypto v0.0.0-20181029175232-7e6ffbd03851/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
|
||||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190225065934-cc5685c2db12/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190225065934-cc5685c2db12/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|
@ -340,38 +329,34 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
|
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||||
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
|
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
|
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
|
||||||
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
|
||||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 h1:FiusG7LWj+4byqhbvmB+Q93B/mOxJLN2DTozDuZm4EU=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b h1:ULiyYQ0FdsJhwwZUwbaXpZF5yUE3h+RA+gxvBu37ucc=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||||
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
|
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||||
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||||
|
|
@ -385,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=
|
||||||
|
|
|
||||||
|
|
@ -78,4 +78,34 @@ type Config struct {
|
||||||
// 預設角色名稱
|
// 預設角色名稱
|
||||||
DefaultRoleName string
|
DefaultRoleName string
|
||||||
}
|
}
|
||||||
|
SMTPConfig struct {
|
||||||
|
Enable bool
|
||||||
|
GoroutinePoolNum int
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Sender string
|
||||||
|
SenderName string
|
||||||
|
}
|
||||||
|
|
||||||
|
DeliveryConfig struct {
|
||||||
|
MaxRetries int `json:"max_retries"` // 最大重試次數
|
||||||
|
InitialDelay time.Duration `json:"initial_delay"` // 初始重試延遲 (100ms)
|
||||||
|
BackoffFactor float64 `json:"backoff_factor"` // 指數退避因子 (2.0)
|
||||||
|
MaxDelay time.Duration `json:"max_delay"` // 最大延遲時間
|
||||||
|
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,3 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
const DefaultBrand = "digimon"
|
||||||
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"backend/internal/svc"
|
"backend/internal/svc"
|
||||||
"backend/internal/types"
|
"backend/internal/types"
|
||||||
errs "backend/pkg/library/errors"
|
errs "backend/pkg/library/errors"
|
||||||
"backend/pkg/library/errors/code"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
|
@ -46,11 +45,7 @@ func LoginHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
Error: e.Unwrap(),
|
Error: e.Unwrap(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
httpx.WriteJsonCtx(r.Context(), w, http.StatusOK, types.Resp{
|
httpx.WriteJsonCtx(r.Context(), w, http.StatusOK, resp)
|
||||||
Code: code.SUCCESSCode,
|
|
||||||
Message: code.SUCCESSMessage,
|
|
||||||
Data: resp,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
errs "backend/pkg/library/errors"
|
errs "backend/pkg/library/errors"
|
||||||
"backend/pkg/library/errors/code"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"backend/internal/logic/auth"
|
"backend/internal/logic/auth"
|
||||||
|
|
@ -46,11 +45,7 @@ func RefreshTokenHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
Error: e.Unwrap(),
|
Error: e.Unwrap(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
httpx.WriteJsonCtx(r.Context(), w, http.StatusOK, types.Resp{
|
httpx.WriteJsonCtx(r.Context(), w, http.StatusOK, resp)
|
||||||
Code: code.SUCCESSCode,
|
|
||||||
Message: code.SUCCESSMessage,
|
|
||||||
Data: resp,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
errs "backend/pkg/library/errors"
|
errs "backend/pkg/library/errors"
|
||||||
"backend/pkg/library/errors/code"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"backend/internal/logic/auth"
|
"backend/internal/logic/auth"
|
||||||
|
|
@ -45,11 +44,7 @@ func RegisterHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
Error: e.Unwrap(),
|
Error: e.Unwrap(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
httpx.WriteJsonCtx(r.Context(), w, http.StatusOK, types.Resp{
|
httpx.WriteJsonCtx(r.Context(), w, http.StatusOK, resp)
|
||||||
Code: code.SUCCESSCode,
|
|
||||||
Message: code.SUCCESSMessage,
|
|
||||||
Data: resp,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
errs "backend/pkg/library/errors"
|
errs "backend/pkg/library/errors"
|
||||||
"backend/pkg/library/errors/code"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"backend/internal/logic/auth"
|
"backend/internal/logic/auth"
|
||||||
|
|
@ -46,11 +45,7 @@ func RequestPasswordResetHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
Error: e.Unwrap(),
|
Error: e.Unwrap(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
httpx.WriteJsonCtx(r.Context(), w, http.StatusOK, types.Resp{
|
httpx.WriteJsonCtx(r.Context(), w, http.StatusOK, resp)
|
||||||
Code: code.SUCCESSCode,
|
|
||||||
Message: code.SUCCESSMessage,
|
|
||||||
Data: resp,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
errs "backend/pkg/library/errors"
|
errs "backend/pkg/library/errors"
|
||||||
"backend/pkg/library/errors/code"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"backend/internal/logic/auth"
|
"backend/internal/logic/auth"
|
||||||
|
|
@ -46,11 +45,7 @@ func ResetPasswordHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
Error: e.Unwrap(),
|
Error: e.Unwrap(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
httpx.WriteJsonCtx(r.Context(), w, http.StatusOK, types.Resp{
|
httpx.WriteJsonCtx(r.Context(), w, http.StatusOK, resp)
|
||||||
Code: code.SUCCESSCode,
|
|
||||||
Message: code.SUCCESSMessage,
|
|
||||||
Data: resp,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
errs "backend/pkg/library/errors"
|
errs "backend/pkg/library/errors"
|
||||||
"backend/pkg/library/errors/code"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"backend/internal/logic/auth"
|
"backend/internal/logic/auth"
|
||||||
|
|
@ -46,11 +45,7 @@ func VerifyPasswordResetCodeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc
|
||||||
Error: e.Unwrap(),
|
Error: e.Unwrap(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
httpx.WriteJsonCtx(r.Context(), w, http.StatusOK, types.Resp{
|
httpx.WriteJsonCtx(r.Context(), w, http.StatusOK, resp)
|
||||||
Code: code.SUCCESSCode,
|
|
||||||
Message: code.SUCCESSMessage,
|
|
||||||
Data: resp,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
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{
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
errs "backend/pkg/library/errors"
|
errs "backend/pkg/library/errors"
|
||||||
"backend/pkg/library/errors/code"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"backend/internal/logic/user"
|
"backend/internal/logic/user"
|
||||||
|
|
@ -45,11 +44,7 @@ func GetUserInfoHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
Error: e.Unwrap(),
|
Error: e.Unwrap(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
httpx.WriteJsonCtx(r.Context(), w, http.StatusOK, types.Resp{
|
httpx.WriteJsonCtx(r.Context(), w, http.StatusOK, resp)
|
||||||
Code: code.SUCCESSCode,
|
|
||||||
Message: code.SUCCESSMessage,
|
|
||||||
Data: resp,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
errs "backend/pkg/library/errors"
|
errs "backend/pkg/library/errors"
|
||||||
"backend/pkg/library/errors/code"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"backend/internal/logic/user"
|
"backend/internal/logic/user"
|
||||||
|
|
@ -36,11 +35,7 @@ func RequestVerificationCodeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc
|
||||||
Error: e.Unwrap(),
|
Error: e.Unwrap(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
httpx.WriteJsonCtx(r.Context(), w, http.StatusOK, types.Resp{
|
httpx.WriteJsonCtx(r.Context(), w, http.StatusOK, resp)
|
||||||
Code: code.SUCCESSCode,
|
|
||||||
Message: code.SUCCESSMessage,
|
|
||||||
Data: resp,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
errs "backend/pkg/library/errors"
|
errs "backend/pkg/library/errors"
|
||||||
"backend/pkg/library/errors/code"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"backend/internal/logic/user"
|
"backend/internal/logic/user"
|
||||||
|
|
@ -36,11 +35,7 @@ func SubmitVerificationCodeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc
|
||||||
Error: e.Unwrap(),
|
Error: e.Unwrap(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
httpx.WriteJsonCtx(r.Context(), w, http.StatusOK, types.Resp{
|
httpx.WriteJsonCtx(r.Context(), w, http.StatusOK, resp)
|
||||||
Code: code.SUCCESSCode,
|
|
||||||
Message: code.SUCCESSMessage,
|
|
||||||
Data: resp,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
errs "backend/pkg/library/errors"
|
errs "backend/pkg/library/errors"
|
||||||
"backend/pkg/library/errors/code"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"backend/internal/logic/user"
|
"backend/internal/logic/user"
|
||||||
|
|
@ -36,11 +35,7 @@ func UpdatePasswordHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
Error: e.Unwrap(),
|
Error: e.Unwrap(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
httpx.WriteJsonCtx(r.Context(), w, http.StatusOK, types.Resp{
|
httpx.WriteJsonCtx(r.Context(), w, http.StatusOK, resp)
|
||||||
Code: code.SUCCESSCode,
|
|
||||||
Message: code.SUCCESSMessage,
|
|
||||||
Data: resp,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
errs "backend/pkg/library/errors"
|
errs "backend/pkg/library/errors"
|
||||||
"backend/pkg/library/errors/code"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"backend/internal/logic/user"
|
"backend/internal/logic/user"
|
||||||
|
|
@ -36,11 +35,7 @@ func UpdateUserInfoHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
Error: e.Unwrap(),
|
Error: e.Unwrap(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
httpx.WriteJsonCtx(r.Context(), w, http.StatusOK, types.Resp{
|
httpx.WriteJsonCtx(r.Context(), w, http.StatusOK, resp)
|
||||||
Code: code.SUCCESSCode,
|
|
||||||
Message: code.SUCCESSMessage,
|
|
||||||
Data: resp,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"backend/internal/domain"
|
||||||
"backend/internal/svc"
|
"backend/internal/svc"
|
||||||
"backend/internal/types"
|
"backend/internal/types"
|
||||||
errs "backend/pkg/library/errors"
|
errs "backend/pkg/library/errors"
|
||||||
|
|
@ -8,6 +9,7 @@ import (
|
||||||
member "backend/pkg/member/domain/usecase"
|
member "backend/pkg/member/domain/usecase"
|
||||||
"backend/pkg/permission/domain/usecase"
|
"backend/pkg/permission/domain/usecase"
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
|
|
@ -79,7 +81,7 @@ func (l *RegisterLogic) Register(req *types.LoginReq) (resp *types.LoginResp, er
|
||||||
_, err = l.svcCtx.UserRoleUC.Assign(l.ctx, usecase.AssignRoleRequest{
|
_, err = l.svcCtx.UserRoleUC.Assign(l.ctx, usecase.AssignRoleRequest{
|
||||||
RoleUID: l.svcCtx.Config.RoleConfig.DefaultRoleName,
|
RoleUID: l.svcCtx.Config.RoleConfig.DefaultRoleName,
|
||||||
UserUID: account.UID,
|
UserUID: account.UID,
|
||||||
Brand: "digimon",
|
Brand: domain.DefaultBrand,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,15 @@ package auth
|
||||||
import (
|
import (
|
||||||
"backend/internal/domain"
|
"backend/internal/domain"
|
||||||
"backend/internal/utils"
|
"backend/internal/utils"
|
||||||
|
"backend/internal/utils/email_template"
|
||||||
errs "backend/pkg/library/errors"
|
errs "backend/pkg/library/errors"
|
||||||
"backend/pkg/member/domain/member"
|
"backend/pkg/member/domain/member"
|
||||||
"backend/pkg/member/domain/usecase"
|
"backend/pkg/member/domain/usecase"
|
||||||
|
notificationUC "backend/pkg/notification/domain/usecase"
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
|
||||||
"backend/internal/svc"
|
"backend/internal/svc"
|
||||||
"backend/internal/types"
|
"backend/internal/types"
|
||||||
|
|
@ -67,13 +71,66 @@ func (l *RequestPasswordResetLogic) RequestPasswordReset(req *types.RequestPassw
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 發送驗證碼
|
nickname := generateMsgName(&info)
|
||||||
fmt.Println("======= send", vcode.Data.VerifyCode, &info)
|
switch member.GetAccountTypeByCode(req.AccountType) {
|
||||||
|
case member.AccountTypeMail:
|
||||||
|
body, title, err := email_template.GetEmailTemplate(email_template.Language(info.PreferredLanguage), email_template.ForgetPasswordVerify)
|
||||||
|
if err != nil {
|
||||||
|
e := errs.ResNotFoundError("failed to get correct email template")
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
|
||||||
//nickname := getEmailShowName(&info)
|
tmpl, err := template.New("ForgetPasswordEmail").Parse(body)
|
||||||
//if err := l.sendVerificationCode(req.AccountType, acc, &info, vcode.Data.VerifyCode, nickname); err != nil {
|
if err != nil {
|
||||||
|
e := errs.ResInvalidFormatError("failed to get correct email template")
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
|
||||||
|
emailParams := email_template.ForgetPasswordEmailReq{
|
||||||
|
Username: nickname,
|
||||||
|
VerifyCode: vcode.Data.VerifyCode,
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, emailParams); err != nil {
|
||||||
|
e := errs.ResInvalidFormatError("failed to build data")
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
|
||||||
|
err = l.svcCtx.DeliveryUC.SendEmail(l.ctx, notificationUC.MailReq{
|
||||||
|
To: []string{req.Identifier},
|
||||||
|
From: l.svcCtx.Config.SMTPConfig.Sender,
|
||||||
|
SenderName: l.svcCtx.Config.SMTPConfig.SenderName,
|
||||||
|
Subject: title,
|
||||||
|
Body: buf.String(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
e := errs.SvcThirdPartyError("failed to send email").Wrap(err)
|
||||||
|
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
case member.AccountTypePhone:
|
||||||
|
//// 送出手機號碼
|
||||||
|
//templateResp, err := l.svcCtx.NotificationUseCase.GetSMSTemplateByTypeID(
|
||||||
|
// l.ctx, notificationModule.Language(info.PreferredLanguage), notificationModule.BindingPhone)
|
||||||
|
//if err != nil {
|
||||||
// return nil, err
|
// return nil, err
|
||||||
//}
|
//}
|
||||||
|
//
|
||||||
|
//fmt.Println(fmt.Sprintf("%s:%s", templateResp.Body, vcode.Data.VerifyCode))
|
||||||
|
////err = l.svcCtx.NotificationUseCase.SendMessage(l.ctx, notificationModule.SMSMessageRequest{
|
||||||
|
//// PhoneNumber: acc,
|
||||||
|
//// RecipientName: nickname,
|
||||||
|
//// MessageContent: fmt.Sprintf("%s:%s", templateResp.Body, vcode),
|
||||||
|
////})
|
||||||
|
////if err != nil {
|
||||||
|
//// return nil, err
|
||||||
|
////}
|
||||||
|
case member.AccountTypeNone:
|
||||||
|
case member.AccountTypeDefine:
|
||||||
|
default:
|
||||||
|
return &types.RespOK{}, errs.InputInvalidRangeError("")
|
||||||
|
}
|
||||||
|
|
||||||
// 設置 Redis 鍵
|
// 設置 Redis 鍵
|
||||||
l.setRedisKeyWithExpiry(rk, vcode.Data.VerifyCode, 60)
|
l.setRedisKeyWithExpiry(rk, vcode.Data.VerifyCode, 60)
|
||||||
|
|
@ -137,3 +194,15 @@ func (l *RequestPasswordResetLogic) setRedisKeyWithExpiry(rk, verifyCode string,
|
||||||
}, "failed to set redis expire").Wrap(err)
|
}, "failed to set redis expire").Wrap(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// generateMsgName 取得寄信用的名稱
|
||||||
|
func generateMsgName(info *usecase.UserInfo) string {
|
||||||
|
if info.FullName != nil {
|
||||||
|
return *info.FullName
|
||||||
|
}
|
||||||
|
if info.Nickname != nil {
|
||||||
|
return *info.Nickname
|
||||||
|
}
|
||||||
|
|
||||||
|
return info.UID
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
package fileStorage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/pkg/permission/domain/token"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUploadImgLogic 上傳圖片檔案
|
||||||
|
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/%s/%d/%s", token.UID(l.ctx), 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,171 @@
|
||||||
|
package fileStorage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/pkg/permission/domain/token"
|
||||||
|
"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/%s/%d/%s", token.UID(l.ctx), 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
package ping
|
package ping
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
|
|
||||||
"backend/internal/svc"
|
"backend/internal/svc"
|
||||||
|
"context"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
)
|
)
|
||||||
|
|
@ -24,7 +23,5 @@ func NewPingLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PingLogic {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *PingLogic) Ping() error {
|
func (l *PingLogic) Ping() error {
|
||||||
// todo: add your logic here and delete this line
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,19 @@
|
||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"backend/internal/domain"
|
||||||
|
"backend/internal/utils/email_template"
|
||||||
|
errs "backend/pkg/library/errors"
|
||||||
|
mbr "backend/pkg/member/domain/member"
|
||||||
|
member "backend/pkg/member/domain/usecase"
|
||||||
|
"backend/pkg/notification/domain/usecase"
|
||||||
|
"backend/pkg/permission/domain/token"
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"backend/internal/svc"
|
"backend/internal/svc"
|
||||||
"backend/internal/types"
|
"backend/internal/types"
|
||||||
|
|
@ -15,7 +27,7 @@ type RequestVerificationCodeLogic struct {
|
||||||
svcCtx *svc.ServiceContext
|
svcCtx *svc.ServiceContext
|
||||||
}
|
}
|
||||||
|
|
||||||
// 請求發送驗證碼 (用於驗證信箱/手機)
|
// NewRequestVerificationCodeLogic 請求發送驗證碼 (用於驗證信箱/手機)
|
||||||
func NewRequestVerificationCodeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RequestVerificationCodeLogic {
|
func NewRequestVerificationCodeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RequestVerificationCodeLogic {
|
||||||
return &RequestVerificationCodeLogic{
|
return &RequestVerificationCodeLogic{
|
||||||
Logger: logx.WithContext(ctx),
|
Logger: logx.WithContext(ctx),
|
||||||
|
|
@ -25,7 +37,175 @@ func NewRequestVerificationCodeLogic(ctx context.Context, svcCtx *svc.ServiceCon
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *RequestVerificationCodeLogic) RequestVerificationCode(req *types.RequestVerificationCodeReq) (resp *types.RespOK, err error) {
|
func (l *RequestVerificationCodeLogic) RequestVerificationCode(req *types.RequestVerificationCodeReq) (resp *types.RespOK, err error) {
|
||||||
// todo: add your logic here and delete this line
|
acc := ""
|
||||||
|
ct := mbr.GenerateCodeTypeEmail
|
||||||
|
switch req.Purpose {
|
||||||
|
case "email_verification":
|
||||||
|
if !isValidEmail(req.Account) {
|
||||||
|
return nil, errs.InputInvalidFormatError("email is invalid")
|
||||||
|
}
|
||||||
|
acc = req.Account
|
||||||
|
// 1. TODO 討論 email 不可以再被使用
|
||||||
|
// 2. TODO 討論 email 跟我帳號是不是一樣(如果是用自己的信箱註冊的話)
|
||||||
|
case "phone_verification":
|
||||||
|
phone, isPhone := normalizeTaiwanMobile(req.Account)
|
||||||
|
if !isPhone {
|
||||||
|
return nil, errs.InputInvalidFormatError("phone number is invalid")
|
||||||
|
}
|
||||||
|
acc = phone
|
||||||
|
// TODO 討論號碼有被用過就不可以再被使用了
|
||||||
|
ct = mbr.GenerateCodeTypePhone
|
||||||
|
default:
|
||||||
|
return &types.RespOK{}, errs.InputInvalidRangeError("")
|
||||||
|
}
|
||||||
|
|
||||||
return
|
uid := token.UID(l.ctx)
|
||||||
|
// 限制三分鐘內只可以發送一次
|
||||||
|
rk := domain.GenerateVerifyCodeRedisKey.With(
|
||||||
|
fmt.Sprintf("%s-%s", uid, req.Purpose),
|
||||||
|
).ToString()
|
||||||
|
|
||||||
|
// 拿不到不會出錯,DB 壞掉才會
|
||||||
|
get, err := l.svcCtx.Redis.GetCtx(l.ctx, rk)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.DBErrorError("failed to connect to redis").Wrap(err)
|
||||||
|
}
|
||||||
|
if get != "" {
|
||||||
|
// 已經發送過驗證碼,返回提示
|
||||||
|
return nil, errs.SysTooManyRequestError("code already sent, please wait 3min for system to send again")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成驗證碼
|
||||||
|
vcode, err := l.svcCtx.AccountUC.GenerateRefreshCode(l.ctx, member.GenerateRefreshCodeRequest{
|
||||||
|
LoginID: acc,
|
||||||
|
CodeType: ct,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取得用戶資訊
|
||||||
|
info, err := l.svcCtx.AccountUC.GetUserInfo(l.ctx, member.GetUserInfoRequest{
|
||||||
|
UID: uid,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nickname := generateMsgName(&info)
|
||||||
|
switch ct {
|
||||||
|
case mbr.GenerateCodeTypeEmail:
|
||||||
|
body, title, err := email_template.GetEmailTemplate(email_template.Language(info.PreferredLanguage), email_template.BindingEmail)
|
||||||
|
if err != nil {
|
||||||
|
e := errs.ResNotFoundError("failed to get correct email template")
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := template.New("BindEmailBody").Parse(body)
|
||||||
|
if err != nil {
|
||||||
|
e := errs.ResInvalidFormatError("failed to get correct email template")
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
|
||||||
|
emailParams := email_template.ForgetPasswordEmailReq{
|
||||||
|
Username: nickname,
|
||||||
|
VerifyCode: vcode.Data.VerifyCode,
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, emailParams); err != nil {
|
||||||
|
e := errs.ResInvalidFormatError("failed to build data")
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
|
||||||
|
err = l.svcCtx.DeliveryUC.SendEmail(l.ctx, usecase.MailReq{
|
||||||
|
To: []string{req.Account},
|
||||||
|
From: l.svcCtx.Config.SMTPConfig.Sender,
|
||||||
|
SenderName: l.svcCtx.Config.SMTPConfig.SenderName,
|
||||||
|
Subject: title,
|
||||||
|
Body: buf.String(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
e := errs.SvcThirdPartyError("failed to send email").Wrap(err)
|
||||||
|
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
case mbr.GenerateCodeTypePhone:
|
||||||
|
//// 送出手機號碼
|
||||||
|
//templateResp, err := l.svcCtx.NotificationUseCase.GetSMSTemplateByTypeID(
|
||||||
|
// l.ctx, notificationModule.Language(info.PreferredLanguage), notificationModule.BindingPhone)
|
||||||
|
//if err != nil {
|
||||||
|
// return nil, err
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//fmt.Println(fmt.Sprintf("%s:%s", templateResp.Body, vcode.Data.VerifyCode))
|
||||||
|
////err = l.svcCtx.NotificationUseCase.SendMessage(l.ctx, notificationModule.SMSMessageRequest{
|
||||||
|
//// PhoneNumber: acc,
|
||||||
|
//// RecipientName: nickname,
|
||||||
|
//// MessageContent: fmt.Sprintf("%s:%s", templateResp.Body, vcode),
|
||||||
|
////})
|
||||||
|
////if err != nil {
|
||||||
|
//// return nil, err
|
||||||
|
////}
|
||||||
|
case mbr.GenerateCodeTypeNone:
|
||||||
|
case mbr.GenerateCodeTypeForgetPassword:
|
||||||
|
default:
|
||||||
|
return &types.RespOK{}, errs.InputInvalidRangeError("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 設置 Redis 鍵,並設置 3 分鐘的過期時間
|
||||||
|
status, err := l.svcCtx.Redis.SetnxExCtx(l.ctx, rk, vcode.Data.VerifyCode, 60*3)
|
||||||
|
if err != nil || !status {
|
||||||
|
// 純記錄,前面都已經成功,就不報錯了
|
||||||
|
_ = errs.DBErrorErrorL(l.svcCtx.Logger,
|
||||||
|
[]errs.LogField{
|
||||||
|
{Key: "req", Val: req},
|
||||||
|
{Key: "func", Val: "Redis.SetnxExCtx"},
|
||||||
|
{Key: "err", Val: err.Error()},
|
||||||
|
}, "failed to set redis expire").Wrap(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.RespOK{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 標準化號碼並驗證是否為合法台灣手機號碼
|
||||||
|
func normalizeTaiwanMobile(phone string) (string, bool) {
|
||||||
|
// 移除空格
|
||||||
|
phone = strings.ReplaceAll(phone, " ", "")
|
||||||
|
|
||||||
|
// 移除 "+886" 並將剩餘部分標準化
|
||||||
|
if strings.HasPrefix(phone, "+886") {
|
||||||
|
phone = strings.TrimPrefix(phone, "+886")
|
||||||
|
if !strings.HasPrefix(phone, "0") {
|
||||||
|
phone = "0" + phone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正則表達式驗證標準化後的號碼
|
||||||
|
regex := regexp.MustCompile(`^(09\d{8})$`)
|
||||||
|
if regex.MatchString(phone) {
|
||||||
|
return phone, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 驗證 Email 格式的函數
|
||||||
|
func isValidEmail(email string) bool {
|
||||||
|
// 定義正則表達式
|
||||||
|
regex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
|
||||||
|
|
||||||
|
return regex.MatchString(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateMsgName 取得寄信用的名稱
|
||||||
|
func generateMsgName(info *member.UserInfo) string {
|
||||||
|
if info.FullName != nil {
|
||||||
|
return *info.FullName
|
||||||
|
}
|
||||||
|
if info.Nickname != nil {
|
||||||
|
return *info.Nickname
|
||||||
|
}
|
||||||
|
|
||||||
|
return info.UID
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
|
|
||||||
"backend/internal/svc"
|
"backend/internal/svc"
|
||||||
"backend/internal/types"
|
"backend/internal/types"
|
||||||
|
errs "backend/pkg/library/errors"
|
||||||
|
mbr "backend/pkg/member/domain/member"
|
||||||
|
member "backend/pkg/member/domain/usecase"
|
||||||
|
"backend/pkg/permission/domain/token"
|
||||||
|
"context"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
)
|
)
|
||||||
|
|
@ -15,7 +18,7 @@ type SubmitVerificationCodeLogic struct {
|
||||||
svcCtx *svc.ServiceContext
|
svcCtx *svc.ServiceContext
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提交驗證碼以完成驗證
|
// NewSubmitVerificationCodeLogic 交驗證碼以完成驗證
|
||||||
func NewSubmitVerificationCodeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SubmitVerificationCodeLogic {
|
func NewSubmitVerificationCodeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SubmitVerificationCodeLogic {
|
||||||
return &SubmitVerificationCodeLogic{
|
return &SubmitVerificationCodeLogic{
|
||||||
Logger: logx.WithContext(ctx),
|
Logger: logx.WithContext(ctx),
|
||||||
|
|
@ -24,8 +27,66 @@ func NewSubmitVerificationCodeLogic(ctx context.Context, svcCtx *svc.ServiceCont
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *SubmitVerificationCodeLogic) SubmitVerificationCode(req *types.SubmitVerificationCodeReq) (resp *types.RespOK, err error) {
|
func (l *SubmitVerificationCodeLogic) SubmitVerificationCode(req *types.SubmitVerificationCodeReq) (*types.RespOK, error) {
|
||||||
// todo: add your logic here and delete this line
|
acc := ""
|
||||||
|
ct := mbr.GenerateCodeTypeEmail
|
||||||
|
switch req.Purpose {
|
||||||
|
case "email_verification":
|
||||||
|
if !isValidEmail(req.Account) {
|
||||||
|
return nil, errs.InputInvalidFormatError("email is invalid")
|
||||||
|
}
|
||||||
|
acc = req.Account
|
||||||
|
case "phone_verification":
|
||||||
|
phone, isPhone := normalizeTaiwanMobile(req.Account)
|
||||||
|
if !isPhone {
|
||||||
|
return nil, errs.InputInvalidFormatError("phone number is invalid")
|
||||||
|
}
|
||||||
|
acc = phone
|
||||||
|
ct = mbr.GenerateCodeTypePhone
|
||||||
|
default:
|
||||||
|
return &types.RespOK{}, errs.InputInvalidRangeError("")
|
||||||
|
}
|
||||||
|
|
||||||
return
|
// 先驗證,不刪除
|
||||||
|
if err := l.svcCtx.AccountUC.CheckRefreshCode(l.ctx, member.VerifyRefreshCodeRequest{
|
||||||
|
VerifyCode: req.VerifyCode,
|
||||||
|
LoginID: acc,
|
||||||
|
CodeType: ct,
|
||||||
|
}); err != nil {
|
||||||
|
e := errs.AuthForbiddenError("failed to get verify code").Wrap(err)
|
||||||
|
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
uid := token.UID(l.ctx)
|
||||||
|
switch req.Purpose {
|
||||||
|
case "email_verification":
|
||||||
|
err := l.svcCtx.AccountUC.BindVerifyEmail(l.ctx, uid, acc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
case "phone_verification":
|
||||||
|
err := l.svcCtx.AccountUC.BindVerifyPhone(l.ctx, uid, acc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return &types.RespOK{}, errs.InputInvalidRangeError("")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := l.svcCtx.AccountUC.UpdateStatus(l.ctx, member.UpdateStatusRequest{
|
||||||
|
UID: uid,
|
||||||
|
Status: mbr.AccountStatusActive,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 真的刪除
|
||||||
|
_ = l.svcCtx.AccountUC.VerifyRefreshCode(l.ctx, member.VerifyRefreshCodeRequest{
|
||||||
|
VerifyCode: req.VerifyCode,
|
||||||
|
LoginID: acc,
|
||||||
|
CodeType: ct,
|
||||||
|
})
|
||||||
|
|
||||||
|
return &types.RespOK{}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
|
|
||||||
"backend/internal/svc"
|
"backend/internal/svc"
|
||||||
"backend/internal/types"
|
"backend/internal/types"
|
||||||
|
errs "backend/pkg/library/errors"
|
||||||
|
mbr "backend/pkg/member/domain/member"
|
||||||
|
member "backend/pkg/member/domain/usecase"
|
||||||
|
tokeneEntity "backend/pkg/permission/domain/entity"
|
||||||
|
"backend/pkg/permission/domain/token"
|
||||||
|
"context"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
)
|
)
|
||||||
|
|
@ -15,7 +19,7 @@ type UpdatePasswordLogic struct {
|
||||||
svcCtx *svc.ServiceContext
|
svcCtx *svc.ServiceContext
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修改當前登入使用者的密碼
|
// NewUpdatePasswordLogic 修改當前登入使用者的密碼
|
||||||
func NewUpdatePasswordLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdatePasswordLogic {
|
func NewUpdatePasswordLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdatePasswordLogic {
|
||||||
return &UpdatePasswordLogic{
|
return &UpdatePasswordLogic{
|
||||||
Logger: logx.WithContext(ctx),
|
Logger: logx.WithContext(ctx),
|
||||||
|
|
@ -24,8 +28,41 @@ func NewUpdatePasswordLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Up
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *UpdatePasswordLogic) UpdatePassword(req *types.UpdatePasswordReq) (resp *types.RespOK, err error) {
|
func (l *UpdatePasswordLogic) UpdatePassword(req *types.UpdatePasswordReq) (*types.RespOK, error) {
|
||||||
// todo: add your logic here and delete this line
|
if req.NewPassword != req.NewPasswordConfirm {
|
||||||
|
return nil, errs.InputInvalidFormatError("failed to check token")
|
||||||
|
}
|
||||||
|
loginID := token.LoginID(l.ctx)
|
||||||
|
// 驗證是否本地平台
|
||||||
|
info, err := l.svcCtx.AccountUC.GetUserAccountInfo(l.ctx, member.GetUIDByAccountRequest{
|
||||||
|
Account: loginID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return
|
if info.Data.Platform != mbr.Digimon {
|
||||||
|
return nil, errs.InputInvalidFormatError("failed th change password via third party login")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 驗證舊密碼是否正確
|
||||||
|
if _, err := l.svcCtx.AccountUC.VerifyPlatformAuthResult(l.ctx, member.VerifyAuthResultRequest{
|
||||||
|
Account: loginID,
|
||||||
|
Token: req.CurrentPassword,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, errs.AuthForbiddenError("failed to verify correct password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新
|
||||||
|
err = l.svcCtx.AccountUC.UpdateUserToken(l.ctx, member.UpdateTokenRequest{
|
||||||
|
Account: loginID,
|
||||||
|
Token: req.NewPassword,
|
||||||
|
Platform: mbr.Digimon.ToInt64(),
|
||||||
|
})
|
||||||
|
|
||||||
|
_ = l.svcCtx.TokenUC.CancelToken(l.ctx, tokeneEntity.CancelTokenReq{
|
||||||
|
Token: req.Authorization.Authorization,
|
||||||
|
})
|
||||||
|
|
||||||
|
return &types.RespOK{}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
errs "backend/pkg/library/errors"
|
||||||
|
mbr "backend/pkg/member/domain/member"
|
||||||
|
member "backend/pkg/member/domain/usecase"
|
||||||
|
"backend/pkg/permission/domain/token"
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
|
||||||
"backend/internal/svc"
|
"backend/internal/svc"
|
||||||
"backend/internal/types"
|
"backend/internal/types"
|
||||||
|
|
@ -15,7 +22,7 @@ type UpdateUserInfoLogic struct {
|
||||||
svcCtx *svc.ServiceContext
|
svcCtx *svc.ServiceContext
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新當前登入的會員資訊
|
// NewUpdateUserInfoLogic 更新當前登入的會員資訊
|
||||||
func NewUpdateUserInfoLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateUserInfoLogic {
|
func NewUpdateUserInfoLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateUserInfoLogic {
|
||||||
return &UpdateUserInfoLogic{
|
return &UpdateUserInfoLogic{
|
||||||
Logger: logx.WithContext(ctx),
|
Logger: logx.WithContext(ctx),
|
||||||
|
|
@ -25,7 +32,108 @@ func NewUpdateUserInfoLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Up
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *UpdateUserInfoLogic) UpdateUserInfo(req *types.UpdateUserInfoReq) (resp *types.UserInfoResp, err error) {
|
func (l *UpdateUserInfoLogic) UpdateUserInfo(req *types.UpdateUserInfoReq) (resp *types.UserInfoResp, err error) {
|
||||||
// todo: add your logic here and delete this line
|
update, err := ConvertBindingUserInfoToUpdateRequest(token.UID(l.ctx), req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.InputInvalidFormatError("failed to get correct user info", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
return
|
err = l.svcCtx.AccountUC.UpdateUserInfo(l.ctx, update)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
info, err := l.svcCtx.AccountUC.GetUserInfo(l.ctx, member.GetUserInfoRequest{
|
||||||
|
UID: token.UID(l.ctx),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
accountInfo, err := l.svcCtx.AccountUC.GetUserAccountInfo(l.ctx, member.GetUIDByAccountRequest{Account: token.LoginID(l.ctx)})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res := &types.UserInfoResp{
|
||||||
|
UID: token.UID(l.ctx),
|
||||||
|
Platform: accountInfo.Data.Platform.ToString(),
|
||||||
|
UserStatus: info.UserStatus.CodeToString(),
|
||||||
|
PreferredLanguage: info.PreferredLanguage,
|
||||||
|
Currency: info.Currency,
|
||||||
|
UpdateAt: time.Unix(0, info.CreateTime).UTC().Format(time.RFC3339),
|
||||||
|
CreateAt: time.Unix(0, info.UpdateTime).UTC().Format(time.RFC3339),
|
||||||
|
//Role string `json:"role"`
|
||||||
|
}
|
||||||
|
if info.Address != nil {
|
||||||
|
res.Address = *info.Address
|
||||||
|
}
|
||||||
|
if info.AvatarURL != nil {
|
||||||
|
res.AvatarURL = *info.AvatarURL
|
||||||
|
}
|
||||||
|
if info.FullName != nil {
|
||||||
|
res.FullName = *info.FullName
|
||||||
|
}
|
||||||
|
if info.PhoneNumber != nil {
|
||||||
|
res.PhoneNumber = *info.PhoneNumber
|
||||||
|
res.IsPhoneVerified = true
|
||||||
|
}
|
||||||
|
if info.Nickname != nil {
|
||||||
|
res.Nickname = *info.Nickname
|
||||||
|
}
|
||||||
|
if info.Email != nil {
|
||||||
|
res.Email = *info.Email
|
||||||
|
res.IsEmailVerified = true
|
||||||
|
}
|
||||||
|
if info.GenderCode != nil {
|
||||||
|
res.GenderCode = mbr.GetGenderByCode(*info.GenderCode)
|
||||||
|
}
|
||||||
|
if info.Birthdate != nil {
|
||||||
|
res.Birthdate = toStringStr(info.Birthdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toStringStr(n *int64) string {
|
||||||
|
result := ""
|
||||||
|
if n != nil {
|
||||||
|
result = time.Unix(*n, 0).UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConvertBindingUserInfoToUpdateRequest(uid string, bindingInfo *types.UpdateUserInfoReq) (*member.UpdateUserInfoRequest, error) {
|
||||||
|
updateRequest := &member.UpdateUserInfoRequest{
|
||||||
|
UID: uid,
|
||||||
|
AvatarURL: bindingInfo.AvatarURL,
|
||||||
|
FullName: bindingInfo.FullName,
|
||||||
|
Nickname: bindingInfo.Nickname,
|
||||||
|
Address: bindingInfo.Address,
|
||||||
|
PreferredLanguage: bindingInfo.PreferredLanguage,
|
||||||
|
Currency: bindingInfo.Currency,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert GenderCode from string to *int8
|
||||||
|
if &bindingInfo.GenderCode != nil {
|
||||||
|
gender := mbr.GetGenderCodeByStr(*bindingInfo.GenderCode)
|
||||||
|
// 檢查 gender 是否在 int8 範圍內
|
||||||
|
if gender < math.MinInt8 || gender > math.MaxInt8 {
|
||||||
|
return nil, fmt.Errorf("gender code %d is out of int8 range", gender)
|
||||||
|
}
|
||||||
|
genderInt8 := int8(gender)
|
||||||
|
updateRequest.GenderCode = &genderInt8
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert Birthdate from string to *int64
|
||||||
|
if &bindingInfo.Birthdate != nil {
|
||||||
|
parse, err := time.Parse(time.RFC3339, *bindingInfo.Birthdate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
date := parse.Unix()
|
||||||
|
|
||||||
|
updateRequest.Birthdate = &date
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateRequest, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"backend/internal/types"
|
"backend/internal/types"
|
||||||
|
errs "backend/pkg/library/errors"
|
||||||
"backend/pkg/permission/domain/entity"
|
"backend/pkg/permission/domain/entity"
|
||||||
"backend/pkg/permission/domain/token"
|
"backend/pkg/permission/domain/token"
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -34,7 +35,11 @@ func (m *AuthMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
|
||||||
// 解析 Header
|
// 解析 Header
|
||||||
header := types.Authorization{}
|
header := types.Authorization{}
|
||||||
if err := httpx.ParseHeaders(r, &header); err != nil {
|
if err := httpx.ParseHeaders(r, &header); err != nil {
|
||||||
//m.writeErrorResponse(w, r, http.StatusBadRequest, "Failed to parse headers")
|
e := errs.AuthInvalidPosixTimeError("failed to parse headers")
|
||||||
|
httpx.WriteJsonCtx(r.Context(), w, e.HTTPStatus(), types.Resp{
|
||||||
|
Code: e.DisplayCode(),
|
||||||
|
Message: e.Error(),
|
||||||
|
})
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -42,19 +47,23 @@ func (m *AuthMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
|
||||||
// 驗證 Token
|
// 驗證 Token
|
||||||
claim, err := uc.ParseClaims(header.Authorization, m.TokenSec, true)
|
claim, err := uc.ParseClaims(header.Authorization, m.TokenSec, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
//// 是否需要紀錄錯誤,是不是只要紀錄除了驗證失敗或過期之外的真錯誤
|
|
||||||
//m.writeErrorResponse(w, r,
|
e := errs.AuthInvalidPosixTimeError(err.Error())
|
||||||
// http.StatusUnauthorized, "failed to verify toke",
|
httpx.WriteJsonCtx(r.Context(), w, e.HTTPStatus(), types.Resp{
|
||||||
// int64(100400))
|
Code: e.DisplayCode(),
|
||||||
|
Message: e.Error(),
|
||||||
|
})
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 驗證 Token 是否在黑名單中
|
// 驗證 Token 是否在黑名單中
|
||||||
if _, err := m.TokenUseCase.ValidationToken(r.Context(), entity.ValidationTokenReq{Token: header.Authorization}); err != nil {
|
if _, err := m.TokenUseCase.ValidationToken(r.Context(), entity.ValidationTokenReq{Token: header.Authorization}); err != nil {
|
||||||
//m.writeErrorResponse(w, r, http.StatusForbidden,
|
e := errs.AuthUnauthorizedError("failed to use this token")
|
||||||
// "failed to get toke",
|
httpx.WriteJsonCtx(r.Context(), w, e.HTTPStatus(), types.Resp{
|
||||||
// int64(100400))
|
Code: e.DisplayCode(),
|
||||||
|
Message: e.Error(),
|
||||||
|
})
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -74,11 +83,3 @@ func SetContext(r *http.Request, claim uc.TokenClaims) context.Context {
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
//// writeErrorResponse 用於處理錯誤回應
|
|
||||||
//func (m *AuthMiddleware) writeErrorResponse(w http.ResponseWriter, r *http.Request, statusCode int, message string, code int64) {
|
|
||||||
// httpx.WriteJsonCtx(r.Context(), w, statusCode, types.Resp{
|
|
||||||
// Code: int(code),
|
|
||||||
// Msg: message,
|
|
||||||
// })
|
|
||||||
//}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
package svc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/internal/config"
|
||||||
|
errs "backend/pkg/library/errors"
|
||||||
|
"backend/pkg/notification/domain/usecase"
|
||||||
|
uc "backend/pkg/notification/usecase"
|
||||||
|
|
||||||
|
cfg "backend/pkg/notification/config"
|
||||||
|
"backend/pkg/notification/domain/repository"
|
||||||
|
rp "backend/pkg/notification/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func MustSMTPEmailSender(c *config.Config) repository.MailRepository {
|
||||||
|
return rp.MustSMTPUseCase(rp.SMTPMailUseCaseParam{
|
||||||
|
Conf: cfg.SMTPConfig{
|
||||||
|
Enable: c.SMTPConfig.Enable,
|
||||||
|
Sort: 1,
|
||||||
|
GoroutinePoolNum: c.SMTPConfig.GoroutinePoolNum,
|
||||||
|
Host: c.SMTPConfig.Host,
|
||||||
|
Port: c.SMTPConfig.Port,
|
||||||
|
Username: c.SMTPConfig.Username,
|
||||||
|
Password: c.SMTPConfig.Password,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func MustAwsEmailSender(c *config.Config) repository.MailRepository {
|
||||||
|
return rp.MustAwsSesMailRepository(rp.AwsEmailDeliveryParam{
|
||||||
|
Conf: &cfg.AmazonSesSettings{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func MustSMS(c *config.Config) repository.SMSClientRepository {
|
||||||
|
return rp.MustMitakeRepository(rp.MitakeSMSDeliveryParam{
|
||||||
|
Conf: &cfg.MitakeSMSSender{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func MustDeliveryUseCase(c *config.Config, logger errs.Logger) usecase.DeliveryUseCase {
|
||||||
|
emailProviders := make([]usecase.EmailProvider, 0, 10)
|
||||||
|
smsProviders := make([]usecase.SMSProvider, 0)
|
||||||
|
//smsProviders = append(smsProviders, usecase.SMSProvider{
|
||||||
|
// Sort: 1,
|
||||||
|
// Repo: MustSMS(c),
|
||||||
|
//})
|
||||||
|
|
||||||
|
//emailProviders = append(emailProviders, usecase.EmailProvider{
|
||||||
|
// Sort: 2,
|
||||||
|
// Repo: MustAwsEmailSender(c),
|
||||||
|
//})
|
||||||
|
|
||||||
|
emailProviders = append(emailProviders, usecase.EmailProvider{
|
||||||
|
Sort: 1,
|
||||||
|
Repo: MustSMTPEmailSender(c),
|
||||||
|
})
|
||||||
|
|
||||||
|
return uc.MustDeliveryUseCase(uc.DeliveryUseCaseParam{
|
||||||
|
SMSProviders: smsProviders,
|
||||||
|
EmailProviders: emailProviders,
|
||||||
|
DeliveryConfig: cfg.DeliveryConfig{
|
||||||
|
MaxRetries: c.DeliveryConfig.MaxRetries,
|
||||||
|
InitialDelay: c.DeliveryConfig.InitialDelay,
|
||||||
|
BackoffFactor: c.DeliveryConfig.BackoffFactor,
|
||||||
|
MaxDelay: c.DeliveryConfig.MaxDelay,
|
||||||
|
Timeout: c.DeliveryConfig.Timeout,
|
||||||
|
EnableHistory: false,
|
||||||
|
},
|
||||||
|
Logger: logger,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -8,8 +8,10 @@ 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"
|
||||||
tokenUC "backend/pkg/permission/domain/usecase"
|
tokenUC "backend/pkg/permission/domain/usecase"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/stores/redis"
|
"github.com/zeromicro/go-zero/core/stores/redis"
|
||||||
|
|
@ -26,6 +28,8 @@ type ServiceContext struct {
|
||||||
RoleUC tokenUC.RoleUseCase
|
RoleUC tokenUC.RoleUseCase
|
||||||
RolePermission tokenUC.RolePermissionUseCase
|
RolePermission tokenUC.RolePermissionUseCase
|
||||||
UserRoleUC tokenUC.UserRoleUseCase
|
UserRoleUC tokenUC.UserRoleUseCase
|
||||||
|
DeliveryUC deliveryUC.DeliveryUseCase
|
||||||
|
FileStorageUC fileStorageUC.FileStorageUseCase
|
||||||
Redis *redis.Redis
|
Redis *redis.Redis
|
||||||
Logger errs.Logger
|
Logger errs.Logger
|
||||||
}
|
}
|
||||||
|
|
@ -39,6 +43,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
||||||
|
|
||||||
rp := NewPermissionUC(&c)
|
rp := NewPermissionUC(&c)
|
||||||
tkUC := NewTokenUC(&c, rds)
|
tkUC := NewTokenUC(&c, rds)
|
||||||
|
lgr := MustLogger(logx.WithContext(context.Background()))
|
||||||
|
|
||||||
return &ServiceContext{
|
return &ServiceContext{
|
||||||
Config: c,
|
Config: c,
|
||||||
|
|
@ -54,6 +59,8 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
||||||
RolePermission: rp.RolePermission,
|
RolePermission: rp.RolePermission,
|
||||||
UserRoleUC: rp.UserRole,
|
UserRoleUC: rp.UserRole,
|
||||||
Redis: rds,
|
Redis: rds,
|
||||||
Logger: MustLogger(logx.WithContext(context.Background())),
|
DeliveryUC: MustDeliveryUseCase(&c, lgr),
|
||||||
|
FileStorageUC: MustS3Storage(&c, 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
|
||||||
|
|
||||||
|
|
@ -82,6 +82,7 @@ type RequestPasswordResetReq struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type RequestVerificationCodeReq struct {
|
type RequestVerificationCodeReq struct {
|
||||||
|
Account string `json:"account" validate:"required`
|
||||||
Purpose string `json:"purpose" validate:"required,oneof=email_verification phone_verification"`
|
Purpose string `json:"purpose" validate:"required,oneof=email_verification phone_verification"`
|
||||||
Authorization
|
Authorization
|
||||||
}
|
}
|
||||||
|
|
@ -104,6 +105,7 @@ type RespOK struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubmitVerificationCodeReq struct {
|
type SubmitVerificationCodeReq struct {
|
||||||
|
Account string `json:"account" validate:"required`
|
||||||
Purpose string `json:"purpose" validate:"required,oneof=email_verification phone_verification"`
|
Purpose string `json:"purpose" validate:"required,oneof=email_verification phone_verification"`
|
||||||
VerifyCode string `json:"verify_code" validate:"required,len=6"`
|
VerifyCode string `json:"verify_code" validate:"required,len=6"`
|
||||||
Authorization
|
Authorization
|
||||||
|
|
@ -130,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
|
||||||
|
|
@ -146,13 +163,9 @@ type UserInfoResp struct {
|
||||||
UserStatus string `json:"user_status"` // 用戶狀態
|
UserStatus string `json:"user_status"` // 用戶狀態
|
||||||
PreferredLanguage string `json:"preferred_language"` // 偏好語言
|
PreferredLanguage string `json:"preferred_language"` // 偏好語言
|
||||||
Currency string `json:"currency"` // 偏好幣種
|
Currency string `json:"currency"` // 偏好幣種
|
||||||
National string `json:"national"` // 國家
|
|
||||||
PostCode string `json:"post_code"` // 郵遞區號
|
|
||||||
Carrier string `json:"carrier"` // 載具
|
|
||||||
Role string `json:"role"` // 角色
|
Role string `json:"role"` // 角色
|
||||||
UpdateAt string `json:"update_at"`
|
UpdateAt string `json:"update_at"`
|
||||||
CreateAt string `json:"create_at"`
|
CreateAt string `json:"create_at"`
|
||||||
Authorization
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type VerifyCodeReq struct {
|
type VerifyCodeReq struct {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package email_template
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Language string
|
||||||
|
|
||||||
|
const (
|
||||||
|
LanguageZhTW Language = "zh_tw"
|
||||||
|
LanguageEnUS Language = "en_us"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TypeID int64
|
||||||
|
|
||||||
|
func (id TypeID) String() string {
|
||||||
|
return fmt.Sprintf("%4d", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 驗證碼通知類 0 ~ 100
|
||||||
|
const (
|
||||||
|
BindingEmail TypeID = 1 // 驗證碼:綁定 Email
|
||||||
|
BindingPhone TypeID = 2 // 驗證碼:綁定 手機
|
||||||
|
ForgetPasswordVerify TypeID = 3 // 驗證碼: 忘記密碼
|
||||||
|
)
|
||||||
|
|
||||||
|
var EmailTemplateMap = map[Language]map[TypeID]func() (string, string, error){
|
||||||
|
LanguageZhTW: {
|
||||||
|
ForgetPasswordVerify: GenerateForgetPasswordEmailZHTW,
|
||||||
|
BindingEmail: GenerateBindingEmailZHTW,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package email_template
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// GetEmailTemplate 取得指定的 Email 樣板
|
||||||
|
func GetEmailTemplate(lang Language, typeID TypeID) (string, string, error) {
|
||||||
|
// 查找指定語言的模板映射
|
||||||
|
templateByLang, exists := EmailTemplateMap[lang]
|
||||||
|
if !exists {
|
||||||
|
return "", "", fmt.Errorf("email template not found for language: %s", lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找指定類型的模板生成函數
|
||||||
|
templateFunc, exists := templateByLang[typeID]
|
||||||
|
if !exists {
|
||||||
|
return "", "", fmt.Errorf("email template not found for type ID: %s", typeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 執行模板生成函數
|
||||||
|
return templateFunc()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
package email_template
|
||||||
|
|
||||||
|
import "github.com/matcornic/hermes/v2"
|
||||||
|
|
||||||
|
// ProductInfo 包含產品相關的資訊,用於郵件模板中的產品展示部分
|
||||||
|
type ProductInfo struct {
|
||||||
|
Name string
|
||||||
|
Link string
|
||||||
|
Logo string
|
||||||
|
Copyright string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmailBodyContent 包含郵件正文的資訊,用於生成郵件的主要內容
|
||||||
|
type EmailBodyContent struct {
|
||||||
|
RecipientName string
|
||||||
|
Intros []string
|
||||||
|
Actions []hermes.Action
|
||||||
|
Outros []string
|
||||||
|
Signature string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForgetPasswordEmailContentParams 包含生成忘記密碼郵件所需的參數
|
||||||
|
type ForgetPasswordEmailContentParams struct {
|
||||||
|
Product ProductInfo
|
||||||
|
Content EmailBodyContent
|
||||||
|
}
|
||||||
|
|
||||||
|
type ForgetPasswordEmailReq struct {
|
||||||
|
Username string
|
||||||
|
VerifyCode string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CooperateThanksEmailReq struct {
|
||||||
|
Username string
|
||||||
|
BusinessID string
|
||||||
|
Rewards []RewardItem
|
||||||
|
Country string
|
||||||
|
TotalAmount string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RewardItem struct {
|
||||||
|
Title string
|
||||||
|
Content string
|
||||||
|
Amount string
|
||||||
|
//Add string
|
||||||
|
}
|
||||||
|
type CooperateUserEmailReq struct {
|
||||||
|
BusinessID string `json:"business_id"` // 開案編號
|
||||||
|
Name string `json:"name"` // 聯絡人姓名
|
||||||
|
Phone string `json:"phone"` // 聯絡電話
|
||||||
|
OrgName string `json:"org_name"` // 團體名稱
|
||||||
|
Email string `json:"email"` // 電子信箱
|
||||||
|
TargetAmount string `json:"target_amount"` // 目標募款金額
|
||||||
|
HasPermit string `json:"has_permit"` // 是否有勸募字號
|
||||||
|
FundRequest string `json:"fund_request"` // 募款需求
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
nTw = "Digimon 團隊"
|
||||||
|
link = "https://code.30cm.net"
|
||||||
|
logo = "https://true-heart-dev.s3.ap-northeast-3.amazonaws.com/f70904eb-1a29-40f7-8940-9a124f23793a.png"
|
||||||
|
cryptoTw = "© 2025~ Digimon Inc. 版權所有"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateForgetPasswordEmailZHTW 生成繁體中文的忘記密碼驗證信
|
||||||
|
func GenerateForgetPasswordEmailZHTW() (string, string, error) {
|
||||||
|
req := ForgetPasswordEmailContentParams{
|
||||||
|
Product: ProductInfo{
|
||||||
|
Name: nTw,
|
||||||
|
Link: link,
|
||||||
|
Logo: logo,
|
||||||
|
Copyright: cryptoTw,
|
||||||
|
},
|
||||||
|
Content: EmailBodyContent{
|
||||||
|
RecipientName: "{{.Username}}",
|
||||||
|
Intros: []string{
|
||||||
|
"您收到此電子郵件是因為我們收到了針對帳戶的密碼重置請求。",
|
||||||
|
},
|
||||||
|
Actions: []hermes.Action{
|
||||||
|
{
|
||||||
|
Instructions: "請複製您的驗證碼,到網頁重置",
|
||||||
|
InviteCode: "{{.VerifyCode}}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Outros: []string{
|
||||||
|
"如果您不要求重設密碼,則無需您採取進一步的措施。",
|
||||||
|
},
|
||||||
|
Signature: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
emailBody, err := buildForgetPasswordEmailContent(req)
|
||||||
|
|
||||||
|
return emailBody, "Digimon 重設密碼驗證信", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateBindingEmailZHTW 生成綁定帳號驗證信
|
||||||
|
func GenerateBindingEmailZHTW() (string, string, error) {
|
||||||
|
req := ForgetPasswordEmailContentParams{
|
||||||
|
Product: ProductInfo{
|
||||||
|
Name: nTw,
|
||||||
|
Link: link,
|
||||||
|
Logo: logo,
|
||||||
|
Copyright: cryptoTw,
|
||||||
|
},
|
||||||
|
Content: EmailBodyContent{
|
||||||
|
RecipientName: "{{.Username}}",
|
||||||
|
Intros: []string{
|
||||||
|
"您收到此電子郵件是因為我們收到了針對帳戶的Email認證請求。",
|
||||||
|
},
|
||||||
|
Actions: []hermes.Action{
|
||||||
|
{
|
||||||
|
Instructions: "請複製您的驗證碼,到網頁重置",
|
||||||
|
InviteCode: "{{.VerifyCode}}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Outros: []string{
|
||||||
|
"如果您不要求重設密碼,則無需您採取進一步的措施。",
|
||||||
|
},
|
||||||
|
Signature: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
emailBody, err := buildForgetPasswordEmailContent(req)
|
||||||
|
|
||||||
|
return emailBody, "Digimon 綁定信箱驗證信", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildForgetPasswordEmailContent 根據參數生成忘記密碼郵件的產品和內容結構
|
||||||
|
func buildForgetPasswordEmailContent(params ForgetPasswordEmailContentParams) (string, error) {
|
||||||
|
product := hermes.Product{
|
||||||
|
Name: params.Product.Name,
|
||||||
|
Link: params.Product.Link,
|
||||||
|
Logo: params.Product.Logo,
|
||||||
|
Copyright: params.Product.Copyright,
|
||||||
|
}
|
||||||
|
|
||||||
|
body := hermes.Body{
|
||||||
|
Name: params.Content.RecipientName,
|
||||||
|
Intros: params.Content.Intros,
|
||||||
|
Actions: params.Content.Actions,
|
||||||
|
Outros: params.Content.Outros,
|
||||||
|
Signature: params.Content.Signature,
|
||||||
|
}
|
||||||
|
|
||||||
|
h := hermes.Hermes{Product: product}
|
||||||
|
email := hermes.Email{Body: body}
|
||||||
|
|
||||||
|
return h.GenerateHTML(email)
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
package domain
|
||||||
|
|
@ -12,4 +12,5 @@ type MailReq struct {
|
||||||
From string
|
From string
|
||||||
Subject string
|
Subject string
|
||||||
Body string
|
Body string
|
||||||
|
SenderName string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ type DeliveryUseCase interface {
|
||||||
type MailReq struct {
|
type MailReq struct {
|
||||||
To []string
|
To []string
|
||||||
From string
|
From string
|
||||||
|
SenderName string
|
||||||
Subject string
|
Subject string
|
||||||
Body string
|
Body string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,12 @@ func (repo *SMTPMailRepository) SendMail(ctx context.Context, req repository.Mai
|
||||||
|
|
||||||
// 構建郵件
|
// 構建郵件
|
||||||
m := gomail.NewMessage()
|
m := gomail.NewMessage()
|
||||||
|
|
||||||
|
if req.SenderName != "" {
|
||||||
|
m.SetAddressHeader("From", req.From, req.SenderName)
|
||||||
|
} else {
|
||||||
m.SetHeader("From", req.From)
|
m.SetHeader("From", req.From)
|
||||||
|
}
|
||||||
m.SetHeader("To", req.To...)
|
m.SetHeader("To", req.To...)
|
||||||
m.SetHeader("Subject", req.Subject)
|
m.SetHeader("Subject", req.Subject)
|
||||||
m.SetBody("text/html", req.Body)
|
m.SetBody("text/html", req.Body)
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,7 @@ func (a *emailProviderAdapter) send(ctx context.Context, providerIndex int) erro
|
||||||
To: a.request.To,
|
To: a.request.To,
|
||||||
Subject: a.request.Subject,
|
Subject: a.request.Subject,
|
||||||
Body: a.request.Body,
|
Body: a.request.Body,
|
||||||
|
SenderName: a.request.SenderName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
# 測試結果文件
|
||||||
|
results/*.json
|
||||||
|
results/*.csv
|
||||||
|
results/*.log
|
||||||
|
!results/.gitkeep
|
||||||
|
|
||||||
|
# 日誌文件
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# 環境變數文件
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# 系統文件
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
|
@ -0,0 +1,321 @@
|
||||||
|
.PHONY: help build run smoke load stress nightly clean test
|
||||||
|
|
||||||
|
# 默認目標
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
# 顏色定義
|
||||||
|
GREEN := \033[0;32m
|
||||||
|
YELLOW := \033[0;33m
|
||||||
|
NC := \033[0m # No Color
|
||||||
|
|
||||||
|
# 環境變數
|
||||||
|
BASE_URL ?= http://localhost:8888
|
||||||
|
TEST_LOGIN_ID ?=
|
||||||
|
TEST_PASSWORD ?=
|
||||||
|
K6_IMAGE ?= k6-test:latest
|
||||||
|
|
||||||
|
help: ## 顯示幫助信息
|
||||||
|
@echo "$(GREEN)可用命令:$(NC)"
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " $(YELLOW)%-20s$(NC) %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
|
||||||
|
smoke-auth: ## 運行認證功能冒煙測試
|
||||||
|
@echo "$(GREEN)運行認證功能冒煙測試...$(NC)"
|
||||||
|
@docker run --rm -i \
|
||||||
|
--network host \
|
||||||
|
-v $(PWD)/scenarios:/app/scenarios \
|
||||||
|
-v $(PWD)/tests:/app/tests \
|
||||||
|
-e BASE_URL=$(BASE_URL) \
|
||||||
|
grafana/k6:latest run /app/tests/smoke/smoke-auth-test.js
|
||||||
|
|
||||||
|
load-auth: ## 運行認證功能負載測試
|
||||||
|
@echo "$(GREEN)運行認證功能負載測試...$(NC)"
|
||||||
|
@docker run --rm -i \
|
||||||
|
--network host \
|
||||||
|
-v $(PWD)/scenarios:/app/scenarios \
|
||||||
|
-v $(PWD)/tests:/app/tests \
|
||||||
|
-e BASE_URL=$(BASE_URL) \
|
||||||
|
grafana/k6:latest run /app/tests/pre/load-auth-test.js
|
||||||
|
|
||||||
|
|
||||||
|
# build: ## 構建 Docker 映像
|
||||||
|
# @echo "$(GREEN)構建 k6 測試映像...$(NC)"
|
||||||
|
# docker-compose build
|
||||||
|
|
||||||
|
# # ==================== 冒煙測試 ====================
|
||||||
|
# smoke: smoke-all ## 運行所有冒煙測試
|
||||||
|
|
||||||
|
# smoke-all: ## 運行所有冒煙測試
|
||||||
|
# @echo "$(GREEN)運行所有冒煙測試...$(NC)"
|
||||||
|
# @docker run --rm -i \
|
||||||
|
# --network host \
|
||||||
|
# -v $(PWD)/scenarios:/app/scenarios \
|
||||||
|
# -v $(PWD)/tests:/app/tests \
|
||||||
|
# -e BASE_URL=$(BASE_URL) \
|
||||||
|
# grafana/k6:latest run /app/tests/smoke/smoke-health-test.js
|
||||||
|
# @docker run --rm -i \
|
||||||
|
# --network host \
|
||||||
|
# -v $(PWD)/scenarios:/app/scenarios \
|
||||||
|
# -v $(PWD)/tests:/app/tests \
|
||||||
|
# -e BASE_URL=$(BASE_URL) \
|
||||||
|
# grafana/k6:latest run /app/tests/smoke/smoke-auth-test.js
|
||||||
|
# @docker run --rm -i \
|
||||||
|
# --network host \
|
||||||
|
# -v $(PWD)/scenarios:/app/scenarios \
|
||||||
|
# -v $(PWD)/tests:/app/tests \
|
||||||
|
# -e BASE_URL=$(BASE_URL) \
|
||||||
|
# grafana/k6:latest run /app/tests/smoke/smoke-user-test.js
|
||||||
|
# @docker run --rm -i \
|
||||||
|
# --network host \
|
||||||
|
# -v $(PWD)/scenarios:/app/scenarios \
|
||||||
|
# -v $(PWD)/tests:/app/tests \
|
||||||
|
# -e BASE_URL=$(BASE_URL) \
|
||||||
|
# grafana/k6:latest run /app/tests/smoke/smoke-user-profile-flow-test.js
|
||||||
|
|
||||||
|
# smoke-health: ## 運行健康檢查冒煙測試
|
||||||
|
# @echo "$(GREEN)運行健康檢查冒煙測試...$(NC)"
|
||||||
|
# @docker run --rm -i \
|
||||||
|
# --network host \
|
||||||
|
# -v $(PWD)/scenarios:/app/scenarios \
|
||||||
|
# -v $(PWD)/tests:/app/tests \
|
||||||
|
# -e BASE_URL=$(BASE_URL) \
|
||||||
|
# grafana/k6:latest run /app/tests/smoke/smoke-health-test.js
|
||||||
|
|
||||||
|
# smoke-auth: ## 運行認證功能冒煙測試
|
||||||
|
# @echo "$(GREEN)運行認證功能冒煙測試...$(NC)"
|
||||||
|
# @docker run --rm -i \
|
||||||
|
# --network host \
|
||||||
|
# -v $(PWD)/scenarios:/app/scenarios \
|
||||||
|
# -v $(PWD)/tests:/app/tests \
|
||||||
|
# -e BASE_URL=$(BASE_URL) \
|
||||||
|
# grafana/k6:latest run /app/tests/smoke/smoke-auth-test.js
|
||||||
|
|
||||||
|
# smoke-user: ## 運行使用者功能冒煙測試
|
||||||
|
# @echo "$(GREEN)運行使用者功能冒煙測試...$(NC)"
|
||||||
|
# @docker run --rm -i \
|
||||||
|
# --network host \
|
||||||
|
# -v $(PWD)/scenarios:/app/scenarios \
|
||||||
|
# -v $(PWD)/tests:/app/tests \
|
||||||
|
# -e BASE_URL=$(BASE_URL) \
|
||||||
|
# grafana/k6:latest run /app/tests/smoke/smoke-user-test.js
|
||||||
|
|
||||||
|
# smoke-user-profile-flow: ## 運行使用者資料流程冒煙測試
|
||||||
|
# @echo "$(GREEN)運行使用者資料流程冒煙測試...$(NC)"
|
||||||
|
# @docker run --rm -i \
|
||||||
|
# --network host \
|
||||||
|
# -v $(PWD)/scenarios:/app/scenarios \
|
||||||
|
# -e BASE_URL=$(BASE_URL) \
|
||||||
|
# grafana/k6:latest run /app/scenarios/e2e/user-profile-flow.js
|
||||||
|
|
||||||
|
# # ==================== 負載測試 ====================
|
||||||
|
# load: load-all ## 運行所有負載測試
|
||||||
|
|
||||||
|
# load-all: ## 運行所有負載測試
|
||||||
|
# @echo "$(GREEN)運行所有負載測試...$(NC)"
|
||||||
|
# @docker run --rm -i \
|
||||||
|
# --network host \
|
||||||
|
# -v $(PWD)/scenarios:/app/scenarios \
|
||||||
|
# -v $(PWD)/tests:/app/tests \
|
||||||
|
# -e BASE_URL=$(BASE_URL) \
|
||||||
|
# grafana/k6:latest run /app/tests/pre/load-auth-test.js
|
||||||
|
# @docker run --rm -i \
|
||||||
|
# --network host \
|
||||||
|
# -v $(PWD)/scenarios:/app/scenarios \
|
||||||
|
# -v $(PWD)/tests:/app/tests \
|
||||||
|
# -e BASE_URL=$(BASE_URL) \
|
||||||
|
# grafana/k6:latest run /app/tests/pre/load-user-test.js
|
||||||
|
# @docker run --rm -i \
|
||||||
|
# --network host \
|
||||||
|
# -v $(PWD)/scenarios:/app/scenarios \
|
||||||
|
# -v $(PWD)/tests:/app/tests \
|
||||||
|
# -e BASE_URL=$(BASE_URL) \
|
||||||
|
# grafana/k6:latest run /app/tests/pre/load-user-profile-flow-test.js
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# load-user: ## 運行使用者功能負載測試
|
||||||
|
# @echo "$(GREEN)運行使用者功能負載測試...$(NC)"
|
||||||
|
# @docker run --rm -i \
|
||||||
|
# --network host \
|
||||||
|
# -v $(PWD)/scenarios:/app/scenarios \
|
||||||
|
# -v $(PWD)/tests:/app/tests \
|
||||||
|
# -e BASE_URL=$(BASE_URL) \
|
||||||
|
# grafana/k6:latest run /app/tests/pre/load-user-test.js
|
||||||
|
|
||||||
|
# load-user-profile-flow: ## 運行使用者資料流程負載測試
|
||||||
|
# @echo "$(GREEN)運行使用者資料流程負載測試...$(NC)"
|
||||||
|
# @docker run --rm -i \
|
||||||
|
# --network host \
|
||||||
|
# -v $(PWD)/scenarios:/app/scenarios \
|
||||||
|
# -v $(PWD)/tests:/app/tests \
|
||||||
|
# -e BASE_URL=$(BASE_URL) \
|
||||||
|
# grafana/k6:latest run /app/tests/pre/load-user-profile-flow-test.js
|
||||||
|
|
||||||
|
# # ==================== 壓力測試 ====================
|
||||||
|
# stress: stress-all ## 運行所有壓力測試
|
||||||
|
|
||||||
|
# stress-all: ## 運行所有壓力測試
|
||||||
|
# @echo "$(GREEN)運行所有壓力測試...$(NC)"
|
||||||
|
# @docker run --rm -i \
|
||||||
|
# --network host \
|
||||||
|
# -v $(PWD)/scenarios:/app/scenarios \
|
||||||
|
# -v $(PWD)/tests:/app/tests \
|
||||||
|
# -e BASE_URL=$(BASE_URL) \
|
||||||
|
# grafana/k6:latest run /app/tests/pre/stress-auth-test.js
|
||||||
|
# @docker run --rm -i \
|
||||||
|
# --network host \
|
||||||
|
# -v $(PWD)/scenarios:/app/scenarios \
|
||||||
|
# -v $(PWD)/tests:/app/tests \
|
||||||
|
# -e BASE_URL=$(BASE_URL) \
|
||||||
|
# grafana/k6:latest run /app/tests/pre/stress-user-test.js
|
||||||
|
|
||||||
|
# stress-auth: ## 運行認證功能壓力測試
|
||||||
|
# @echo "$(GREEN)運行認證功能壓力測試...$(NC)"
|
||||||
|
# @docker run --rm -i \
|
||||||
|
# --network host \
|
||||||
|
# -v $(PWD)/scenarios:/app/scenarios \
|
||||||
|
# -v $(PWD)/tests:/app/tests \
|
||||||
|
# -e BASE_URL=$(BASE_URL) \
|
||||||
|
# grafana/k6:latest run /app/tests/pre/stress-auth-test.js
|
||||||
|
|
||||||
|
# stress-user: ## 運行使用者功能壓力測試
|
||||||
|
# @echo "$(GREEN)運行使用者功能壓力測試...$(NC)"
|
||||||
|
# @docker run --rm -i \
|
||||||
|
# --network host \
|
||||||
|
# -v $(PWD)/scenarios:/app/scenarios \
|
||||||
|
# -v $(PWD)/tests:/app/tests \
|
||||||
|
# -e BASE_URL=$(BASE_URL) \
|
||||||
|
# grafana/k6:latest run /app/tests/pre/stress-user-test.js
|
||||||
|
|
||||||
|
# # ==================== 生產環境測試 ====================
|
||||||
|
# nightly: nightly-all ## 運行所有夜間測試
|
||||||
|
|
||||||
|
# nightly-all: ## 運行所有夜間測試
|
||||||
|
# @echo "$(GREEN)運行所有夜間測試...$(NC)"
|
||||||
|
# @docker run --rm -i \
|
||||||
|
# --network host \
|
||||||
|
# -v $(PWD)/scenarios:/app/scenarios \
|
||||||
|
# -v $(PWD)/tests:/app/tests \
|
||||||
|
# -e BASE_URL=$(BASE_URL) \
|
||||||
|
# -e TEST_LOGIN_ID=$(TEST_LOGIN_ID) \
|
||||||
|
# -e TEST_PASSWORD=$(TEST_PASSWORD) \
|
||||||
|
# grafana/k6:latest run /app/tests/prod/nightly-health-test.js
|
||||||
|
# @docker run --rm -i \
|
||||||
|
# --network host \
|
||||||
|
# -v $(PWD)/scenarios:/app/scenarios \
|
||||||
|
# -v $(PWD)/tests:/app/tests \
|
||||||
|
# -e BASE_URL=$(BASE_URL) \
|
||||||
|
# -e TEST_LOGIN_ID=$(TEST_LOGIN_ID) \
|
||||||
|
# -e TEST_PASSWORD=$(TEST_PASSWORD) \
|
||||||
|
# grafana/k6:latest run /app/tests/prod/nightly-auth-test.js
|
||||||
|
# @docker run --rm -i \
|
||||||
|
# --network host \
|
||||||
|
# -v $(PWD)/scenarios:/app/scenarios \
|
||||||
|
# -v $(PWD)/tests:/app/tests \
|
||||||
|
# -e BASE_URL=$(BASE_URL) \
|
||||||
|
# -e TEST_LOGIN_ID=$(TEST_LOGIN_ID) \
|
||||||
|
# -e TEST_PASSWORD=$(TEST_PASSWORD) \
|
||||||
|
# grafana/k6:latest run /app/tests/prod/nightly-user-test.js
|
||||||
|
|
||||||
|
# nightly-health: ## 運行健康檢查夜間測試
|
||||||
|
# @echo "$(GREEN)運行健康檢查夜間測試...$(NC)"
|
||||||
|
# @docker run --rm -i \
|
||||||
|
# --network host \
|
||||||
|
# -v $(PWD)/scenarios:/app/scenarios \
|
||||||
|
# -v $(PWD)/tests:/app/tests \
|
||||||
|
# -e BASE_URL=$(BASE_URL) \
|
||||||
|
# grafana/k6:latest run /app/tests/prod/nightly-health-test.js
|
||||||
|
|
||||||
|
# nightly-auth: ## 運行認證功能夜間測試
|
||||||
|
# @echo "$(GREEN)運行認證功能夜間測試...$(NC)"
|
||||||
|
# @docker run --rm -i \
|
||||||
|
# --network host \
|
||||||
|
# -v $(PWD)/scenarios:/app/scenarios \
|
||||||
|
# -v $(PWD)/tests:/app/tests \
|
||||||
|
# -e BASE_URL=$(BASE_URL) \
|
||||||
|
# -e TEST_LOGIN_ID=$(TEST_LOGIN_ID) \
|
||||||
|
# -e TEST_PASSWORD=$(TEST_PASSWORD) \
|
||||||
|
# grafana/k6:latest run /app/tests/prod/nightly-auth-test.js
|
||||||
|
|
||||||
|
# nightly-user: ## 運行使用者功能夜間測試
|
||||||
|
# @echo "$(GREEN)運行使用者功能夜間測試...$(NC)"
|
||||||
|
# @docker run --rm -i \
|
||||||
|
# --network host \
|
||||||
|
# -v $(PWD)/scenarios:/app/scenarios \
|
||||||
|
# -v $(PWD)/tests:/app/tests \
|
||||||
|
# -e BASE_URL=$(BASE_URL) \
|
||||||
|
# -e TEST_LOGIN_ID=$(TEST_LOGIN_ID) \
|
||||||
|
# -e TEST_PASSWORD=$(TEST_PASSWORD) \
|
||||||
|
# grafana/k6:latest run /app/tests/prod/nightly-user-test.js
|
||||||
|
|
||||||
|
# # ==================== 通用命令 ====================
|
||||||
|
# run: ## 運行指定的測試文件 (使用: make run TEST=tests/smoke/smoke-auth-test.js)
|
||||||
|
# @if [ -z "$(TEST)" ]; then \
|
||||||
|
# echo "$(YELLOW)錯誤: 請指定測試文件$(NC)"; \
|
||||||
|
# echo "用法: make run TEST=tests/smoke/smoke-auth-test.js"; \
|
||||||
|
# exit 1; \
|
||||||
|
# fi
|
||||||
|
# @echo "$(GREEN)運行測試: $(TEST)$(NC)"
|
||||||
|
# @docker run --rm -i \
|
||||||
|
# --network host \
|
||||||
|
# -v $(PWD)/scenarios:/app/scenarios \
|
||||||
|
# -v $(PWD)/tests:/app/tests \
|
||||||
|
# -e BASE_URL=$(BASE_URL) \
|
||||||
|
# -e TEST_LOGIN_ID=$(TEST_LOGIN_ID) \
|
||||||
|
# -e TEST_PASSWORD=$(TEST_PASSWORD) \
|
||||||
|
# grafana/k6:latest run /app/$(TEST)
|
||||||
|
|
||||||
|
# run-local: ## 本地運行測試(不使用 Docker,需要本地安裝 k6)
|
||||||
|
# @if [ -z "$(TEST)" ]; then \
|
||||||
|
# echo "$(YELLOW)錯誤: 請指定測試文件$(NC)"; \
|
||||||
|
# echo "用法: make run-local TEST=tests/smoke/smoke-auth-test.js"; \
|
||||||
|
# exit 1; \
|
||||||
|
# fi
|
||||||
|
# @echo "$(GREEN)本地運行測試: $(TEST)$(NC)"
|
||||||
|
# @BASE_URL=$(BASE_URL) \
|
||||||
|
# TEST_LOGIN_ID=$(TEST_LOGIN_ID) \
|
||||||
|
# TEST_PASSWORD=$(TEST_PASSWORD) \
|
||||||
|
# k6 run $(TEST)
|
||||||
|
|
||||||
|
# clean: ## 清理 Docker 資源
|
||||||
|
# @echo "$(GREEN)清理 Docker 資源...$(NC)"
|
||||||
|
# @docker-compose down -v 2>/dev/null || true
|
||||||
|
# @docker rmi $(K6_IMAGE) 2>/dev/null || true
|
||||||
|
# @echo "$(GREEN)清理完成$(NC)"
|
||||||
|
|
||||||
|
# # ==================== 測試結果輸出 ====================
|
||||||
|
# run-with-output: ## 運行測試並輸出結果到文件 (使用: make run-with-output TEST=tests/smoke/smoke-auth-test.js OUTPUT=results.json)
|
||||||
|
# @if [ -z "$(TEST)" ]; then \
|
||||||
|
# echo "$(YELLOW)錯誤: 請指定測試文件$(NC)"; \
|
||||||
|
# echo "用法: make run-with-output TEST=tests/smoke/smoke-auth-test.js OUTPUT=results.json"; \
|
||||||
|
# exit 1; \
|
||||||
|
# fi
|
||||||
|
# @mkdir -p results
|
||||||
|
# @echo "$(GREEN)運行測試並輸出結果: $(TEST) -> results/$(OUTPUT)$(NC)"
|
||||||
|
# @docker run --rm -i \
|
||||||
|
# --network host \
|
||||||
|
# -v $(PWD)/scenarios:/app/scenarios \
|
||||||
|
# -v $(PWD)/tests:/app/tests \
|
||||||
|
# -v $(PWD)/results:/app/results \
|
||||||
|
# -e BASE_URL=$(BASE_URL) \
|
||||||
|
# -e TEST_LOGIN_ID=$(TEST_LOGIN_ID) \
|
||||||
|
# -e TEST_PASSWORD=$(TEST_PASSWORD) \
|
||||||
|
# grafana/k6:latest run --out json=/app/results/$(OUTPUT) /app/$(TEST)
|
||||||
|
|
||||||
|
# # ==================== 環境設置 ====================
|
||||||
|
# env-dev: ## 設置開發環境變數
|
||||||
|
# @echo "$(GREEN)設置開發環境...$(NC)"
|
||||||
|
# @export BASE_URL=https://dev-api.example.com
|
||||||
|
# @echo "BASE_URL=$(BASE_URL)"
|
||||||
|
|
||||||
|
# env-pre: ## 設置預發布環境變數
|
||||||
|
# @echo "$(GREEN)設置預發布環境...$(NC)"
|
||||||
|
# @export BASE_URL=https://pre-api.example.com
|
||||||
|
# @echo "BASE_URL=$(BASE_URL)"
|
||||||
|
|
||||||
|
# env-prod: ## 設置生產環境變數
|
||||||
|
# @echo "$(GREEN)設置生產環境...$(NC)"
|
||||||
|
# @export BASE_URL=https://api.example.com
|
||||||
|
# @echo "BASE_URL=$(BASE_URL)"
|
||||||
|
# @echo "$(YELLOW)注意: 生產環境測試需要設置 TEST_LOGIN_ID 和 TEST_PASSWORD$(NC)"
|
||||||
|
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
# 測試架構說明
|
||||||
|
|
||||||
|
本目錄包含基於 k6 的負載測試架構,採用模組化設計,支援場景重複使用。
|
||||||
|
|
||||||
|
## 📁 目錄結構
|
||||||
|
|
||||||
|
```
|
||||||
|
test/
|
||||||
|
├── scenarios/ # 可重複使用的場景模組
|
||||||
|
│ ├── apis/ # API 層級場景(單一 API 端點)
|
||||||
|
│ └── e2e/ # 端到端業務流程場景
|
||||||
|
├── tests/ # 不同環境的測試配置
|
||||||
|
│ ├── smoke/ # 冒煙測試(Dev/QA 環境)
|
||||||
|
│ ├── pre/ # 預發布環境測試(負載/壓力測試)
|
||||||
|
│ └── prod/ # 生產環境測試(夜間監控)
|
||||||
|
├── Dockerfile # Docker 映像定義
|
||||||
|
├── docker-compose.yml # Docker Compose 配置
|
||||||
|
├── Makefile # Make 命令文件
|
||||||
|
├── AI_GUIDE.md # AI 助手指南(如何添加新 API)
|
||||||
|
└── README.md # 本文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 設計原則
|
||||||
|
|
||||||
|
1. **模組化場景**:每個 API 端點對應一個場景函數,可獨立使用
|
||||||
|
2. **可擴展預設行為**:場景函數接受 `options` 參數,可覆蓋預設行為
|
||||||
|
3. **自定義指標可選**:支援可選的自定義指標,不強制使用
|
||||||
|
4. **請求標籤與檢查**:每個請求添加 tag,並檢查響應結果
|
||||||
|
5. **使用 Scenarios 設置工作負載**:提供更大的靈活性
|
||||||
|
6. **避免多用途測試**:每個測試文件專注於一個主要目的
|
||||||
|
|
||||||
|
## 🚀 快速開始
|
||||||
|
|
||||||
|
### 使用 Makefile(推薦)
|
||||||
|
|
||||||
|
最簡單的方式是使用 Makefile 提供的命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看所有可用命令
|
||||||
|
make help
|
||||||
|
|
||||||
|
# 運行所有冒煙測試
|
||||||
|
make smoke
|
||||||
|
|
||||||
|
# 運行特定冒煙測試
|
||||||
|
make smoke-health
|
||||||
|
make smoke-auth
|
||||||
|
make smoke-user
|
||||||
|
|
||||||
|
# 運行負載測試
|
||||||
|
make load
|
||||||
|
make load-auth
|
||||||
|
make load-user
|
||||||
|
|
||||||
|
# 運行壓力測試
|
||||||
|
make stress
|
||||||
|
make stress-auth
|
||||||
|
make stress-user
|
||||||
|
|
||||||
|
# 運行生產環境測試(需要設置環境變數)
|
||||||
|
export BASE_URL=https://api.example.com
|
||||||
|
export TEST_LOGIN_ID=test@example.com
|
||||||
|
export TEST_PASSWORD=TestPassword123!
|
||||||
|
make nightly
|
||||||
|
|
||||||
|
# 運行指定的測試文件
|
||||||
|
make run TEST=tests/smoke/smoke-auth-test.js
|
||||||
|
|
||||||
|
# 運行測試並輸出結果
|
||||||
|
make run-with-output TEST=tests/smoke/smoke-auth-test.js OUTPUT=results.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用 Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 構建 Docker 映像
|
||||||
|
docker-compose build
|
||||||
|
|
||||||
|
# 運行測試(需要設置環境變數)
|
||||||
|
BASE_URL=https://api.example.com docker-compose run --rm k6 run tests/smoke/smoke-auth-test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### 直接使用 k6(需要本地安裝 k6)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 設置環境變數
|
||||||
|
export BASE_URL=https://api.example.com
|
||||||
|
export TEST_LOGIN_ID=test@example.com
|
||||||
|
export TEST_PASSWORD=TestPassword123!
|
||||||
|
|
||||||
|
# 運行冒煙測試
|
||||||
|
k6 run tests/smoke/smoke-auth-test.js
|
||||||
|
k6 run tests/smoke/smoke-user-test.js
|
||||||
|
k6 run tests/smoke/smoke-health-test.js
|
||||||
|
|
||||||
|
# 運行負載測試
|
||||||
|
k6 run tests/pre/load-auth-test.js
|
||||||
|
k6 run tests/pre/stress-auth-test.js
|
||||||
|
|
||||||
|
# 運行生產環境測試
|
||||||
|
k6 run tests/prod/nightly-auth-test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### 環境變數設置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 設置 API 基礎 URL(必需)
|
||||||
|
export BASE_URL=https://api.example.com
|
||||||
|
|
||||||
|
# 生產環境測試帳號(僅用於生產環境測試)
|
||||||
|
export TEST_LOGIN_ID=test@example.com
|
||||||
|
export TEST_PASSWORD=TestPassword123!
|
||||||
|
|
||||||
|
# 或在 Makefile 命令中直接指定
|
||||||
|
make smoke BASE_URL=https://api.example.com
|
||||||
|
make nightly BASE_URL=https://api.example.com TEST_LOGIN_ID=test@example.com TEST_PASSWORD=TestPassword123!
|
||||||
|
```
|
||||||
|
|
||||||
|
### 網絡配置說明
|
||||||
|
|
||||||
|
**重要**:如果 API 服務器在主機上運行(不在容器內),測試容器已經配置為使用主機網絡模式(`--network host`),可以直接訪問主機上的服務。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 如果 API 在本地主機運行
|
||||||
|
export BASE_URL=http://localhost:8888
|
||||||
|
# 或
|
||||||
|
export BASE_URL=https://localhost:8888
|
||||||
|
|
||||||
|
# 如果 API 在其他地址運行
|
||||||
|
export BASE_URL=https://api.example.com
|
||||||
|
|
||||||
|
# 運行測試
|
||||||
|
make smoke-health
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:
|
||||||
|
- 使用 `--network host` 模式時,容器直接使用主機網絡,可以訪問 `localhost` 和主機上的所有端口
|
||||||
|
- 如果 API 使用 HTTPS 但沒有有效證書,可能需要設置 `K6_SKIP_TLS_VERIFY=true`
|
||||||
|
|
||||||
|
## 📝 添加新 API 場景
|
||||||
|
|
||||||
|
請參考 `AI_GUIDE.md` 文件,其中包含詳細的步驟和模板。
|
||||||
|
|
||||||
|
快速步驟:
|
||||||
|
1. 在 `scenarios/apis/` 創建場景模組
|
||||||
|
2. 在 `scenarios/e2e/` 創建流程場景(如果需要)
|
||||||
|
3. 在 `tests/smoke/` 創建冒煙測試
|
||||||
|
4. 在 `tests/pre/` 創建負載/壓力測試
|
||||||
|
5. 在 `tests/prod/` 創建夜間測試(如果適用)
|
||||||
|
|
||||||
|
## 📊 測試類型
|
||||||
|
|
||||||
|
| 環境 | 測試類型 | 目的 | 並發數 | 持續時間 |
|
||||||
|
|------|---------|------|--------|---------|
|
||||||
|
| Dev/QA | Smoke | 快速驗證功能可用性 | 1 | 30s |
|
||||||
|
| Pre-release | Load | 正常負載下的性能 | 10-20 | 5-10m |
|
||||||
|
| Pre-release | Stress | 高負載下的穩定性 | 50-100 | 5-10m |
|
||||||
|
| Production | Nightly | 監控生產環境穩定性 | 5 | 5-10m |
|
||||||
|
|
||||||
|
## 🔗 相關資源
|
||||||
|
|
||||||
|
- [k6 官方文檔](https://k6.io/docs/)
|
||||||
|
- [AI_GUIDE.md](./AI_GUIDE.md) - 詳細的 AI 助手指南
|
||||||
|
|
||||||
|
|
@ -0,0 +1,476 @@
|
||||||
|
# 測試架構 AI 指南
|
||||||
|
|
||||||
|
本文檔旨在幫助 AI 助手理解此測試架構的設計原則和使用方式,以便在添加新 API 時能夠快速套用相同的模式。
|
||||||
|
|
||||||
|
## 📁 目錄結構
|
||||||
|
|
||||||
|
```
|
||||||
|
test/
|
||||||
|
├── scenarios/ # 可重複使用的場景模組
|
||||||
|
│ ├── apis/ # API 層級場景(單一 API 端點)
|
||||||
|
│ │ ├── auth.js # 認證相關場景
|
||||||
|
│ │ ├── user.js # 使用者相關場景
|
||||||
|
│ │ └── health.js # 健康檢查場景
|
||||||
|
│ └── e2e/ # 端到端業務流程場景
|
||||||
|
│ ├── authentication-flow.js # 認證流程
|
||||||
|
│ └── user-profile-flow.js # 使用者資料流程
|
||||||
|
├── tests/ # 不同環境的測試配置
|
||||||
|
│ ├── smoke/ # 冒煙測試(Dev/QA 環境)
|
||||||
|
│ ├── pre/ # 預發布環境測試(負載/壓力測試)
|
||||||
|
│ └── prod/ # 生產環境測試(夜間監控)
|
||||||
|
└── AI_GUIDE.md # 本文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 設計原則
|
||||||
|
|
||||||
|
### 1. 模組化場景設計
|
||||||
|
- **場景模組化**:每個 API 端點對應一個場景函數,可獨立使用
|
||||||
|
- **可擴展預設行為**:場景函數接受 `options` 參數,可覆蓋預設行為
|
||||||
|
- **避免緊密耦合**:場景邏輯與測試配置分離
|
||||||
|
|
||||||
|
### 2. 自定義指標(可選)
|
||||||
|
- 場景函數支援可選的 `customMetrics` 參數
|
||||||
|
- 如果不提供,使用預設指標
|
||||||
|
- 如果需要特殊指標,可以傳入自定義指標對象
|
||||||
|
|
||||||
|
### 3. 請求標籤與檢查
|
||||||
|
- 每個請求都添加 `tags`,便於在 k6 中過濾和分析
|
||||||
|
- 使用 `check()` 函數檢查請求結果
|
||||||
|
- 檢查項目包括:狀態碼、響應結構、業務邏輯驗證
|
||||||
|
|
||||||
|
### 4. 使用 Scenarios 設置工作負載
|
||||||
|
- 測試文件使用 k6 的 `scenarios` 配置工作負載
|
||||||
|
- 不同環境使用不同的 executor 和配置
|
||||||
|
- 避免只使用 Groups,使用 Scenarios 提供更大靈活性
|
||||||
|
|
||||||
|
### 5. 避免多用途測試
|
||||||
|
- 每個測試文件專注於一個主要目的
|
||||||
|
- 每個環境一個測試文件
|
||||||
|
- 這樣可以避免混合責任,並有助於追踪歷史結果
|
||||||
|
|
||||||
|
## 📝 如何添加新 API 場景
|
||||||
|
|
||||||
|
### 步驟 1: 在 `scenarios/apis/` 創建或更新場景模組
|
||||||
|
|
||||||
|
假設要添加一個新的 API 模組 `order.js`(訂單相關):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* 訂單相關 API 場景模組
|
||||||
|
*/
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { check } from 'k6';
|
||||||
|
import { Rate, Trend } from 'k6/metrics';
|
||||||
|
|
||||||
|
// 可選的自定義指標
|
||||||
|
const createOrderSuccessRate = new Rate('order_create_success');
|
||||||
|
const createOrderDuration = new Trend('order_create_duration');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 創建訂單
|
||||||
|
* @param {Object} options - 配置選項
|
||||||
|
* @param {string} options.baseUrl - API 基礎 URL
|
||||||
|
* @param {string} options.accessToken - Access Token
|
||||||
|
* @param {Object} options.orderData - 訂單資料
|
||||||
|
* @param {Object} options.customMetrics - 自定義指標對象(可選)
|
||||||
|
* @returns {Object} 創建結果
|
||||||
|
*/
|
||||||
|
export function createOrder(options = {}) {
|
||||||
|
const {
|
||||||
|
baseUrl = __ENV.BASE_URL || 'https://localhost:8888',
|
||||||
|
accessToken,
|
||||||
|
orderData = {},
|
||||||
|
customMetrics = null,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('accessToken is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${baseUrl}/api/v1/orders`;
|
||||||
|
const payload = JSON.stringify(orderData);
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
name: 'order_create',
|
||||||
|
api: 'order',
|
||||||
|
method: 'create_order',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const res = http.post(url, payload, params);
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
const success = check(res, {
|
||||||
|
'create order status is 200': (r) => r.status === 200,
|
||||||
|
'create order has order_id': (r) => {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(r.body);
|
||||||
|
return body.order_id && body.order_id.length > 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, { name: 'create_order_checks' });
|
||||||
|
|
||||||
|
if (customMetrics) {
|
||||||
|
customMetrics.createOrderSuccessRate?.add(success);
|
||||||
|
customMetrics.createOrderDuration?.add(duration);
|
||||||
|
} else {
|
||||||
|
createOrderSuccessRate.add(success);
|
||||||
|
createOrderDuration.add(duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = null;
|
||||||
|
if (res.status === 200) {
|
||||||
|
try {
|
||||||
|
result = JSON.parse(res.body);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse create order response:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
status: res.status,
|
||||||
|
response: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步驟 2: 在 `scenarios/e2e/` 創建流程場景(如果需要)
|
||||||
|
|
||||||
|
如果有多個 API 需要組合使用,創建流程場景:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* 訂單流程端到端場景
|
||||||
|
*/
|
||||||
|
import * as order from '../apis/order.js';
|
||||||
|
import { sleep } from 'k6';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完整訂單流程(創建 → 查詢 → 取消)
|
||||||
|
*/
|
||||||
|
export function orderLifecycleFlow(options = {}) {
|
||||||
|
const {
|
||||||
|
baseUrl = __ENV.BASE_URL || 'https://localhost:8888',
|
||||||
|
accessToken,
|
||||||
|
orderData = {},
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// 步驟 1: 創建訂單
|
||||||
|
const createResult = order.createOrder({
|
||||||
|
baseUrl,
|
||||||
|
accessToken,
|
||||||
|
orderData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createResult.success || !createResult.response) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
step: 'create',
|
||||||
|
error: 'Create order failed',
|
||||||
|
createResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// 步驟 2: 查詢訂單
|
||||||
|
const orderId = createResult.response.order_id;
|
||||||
|
const getResult = order.getOrder({
|
||||||
|
baseUrl,
|
||||||
|
accessToken,
|
||||||
|
orderId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ... 其他步驟
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
createResult,
|
||||||
|
getResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步驟 3: 在 `tests/` 創建測試文件
|
||||||
|
|
||||||
|
#### 3.1 冒煙測試 (`tests/smoke/smoke-order-test.js`)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { createOrder } from '../../scenarios/apis/order.js';
|
||||||
|
import { loginWithCredentials } from '../../scenarios/apis/auth.js';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
smoke_order: {
|
||||||
|
executor: 'shared-iterations',
|
||||||
|
vus: 1,
|
||||||
|
iterations: 1,
|
||||||
|
maxDuration: '30s',
|
||||||
|
tags: { test_type: 'smoke', api: 'order' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
checks: ['rate==1.0'],
|
||||||
|
http_req_duration: ['p(95)<2000'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const baseUrl = __ENV.BASE_URL || 'https://localhost:8888';
|
||||||
|
|
||||||
|
// 1. 登入獲取 Token
|
||||||
|
const loginResult = loginWithCredentials({
|
||||||
|
baseUrl,
|
||||||
|
loginId: 'test@example.com',
|
||||||
|
password: 'Test123!',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loginResult.success || !loginResult.tokens) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 創建訂單
|
||||||
|
createOrder({
|
||||||
|
baseUrl,
|
||||||
|
accessToken: loginResult.tokens.accessToken,
|
||||||
|
orderData: { /* ... */ },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2 負載測試 (`tests/pre/load-order-test.js`)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { createOrder } from '../../scenarios/apis/order.js';
|
||||||
|
import { loginWithCredentials } from '../../scenarios/apis/auth.js';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
load_order: {
|
||||||
|
executor: 'ramping-vus',
|
||||||
|
startVUs: 0,
|
||||||
|
stages: [
|
||||||
|
{ duration: '30s', target: 10 },
|
||||||
|
{ duration: '1m', target: 10 },
|
||||||
|
{ duration: '30s', target: 20 },
|
||||||
|
{ duration: '1m', target: 20 },
|
||||||
|
{ duration: '30s', target: 0 },
|
||||||
|
],
|
||||||
|
gracefulRampDown: '30s',
|
||||||
|
tags: { test_type: 'load', api: 'order', environment: 'pre' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
checks: ['rate>0.95'],
|
||||||
|
http_req_duration: ['p(95)<2000'],
|
||||||
|
http_req_failed: ['rate<0.05'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
// ... 測試邏輯
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3 生產環境測試 (`tests/prod/nightly-order-test.js`)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { createOrder } from '../../scenarios/apis/order.js';
|
||||||
|
import { loginWithCredentials } from '../../scenarios/apis/auth.js';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
nightly_order: {
|
||||||
|
executor: 'constant-vus',
|
||||||
|
vus: 5,
|
||||||
|
duration: '5m',
|
||||||
|
tags: { test_type: 'nightly', api: 'order', environment: 'prod' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
checks: ['rate>0.98'],
|
||||||
|
http_req_duration: ['p(95)<2000'],
|
||||||
|
http_req_failed: ['rate<0.02'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
// 注意:生產環境使用預先創建的測試帳號
|
||||||
|
const loginId = __ENV.TEST_LOGIN_ID || 'test@example.com';
|
||||||
|
const password = __ENV.TEST_PASSWORD || 'TestPassword123!';
|
||||||
|
|
||||||
|
// ... 測試邏輯
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔑 關鍵模式
|
||||||
|
|
||||||
|
### 場景函數模板
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export function apiFunctionName(options = {}) {
|
||||||
|
const {
|
||||||
|
baseUrl = __ENV.BASE_URL || 'https://localhost:8888',
|
||||||
|
// 必需參數
|
||||||
|
requiredParam,
|
||||||
|
// 可選參數
|
||||||
|
optionalParam = defaultValue,
|
||||||
|
// 自定義指標(可選)
|
||||||
|
customMetrics = null,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// 參數驗證
|
||||||
|
if (!requiredParam) {
|
||||||
|
throw new Error('requiredParam is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 構建請求
|
||||||
|
const url = `${baseUrl}/api/v1/endpoint`;
|
||||||
|
const payload = JSON.stringify({ /* ... */ });
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
// 如果需要認證
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
name: 'api_function_name',
|
||||||
|
api: 'api_name',
|
||||||
|
method: 'method_name',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 發送請求
|
||||||
|
const startTime = Date.now();
|
||||||
|
const res = http.post(url, payload, params); // 或 get, put, delete
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
// 檢查結果
|
||||||
|
const success = check(res, {
|
||||||
|
'status is 200': (r) => r.status === 200,
|
||||||
|
'has required field': (r) => {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(r.body);
|
||||||
|
return body.required_field !== undefined;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, { name: 'api_function_checks' });
|
||||||
|
|
||||||
|
// 使用指標
|
||||||
|
if (customMetrics) {
|
||||||
|
customMetrics.successRate?.add(success);
|
||||||
|
customMetrics.duration?.add(duration);
|
||||||
|
} else {
|
||||||
|
// 使用預設指標
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析響應
|
||||||
|
let result = null;
|
||||||
|
if (res.status === 200) {
|
||||||
|
try {
|
||||||
|
result = JSON.parse(res.body);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse response:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回結果
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
status: res.status,
|
||||||
|
response: result,
|
||||||
|
// 其他有用的數據
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 測試文件模板
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { apiFunction } from '../../scenarios/apis/api-module.js';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
test_name: {
|
||||||
|
executor: 'executor_type', // shared-iterations, ramping-vus, constant-vus, etc.
|
||||||
|
// executor 特定配置
|
||||||
|
tags: { test_type: 'smoke|load|stress|nightly', api: 'api_name', environment: 'dev|pre|prod' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
checks: ['rate>0.95'], // 根據環境調整
|
||||||
|
http_req_duration: ['p(95)<2000'],
|
||||||
|
http_req_failed: ['rate<0.05'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const baseUrl = __ENV.BASE_URL || 'https://localhost:8888';
|
||||||
|
|
||||||
|
// 測試邏輯
|
||||||
|
const result = apiFunction({
|
||||||
|
baseUrl,
|
||||||
|
// 參數
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 環境配置
|
||||||
|
|
||||||
|
### 不同環境的測試配置
|
||||||
|
|
||||||
|
| 環境 | 測試類型 | Executor | VUs | 持續時間 | 成功率要求 |
|
||||||
|
|------|---------|----------|-----|---------|-----------|
|
||||||
|
| Dev/QA | Smoke | shared-iterations | 1 | 30s | 100% |
|
||||||
|
| Pre-release | Load | ramping-vus | 0-20 | 5-10m | 95% |
|
||||||
|
| Pre-release | Stress | ramping-vus | 0-100 | 5-10m | 90% |
|
||||||
|
| Production | Nightly | constant-vus | 5 | 5-10m | 98% |
|
||||||
|
|
||||||
|
## ✅ 檢查清單
|
||||||
|
|
||||||
|
添加新 API 場景時,請確保:
|
||||||
|
|
||||||
|
- [ ] 在 `scenarios/apis/` 創建場景模組
|
||||||
|
- [ ] 場景函數接受 `options` 參數,包含 `baseUrl` 和 `customMetrics`
|
||||||
|
- [ ] 為請求添加 `tags`,包含 `name`, `api`, `method`
|
||||||
|
- [ ] 使用 `check()` 驗證響應結果
|
||||||
|
- [ ] 支援可選的自定義指標
|
||||||
|
- [ ] 返回結構化的結果對象
|
||||||
|
- [ ] 如果需要,在 `scenarios/e2e/` 創建流程場景
|
||||||
|
- [ ] 在 `tests/smoke/` 創建冒煙測試
|
||||||
|
- [ ] 在 `tests/pre/` 創建負載/壓力測試
|
||||||
|
- [ ] 在 `tests/prod/` 創建夜間測試(如果適用)
|
||||||
|
- [ ] 所有測試文件使用適當的 `scenarios` 配置
|
||||||
|
- [ ] 設置適當的 `thresholds`
|
||||||
|
|
||||||
|
## 🚀 快速開始
|
||||||
|
|
||||||
|
1. **查看現有場景**:參考 `scenarios/apis/auth.js` 或 `scenarios/apis/user.js`
|
||||||
|
2. **複製模板**:使用上面的場景函數模板
|
||||||
|
3. **調整參數**:根據新 API 的需求調整
|
||||||
|
4. **創建測試**:在對應的 `tests/` 目錄下創建測試文件
|
||||||
|
5. **運行測試**:使用 k6 運行測試文件
|
||||||
|
|
||||||
|
## 📚 參考資源
|
||||||
|
|
||||||
|
- [k6 官方文檔](https://k6.io/docs/)
|
||||||
|
- [k6 Scenarios](https://k6.io/docs/using-k6/scenarios/)
|
||||||
|
- [k6 Thresholds](https://k6.io/docs/using-k6/thresholds/)
|
||||||
|
- [k6 Tags](https://k6.io/docs/using-k6/tags-and-groups/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**記住**:此架構的核心是**模組化**和**可重複使用**。每個場景應該獨立、可測試,並且可以輕鬆組合形成更複雜的流程。
|
||||||
|
|
||||||
|
|
@ -0,0 +1,192 @@
|
||||||
|
# 如何運行流程測試
|
||||||
|
|
||||||
|
## 📝 說明
|
||||||
|
|
||||||
|
`user-profile-flow.js` 是一個**場景模組**,不是可以直接運行的測試文件。它提供了可重複使用的流程函數,需要在測試文件中導入並使用。
|
||||||
|
|
||||||
|
## 🚀 快速開始
|
||||||
|
|
||||||
|
### 方式 1: 使用已創建的測試文件(推薦)
|
||||||
|
|
||||||
|
我們已經創建了使用這些流程的測試文件:
|
||||||
|
|
||||||
|
#### 冒煙測試
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 設置 API 地址
|
||||||
|
export BASE_URL=http://localhost:8888
|
||||||
|
|
||||||
|
# 運行使用者資料流程冒煙測試
|
||||||
|
make smoke-user-profile-flow
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 負載測試
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 設置 API 地址
|
||||||
|
export BASE_URL=http://localhost:8888
|
||||||
|
|
||||||
|
# 運行使用者資料流程負載測試
|
||||||
|
make load-user-profile-flow
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式 2: 使用通用 run 命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 設置 API 地址
|
||||||
|
export BASE_URL=http://localhost:8888
|
||||||
|
|
||||||
|
# 運行指定的流程測試
|
||||||
|
make run TEST=tests/smoke/smoke-user-profile-flow-test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式 3: 直接使用 k6(如果本地已安裝)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 設置 API 地址
|
||||||
|
export BASE_URL=http://localhost:8888
|
||||||
|
|
||||||
|
# 運行測試
|
||||||
|
k6 run tests/smoke/smoke-user-profile-flow-test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 可用的流程函數
|
||||||
|
|
||||||
|
`user-profile-flow.js` 提供了以下流程函數:
|
||||||
|
|
||||||
|
### 1. `getAndUpdateProfileFlow`
|
||||||
|
取得並更新個人資料流程
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { getAndUpdateProfileFlow } from '../../scenarios/e2e/user-profile-flow.js';
|
||||||
|
|
||||||
|
const result = getAndUpdateProfileFlow({
|
||||||
|
baseUrl: 'http://localhost:8888',
|
||||||
|
accessToken: 'your_access_token',
|
||||||
|
updateData: {
|
||||||
|
nickname: 'NewNickname',
|
||||||
|
preferred_language: 'zh-tw',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. `emailVerificationFlow`
|
||||||
|
完整 Email 驗證流程(請求 → 提交)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { emailVerificationFlow } from '../../scenarios/e2e/user-profile-flow.js';
|
||||||
|
|
||||||
|
const result = emailVerificationFlow({
|
||||||
|
baseUrl: 'http://localhost:8888',
|
||||||
|
accessToken: 'your_access_token',
|
||||||
|
verifyCode: '123456', // 需要從外部獲取
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. `phoneVerificationFlow`
|
||||||
|
完整手機驗證流程(請求 → 提交)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { phoneVerificationFlow } from '../../scenarios/e2e/user-profile-flow.js';
|
||||||
|
|
||||||
|
const result = phoneVerificationFlow({
|
||||||
|
baseUrl: 'http://localhost:8888',
|
||||||
|
accessToken: 'your_access_token',
|
||||||
|
verifyCode: '123456', // 需要從外部獲取
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. `passwordChangeFlow`
|
||||||
|
登入狀態下修改密碼流程
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { passwordChangeFlow } from '../../scenarios/e2e/user-profile-flow.js';
|
||||||
|
|
||||||
|
const result = passwordChangeFlow({
|
||||||
|
baseUrl: 'http://localhost:8888',
|
||||||
|
accessToken: 'your_access_token',
|
||||||
|
currentPassword: 'OldPassword123!',
|
||||||
|
newPassword: 'NewPassword123!',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. `userProfileInitializationFlow`
|
||||||
|
完整的使用者資料初始化流程(註冊 → 登入 → 取得資訊 → 更新資訊)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { userProfileInitializationFlow } from '../../scenarios/e2e/user-profile-flow.js';
|
||||||
|
|
||||||
|
const result = userProfileInitializationFlow({
|
||||||
|
baseUrl: 'http://localhost:8888',
|
||||||
|
loginId: 'test@example.com',
|
||||||
|
password: 'Test123456!',
|
||||||
|
updateData: {
|
||||||
|
nickname: 'TestUser',
|
||||||
|
preferred_language: 'zh-tw',
|
||||||
|
currency: 'TWD',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 創建自定義測試文件
|
||||||
|
|
||||||
|
如果你想創建自己的測試文件來使用這些流程,可以參考以下模板:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* 自定義流程測試
|
||||||
|
*/
|
||||||
|
import { userProfileInitializationFlow } from '../../scenarios/e2e/user-profile-flow.js';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
custom_flow: {
|
||||||
|
executor: 'shared-iterations',
|
||||||
|
vus: 1,
|
||||||
|
iterations: 1,
|
||||||
|
maxDuration: '30s',
|
||||||
|
tags: { test_type: 'custom', flow: 'user_profile' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
checks: ['rate==1.0'],
|
||||||
|
http_req_duration: ['p(95)<3000'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const baseUrl = __ENV.BASE_URL || 'http://localhost:8888';
|
||||||
|
|
||||||
|
// 使用流程函數
|
||||||
|
const result = userProfileInitializationFlow({
|
||||||
|
baseUrl,
|
||||||
|
loginId: `test_${Date.now()}@example.com`,
|
||||||
|
password: 'Test123456!',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error(`Test failed: ${result.step} - ${result.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Test passed!');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
然後運行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make run TEST=tests/your-custom-test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 測試文件位置
|
||||||
|
|
||||||
|
- **冒煙測試**: `tests/smoke/smoke-user-profile-flow-test.js`
|
||||||
|
- **負載測試**: `tests/pre/load-user-profile-flow-test.js`
|
||||||
|
|
||||||
|
## 📚 相關文檔
|
||||||
|
|
||||||
|
- [AI_GUIDE.md](./AI_GUIDE.md) - 如何添加新 API 場景
|
||||||
|
- [README.md](./README.md) - 完整文檔
|
||||||
|
- [QUICK_START.md](./QUICK_START.md) - 快速開始指南
|
||||||
|
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
# 網絡配置說明
|
||||||
|
|
||||||
|
## 🐳 Docker 容器訪問主機服務
|
||||||
|
|
||||||
|
當 API 服務器在主機上運行,而 k6 測試在 Docker 容器內運行時,需要配置網絡讓容器能夠訪問主機上的服務。
|
||||||
|
|
||||||
|
## ✅ 已配置的解決方案
|
||||||
|
|
||||||
|
本測試架構已經配置為使用**主機網絡模式**(`--network host`),容器可以直接訪問主機上的服務。
|
||||||
|
|
||||||
|
### 配置說明
|
||||||
|
|
||||||
|
1. **Makefile**:所有 `docker run` 命令都添加了 `--network host` 參數
|
||||||
|
2. **docker-compose.yml**:k6 服務使用 `network_mode: host`
|
||||||
|
|
||||||
|
### 使用方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 如果 API 在本地主機運行(localhost)
|
||||||
|
export BASE_URL=http://localhost:8888
|
||||||
|
# 或
|
||||||
|
export BASE_URL=https://localhost:8888
|
||||||
|
|
||||||
|
# 運行測試
|
||||||
|
make smoke-health
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 其他網絡配置選項
|
||||||
|
|
||||||
|
### 選項 1: 使用 host.docker.internal(僅限 macOS/Windows)
|
||||||
|
|
||||||
|
在 macOS 和 Windows 上,Docker Desktop 提供了 `host.docker.internal` 主機名:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export BASE_URL=http://host.docker.internal:8888
|
||||||
|
make smoke-health
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:在 Linux 上,`host.docker.internal` 默認不可用,需要額外配置。
|
||||||
|
|
||||||
|
### 選項 2: 使用主機 IP 地址
|
||||||
|
|
||||||
|
如果知道主機的 IP 地址,可以直接使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 獲取主機 IP 地址
|
||||||
|
ip addr show | grep "inet " | grep -v 127.0.0.1
|
||||||
|
|
||||||
|
# 使用主機 IP
|
||||||
|
export BASE_URL=http://192.168.1.100:8888
|
||||||
|
make smoke-health
|
||||||
|
```
|
||||||
|
|
||||||
|
### 選項 3: 使用 Docker 網絡(如果 API 也在容器中)
|
||||||
|
|
||||||
|
如果 API 服務器也在 Docker 容器中運行,可以使用 Docker 網絡:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
# ... API 配置
|
||||||
|
|
||||||
|
k6:
|
||||||
|
# ... k6 配置
|
||||||
|
networks:
|
||||||
|
- test-network
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 常見問題
|
||||||
|
|
||||||
|
### Q: 為什麼使用 `--network host`?
|
||||||
|
|
||||||
|
A: `--network host` 是最簡單可靠的方式,讓容器直接使用主機網絡,可以訪問 `localhost` 和主機上的所有端口。
|
||||||
|
|
||||||
|
### Q: 使用 `--network host` 有什麼限制?
|
||||||
|
|
||||||
|
A:
|
||||||
|
- 容器無法使用端口映射(因為直接使用主機端口)
|
||||||
|
- 在 macOS/Windows 上,`--network host` 可能不工作(需要使用 `host.docker.internal`)
|
||||||
|
|
||||||
|
### Q: 如何測試連接?
|
||||||
|
|
||||||
|
A: 在容器內測試連接:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 進入容器
|
||||||
|
docker run -it --rm --network host grafana/k6:latest sh
|
||||||
|
|
||||||
|
# 在容器內測試連接
|
||||||
|
curl http://localhost:8888/api/v1/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: HTTPS 證書問題?
|
||||||
|
|
||||||
|
A: 如果使用 HTTPS 但沒有有效證書,可以設置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export K6_SKIP_TLS_VERIFY=true
|
||||||
|
make smoke-health
|
||||||
|
```
|
||||||
|
|
||||||
|
或在測試文件中設置:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export const options = {
|
||||||
|
// ...
|
||||||
|
insecureSkipTLSVerify: true,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 快速測試
|
||||||
|
|
||||||
|
### 測試本地 API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 確認 API 在本地運行
|
||||||
|
curl http://localhost:8888/api/v1/health
|
||||||
|
|
||||||
|
# 2. 設置 BASE_URL
|
||||||
|
export BASE_URL=http://localhost:8888
|
||||||
|
|
||||||
|
# 3. 運行測試
|
||||||
|
make smoke-health
|
||||||
|
```
|
||||||
|
|
||||||
|
### 測試遠程 API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 設置遠程 API 地址
|
||||||
|
export BASE_URL=https://api.example.com
|
||||||
|
|
||||||
|
# 2. 運行測試
|
||||||
|
make smoke-health
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 參考資源
|
||||||
|
|
||||||
|
- [Docker 網絡文檔](https://docs.docker.com/network/)
|
||||||
|
- [k6 網絡配置](https://k6.io/docs/using-k6/options/#insecureskiptlsverify)
|
||||||
|
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
# 快速開始指南
|
||||||
|
|
||||||
|
## 🚀 最簡單的使用方式
|
||||||
|
|
||||||
|
### 1. 查看所有可用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd test
|
||||||
|
make help
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 運行冒煙測試(最常用)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 設置 API 地址
|
||||||
|
export BASE_URL=https://api.example.com
|
||||||
|
|
||||||
|
# 運行所有冒煙測試
|
||||||
|
make smoke
|
||||||
|
|
||||||
|
# 或運行單個測試
|
||||||
|
make smoke-health
|
||||||
|
make smoke-auth
|
||||||
|
make smoke-user
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 運行負載測試
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 設置 API 地址
|
||||||
|
export BASE_URL=https://pre-api.example.com
|
||||||
|
|
||||||
|
# 運行負載測試
|
||||||
|
make load
|
||||||
|
make load-auth
|
||||||
|
make load-user
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 運行壓力測試
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 設置 API 地址
|
||||||
|
export BASE_URL=https://pre-api.example.com
|
||||||
|
|
||||||
|
# 運行壓力測試
|
||||||
|
make stress
|
||||||
|
make stress-auth
|
||||||
|
make stress-user
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 運行生產環境測試
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 設置環境變數
|
||||||
|
export BASE_URL=https://api.example.com
|
||||||
|
export TEST_LOGIN_ID=test@example.com
|
||||||
|
export TEST_PASSWORD=TestPassword123!
|
||||||
|
|
||||||
|
# 運行夜間測試
|
||||||
|
make nightly
|
||||||
|
make nightly-health
|
||||||
|
make nightly-auth
|
||||||
|
make nightly-user
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 常用命令速查
|
||||||
|
|
||||||
|
| 命令 | 說明 |
|
||||||
|
|------|------|
|
||||||
|
| `make help` | 顯示所有可用命令 |
|
||||||
|
| `make smoke` | 運行所有冒煙測試 |
|
||||||
|
| `make load` | 運行所有負載測試 |
|
||||||
|
| `make stress` | 運行所有壓力測試 |
|
||||||
|
| `make nightly` | 運行所有夜間測試 |
|
||||||
|
| `make run TEST=tests/smoke/smoke-auth-test.js` | 運行指定測試 |
|
||||||
|
| `make clean` | 清理 Docker 資源 |
|
||||||
|
|
||||||
|
## 🔧 環境變數設置
|
||||||
|
|
||||||
|
### 方式 1: 使用 export(推薦)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export BASE_URL=https://api.example.com
|
||||||
|
export TEST_LOGIN_ID=test@example.com
|
||||||
|
export TEST_PASSWORD=TestPassword123!
|
||||||
|
make smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式 2: 直接在命令中指定
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make smoke BASE_URL=https://api.example.com
|
||||||
|
make nightly BASE_URL=https://api.example.com TEST_LOGIN_ID=test@example.com TEST_PASSWORD=TestPassword123!
|
||||||
|
```
|
||||||
|
|
||||||
|
## 💡 提示
|
||||||
|
|
||||||
|
1. **首次使用**:不需要構建 Docker 映像,Makefile 會自動使用官方的 `grafana/k6:latest` 映像
|
||||||
|
2. **本地運行**:如果本地已安裝 k6,可以使用 `make run-local TEST=...`
|
||||||
|
3. **查看結果**:測試結果會直接輸出到終端,也可以使用 `make run-with-output` 保存到文件
|
||||||
|
|
||||||
|
## 🐛 常見問題
|
||||||
|
|
||||||
|
### Q: 如何運行自定義測試文件?
|
||||||
|
|
||||||
|
A: 使用 `make run` 命令:
|
||||||
|
```bash
|
||||||
|
make run TEST=tests/smoke/smoke-auth-test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 如何設置不同的 API 地址?
|
||||||
|
|
||||||
|
A: 使用環境變數:
|
||||||
|
```bash
|
||||||
|
export BASE_URL=https://dev-api.example.com
|
||||||
|
make smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 如何查看測試結果?
|
||||||
|
|
||||||
|
A: 測試結果會直接輸出到終端。如果需要保存,使用:
|
||||||
|
```bash
|
||||||
|
make run-with-output TEST=tests/smoke/smoke-auth-test.js OUTPUT=results.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 如何清理 Docker 資源?
|
||||||
|
|
||||||
|
A: 使用 `make clean` 命令:
|
||||||
|
```bash
|
||||||
|
make clean
|
||||||
|
```
|
||||||
|
|
||||||
|
|
@ -0,0 +1,264 @@
|
||||||
|
# 測試結果解讀指南
|
||||||
|
|
||||||
|
## 📊 如何解讀測試結果
|
||||||
|
|
||||||
|
### ✅ 成功的測試結果特徵
|
||||||
|
|
||||||
|
1. **所有檢查通過**:`checks_succeeded: 100%`
|
||||||
|
2. **請求成功**:`http_req_failed: 0%`
|
||||||
|
3. **閾值通過**:所有閾值顯示 `✓`
|
||||||
|
4. **響應時間正常**:在預期的時間範圍內
|
||||||
|
|
||||||
|
### ❌ 失敗的測試結果特徵
|
||||||
|
|
||||||
|
1. **連接錯誤**:`connection refused`、`timeout`、`no route to host`
|
||||||
|
2. **HTTP 錯誤**:`http_req_failed > 0%`
|
||||||
|
3. **檢查失敗**:`checks_succeeded < 100%`
|
||||||
|
4. **閾值失敗**:閾值顯示 `✗`
|
||||||
|
|
||||||
|
## 🔍 常見錯誤及解決方案
|
||||||
|
|
||||||
|
### 錯誤 1: Connection Refused
|
||||||
|
|
||||||
|
**錯誤信息:**
|
||||||
|
```
|
||||||
|
dial tcp 127.0.0.1:8888: connect: connection refused
|
||||||
|
```
|
||||||
|
|
||||||
|
**原因:**
|
||||||
|
- API 服務器沒有運行
|
||||||
|
- BASE_URL 設置錯誤
|
||||||
|
- 服務器運行在不同的端口或地址
|
||||||
|
|
||||||
|
**解決方案:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 檢查 API 服務器是否運行
|
||||||
|
curl https://api.example.com/api/v1/health
|
||||||
|
|
||||||
|
# 2. 設置正確的 BASE_URL
|
||||||
|
export BASE_URL=https://api.example.com
|
||||||
|
make smoke-health
|
||||||
|
|
||||||
|
# 或直接在命令中指定
|
||||||
|
make smoke-health BASE_URL=https://api.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 錯誤 2: Timeout
|
||||||
|
|
||||||
|
**錯誤信息:**
|
||||||
|
```
|
||||||
|
context deadline exceeded
|
||||||
|
```
|
||||||
|
|
||||||
|
**原因:**
|
||||||
|
- 服務器響應太慢
|
||||||
|
- 網絡問題
|
||||||
|
- 服務器過載
|
||||||
|
|
||||||
|
**解決方案:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 檢查服務器響應時間
|
||||||
|
curl -w "@-" -o /dev/null -s https://api.example.com/api/v1/health <<'EOF'
|
||||||
|
time_namelookup: %{time_namelookup}\n
|
||||||
|
time_connect: %{time_connect}\n
|
||||||
|
time_appconnect: %{time_appconnect}\n
|
||||||
|
time_pretransfer: %{time_pretransfer}\n
|
||||||
|
time_redirect: %{time_redirect}\n
|
||||||
|
time_starttransfer: %{time_starttransfer}\n
|
||||||
|
----------\n
|
||||||
|
time_total: %{time_total}\n
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 2. 調整測試的閾值(如果需要)
|
||||||
|
# 編輯測試文件,增加響應時間閾值
|
||||||
|
```
|
||||||
|
|
||||||
|
### 錯誤 3: HTTP 4xx/5xx 錯誤
|
||||||
|
|
||||||
|
**錯誤信息:**
|
||||||
|
```
|
||||||
|
http_req_failed: 20.00% (4xx/5xx responses)
|
||||||
|
```
|
||||||
|
|
||||||
|
**原因:**
|
||||||
|
- API 端點不存在
|
||||||
|
- 認證失敗
|
||||||
|
- 服務器內部錯誤
|
||||||
|
|
||||||
|
**解決方案:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 檢查 API 端點是否正確
|
||||||
|
curl -v https://api.example.com/api/v1/health
|
||||||
|
|
||||||
|
# 2. 檢查認證(如果需要)
|
||||||
|
curl -H "Authorization: Bearer YOUR_TOKEN" https://api.example.com/api/v1/health
|
||||||
|
|
||||||
|
# 3. 查看服務器日誌
|
||||||
|
# 檢查應用程序的日誌文件
|
||||||
|
```
|
||||||
|
|
||||||
|
### 錯誤 4: 閾值失敗
|
||||||
|
|
||||||
|
**錯誤信息:**
|
||||||
|
```
|
||||||
|
✗ 'rate==1.0' rate=95.00%
|
||||||
|
```
|
||||||
|
|
||||||
|
**原因:**
|
||||||
|
- 部分請求失敗
|
||||||
|
- 部分檢查未通過
|
||||||
|
- 性能未達標
|
||||||
|
|
||||||
|
**解決方案:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 查看詳細的失敗原因
|
||||||
|
# 檢查輸出中的具體錯誤信息
|
||||||
|
|
||||||
|
# 2. 調整閾值(如果合理)
|
||||||
|
# 編輯測試文件,調整閾值要求
|
||||||
|
# 例如:從 'rate==1.0' 改為 'rate>0.95'
|
||||||
|
|
||||||
|
# 3. 修復根本問題
|
||||||
|
# 如果是服務器問題,需要修復服務器
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📈 測試結果指標說明
|
||||||
|
|
||||||
|
### HTTP 指標
|
||||||
|
|
||||||
|
- **http_reqs**: 總請求數
|
||||||
|
- **http_req_duration**: 請求持續時間
|
||||||
|
- `avg`: 平均時間
|
||||||
|
- `min`: 最短時間
|
||||||
|
- `max`: 最長時間
|
||||||
|
- `p(90)`: 90% 的請求在此時間內完成
|
||||||
|
- `p(95)`: 95% 的請求在此時間內完成
|
||||||
|
- **http_req_failed**: 失敗的請求百分比
|
||||||
|
|
||||||
|
### 檢查指標
|
||||||
|
|
||||||
|
- **checks_total**: 總檢查數
|
||||||
|
- **checks_succeeded**: 成功的檢查數
|
||||||
|
- **checks_failed**: 失敗的檢查數
|
||||||
|
|
||||||
|
### 執行指標
|
||||||
|
|
||||||
|
- **iterations**: 迭代次數
|
||||||
|
- **iteration_duration**: 每次迭代的持續時間
|
||||||
|
- **vus**: 虛擬用戶數
|
||||||
|
|
||||||
|
### 網絡指標
|
||||||
|
|
||||||
|
- **data_received**: 接收的數據量
|
||||||
|
- **data_sent**: 發送的數據量
|
||||||
|
|
||||||
|
## 🎯 閾值說明
|
||||||
|
|
||||||
|
閾值(Thresholds)是測試的通過標準:
|
||||||
|
|
||||||
|
- **`rate==1.0`**: 100% 必須通過
|
||||||
|
- **`rate>0.95`**: 至少 95% 必須通過
|
||||||
|
- **`p(95)<2000`**: 95% 的請求必須在 2000ms 內完成
|
||||||
|
- **`rate<0.05`**: 失敗率必須低於 5%
|
||||||
|
|
||||||
|
## 💡 最佳實踐
|
||||||
|
|
||||||
|
1. **先運行健康檢查**:確保服務器可用
|
||||||
|
```bash
|
||||||
|
make smoke-health BASE_URL=https://api.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **逐步增加負載**:從冒煙測試開始,然後負載測試,最後壓力測試
|
||||||
|
|
||||||
|
3. **監控關鍵指標**:
|
||||||
|
- 響應時間
|
||||||
|
- 錯誤率
|
||||||
|
- 吞吐量
|
||||||
|
|
||||||
|
4. **設置合理的閾值**:
|
||||||
|
- 開發環境:較寬鬆的閾值
|
||||||
|
- 生產環境:嚴格的閾值
|
||||||
|
|
||||||
|
5. **保存測試結果**:
|
||||||
|
```bash
|
||||||
|
make run-with-output TEST=tests/smoke/smoke-health-test.js OUTPUT=results.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 調試技巧
|
||||||
|
|
||||||
|
### 1. 使用詳細輸出
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看詳細的請求信息
|
||||||
|
k6 run --http-debug tests/smoke/smoke-health-test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 檢查網絡連接
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 測試連接
|
||||||
|
curl -v https://api.example.com/api/v1/health
|
||||||
|
|
||||||
|
# 檢查 DNS
|
||||||
|
nslookup api.example.com
|
||||||
|
|
||||||
|
# 檢查端口
|
||||||
|
telnet api.example.com 443
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 查看 Docker 日誌
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 如果使用 Docker,查看容器日誌
|
||||||
|
docker logs k6-test
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 本地測試
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 如果本地安裝了 k6,可以直接運行
|
||||||
|
export BASE_URL=https://api.example.com
|
||||||
|
k6 run tests/smoke/smoke-health-test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 示例:解讀你的測試結果
|
||||||
|
|
||||||
|
### 你的測試結果分析
|
||||||
|
|
||||||
|
```
|
||||||
|
✗ checks: rate==1.0 (實際: 50.00%)
|
||||||
|
- 10 個檢查中,5 個通過,5 個失敗
|
||||||
|
- 狀態碼檢查:0% 通過(因為連接失敗)
|
||||||
|
- 響應時間檢查:100% 通過(沒有實際請求)
|
||||||
|
|
||||||
|
✗ health_check_success: rate==1.0 (實際: 0.00%)
|
||||||
|
- 所有健康檢查都失敗了
|
||||||
|
|
||||||
|
✓ http_req_duration: p(95)<500 (實際: 0s)
|
||||||
|
- 沒有實際請求,所以響應時間為 0
|
||||||
|
|
||||||
|
http_req_failed: 100.00% (5 out of 5)
|
||||||
|
- 所有請求都失敗了
|
||||||
|
```
|
||||||
|
|
||||||
|
### 解決方案
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 確認 API 服務器地址
|
||||||
|
# 例如:https://dev-api.example.com 或 https://localhost:8888
|
||||||
|
|
||||||
|
# 2. 設置 BASE_URL
|
||||||
|
export BASE_URL=https://dev-api.example.com
|
||||||
|
|
||||||
|
# 3. 重新運行測試
|
||||||
|
make smoke-health
|
||||||
|
|
||||||
|
# 或如果服務器在本地運行
|
||||||
|
export BASE_URL=http://localhost:8888
|
||||||
|
make smoke-health
|
||||||
|
```
|
||||||
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
# 忽略不需要的文件
|
||||||
|
results/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
AI_GUIDE.md
|
||||||
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
# k6 測試環境 Dockerfile
|
||||||
|
FROM grafana/k6:latest
|
||||||
|
|
||||||
|
# 設置工作目錄
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 複製測試文件
|
||||||
|
COPY scenarios/ /app/scenarios/
|
||||||
|
COPY tests/ /app/tests/
|
||||||
|
|
||||||
|
# 設置默認命令
|
||||||
|
CMD ["run", "tests/smoke/smoke-health-test.js"]
|
||||||
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
k6:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: k6-test:latest
|
||||||
|
container_name: k6-test
|
||||||
|
network_mode: host # 使用主機網絡,讓容器可以訪問主機上的服務
|
||||||
|
volumes:
|
||||||
|
- ./scenarios:/app/scenarios
|
||||||
|
- ./tests:/app/tests
|
||||||
|
- ./results:/app/results
|
||||||
|
environment:
|
||||||
|
- BASE_URL=${BASE_URL:-https://localhost:8888}
|
||||||
|
- TEST_LOGIN_ID=${TEST_LOGIN_ID:-}
|
||||||
|
- TEST_PASSWORD=${TEST_PASSWORD:-}
|
||||||
|
# 不自動啟動,通過 make 命令運行
|
||||||
|
command: ["run", "tests/smoke/smoke-health-test.js"]
|
||||||
|
|
||||||
|
# 可選:如果需要測試資料庫或其他服務
|
||||||
|
# influxdb:
|
||||||
|
# image: influxdb:2.7
|
||||||
|
# container_name: k6-influxdb
|
||||||
|
# ports:
|
||||||
|
# - "8086:8086"
|
||||||
|
# environment:
|
||||||
|
# - DOCKER_INFLUXDB_INIT_MODE=setup
|
||||||
|
# - DOCKER_INFLUXDB_INIT_USERNAME=admin
|
||||||
|
# - DOCKER_INFLUXDB_INIT_PASSWORD=admin123456
|
||||||
|
# - DOCKER_INFLUXDB_INIT_ORG=myorg
|
||||||
|
# - DOCKER_INFLUXDB_INIT_BUCKET=mybucket
|
||||||
|
# volumes:
|
||||||
|
# - influxdb-data:/var/lib/influxdb2
|
||||||
|
# networks:
|
||||||
|
# - test-network
|
||||||
|
|
||||||
|
# grafana:
|
||||||
|
# image: grafana/grafana:latest
|
||||||
|
# container_name: k6-grafana
|
||||||
|
# ports:
|
||||||
|
# - "3000:3000"
|
||||||
|
# environment:
|
||||||
|
# - GF_AUTH_ANONYMOUS_ENABLED=true
|
||||||
|
# - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
|
||||||
|
# volumes:
|
||||||
|
# - grafana-data:/var/lib/grafana
|
||||||
|
# - ./provisioning:/etc/grafana/provisioning
|
||||||
|
# networks:
|
||||||
|
# - test-network
|
||||||
|
# depends_on:
|
||||||
|
# - influxdb
|
||||||
|
|
||||||
|
networks:
|
||||||
|
test-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
# volumes:
|
||||||
|
# influxdb-data:
|
||||||
|
# grafana-data:
|
||||||
|
|
||||||
|
|
@ -0,0 +1,643 @@
|
||||||
|
/**
|
||||||
|
* 認證相關 API 場景模組
|
||||||
|
*
|
||||||
|
* 此模組提供可重複使用的認證相關場景,包括:
|
||||||
|
* - 註冊(帳號密碼、第三方平台)
|
||||||
|
* - 登入(帳號密碼、第三方平台)
|
||||||
|
* - Token 刷新
|
||||||
|
* - 密碼重設流程
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* import { registerWithCredentials, loginWithCredentials } from './scenarios/apis/auth.js';
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { check, sleep } from 'k6';
|
||||||
|
import { Rate, Trend } from 'k6/metrics';
|
||||||
|
|
||||||
|
// 可選的自定義指標
|
||||||
|
const registerSuccessRate = new Rate('auth_register_success');
|
||||||
|
const loginSuccessRate = new Rate('auth_login_success');
|
||||||
|
const registerDuration = new Trend('auth_register_duration');
|
||||||
|
const loginDuration = new Trend('auth_login_duration');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用帳號密碼註冊
|
||||||
|
* @param {Object} options - 配置選項
|
||||||
|
* @param {string} options.baseUrl - API 基礎 URL
|
||||||
|
* @param {string} options.loginId - 登入 ID(email 或 phone)
|
||||||
|
* @param {string} options.password - 密碼
|
||||||
|
* @param {string} options.accountType - 帳號類型(email/phone/any)
|
||||||
|
* @param {Object} options.customMetrics - 自定義指標對象(可選)
|
||||||
|
* @returns {Object} 註冊結果,包含 tokens 和響應
|
||||||
|
*/
|
||||||
|
export function registerWithCredentials(options = {}) {
|
||||||
|
const {
|
||||||
|
baseUrl = __ENV.BASE_URL || 'http://localhost:8888',
|
||||||
|
loginId = `test_${Date.now()}@example.com`,
|
||||||
|
password = 'Test123456!',
|
||||||
|
accountType = 'email',
|
||||||
|
customMetrics = null,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const url = `${baseUrl}/api/v1/auth/register`;
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
auth_method: 'credentials',
|
||||||
|
login_id: loginId,
|
||||||
|
credentials: {
|
||||||
|
password: password,
|
||||||
|
password_confirm: password,
|
||||||
|
account_type: accountType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
name: 'auth_register_credentials',
|
||||||
|
api: 'auth',
|
||||||
|
method: 'register',
|
||||||
|
auth_type: 'credentials',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const res = http.post(url, payload, params);
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
// 解析響應
|
||||||
|
let result = null;
|
||||||
|
let responseData = null;
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
try {
|
||||||
|
result = JSON.parse(res.body);
|
||||||
|
|
||||||
|
// 支持兩種響應格式:
|
||||||
|
// 1. 直接返回 LoginResp: { access_token, refresh_token, uid, ... }
|
||||||
|
// 2. 包裝在 Resp 中: { code, message, data: { access_token, ... } }
|
||||||
|
if (result.data && typeof result.data === 'object') {
|
||||||
|
// 格式 2: 包裝在 Resp 中
|
||||||
|
responseData = result.data;
|
||||||
|
} else if (result.access_token) {
|
||||||
|
// 格式 1: 直接返回 LoginResp
|
||||||
|
responseData = result;
|
||||||
|
} else {
|
||||||
|
// 無法識別的格式,記錄響應以便調試
|
||||||
|
console.warn('Unexpected response format. Full response:', JSON.stringify(result));
|
||||||
|
console.warn('Response keys:', Object.keys(result));
|
||||||
|
responseData = result;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse register response:', e);
|
||||||
|
console.error('Response body:', res.body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 檢查響應結果
|
||||||
|
const success = check(res, {
|
||||||
|
'register status is 200': (r) => r.status === 200,
|
||||||
|
'register has access_token': () => {
|
||||||
|
return responseData && responseData.access_token && responseData.access_token.length > 0;
|
||||||
|
},
|
||||||
|
'register has refresh_token': () => {
|
||||||
|
return responseData && responseData.refresh_token && responseData.refresh_token.length > 0;
|
||||||
|
},
|
||||||
|
'register has uid': () => {
|
||||||
|
return responseData && responseData.uid && responseData.uid.length > 0;
|
||||||
|
},
|
||||||
|
}, { name: 'register_checks' });
|
||||||
|
|
||||||
|
// 使用自定義指標(如果提供)
|
||||||
|
if (customMetrics) {
|
||||||
|
customMetrics.registerSuccessRate.add(success);
|
||||||
|
customMetrics.registerDuration.add(duration);
|
||||||
|
} else {
|
||||||
|
// 使用預設指標
|
||||||
|
registerSuccessRate.add(success);
|
||||||
|
registerDuration.add(duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
status: res.status,
|
||||||
|
response: result,
|
||||||
|
responseData: responseData,
|
||||||
|
tokens: responseData && responseData.access_token ? {
|
||||||
|
accessToken: responseData.access_token,
|
||||||
|
refreshToken: responseData.refresh_token,
|
||||||
|
uid: responseData.uid,
|
||||||
|
} : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用第三方平台註冊
|
||||||
|
* @param {Object} options - 配置選項
|
||||||
|
* @param {string} options.baseUrl - API 基礎 URL
|
||||||
|
* @param {string} options.loginId - 登入 ID
|
||||||
|
* @param {string} options.provider - 平台名稱(google/line/apple)
|
||||||
|
* @param {string} options.token - 平台提供的 Token
|
||||||
|
* @param {Object} options.customMetrics - 自定義指標對象(可選)
|
||||||
|
* @returns {Object} 註冊結果
|
||||||
|
*/
|
||||||
|
export function registerWithPlatform(options = {}) {
|
||||||
|
const {
|
||||||
|
baseUrl = __ENV.BASE_URL || 'https://localhost:8888',
|
||||||
|
loginId = `platform_${Date.now()}@example.com`,
|
||||||
|
provider = 'google',
|
||||||
|
token = 'mock_platform_token',
|
||||||
|
customMetrics = null,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const url = `${baseUrl}/api/v1/auth/register`;
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
auth_method: 'platform',
|
||||||
|
login_id: loginId,
|
||||||
|
platform: {
|
||||||
|
provider: provider,
|
||||||
|
token: token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
name: 'auth_register_platform',
|
||||||
|
api: 'auth',
|
||||||
|
method: 'register',
|
||||||
|
auth_type: 'platform',
|
||||||
|
provider: provider,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const res = http.post(url, payload, params);
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
const success = check(res, {
|
||||||
|
'register platform status is 200': (r) => r.status === 200,
|
||||||
|
'register platform has tokens': (r) => {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(r.body);
|
||||||
|
return body.access_token && body.refresh_token;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, { name: 'register_platform_checks' });
|
||||||
|
|
||||||
|
if (customMetrics) {
|
||||||
|
customMetrics.registerSuccessRate?.add(success);
|
||||||
|
customMetrics.registerDuration?.add(duration);
|
||||||
|
} else {
|
||||||
|
registerSuccessRate.add(success);
|
||||||
|
registerDuration.add(duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = null;
|
||||||
|
if (res.status === 200) {
|
||||||
|
try {
|
||||||
|
result = JSON.parse(res.body);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse register platform response:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
status: res.status,
|
||||||
|
response: result,
|
||||||
|
tokens: result ? {
|
||||||
|
accessToken: result.access_token,
|
||||||
|
refreshToken: result.refresh_token,
|
||||||
|
uid: result.uid,
|
||||||
|
} : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用帳號密碼登入
|
||||||
|
* @param {Object} options - 配置選項
|
||||||
|
* @param {string} options.baseUrl - API 基礎 URL
|
||||||
|
* @param {string} options.loginId - 登入 ID
|
||||||
|
* @param {string} options.password - 密碼
|
||||||
|
* @param {Object} options.customMetrics - 自定義指標對象(可選)
|
||||||
|
* @returns {Object} 登入結果
|
||||||
|
*/
|
||||||
|
export function loginWithCredentials(options = {}) {
|
||||||
|
const {
|
||||||
|
baseUrl = __ENV.BASE_URL || 'http://localhost:8888',
|
||||||
|
loginId,
|
||||||
|
password,
|
||||||
|
customMetrics = null,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!loginId || !password) {
|
||||||
|
throw new Error('loginId and password are required for credentials login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${baseUrl}/api/v1/auth/sessions`;
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
auth_method: 'credentials',
|
||||||
|
login_id: loginId,
|
||||||
|
credentials: {
|
||||||
|
password: password,
|
||||||
|
password_confirm: password,
|
||||||
|
account_type: 'email',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
name: 'auth_login_credentials',
|
||||||
|
api: 'auth',
|
||||||
|
method: 'login',
|
||||||
|
auth_type: 'credentials',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const res = http.post(url, payload, params);
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
// 解析響應
|
||||||
|
let result = null;
|
||||||
|
let responseData = null;
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
try {
|
||||||
|
result = JSON.parse(res.body);
|
||||||
|
|
||||||
|
// 支持兩種響應格式:
|
||||||
|
// 1. 直接返回 LoginResp: { access_token, refresh_token, uid, ... }
|
||||||
|
// 2. 包裝在 Resp 中: { code, message, data: { access_token, ... } }
|
||||||
|
if (result.data && typeof result.data === 'object') {
|
||||||
|
// 格式 2: 包裝在 Resp 中
|
||||||
|
responseData = result.data;
|
||||||
|
} else if (result.access_token) {
|
||||||
|
// 格式 1: 直接返回 LoginResp
|
||||||
|
responseData = result;
|
||||||
|
} else {
|
||||||
|
// 無法識別的格式,記錄響應以便調試
|
||||||
|
console.warn('Unexpected login response format. Full response:', JSON.stringify(result));
|
||||||
|
console.warn('Response keys:', Object.keys(result));
|
||||||
|
responseData = result;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse login response:', e);
|
||||||
|
console.error('Response body:', res.body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 檢查響應結果
|
||||||
|
const success = check(res, {
|
||||||
|
'login status is 200': (r) => r.status === 200,
|
||||||
|
'login has access_token': () => {
|
||||||
|
return responseData && responseData.access_token && responseData.access_token.length > 0;
|
||||||
|
},
|
||||||
|
'login has refresh_token': () => {
|
||||||
|
return responseData && responseData.refresh_token && responseData.refresh_token.length > 0;
|
||||||
|
},
|
||||||
|
}, { name: 'login_checks' });
|
||||||
|
|
||||||
|
if (customMetrics) {
|
||||||
|
customMetrics.loginSuccessRate?.add(success);
|
||||||
|
customMetrics.loginDuration?.add(duration);
|
||||||
|
} else {
|
||||||
|
loginSuccessRate.add(success);
|
||||||
|
loginDuration.add(duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
status: res.status,
|
||||||
|
response: result,
|
||||||
|
responseData: responseData,
|
||||||
|
tokens: responseData && responseData.access_token ? {
|
||||||
|
accessToken: responseData.access_token,
|
||||||
|
refreshToken: responseData.refresh_token,
|
||||||
|
uid: responseData.uid,
|
||||||
|
} : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用第三方平台登入
|
||||||
|
* @param {Object} options - 配置選項
|
||||||
|
* @param {string} options.baseUrl - API 基礎 URL
|
||||||
|
* @param {string} options.loginId - 登入 ID
|
||||||
|
* @param {string} options.provider - 平台名稱
|
||||||
|
* @param {string} options.token - 平台 Token
|
||||||
|
* @param {Object} options.customMetrics - 自定義指標對象(可選)
|
||||||
|
* @returns {Object} 登入結果
|
||||||
|
*/
|
||||||
|
export function loginWithPlatform(options = {}) {
|
||||||
|
const {
|
||||||
|
baseUrl = __ENV.BASE_URL || 'https://localhost:8888',
|
||||||
|
loginId,
|
||||||
|
provider = 'google',
|
||||||
|
token = 'mock_platform_token',
|
||||||
|
customMetrics = null,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!loginId) {
|
||||||
|
throw new Error('loginId is required for platform login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${baseUrl}/api/v1/auth/sessions`;
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
auth_method: 'platform',
|
||||||
|
login_id: loginId,
|
||||||
|
platform: {
|
||||||
|
provider: provider,
|
||||||
|
token: token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
name: 'auth_login_platform',
|
||||||
|
api: 'auth',
|
||||||
|
method: 'login',
|
||||||
|
auth_type: 'platform',
|
||||||
|
provider: provider,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const res = http.post(url, payload, params);
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
const success = check(res, {
|
||||||
|
'login platform status is 200': (r) => r.status === 200,
|
||||||
|
'login platform has tokens': (r) => {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(r.body);
|
||||||
|
return body.access_token && body.refresh_token;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, { name: 'login_platform_checks' });
|
||||||
|
|
||||||
|
if (customMetrics) {
|
||||||
|
customMetrics.loginSuccessRate?.add(success);
|
||||||
|
customMetrics.loginDuration?.add(duration);
|
||||||
|
} else {
|
||||||
|
loginSuccessRate.add(success);
|
||||||
|
loginDuration.add(duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = null;
|
||||||
|
if (res.status === 200) {
|
||||||
|
try {
|
||||||
|
result = JSON.parse(res.body);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse login platform response:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
status: res.status,
|
||||||
|
response: result,
|
||||||
|
tokens: result ? {
|
||||||
|
accessToken: result.access_token,
|
||||||
|
refreshToken: result.refresh_token,
|
||||||
|
uid: result.uid,
|
||||||
|
} : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新 Access Token
|
||||||
|
* @param {Object} options - 配置選項
|
||||||
|
* @param {string} options.baseUrl - API 基礎 URL
|
||||||
|
* @param {string} options.accessToken - 當前的 Access Token
|
||||||
|
* @param {string} options.refreshToken - Refresh Token
|
||||||
|
* @param {Object} options.customMetrics - 自定義指標對象(可選)
|
||||||
|
* @returns {Object} 刷新結果
|
||||||
|
*/
|
||||||
|
export function refreshToken(options = {}) {
|
||||||
|
const {
|
||||||
|
baseUrl = __ENV.BASE_URL || 'https://localhost:8888',
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
customMetrics = null,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!accessToken || !refreshToken) {
|
||||||
|
throw new Error('accessToken and refreshToken are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${baseUrl}/api/v1/auth/sessions/refresh`;
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
name: 'auth_refresh_token',
|
||||||
|
api: 'auth',
|
||||||
|
method: 'refresh_token',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = http.post(url, payload, params);
|
||||||
|
|
||||||
|
const success = check(res, {
|
||||||
|
'refresh token status is 200': (r) => r.status === 200,
|
||||||
|
'refresh token has new access_token': (r) => {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(r.body);
|
||||||
|
console.log('refresh token response:', body.data);
|
||||||
|
return body.data.access_token && body.data.access_token.length > 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, { name: 'refresh_token_checks' });
|
||||||
|
|
||||||
|
let result = null;
|
||||||
|
if (res.status === 200) {
|
||||||
|
try {
|
||||||
|
result = JSON.parse(res.body.data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse refresh token response:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
status: res.status,
|
||||||
|
response: result,
|
||||||
|
tokens: result ? {
|
||||||
|
accessToken: result.access_token,
|
||||||
|
refreshToken: result.refresh_token,
|
||||||
|
} : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 請求密碼重設驗證碼
|
||||||
|
* @param {Object} options - 配置選項
|
||||||
|
* @param {string} options.baseUrl - API 基礎 URL
|
||||||
|
* @param {string} options.identifier - 使用者帳號(email 或 phone)
|
||||||
|
* @param {string} options.accountType - 帳號類型(email/phone)
|
||||||
|
* @returns {Object} 請求結果
|
||||||
|
*/
|
||||||
|
export function requestPasswordReset(options = {}) {
|
||||||
|
const {
|
||||||
|
baseUrl = __ENV.BASE_URL || 'http://localhost:8888',
|
||||||
|
identifier,
|
||||||
|
accountType = 'email',
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!identifier) {
|
||||||
|
throw new Error('identifier is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${baseUrl}/api/v1/auth/password-resets/request`;
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
identifier: identifier,
|
||||||
|
account_type: accountType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
name: 'auth_request_password_reset',
|
||||||
|
api: 'auth',
|
||||||
|
method: 'request_password_reset',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = http.post(url, payload, params);
|
||||||
|
|
||||||
|
const success = check(res, {
|
||||||
|
'request password reset status is 200': (r) => r.status === 200,
|
||||||
|
}, { name: 'request_password_reset_checks' });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
status: res.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 驗證密碼重設驗證碼
|
||||||
|
* @param {Object} options - 配置選項
|
||||||
|
* @param {string} options.baseUrl - API 基礎 URL
|
||||||
|
* @param {string} options.identifier - 使用者帳號
|
||||||
|
* @param {string} options.verifyCode - 驗證碼
|
||||||
|
* @returns {Object} 驗證結果
|
||||||
|
*/
|
||||||
|
export function verifyPasswordResetCode(options = {}) {
|
||||||
|
const {
|
||||||
|
baseUrl = __ENV.BASE_URL || 'http://localhost:8888',
|
||||||
|
identifier,
|
||||||
|
verifyCode,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!identifier || !verifyCode) {
|
||||||
|
throw new Error('identifier and verifyCode are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${baseUrl}/api/v1/auth/password-resets/verify`;
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
identifier: identifier,
|
||||||
|
verify_code: verifyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
name: 'auth_verify_password_reset_code',
|
||||||
|
api: 'auth',
|
||||||
|
method: 'verify_password_reset_code',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = http.post(url, payload, params);
|
||||||
|
|
||||||
|
const success = check(res, {
|
||||||
|
'verify password reset code status is 200': (r) => r.status === 200,
|
||||||
|
}, { name: 'verify_password_reset_code_checks' });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
status: res.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 執行密碼重設
|
||||||
|
* @param {Object} options - 配置選項
|
||||||
|
* @param {string} options.baseUrl - API 基礎 URL
|
||||||
|
* @param {string} options.identifier - 使用者帳號
|
||||||
|
* @param {string} options.verifyCode - 驗證碼
|
||||||
|
* @param {string} options.newPassword - 新密碼
|
||||||
|
* @returns {Object} 重設結果
|
||||||
|
*/
|
||||||
|
export function resetPassword(options = {}) {
|
||||||
|
const {
|
||||||
|
baseUrl = __ENV.BASE_URL || 'https://localhost:8888',
|
||||||
|
identifier,
|
||||||
|
verifyCode,
|
||||||
|
newPassword,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!identifier || !verifyCode || !newPassword) {
|
||||||
|
throw new Error('identifier, verifyCode, and newPassword are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${baseUrl}/api/v1/auth/password-resets`;
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
identifier: identifier,
|
||||||
|
verify_code: verifyCode,
|
||||||
|
password: newPassword,
|
||||||
|
password_confirm: newPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
name: 'auth_reset_password',
|
||||||
|
api: 'auth',
|
||||||
|
method: 'reset_password',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = http.put(url, payload, params);
|
||||||
|
|
||||||
|
const success = check(res, {
|
||||||
|
'reset password status is 200': (r) => r.status === 200,
|
||||||
|
}, { name: 'reset_password_checks' });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
status: res.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
/**
|
||||||
|
* 健康檢查 API 場景模組
|
||||||
|
*
|
||||||
|
* 此模組提供系統健康檢查場景,用於監控系統狀態。
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* import { healthCheck } from './scenarios/apis/health.js';
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { check } from 'k6';
|
||||||
|
import { Rate, Trend } from 'k6/metrics';
|
||||||
|
|
||||||
|
// 可選的自定義指標
|
||||||
|
const healthCheckSuccessRate = new Rate('health_check_success');
|
||||||
|
const healthCheckDuration = new Trend('health_check_duration');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系統健康檢查
|
||||||
|
* @param {Object} options - 配置選項
|
||||||
|
* @param {string} options.baseUrl - API 基礎 URL
|
||||||
|
* @param {Object} options.customMetrics - 自定義指標對象(可選)
|
||||||
|
* @returns {Object} 健康檢查結果
|
||||||
|
*/
|
||||||
|
export function healthCheck(options = {}) {
|
||||||
|
const {
|
||||||
|
baseUrl = __ENV.BASE_URL || 'http://localhost:8888',
|
||||||
|
customMetrics = null,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const url = `${baseUrl}/api/v1/health`;
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
name: 'health_check',
|
||||||
|
api: 'health',
|
||||||
|
method: 'health_check',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const res = http.get(url, params);
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
const success = check(res, {
|
||||||
|
'health check status is 200': (r) => r.status === 200,
|
||||||
|
'health check response time < 500ms': (r) => r.timings.duration < 500,
|
||||||
|
}, { name: 'health_check_checks' });
|
||||||
|
|
||||||
|
if (customMetrics) {
|
||||||
|
customMetrics.healthCheckSuccessRate?.add(success);
|
||||||
|
customMetrics.healthCheckDuration?.add(duration);
|
||||||
|
} else {
|
||||||
|
healthCheckSuccessRate.add(success);
|
||||||
|
healthCheckDuration.add(duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
status: res.status,
|
||||||
|
responseTime: res.timings.duration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,341 @@
|
||||||
|
/**
|
||||||
|
* 使用者資訊相關 API 場景模組
|
||||||
|
*
|
||||||
|
* 此模組提供可重複使用的使用者資訊相關場景,包括:
|
||||||
|
* - 取得使用者資訊
|
||||||
|
* - 更新使用者資訊
|
||||||
|
* - 修改密碼
|
||||||
|
* - 驗證碼流程(email/phone)
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* import { getUserInfo, updateUserInfo } from './scenarios/apis/user.js';
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { check } from 'k6';
|
||||||
|
import { Rate, Trend } from 'k6/metrics';
|
||||||
|
|
||||||
|
// 可選的自定義指標
|
||||||
|
const getUserInfoSuccessRate = new Rate('user_get_info_success');
|
||||||
|
const updateUserInfoSuccessRate = new Rate('user_update_info_success');
|
||||||
|
const getUserInfoDuration = new Trend('user_get_info_duration');
|
||||||
|
const updateUserInfoDuration = new Trend('user_update_info_duration');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得當前登入的使用者資訊
|
||||||
|
* @param {Object} options - 配置選項
|
||||||
|
* @param {string} options.baseUrl - API 基礎 URL
|
||||||
|
* @param {string} options.accessToken - Access Token
|
||||||
|
* @param {Object} options.customMetrics - 自定義指標對象(可選)
|
||||||
|
* @returns {Object} 使用者資訊結果
|
||||||
|
*/
|
||||||
|
export function getUserInfo(options = {}) {
|
||||||
|
const {
|
||||||
|
baseUrl = __ENV.BASE_URL || 'https://localhost:8888',
|
||||||
|
accessToken,
|
||||||
|
customMetrics = null,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('accessToken is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${baseUrl}/api/v1/user/me`;
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
name: 'user_get_info',
|
||||||
|
api: 'user',
|
||||||
|
method: 'get_user_info',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const res = http.get(url, params);
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
const success = check(res, {
|
||||||
|
'get user info status is 200': (r) => r.status === 200,
|
||||||
|
'get user info has uid': (r) => {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(r.body);
|
||||||
|
return body.uid && body.uid.length > 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'get user info has user_status': (r) => {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(r.body);
|
||||||
|
return body.user_status !== undefined;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, { name: 'get_user_info_checks' });
|
||||||
|
|
||||||
|
if (customMetrics) {
|
||||||
|
customMetrics.getUserInfoSuccessRate?.add(success);
|
||||||
|
customMetrics.getUserInfoDuration?.add(duration);
|
||||||
|
} else {
|
||||||
|
getUserInfoSuccessRate.add(success);
|
||||||
|
getUserInfoDuration.add(duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = null;
|
||||||
|
if (res.status === 200) {
|
||||||
|
try {
|
||||||
|
result = JSON.parse(res.body);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse get user info response:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
status: res.status,
|
||||||
|
response: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新當前登入的使用者資訊
|
||||||
|
* @param {Object} options - 配置選項
|
||||||
|
* @param {string} options.baseUrl - API 基礎 URL
|
||||||
|
* @param {string} options.accessToken - Access Token
|
||||||
|
* @param {Object} options.updateData - 要更新的資料(可選欄位)
|
||||||
|
* @param {Object} options.customMetrics - 自定義指標對象(可選)
|
||||||
|
* @returns {Object} 更新結果
|
||||||
|
*/
|
||||||
|
export function updateUserInfo(options = {}) {
|
||||||
|
const {
|
||||||
|
baseUrl = __ENV.BASE_URL || 'https://localhost:8888',
|
||||||
|
accessToken,
|
||||||
|
updateData = {},
|
||||||
|
customMetrics = null,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('accessToken is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${baseUrl}/api/v1/user/me`;
|
||||||
|
const payload = JSON.stringify(updateData);
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
name: 'user_update_info',
|
||||||
|
api: 'user',
|
||||||
|
method: 'update_user_info',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const res = http.put(url, payload, params);
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
const success = check(res, {
|
||||||
|
'update user info status is 200': (r) => r.status === 200,
|
||||||
|
'update user info returns updated data': (r) => {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(r.body);
|
||||||
|
return body.uid && body.uid.length > 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, { name: 'update_user_info_checks' });
|
||||||
|
|
||||||
|
if (customMetrics) {
|
||||||
|
customMetrics.updateUserInfoSuccessRate?.add(success);
|
||||||
|
customMetrics.updateUserInfoDuration?.add(duration);
|
||||||
|
} else {
|
||||||
|
updateUserInfoSuccessRate.add(success);
|
||||||
|
updateUserInfoDuration.add(duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = null;
|
||||||
|
if (res.status === 200) {
|
||||||
|
try {
|
||||||
|
result = JSON.parse(res.body);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse update user info response:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
status: res.status,
|
||||||
|
response: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改當前登入使用者的密碼
|
||||||
|
* @param {Object} options - 配置選項
|
||||||
|
* @param {string} options.baseUrl - API 基礎 URL
|
||||||
|
* @param {string} options.accessToken - Access Token
|
||||||
|
* @param {string} options.currentPassword - 當前密碼
|
||||||
|
* @param {string} options.newPassword - 新密碼
|
||||||
|
* @returns {Object} 修改結果
|
||||||
|
*/
|
||||||
|
export function updatePassword(options = {}) {
|
||||||
|
const {
|
||||||
|
baseUrl = __ENV.BASE_URL || 'https://localhost:8888',
|
||||||
|
accessToken,
|
||||||
|
currentPassword,
|
||||||
|
newPassword,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!accessToken || !currentPassword || !newPassword) {
|
||||||
|
throw new Error('accessToken, currentPassword, and newPassword are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${baseUrl}/api/v1/user/me/password`;
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
current_password: currentPassword,
|
||||||
|
new_password: newPassword,
|
||||||
|
new_password_confirm: newPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
name: 'user_update_password',
|
||||||
|
api: 'user',
|
||||||
|
method: 'update_password',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = http.put(url, payload, params);
|
||||||
|
|
||||||
|
const success = check(res, {
|
||||||
|
'update password status is 200': (r) => r.status === 200,
|
||||||
|
}, { name: 'update_password_checks' });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
status: res.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 請求發送驗證碼(用於驗證 email/phone)
|
||||||
|
* @param {Object} options - 配置選項
|
||||||
|
* @param {string} options.baseUrl - API 基礎 URL
|
||||||
|
* @param {string} options.accessToken - Access Token
|
||||||
|
* @param {string} options.purpose - 驗證目的(email_verification/phone_verification)
|
||||||
|
* @returns {Object} 請求結果
|
||||||
|
*/
|
||||||
|
export function requestVerificationCode(options = {}) {
|
||||||
|
const {
|
||||||
|
baseUrl = __ENV.BASE_URL || 'https://localhost:8888',
|
||||||
|
accessToken,
|
||||||
|
purpose = 'email_verification',
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('accessToken is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['email_verification', 'phone_verification'].includes(purpose)) {
|
||||||
|
throw new Error('purpose must be email_verification or phone_verification');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${baseUrl}/api/v1/user/me/verifications`;
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
purpose: purpose,
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
name: 'user_request_verification_code',
|
||||||
|
api: 'user',
|
||||||
|
method: 'request_verification_code',
|
||||||
|
purpose: purpose,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = http.post(url, payload, params);
|
||||||
|
|
||||||
|
const success = check(res, {
|
||||||
|
'request verification code status is 200': (r) => r.status === 200,
|
||||||
|
}, { name: 'request_verification_code_checks' });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
status: res.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交驗證碼以完成驗證
|
||||||
|
* @param {Object} options - 配置選項
|
||||||
|
* @param {string} options.baseUrl - API 基礎 URL
|
||||||
|
* @param {string} options.accessToken - Access Token
|
||||||
|
* @param {string} options.purpose - 驗證目的(email_verification/phone_verification)
|
||||||
|
* @param {string} options.verifyCode - 驗證碼
|
||||||
|
* @returns {Object} 提交結果
|
||||||
|
*/
|
||||||
|
export function submitVerificationCode(options = {}) {
|
||||||
|
const {
|
||||||
|
baseUrl = __ENV.BASE_URL || 'https://localhost:8888',
|
||||||
|
accessToken,
|
||||||
|
purpose = 'email_verification',
|
||||||
|
verifyCode,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!accessToken || !verifyCode) {
|
||||||
|
throw new Error('accessToken and verifyCode are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['email_verification', 'phone_verification'].includes(purpose)) {
|
||||||
|
throw new Error('purpose must be email_verification or phone_verification');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${baseUrl}/api/v1/user/me/verifications`;
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
purpose: purpose,
|
||||||
|
verify_code: verifyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
name: 'user_submit_verification_code',
|
||||||
|
api: 'user',
|
||||||
|
method: 'submit_verification_code',
|
||||||
|
purpose: purpose,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = http.put(url, payload, params);
|
||||||
|
|
||||||
|
const success = check(res, {
|
||||||
|
'submit verification code status is 200': (r) => r.status === 200,
|
||||||
|
}, { name: 'submit_verification_code_checks' });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
status: res.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,242 @@
|
||||||
|
/**
|
||||||
|
* 認證流程端到端場景
|
||||||
|
*
|
||||||
|
* 此模組提供完整的認證流程場景,組合多個 API 場景形成業務流程。
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* import { registerAndLoginFlow, loginAndRefreshFlow, passwordResetFlow } from './scenarios/e2e/authentication-flow.js';
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as auth from '../apis/auth.js';
|
||||||
|
import { sleep } from 'k6';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 註冊後立即登入流程
|
||||||
|
* @param {Object} options - 配置選項
|
||||||
|
* @param {string} options.baseUrl - API 基礎 URL
|
||||||
|
* @param {string} options.loginId - 登入 ID
|
||||||
|
* @param {string} options.password - 密碼
|
||||||
|
* @param {Object} options.customMetrics - 自定義指標對象(可選)
|
||||||
|
* @returns {Object} 流程結果
|
||||||
|
*/
|
||||||
|
export function registerAndLoginFlow(options = {}) {
|
||||||
|
const {
|
||||||
|
baseUrl = __ENV.BASE_URL || 'https://localhost:8888',
|
||||||
|
loginId = `test_${Date.now()}@example.com`,
|
||||||
|
password = 'Test123456!',
|
||||||
|
customMetrics = null,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// 步驟 1: 註冊
|
||||||
|
const registerResult = auth.registerWithCredentials({
|
||||||
|
baseUrl,
|
||||||
|
loginId,
|
||||||
|
password,
|
||||||
|
customMetrics,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!registerResult.success || !registerResult.tokens) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
step: 'register',
|
||||||
|
error: 'Registration failed',
|
||||||
|
registerResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 短暫等待,模擬真實用戶行為
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// 步驟 2: 使用註冊的帳號登入
|
||||||
|
const loginResult = auth.loginWithCredentials({
|
||||||
|
baseUrl,
|
||||||
|
loginId,
|
||||||
|
password,
|
||||||
|
customMetrics,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loginResult.success || !loginResult.tokens) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
step: 'login',
|
||||||
|
error: 'Login failed after registration',
|
||||||
|
registerResult,
|
||||||
|
loginResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
registerResult,
|
||||||
|
loginResult,
|
||||||
|
tokens: loginResult.tokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登入後刷新 Token 流程
|
||||||
|
* @param {Object} options - 配置選項
|
||||||
|
* @param {string} options.baseUrl - API 基礎 URL
|
||||||
|
* @param {string} options.loginId - 登入 ID
|
||||||
|
* @param {string} options.password - 密碼
|
||||||
|
* @param {Object} options.customMetrics - 自定義指標對象(可選)
|
||||||
|
* @returns {Object} 流程結果
|
||||||
|
*/
|
||||||
|
export function loginAndRefreshFlow(options = {}) {
|
||||||
|
const {
|
||||||
|
baseUrl = __ENV.BASE_URL || 'https://localhost:8888',
|
||||||
|
loginId,
|
||||||
|
password,
|
||||||
|
customMetrics = null,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!loginId || !password) {
|
||||||
|
throw new Error('loginId and password are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步驟 1: 登入
|
||||||
|
const loginResult = auth.loginWithCredentials({
|
||||||
|
baseUrl,
|
||||||
|
loginId,
|
||||||
|
password,
|
||||||
|
customMetrics,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loginResult.success || !loginResult.tokens) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
step: 'login',
|
||||||
|
error: 'Login failed',
|
||||||
|
loginResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 短暫等待
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// 步驟 2: 刷新 Token
|
||||||
|
const refreshResult = auth.refreshToken({
|
||||||
|
baseUrl,
|
||||||
|
accessToken: loginResult.tokens.accessToken,
|
||||||
|
refreshToken: loginResult.tokens.refreshToken,
|
||||||
|
customMetrics,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!refreshResult.success || !refreshResult.tokens) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
step: 'refresh',
|
||||||
|
error: 'Token refresh failed',
|
||||||
|
loginResult,
|
||||||
|
refreshResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
loginResult,
|
||||||
|
refreshResult,
|
||||||
|
tokens: refreshResult.tokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完整密碼重設流程(請求 → 驗證 → 重設)
|
||||||
|
* @param {Object} options - 配置選項
|
||||||
|
* @param {string} options.baseUrl - API 基礎 URL
|
||||||
|
* @param {string} options.identifier - 使用者帳號(email 或 phone)
|
||||||
|
* @param {string} options.accountType - 帳號類型(email/phone)
|
||||||
|
* @param {string} options.verifyCode - 驗證碼(在實際測試中可能需要從外部獲取)
|
||||||
|
* @param {string} options.newPassword - 新密碼
|
||||||
|
* @returns {Object} 流程結果
|
||||||
|
*/
|
||||||
|
export function passwordResetFlow(options = {}) {
|
||||||
|
const {
|
||||||
|
baseUrl = __ENV.BASE_URL || 'https://localhost:8888',
|
||||||
|
identifier,
|
||||||
|
accountType = 'email',
|
||||||
|
verifyCode,
|
||||||
|
newPassword = 'NewPassword123!',
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!identifier) {
|
||||||
|
throw new Error('identifier is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步驟 1: 請求密碼重設驗證碼
|
||||||
|
const requestResult = auth.requestPasswordReset({
|
||||||
|
baseUrl,
|
||||||
|
identifier,
|
||||||
|
accountType,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!requestResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
step: 'request',
|
||||||
|
error: 'Request password reset failed',
|
||||||
|
requestResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 短暫等待,模擬用戶收到驗證碼的時間
|
||||||
|
sleep(2);
|
||||||
|
|
||||||
|
// 步驟 2: 驗證密碼重設驗證碼
|
||||||
|
if (!verifyCode) {
|
||||||
|
// 在實際測試中,驗證碼可能需要從外部獲取(如測試資料庫、郵件服務等)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
step: 'verify',
|
||||||
|
error: 'verifyCode is required but not provided',
|
||||||
|
requestResult,
|
||||||
|
note: 'In real testing, verifyCode should be retrieved from external source (DB, email service, etc.)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifyResult = auth.verifyPasswordResetCode({
|
||||||
|
baseUrl,
|
||||||
|
identifier,
|
||||||
|
verifyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!verifyResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
step: 'verify',
|
||||||
|
error: 'Verify password reset code failed',
|
||||||
|
requestResult,
|
||||||
|
verifyResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 短暫等待
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// 步驟 3: 執行密碼重設
|
||||||
|
const resetResult = auth.resetPassword({
|
||||||
|
baseUrl,
|
||||||
|
identifier,
|
||||||
|
verifyCode,
|
||||||
|
newPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resetResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
step: 'reset',
|
||||||
|
error: 'Reset password failed',
|
||||||
|
requestResult,
|
||||||
|
verifyResult,
|
||||||
|
resetResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
requestResult,
|
||||||
|
verifyResult,
|
||||||
|
resetResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,369 @@
|
||||||
|
/**
|
||||||
|
* 使用者資料管理流程端到端場景
|
||||||
|
*
|
||||||
|
* 此模組提供完整的使用者資料管理流程場景,組合多個 API 場景形成業務流程。
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* import { getAndUpdateProfileFlow, emailVerificationFlow, phoneVerificationFlow, passwordChangeFlow } from './scenarios/e2e/user-profile-flow.js';
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as auth from '../apis/auth.js';
|
||||||
|
import * as user from '../apis/user.js';
|
||||||
|
import { sleep } from 'k6';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得並更新個人資料流程
|
||||||
|
* @param {Object} options - 配置選項
|
||||||
|
* @param {string} options.baseUrl - API 基礎 URL
|
||||||
|
* @param {string} options.accessToken - Access Token
|
||||||
|
* @param {Object} options.updateData - 要更新的資料
|
||||||
|
* @param {Object} options.customMetrics - 自定義指標對象(可選)
|
||||||
|
* @returns {Object} 流程結果
|
||||||
|
*/
|
||||||
|
export function getAndUpdateProfileFlow(options = {}) {
|
||||||
|
const {
|
||||||
|
baseUrl = __ENV.BASE_URL || 'https://localhost:8888',
|
||||||
|
accessToken,
|
||||||
|
updateData = {
|
||||||
|
nickname: `TestUser_${Date.now()}`,
|
||||||
|
preferred_language: 'zh-tw',
|
||||||
|
},
|
||||||
|
customMetrics = null,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('accessToken is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步驟 1: 取得使用者資訊
|
||||||
|
const getInfoResult = user.getUserInfo({
|
||||||
|
baseUrl,
|
||||||
|
accessToken,
|
||||||
|
customMetrics,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!getInfoResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
step: 'get_info',
|
||||||
|
error: 'Get user info failed',
|
||||||
|
getInfoResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 短暫等待
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// 步驟 2: 更新使用者資訊
|
||||||
|
const updateInfoResult = user.updateUserInfo({
|
||||||
|
baseUrl,
|
||||||
|
accessToken,
|
||||||
|
updateData,
|
||||||
|
customMetrics,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updateInfoResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
step: 'update_info',
|
||||||
|
error: 'Update user info failed',
|
||||||
|
getInfoResult,
|
||||||
|
updateInfoResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
getInfoResult,
|
||||||
|
updateInfoResult,
|
||||||
|
originalData: getInfoResult.response,
|
||||||
|
updatedData: updateInfoResult.response,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完整 Email 驗證流程(請求 → 提交)
|
||||||
|
* @param {Object} options - 配置選項
|
||||||
|
* @param {string} options.baseUrl - API 基礎 URL
|
||||||
|
* @param {string} options.accessToken - Access Token
|
||||||
|
* @param {string} options.verifyCode - 驗證碼(在實際測試中可能需要從外部獲取)
|
||||||
|
* @returns {Object} 流程結果
|
||||||
|
*/
|
||||||
|
export function emailVerificationFlow(options = {}) {
|
||||||
|
const {
|
||||||
|
baseUrl = __ENV.BASE_URL || 'https://localhost:8888',
|
||||||
|
accessToken,
|
||||||
|
verifyCode,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('accessToken is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步驟 1: 請求 Email 驗證碼
|
||||||
|
const requestResult = user.requestVerificationCode({
|
||||||
|
baseUrl,
|
||||||
|
accessToken,
|
||||||
|
purpose: 'email_verification',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!requestResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
step: 'request',
|
||||||
|
error: 'Request email verification code failed',
|
||||||
|
requestResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 短暫等待,模擬用戶收到驗證碼的時間
|
||||||
|
sleep(2);
|
||||||
|
|
||||||
|
// 步驟 2: 提交驗證碼
|
||||||
|
if (!verifyCode) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
step: 'submit',
|
||||||
|
error: 'verifyCode is required but not provided',
|
||||||
|
requestResult,
|
||||||
|
note: 'In real testing, verifyCode should be retrieved from external source (DB, email service, etc.)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitResult = user.submitVerificationCode({
|
||||||
|
baseUrl,
|
||||||
|
accessToken,
|
||||||
|
purpose: 'email_verification',
|
||||||
|
verifyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!submitResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
step: 'submit',
|
||||||
|
error: 'Submit email verification code failed',
|
||||||
|
requestResult,
|
||||||
|
submitResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
requestResult,
|
||||||
|
submitResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完整手機驗證流程(請求 → 提交)
|
||||||
|
* @param {Object} options - 配置選項
|
||||||
|
* @param {string} options.baseUrl - API 基礎 URL
|
||||||
|
* @param {string} options.accessToken - Access Token
|
||||||
|
* @param {string} options.verifyCode - 驗證碼(在實際測試中可能需要從外部獲取)
|
||||||
|
* @returns {Object} 流程結果
|
||||||
|
*/
|
||||||
|
export function phoneVerificationFlow(options = {}) {
|
||||||
|
const {
|
||||||
|
baseUrl = __ENV.BASE_URL || 'https://localhost:8888',
|
||||||
|
accessToken,
|
||||||
|
verifyCode,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('accessToken is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步驟 1: 請求手機驗證碼
|
||||||
|
const requestResult = user.requestVerificationCode({
|
||||||
|
baseUrl,
|
||||||
|
accessToken,
|
||||||
|
purpose: 'phone_verification',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!requestResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
step: 'request',
|
||||||
|
error: 'Request phone verification code failed',
|
||||||
|
requestResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 短暫等待,模擬用戶收到驗證碼的時間
|
||||||
|
sleep(2);
|
||||||
|
|
||||||
|
// 步驟 2: 提交驗證碼
|
||||||
|
if (!verifyCode) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
step: 'submit',
|
||||||
|
error: 'verifyCode is required but not provided',
|
||||||
|
requestResult,
|
||||||
|
note: 'In real testing, verifyCode should be retrieved from external source (DB, SMS service, etc.)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitResult = user.submitVerificationCode({
|
||||||
|
baseUrl,
|
||||||
|
accessToken,
|
||||||
|
purpose: 'phone_verification',
|
||||||
|
verifyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!submitResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
step: 'submit',
|
||||||
|
error: 'Submit phone verification code failed',
|
||||||
|
requestResult,
|
||||||
|
submitResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
requestResult,
|
||||||
|
submitResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登入狀態下修改密碼流程
|
||||||
|
* @param {Object} options - 配置選項
|
||||||
|
* @param {string} options.baseUrl - API 基礎 URL
|
||||||
|
* @param {string} options.accessToken - Access Token
|
||||||
|
* @param {string} options.currentPassword - 當前密碼
|
||||||
|
* @param {string} options.newPassword - 新密碼
|
||||||
|
* @returns {Object} 流程結果
|
||||||
|
*/
|
||||||
|
export function passwordChangeFlow(options = {}) {
|
||||||
|
const {
|
||||||
|
baseUrl = __ENV.BASE_URL || 'https://localhost:8888',
|
||||||
|
accessToken,
|
||||||
|
currentPassword,
|
||||||
|
newPassword = 'NewPassword123!',
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!accessToken || !currentPassword) {
|
||||||
|
throw new Error('accessToken and currentPassword are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步驟 1: 修改密碼
|
||||||
|
const updatePasswordResult = user.updatePassword({
|
||||||
|
baseUrl,
|
||||||
|
accessToken,
|
||||||
|
currentPassword,
|
||||||
|
newPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updatePasswordResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
step: 'update_password',
|
||||||
|
error: 'Update password failed',
|
||||||
|
updatePasswordResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 短暫等待
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// 步驟 2: 使用新密碼登入驗證(可選)
|
||||||
|
// 注意:這需要知道 loginId,可能需要從 getUserInfo 獲取
|
||||||
|
// 這裡僅作為示例,實際使用時可能需要調整
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
updatePasswordResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完整的使用者資料初始化流程(註冊 → 登入 → 取得資訊 → 更新資訊)
|
||||||
|
* @param {Object} options - 配置選項
|
||||||
|
* @param {string} options.baseUrl - API 基礎 URL
|
||||||
|
* @param {string} options.loginId - 登入 ID
|
||||||
|
* @param {string} options.password - 密碼
|
||||||
|
* @param {Object} options.updateData - 要更新的資料
|
||||||
|
* @param {Object} options.customMetrics - 自定義指標對象(可選)
|
||||||
|
* @returns {Object} 流程結果
|
||||||
|
*/
|
||||||
|
export function userProfileInitializationFlow(options = {}) {
|
||||||
|
const {
|
||||||
|
baseUrl = __ENV.BASE_URL || 'https://localhost:8888',
|
||||||
|
loginId = `test_${Date.now()}@example.com`,
|
||||||
|
password = 'Test123456!',
|
||||||
|
updateData = {
|
||||||
|
nickname: `TestUser_${Date.now()}`,
|
||||||
|
preferred_language: 'zh-tw',
|
||||||
|
currency: 'TWD',
|
||||||
|
},
|
||||||
|
customMetrics = null,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// 步驟 1: 註冊
|
||||||
|
const registerResult = auth.registerWithCredentials({
|
||||||
|
baseUrl,
|
||||||
|
loginId,
|
||||||
|
password,
|
||||||
|
customMetrics,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!registerResult.success || !registerResult.tokens) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
step: 'register',
|
||||||
|
error: 'Registration failed',
|
||||||
|
registerResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// 步驟 2: 取得使用者資訊
|
||||||
|
const getInfoResult = user.getUserInfo({
|
||||||
|
baseUrl,
|
||||||
|
accessToken: registerResult.tokens.accessToken,
|
||||||
|
customMetrics,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!getInfoResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
step: 'get_info',
|
||||||
|
error: 'Get user info failed',
|
||||||
|
registerResult,
|
||||||
|
getInfoResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// 步驟 3: 更新使用者資訊
|
||||||
|
const updateInfoResult = user.updateUserInfo({
|
||||||
|
baseUrl,
|
||||||
|
accessToken: registerResult.tokens.accessToken,
|
||||||
|
updateData,
|
||||||
|
customMetrics,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updateInfoResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
step: 'update_info',
|
||||||
|
error: 'Update user info failed',
|
||||||
|
registerResult,
|
||||||
|
getInfoResult,
|
||||||
|
updateInfoResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
registerResult,
|
||||||
|
getInfoResult,
|
||||||
|
updateInfoResult,
|
||||||
|
tokens: registerResult.tokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
/**
|
||||||
|
* 認證功能負載測試
|
||||||
|
*
|
||||||
|
* 此測試用於 Pre-release 環境,模擬正常負載下的認證功能。
|
||||||
|
* 測試重點:系統在正常負載下的性能和穩定性。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { registerWithCredentials, loginWithCredentials, refreshToken } from '../../scenarios/apis/auth.js';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
load_auth: {
|
||||||
|
executor: 'ramping-vus',
|
||||||
|
startVUs: 0,
|
||||||
|
stages: [
|
||||||
|
{ duration: '30s', target: 10 }, // 30 秒內增加到 10 個 VU
|
||||||
|
{ duration: '1m', target: 10 }, // 維持 10 個 VU 1 分鐘
|
||||||
|
{ duration: '30s', target: 20 }, // 30 秒內增加到 20 個 VU
|
||||||
|
{ duration: '1m', target: 20 }, // 維持 20 個 VU 1 分鐘
|
||||||
|
{ duration: '30s', target: 0 }, // 30 秒內減少到 0 個 VU
|
||||||
|
],
|
||||||
|
gracefulRampDown: '30s',
|
||||||
|
tags: { test_type: 'load', api: 'auth', environment: 'pre' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
checks: ['rate>0.95'], // 95% 的檢查必須通過
|
||||||
|
http_req_duration: ['p(95)<2000'], // 95% 的請求應在 2 秒內完成
|
||||||
|
http_req_failed: ['rate<0.05'], // 失敗率應低於 5%
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const baseUrl = __ENV.BASE_URL || 'http://localhost:8888';
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const randomId = Math.floor(Math.random() * 1000000);
|
||||||
|
const loginId = `load_test_${timestamp}_${randomId}@example.com`;
|
||||||
|
const password = 'LoadTest123!';
|
||||||
|
|
||||||
|
// 1. 註冊
|
||||||
|
const registerResult = registerWithCredentials({
|
||||||
|
baseUrl,
|
||||||
|
loginId,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!registerResult.success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 登入
|
||||||
|
const loginResult = loginWithCredentials({
|
||||||
|
baseUrl,
|
||||||
|
loginId,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loginResult.success || !loginResult.tokens) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 刷新 Token
|
||||||
|
refreshToken({
|
||||||
|
baseUrl,
|
||||||
|
accessToken: loginResult.tokens.accessToken,
|
||||||
|
refreshToken: loginResult.tokens.refreshToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
/**
|
||||||
|
* 使用者資料流程負載測試
|
||||||
|
*
|
||||||
|
* 此測試用於 Pre-release 環境,模擬正常負載下的使用者資料管理流程。
|
||||||
|
* 測試重點:系統在正常負載下的流程性能和穩定性。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { userProfileInitializationFlow } from '../../scenarios/e2e/user-profile-flow.js';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
load_user_profile_flow: {
|
||||||
|
executor: 'ramping-vus',
|
||||||
|
startVUs: 0,
|
||||||
|
stages: [
|
||||||
|
{ duration: '30s', target: 5 }, // 30 秒內增加到 5 個 VU
|
||||||
|
{ duration: '1m', target: 5 }, // 維持 5 個 VU 1 分鐘
|
||||||
|
{ duration: '30s', target: 10 }, // 30 秒內增加到 10 個 VU
|
||||||
|
{ duration: '1m', target: 10 }, // 維持 10 個 VU 1 分鐘
|
||||||
|
{ duration: '30s', target: 0 }, // 30 秒內減少到 0 個 VU
|
||||||
|
],
|
||||||
|
gracefulRampDown: '30s',
|
||||||
|
tags: { test_type: 'load', api: 'user', flow: 'profile', environment: 'pre' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
checks: ['rate>0.95'], // 95% 的檢查必須通過
|
||||||
|
http_req_duration: ['p(95)<3000'], // 95% 的請求應在 3 秒內完成
|
||||||
|
http_req_failed: ['rate<0.05'], // 失敗率應低於 5%
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const baseUrl = __ENV.BASE_URL || 'https://localhost:8888';
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const randomId = Math.floor(Math.random() * 1000000);
|
||||||
|
const loginId = `load_flow_${timestamp}_${randomId}@example.com`;
|
||||||
|
const password = 'LoadTest123!';
|
||||||
|
|
||||||
|
// 運行完整的使用者資料初始化流程
|
||||||
|
const result = userProfileInitializationFlow({
|
||||||
|
baseUrl,
|
||||||
|
loginId,
|
||||||
|
password,
|
||||||
|
updateData: {
|
||||||
|
nickname: `LoadFlow_${timestamp}_${randomId}`,
|
||||||
|
preferred_language: 'zh-tw',
|
||||||
|
currency: 'TWD',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error(`Load test failed: ${result.step} - ${result.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Load test: User profile flow completed successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
/**
|
||||||
|
* 使用者功能負載測試
|
||||||
|
*
|
||||||
|
* 此測試用於 Pre-release 環境,模擬正常負載下的使用者功能。
|
||||||
|
* 測試重點:系統在正常負載下的性能和穩定性。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { registerWithCredentials } from '../../scenarios/apis/auth.js';
|
||||||
|
import { getUserInfo, updateUserInfo } from '../../scenarios/apis/user.js';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
load_user: {
|
||||||
|
executor: 'ramping-vus',
|
||||||
|
startVUs: 0,
|
||||||
|
stages: [
|
||||||
|
{ duration: '30s', target: 10 }, // 30 秒內增加到 10 個 VU
|
||||||
|
{ duration: '1m', target: 10 }, // 維持 10 個 VU 1 分鐘
|
||||||
|
{ duration: '30s', target: 20 }, // 30 秒內增加到 20 個 VU
|
||||||
|
{ duration: '1m', target: 20 }, // 維持 20 個 VU 1 分鐘
|
||||||
|
{ duration: '30s', target: 0 }, // 30 秒內減少到 0 個 VU
|
||||||
|
],
|
||||||
|
gracefulRampDown: '30s',
|
||||||
|
tags: { test_type: 'load', api: 'user', environment: 'pre' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
checks: ['rate>0.95'], // 95% 的檢查必須通過
|
||||||
|
http_req_duration: ['p(95)<2000'], // 95% 的請求應在 2 秒內完成
|
||||||
|
http_req_failed: ['rate<0.05'], // 失敗率應低於 5%
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const baseUrl = __ENV.BASE_URL || 'https://localhost:8888';
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const randomId = Math.floor(Math.random() * 1000000);
|
||||||
|
const loginId = `load_user_${timestamp}_${randomId}@example.com`;
|
||||||
|
const password = 'LoadTest123!';
|
||||||
|
|
||||||
|
// 1. 註冊並獲取 Token
|
||||||
|
const registerResult = registerWithCredentials({
|
||||||
|
baseUrl,
|
||||||
|
loginId,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!registerResult.success || !registerResult.tokens) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = registerResult.tokens.accessToken;
|
||||||
|
|
||||||
|
// 2. 取得使用者資訊
|
||||||
|
const getInfoResult = getUserInfo({
|
||||||
|
baseUrl,
|
||||||
|
accessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!getInfoResult.success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 更新使用者資訊
|
||||||
|
updateUserInfo({
|
||||||
|
baseUrl,
|
||||||
|
accessToken,
|
||||||
|
updateData: {
|
||||||
|
nickname: `LoadTest_${timestamp}_${randomId}`,
|
||||||
|
preferred_language: 'zh-tw',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
/**
|
||||||
|
* 認證功能壓力測試
|
||||||
|
*
|
||||||
|
* 此測試用於 Pre-release 環境,模擬高負載下的認證功能。
|
||||||
|
* 測試重點:系統在高負載下的穩定性和錯誤處理。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { registerWithCredentials, loginWithCredentials } from '../../scenarios/apis/auth.js';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
stress_auth: {
|
||||||
|
executor: 'ramping-vus',
|
||||||
|
startVUs: 0,
|
||||||
|
stages: [
|
||||||
|
{ duration: '1m', target: 50 }, // 1 分鐘內增加到 50 個 VU
|
||||||
|
{ duration: '2m', target: 50 }, // 維持 50 個 VU 2 分鐘
|
||||||
|
{ duration: '1m', target: 100 }, // 1 分鐘內增加到 100 個 VU
|
||||||
|
{ duration: '2m', target: 100 }, // 維持 100 個 VU 2 分鐘
|
||||||
|
{ duration: '1m', target: 0 }, // 1 分鐘內減少到 0 個 VU
|
||||||
|
],
|
||||||
|
gracefulRampDown: '30s',
|
||||||
|
tags: { test_type: 'stress', api: 'auth', environment: 'pre' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
checks: ['rate>0.90'], // 90% 的檢查必須通過(壓力測試允許較低成功率)
|
||||||
|
http_req_duration: ['p(95)<3000'], // 95% 的請求應在 3 秒內完成
|
||||||
|
http_req_failed: ['rate<0.10'], // 失敗率應低於 10%
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const baseUrl = __ENV.BASE_URL || 'https://localhost:8888';
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const randomId = Math.floor(Math.random() * 1000000);
|
||||||
|
const loginId = `stress_test_${timestamp}_${randomId}@example.com`;
|
||||||
|
const password = 'StressTest123!';
|
||||||
|
|
||||||
|
// 1. 註冊
|
||||||
|
const registerResult = registerWithCredentials({
|
||||||
|
baseUrl,
|
||||||
|
loginId,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!registerResult.success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 登入
|
||||||
|
loginWithCredentials({
|
||||||
|
baseUrl,
|
||||||
|
loginId,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
/**
|
||||||
|
* 使用者功能壓力測試
|
||||||
|
*
|
||||||
|
* 此測試用於 Pre-release 環境,模擬高負載下的使用者功能。
|
||||||
|
* 測試重點:系統在高負載下的穩定性和錯誤處理。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { registerWithCredentials } from '../../scenarios/apis/auth.js';
|
||||||
|
import { getUserInfo, updateUserInfo } from '../../scenarios/apis/user.js';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
stress_user: {
|
||||||
|
executor: 'ramping-vus',
|
||||||
|
startVUs: 0,
|
||||||
|
stages: [
|
||||||
|
{ duration: '1m', target: 50 }, // 1 分鐘內增加到 50 個 VU
|
||||||
|
{ duration: '2m', target: 50 }, // 維持 50 個 VU 2 分鐘
|
||||||
|
{ duration: '1m', target: 100 }, // 1 分鐘內增加到 100 個 VU
|
||||||
|
{ duration: '2m', target: 100 }, // 維持 100 個 VU 2 分鐘
|
||||||
|
{ duration: '1m', target: 0 }, // 1 分鐘內減少到 0 個 VU
|
||||||
|
],
|
||||||
|
gracefulRampDown: '30s',
|
||||||
|
tags: { test_type: 'stress', api: 'user', environment: 'pre' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
checks: ['rate>0.90'], // 90% 的檢查必須通過(壓力測試允許較低成功率)
|
||||||
|
http_req_duration: ['p(95)<3000'], // 95% 的請求應在 3 秒內完成
|
||||||
|
http_req_failed: ['rate<0.10'], // 失敗率應低於 10%
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const baseUrl = __ENV.BASE_URL || 'https://localhost:8888';
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const randomId = Math.floor(Math.random() * 1000000);
|
||||||
|
const loginId = `stress_user_${timestamp}_${randomId}@example.com`;
|
||||||
|
const password = 'StressTest123!';
|
||||||
|
|
||||||
|
// 1. 註冊並獲取 Token
|
||||||
|
const registerResult = registerWithCredentials({
|
||||||
|
baseUrl,
|
||||||
|
loginId,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!registerResult.success || !registerResult.tokens) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = registerResult.tokens.accessToken;
|
||||||
|
|
||||||
|
// 2. 取得使用者資訊
|
||||||
|
getUserInfo({
|
||||||
|
baseUrl,
|
||||||
|
accessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 更新使用者資訊
|
||||||
|
updateUserInfo({
|
||||||
|
baseUrl,
|
||||||
|
accessToken,
|
||||||
|
updateData: {
|
||||||
|
nickname: `StressTest_${timestamp}_${randomId}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
/**
|
||||||
|
* 認證功能夜間測試
|
||||||
|
*
|
||||||
|
* 此測試用於 Production 環境,在夜間低峰時段執行。
|
||||||
|
* 測試重點:監控生產環境的認證功能穩定性,識別長期性能變化。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { loginWithCredentials, refreshToken } from '../../scenarios/apis/auth.js';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
nightly_auth: {
|
||||||
|
executor: 'constant-vus',
|
||||||
|
vus: 5, // 低並發,避免影響生產環境
|
||||||
|
duration: '5m', // 執行 5 分鐘
|
||||||
|
tags: { test_type: 'nightly', api: 'auth', environment: 'prod' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
checks: ['rate>0.98'], // 98% 的檢查必須通過(生產環境要求更高)
|
||||||
|
http_req_duration: ['p(95)<2000'], // 95% 的請求應在 2 秒內完成
|
||||||
|
http_req_failed: ['rate<0.02'], // 失敗率應低於 2%
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const baseUrl = __ENV.BASE_URL || 'https://localhost:8888';
|
||||||
|
|
||||||
|
// 注意:生產環境測試應使用預先創建的測試帳號
|
||||||
|
// 不要創建新帳號,避免污染生產資料
|
||||||
|
const loginId = __ENV.TEST_LOGIN_ID || 'test@example.com';
|
||||||
|
const password = __ENV.TEST_PASSWORD || 'TestPassword123!';
|
||||||
|
|
||||||
|
// 1. 登入(使用預先創建的測試帳號)
|
||||||
|
const loginResult = loginWithCredentials({
|
||||||
|
baseUrl,
|
||||||
|
loginId,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loginResult.success || !loginResult.tokens) {
|
||||||
|
console.error('Nightly test failed: Login failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 刷新 Token
|
||||||
|
const refreshResult = refreshToken({
|
||||||
|
baseUrl,
|
||||||
|
accessToken: loginResult.tokens.accessToken,
|
||||||
|
refreshToken: loginResult.tokens.refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!refreshResult.success) {
|
||||||
|
console.error('Nightly test failed: Token refresh failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Nightly test passed: Auth operations succeeded');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
/**
|
||||||
|
* 健康檢查夜間監控
|
||||||
|
*
|
||||||
|
* 此測試用於 Production 環境,持續監控系統健康狀態。
|
||||||
|
* 測試重點:系統可用性監控,識別性能退化。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { healthCheck } from '../../scenarios/apis/health.js';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
nightly_health: {
|
||||||
|
executor: 'constant-arrival-rate',
|
||||||
|
rate: 1, // 每秒 1 個請求
|
||||||
|
timeUnit: '1s',
|
||||||
|
duration: '10m', // 執行 10 分鐘
|
||||||
|
preAllocatedVUs: 2,
|
||||||
|
maxVUs: 5,
|
||||||
|
tags: { test_type: 'nightly', api: 'health', environment: 'prod' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
checks: ['rate==1.0'], // 所有檢查必須通過
|
||||||
|
http_req_duration: ['p(95)<500'], // 95% 的請求應在 500ms 內完成
|
||||||
|
health_check_success: ['rate==1.0'], // 健康檢查成功率應為 100%
|
||||||
|
http_req_failed: ['rate==0'], // 不允許失敗
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const baseUrl = __ENV.BASE_URL || 'http://localhost:8888';
|
||||||
|
|
||||||
|
const result = healthCheck({ baseUrl });
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error(`Health check failed: Status ${result.status}, Response time ${result.responseTime}ms`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 記錄健康檢查結果(在實際環境中,這可以發送到監控系統)
|
||||||
|
console.log(`Health check passed: Status ${result.status}, Response time ${result.responseTime}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
/**
|
||||||
|
* 使用者功能夜間測試
|
||||||
|
*
|
||||||
|
* 此測試用於 Production 環境,在夜間低峰時段執行。
|
||||||
|
* 測試重點:監控生產環境的使用者功能穩定性,識別長期性能變化。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { loginWithCredentials } from '../../scenarios/apis/auth.js';
|
||||||
|
import { getUserInfo, updateUserInfo } from '../../scenarios/apis/user.js';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
nightly_user: {
|
||||||
|
executor: 'constant-vus',
|
||||||
|
vus: 5, // 低並發,避免影響生產環境
|
||||||
|
duration: '5m', // 執行 5 分鐘
|
||||||
|
tags: { test_type: 'nightly', api: 'user', environment: 'prod' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
checks: ['rate>0.98'], // 98% 的檢查必須通過(生產環境要求更高)
|
||||||
|
http_req_duration: ['p(95)<2000'], // 95% 的請求應在 2 秒內完成
|
||||||
|
http_req_failed: ['rate<0.02'], // 失敗率應低於 2%
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const baseUrl = __ENV.BASE_URL || 'https://localhost:8888';
|
||||||
|
|
||||||
|
// 注意:生產環境測試應使用預先創建的測試帳號
|
||||||
|
// 不要創建新帳號,避免污染生產資料
|
||||||
|
const loginId = __ENV.TEST_LOGIN_ID || 'test@example.com';
|
||||||
|
const password = __ENV.TEST_PASSWORD || 'TestPassword123!';
|
||||||
|
|
||||||
|
// 1. 登入(使用預先創建的測試帳號)
|
||||||
|
const loginResult = loginWithCredentials({
|
||||||
|
baseUrl,
|
||||||
|
loginId,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loginResult.success || !loginResult.tokens) {
|
||||||
|
console.error('Nightly test failed: Login failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = loginResult.tokens.accessToken;
|
||||||
|
|
||||||
|
// 2. 取得使用者資訊
|
||||||
|
const getInfoResult = getUserInfo({
|
||||||
|
baseUrl,
|
||||||
|
accessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!getInfoResult.success) {
|
||||||
|
console.error('Nightly test failed: Get user info failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 更新使用者資訊(僅更新非關鍵欄位,避免影響測試帳號)
|
||||||
|
const updateInfoResult = updateUserInfo({
|
||||||
|
baseUrl,
|
||||||
|
accessToken,
|
||||||
|
updateData: {
|
||||||
|
nickname: `NightlyTest_${Date.now()}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updateInfoResult.success) {
|
||||||
|
console.error('Nightly test failed: Update user info failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Nightly test passed: User operations succeeded');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
/**
|
||||||
|
* 認證功能冒煙測試
|
||||||
|
*
|
||||||
|
* 此測試用於 Dev/QA 環境,快速驗證認證功能是否正常運作。
|
||||||
|
* 測試重點:基本功能可用性,不關注性能。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { registerWithCredentials, loginWithCredentials, refreshToken } from '../../scenarios/apis/auth.js';
|
||||||
|
import { healthCheck } from '../../scenarios/apis/health.js';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
smoke_auth: {
|
||||||
|
executor: 'shared-iterations',
|
||||||
|
vus: 1,
|
||||||
|
iterations: 1,
|
||||||
|
maxDuration: '30s',
|
||||||
|
tags: { test_type: 'smoke', api: 'auth' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
checks: ['rate==1.0'], // 所有檢查必須通過
|
||||||
|
http_req_duration: ['p(95)<2000'], // 95% 的請求應在 2 秒內完成
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const baseUrl = __ENV.BASE_URL || 'http://localhost:8888';
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const loginId = `smoke_test_${timestamp}@example.com`;
|
||||||
|
const password = 'SmokeTest123!';
|
||||||
|
|
||||||
|
// 1. 健康檢查
|
||||||
|
healthCheck({ baseUrl });
|
||||||
|
|
||||||
|
// 2. 註冊
|
||||||
|
const registerResult = registerWithCredentials({
|
||||||
|
baseUrl,
|
||||||
|
loginId,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!registerResult.success) {
|
||||||
|
console.error('Smoke test failed: Registration failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 登入
|
||||||
|
const loginResult = loginWithCredentials({
|
||||||
|
baseUrl,
|
||||||
|
loginId,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loginResult.success) {
|
||||||
|
console.error('Smoke test failed: Login failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 刷新 Token
|
||||||
|
if (loginResult.tokens) {
|
||||||
|
const refreshResult = refreshToken({
|
||||||
|
baseUrl,
|
||||||
|
accessToken: loginResult.tokens.accessToken,
|
||||||
|
refreshToken: loginResult.tokens.refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!refreshResult.success) {
|
||||||
|
console.error('Smoke test failed: Token refresh failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Smoke test passed: All auth operations succeeded');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
/**
|
||||||
|
* 健康檢查冒煙測試
|
||||||
|
*
|
||||||
|
* 此測試用於 Dev/QA 環境,快速驗證系統健康狀態。
|
||||||
|
* 測試重點:系統可用性,響應時間。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { healthCheck } from '../../scenarios/apis/health.js';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
smoke_health: {
|
||||||
|
executor: 'shared-iterations',
|
||||||
|
vus: 1,
|
||||||
|
iterations: 5, // 執行 5 次健康檢查
|
||||||
|
maxDuration: '10s',
|
||||||
|
tags: { test_type: 'smoke', api: 'health' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
checks: ['rate==1.0'], // 所有檢查必須通過
|
||||||
|
http_req_duration: ['p(95)<500'], // 95% 的請求應在 500ms 內完成
|
||||||
|
health_check_success: ['rate==1.0'], // 健康檢查成功率應為 100%
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const baseUrl = __ENV.BASE_URL || 'http://localhost:8888';
|
||||||
|
|
||||||
|
const result = healthCheck({ baseUrl });
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error('Smoke test failed: Health check failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Health check passed: Status ${result.status}, Response time ${result.responseTime}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
/**
|
||||||
|
* 使用者資料流程冒煙測試
|
||||||
|
*
|
||||||
|
* 此測試用於 Dev/QA 環境,快速驗證使用者資料管理流程是否正常運作。
|
||||||
|
* 測試重點:完整流程可用性,不關注性能。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { registerWithCredentials } from '../../scenarios/apis/auth.js';
|
||||||
|
import { getAndUpdateProfileFlow, userProfileInitializationFlow } from '../../scenarios/e2e/user-profile-flow.js';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
smoke_user_profile_flow: {
|
||||||
|
executor: 'shared-iterations',
|
||||||
|
vus: 1,
|
||||||
|
iterations: 1,
|
||||||
|
maxDuration: '30s',
|
||||||
|
tags: { test_type: 'smoke', api: 'user', flow: 'profile' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
checks: ['rate==1.0'], // 所有檢查必須通過
|
||||||
|
http_req_duration: ['p(95)<3000'], // 95% 的請求應在 3 秒內完成(流程測試允許稍長)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const baseUrl = __ENV.BASE_URL || 'http://localhost:8888';
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const loginId = `smoke_flow_${timestamp}@example.com`;
|
||||||
|
const password = 'SmokeTest123!';
|
||||||
|
|
||||||
|
// 方式 1: 使用完整初始化流程(註冊 → 取得資訊 → 更新資訊)
|
||||||
|
console.log('測試完整使用者資料初始化流程...');
|
||||||
|
const initFlowResult = userProfileInitializationFlow({
|
||||||
|
baseUrl,
|
||||||
|
loginId,
|
||||||
|
password,
|
||||||
|
updateData: {
|
||||||
|
nickname: `SmokeFlow_${timestamp}`,
|
||||||
|
preferred_language: 'zh-tw',
|
||||||
|
currency: 'TWD',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!initFlowResult.success) {
|
||||||
|
console.error(`Smoke test failed: ${initFlowResult.step} - ${initFlowResult.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('完整初始化流程測試通過');
|
||||||
|
|
||||||
|
// 方式 2: 使用取得並更新流程(需要先有 Token)
|
||||||
|
// 這裡使用上面流程獲得的 Token
|
||||||
|
if (initFlowResult.tokens) {
|
||||||
|
console.log('測試取得並更新流程...');
|
||||||
|
const getUpdateFlowResult = getAndUpdateProfileFlow({
|
||||||
|
baseUrl,
|
||||||
|
accessToken: initFlowResult.tokens.accessToken,
|
||||||
|
updateData: {
|
||||||
|
nickname: `SmokeFlow_Updated_${timestamp}`,
|
||||||
|
preferred_language: 'en-us',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!getUpdateFlowResult.success) {
|
||||||
|
console.error(`Smoke test failed: ${getUpdateFlowResult.step} - ${getUpdateFlowResult.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('取得並更新流程測試通過');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Smoke test passed: All user profile flow operations succeeded');
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
/**
|
||||||
|
* 使用者功能冒煙測試
|
||||||
|
*
|
||||||
|
* 此測試用於 Dev/QA 環境,快速驗證使用者功能是否正常運作。
|
||||||
|
* 測試重點:基本功能可用性,不關注性能。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { registerWithCredentials, loginWithCredentials } from '../../scenarios/apis/auth.js';
|
||||||
|
import { getUserInfo, updateUserInfo } from '../../scenarios/apis/user.js';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
smoke_user: {
|
||||||
|
executor: 'shared-iterations',
|
||||||
|
vus: 1,
|
||||||
|
iterations: 1,
|
||||||
|
maxDuration: '30s',
|
||||||
|
tags: { test_type: 'smoke', api: 'user' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
checks: ['rate==1.0'], // 所有檢查必須通過
|
||||||
|
http_req_duration: ['p(95)<2000'], // 95% 的請求應在 2 秒內完成
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const baseUrl = __ENV.BASE_URL || 'https://localhost:8888';
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const loginId = `smoke_user_${timestamp}@example.com`;
|
||||||
|
const password = 'SmokeTest123!';
|
||||||
|
|
||||||
|
// 1. 註冊並登入以獲取 Token
|
||||||
|
const registerResult = registerWithCredentials({
|
||||||
|
baseUrl,
|
||||||
|
loginId,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!registerResult.success || !registerResult.tokens) {
|
||||||
|
console.error('Smoke test failed: Registration failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = registerResult.tokens.accessToken;
|
||||||
|
|
||||||
|
// 2. 取得使用者資訊
|
||||||
|
const getInfoResult = getUserInfo({
|
||||||
|
baseUrl,
|
||||||
|
accessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!getInfoResult.success) {
|
||||||
|
console.error('Smoke test failed: Get user info failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 更新使用者資訊
|
||||||
|
const updateInfoResult = updateUserInfo({
|
||||||
|
baseUrl,
|
||||||
|
accessToken,
|
||||||
|
updateData: {
|
||||||
|
nickname: `SmokeTest_${timestamp}`,
|
||||||
|
preferred_language: 'zh-tw',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updateInfoResult.success) {
|
||||||
|
console.error('Smoke test failed: Update user info failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Smoke test passed: All user operations succeeded');
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue