diff --git a/common/errorx/error_user.go b/common/errorx/error_user.go new file mode 100644 index 000000000..2d7163e7d --- /dev/null +++ b/common/errorx/error_user.go @@ -0,0 +1,263 @@ +package errorx + +const errUserPrefix = "USER-ERR" + +const ( + needPhone = iota + needDifferentPhone + phoneAlreadyExistsInSSO + forbidChangePhone + failedToUpdatePhone + forbidSendPhoneVerifyCodeFrequently + failedSendPhoneVerifyCode + phoneVerifyCodeExpiredOrNotFound + phoneVerifyCodeInvalid + verificationCodeRequired + verificationCodeLengthInvalid + invalidPhoneNumber + usernameExists + emailExists + adminUserCannotBeDeleted + userHasOrganizations + userHasDeployments + userHasBills +) + +var ( + // phone number is required + // + // Description: The request must include a phone number in the header or body to identify the target account. + // + // Description_ZH: 请求必须在请求头或正文中包含电话号码以识别目标账户。 + // + // en-US: Phone number is required + // + // zh-CN: 需要提供电话号码 + // + // zh-HK: 需要電話號碼 + ErrNeedPhone error = CustomError{prefix: errUserPrefix, code: needPhone} + // new phone number must be different from current phone number + // + // Description: The new phone number must be different from the current phone number. + // + // Description_ZH: 新电话号码必须与当前电话号码不同。 + // + // en-US: New phone number must be different from current phone number + // + // zh-CN: 新电话号码必须与当前电话号码不同 + // + // zh-HK: 新電話號碼必須與當前電話號碼不同 + ErrNeedDifferentPhone error = CustomError{prefix: errUserPrefix, code: needDifferentPhone} + // new phone number already exists in sso service + // + // Description: The new phone number already exists in sso service. + // + // Description_ZH: 新电话号码已经存在于sso服务中。 + // + // en-US: New phone number already exists in sso service + // + // zh-CN: 新电话号码已经存在于sso服务中 + // + // zh-HK: 新電話號碼已經存在於sso服務中 + ErrPhoneAlreadyExistsInSSO error = CustomError{prefix: errUserPrefix, code: phoneAlreadyExistsInSSO} + // forbid change phone number + // + // Description: The phone number cannot be changed. + // + // Description_ZH: 电话号码不能被更改。 + // + // en-US: Forbid change phone number + // + // zh-CN: 禁止更改电话号码 + // + // zh-HK: 禁止更改電話號碼 + ErrForbidChangePhone error = CustomError{prefix: errUserPrefix, code: forbidChangePhone} + // failed to update phone number + // + // Description: Failed to update phone number. + // + // Description_ZH: 更新电话号码失败。 + // + // en-US: Failed to update phone number + // + // zh-CN: 更新电话号码失败 + // + // zh-HK: 更新電話號碼失敗 + ErrFailedToUpdatePhone error = CustomError{prefix: errUserPrefix, code: failedToUpdatePhone} + // forbid send phone verify code frequently + // + // Description: Send phone verify code frequently. + // + // Description_ZH: 发送手机验证码过于频繁。 + // + // en-US: Forbid send phone verify code frequently + // + // zh-CN: 禁止频繁发送手机验证码 + // + // zh-HK: 禁止頻繁發送手機驗證碼 + ErrForbidSendPhoneVerifyCodeFrequently error = CustomError{prefix: errUserPrefix, code: forbidSendPhoneVerifyCodeFrequently} + // failed to send phone verify code + // + // Description: Failed to send phone verify code. + // + // Description_ZH: 发送手机验证码失败。 + // + // en-US: Failed to send phone verify code + // + // zh-CN: 发送手机验证码失败 + // + // zh-HK: 發送手機驗證碼失敗 + ErrFailedSendPhoneVerifyCode error = CustomError{prefix: errUserPrefix, code: failedSendPhoneVerifyCode} + // phone verify code expired or not found + // + // Description: Phone verify code expired or not found. + // + // Description_ZH: 手机验证码已过期或不存在。 + // + // en-US: Phone verify code expired or not found + // + // zh-CN: 手机验证码已过期或不存在 + // + // zh-HK: 手機驗證碼已過期或不存在 + ErrPhoneVerifyCodeExpiredOrNotFound error = CustomError{prefix: errUserPrefix, code: phoneVerifyCodeExpiredOrNotFound} + // phone verify code is invalid + // + // Description: Phone verify code is invalid. + // + // Description_ZH: 手机验证码无效。 + // + // en-US: Phone verify code is invalid + // + // zh-CN: 手机验证码无效 + // + // zh-HK: 手機驗證碼無效 + ErrPhoneVerifyCodeInvalid error = CustomError{prefix: errUserPrefix, code: phoneVerifyCodeInvalid} + + // verification code can not be empty + // + // Description: Verification code can not be empty. + // + // Description_ZH: 验证码不能为空。 + // + // en-US: Verification code can not be empty + // + // zh-CN: 验证码不能为空 + // + // zh-HK: 驗證碼不能為空 + ErrVerificationCodeRequired error = CustomError{prefix: errUserPrefix, code: verificationCodeRequired} + // verification code length must be 6 + // + // Description: Verification code length must be 6. + // + // Description_ZH: 验证码长度必须为6。 + // + // en-US: Verification code length must be 6 + // + // zh-CN: 验证码长度必须为6 + // + // zh-HK: 驗證碼長度必須為6 + ErrVerificationCodeLength error = CustomError{prefix: errUserPrefix, code: verificationCodeLengthInvalid} + // invalid phone number + // + // Description: Invalid phone number. + // + // Description_ZH: 无效的电话号码。 + // + // en-US: Invalid phone number + // + // zh-CN: 无效的电话号码 + // + // zh-HK: 無效的電話號碼 + ErrInvalidPhoneNumber error = CustomError{prefix: errUserPrefix, code: invalidPhoneNumber} + // username already exists + // + // Description: The username provided already exists in the system. + // + // Description_ZH: 提供的用户名已存在于系统中。 + // + // en-US: Username already exists + // + // zh-CN: 用户名已存在 + // + // zh-HK: 用戶名已存在 + ErrUsernameExists error = CustomError{prefix: errUserPrefix, code: usernameExists} + + // email already exists in the system + // + // Description: The email address provided already exists in the system. + // + // Description_ZH: 提供的电子邮件地址已存在于系统中。 + // + // en-US: Email already exists + // + // zh-CN: 邮箱已存在 + // + // zh-HK: 電郵已存在 + ErrEmailExists error = CustomError{prefix: errUserPrefix, code: emailExists} + // admin user cannot be deleted + // + // Description: The admin user cannot be deleted. + // + // Description_ZH: 管理员用户不能被删除。 + // + // en-US: Admin user cannot be deleted + // + // zh-CN: 管理员用户不能被删除 + // + // zh-HK: 管理員用戶不能被刪除 + ErrAdminUserCannotBeDeleted error = CustomError{prefix: errUserPrefix, code: adminUserCannotBeDeleted} + // user has organizations can not be deleted + // + // Description: The user who owns organizations cannot be deleted. + // + // Description_ZH: 拥有组织的用户不能被删除。 + // + // en-US: User who owns organizations cannot be deleted + // + // zh-CN: 拥有组织的用户不能被删除 + // + // zh-HK: 擁有組織的用戶不能被刪除 + ErrUserHasOrganizations error = CustomError{prefix: errUserPrefix, code: userHasOrganizations} + // user has deployments can not be deleted + // + // Description: The user who owns deployments cannot be deleted. + // + // Description_ZH: 拥有部署资源的用户不能被删除。 + // + // en-US: User who owns deployments cannot be deleted + // + // zh-CN: 拥有部署资源的用户不能被删除 + // + // zh-HK: 擁有部署資源的用戶不能被刪除 + ErrUserHasDeployments error = CustomError{prefix: errUserPrefix, code: userHasDeployments} + // user has bills can not be deleted + // + // Description: The user who owns bills cannot be deleted. + // + // Description_ZH: 拥有账单的用户不能被删除。 + // + // en-US: User who owns bills cannot be deleted + // + // zh-CN: 拥有账单的用户不能被删除 + // + // zh-HK: 擁有賬單的用戶不能被刪除 + ErrUserHasBills error = CustomError{prefix: errUserPrefix, code: userHasBills} +) + +// UsernameExists creates a specific error for username conflicts with the conflicting username +func UsernameExists(username string) error { + return CustomError{ + prefix: errUserPrefix, + code: usernameExists, + context: map[string]interface{}{"username": username}, + } +} + +// EmailExists creates a specific error for email conflicts with the conflicting email +func EmailExists(email string) error { + return CustomError{ + prefix: errUserPrefix, + code: emailExists, + context: map[string]interface{}{"email": email}, + } +} diff --git a/common/i18n/en-US/err_git.json b/common/i18n/en-US/err_git.json index a1b3af815..cfec092ec 100644 --- a/common/i18n/en-US/err_git.json +++ b/common/i18n/en-US/err_git.json @@ -86,6 +86,9 @@ "error.GIT-ERR-34": { "other": "replicate repository failed" }, + "error.GIT-ERR-35": { + "other": "Using git in xnet-enabled repository error" + }, "error.GIT-ERR-4": { "other": "Failed to find commit" }, diff --git a/common/i18n/en-US/err_user.json b/common/i18n/en-US/err_user.json new file mode 100644 index 000000000..a5738dbac --- /dev/null +++ b/common/i18n/en-US/err_user.json @@ -0,0 +1,56 @@ +{ + "error.USER-ERR-0": { + "other": "Phone number is required" + }, + "error.USER-ERR-1": { + "other": "New phone number must be different from current phone number" + }, + "error.USER-ERR-10": { + "other": "Verification code length must be 6" + }, + "error.USER-ERR-11": { + "other": "Invalid phone number" + }, + "error.USER-ERR-12": { + "other": "Username already exists" + }, + "error.USER-ERR-13": { + "other": "Email already exists" + }, + "error.USER-ERR-14": { + "other": "Admin user cannot be deleted" + }, + "error.USER-ERR-15": { + "other": "User who owns organizations cannot be deleted" + }, + "error.USER-ERR-16": { + "other": "User who owns deployments cannot be deleted" + }, + "error.USER-ERR-17": { + "other": "User who owns bills cannot be deleted" + }, + "error.USER-ERR-2": { + "other": "New phone number already exists in sso service" + }, + "error.USER-ERR-3": { + "other": "Forbid change phone number" + }, + "error.USER-ERR-4": { + "other": "Failed to update phone number" + }, + "error.USER-ERR-5": { + "other": "Forbid send phone verify code frequently" + }, + "error.USER-ERR-6": { + "other": "Failed to send phone verify code" + }, + "error.USER-ERR-7": { + "other": "Phone verify code expired or not found" + }, + "error.USER-ERR-8": { + "other": "Phone verify code is invalid" + }, + "error.USER-ERR-9": { + "other": "Verification code can not be empty" + } +} \ No newline at end of file diff --git a/common/i18n/zh-CN/err_git.json b/common/i18n/zh-CN/err_git.json index 6beacb297..b77cb0ec7 100644 --- a/common/i18n/zh-CN/err_git.json +++ b/common/i18n/zh-CN/err_git.json @@ -86,6 +86,9 @@ "error.GIT-ERR-34": { "other": "转移仓库失败" }, + "error.GIT-ERR-35": { + "other": "在 xnet 启用的仓库中使用 git 失败" + }, "error.GIT-ERR-4": { "other": "查找提交失败" }, diff --git a/common/i18n/zh-CN/err_user.json b/common/i18n/zh-CN/err_user.json new file mode 100644 index 000000000..047ad70b4 --- /dev/null +++ b/common/i18n/zh-CN/err_user.json @@ -0,0 +1,56 @@ +{ + "error.USER-ERR-0": { + "other": "需要提供电话号码" + }, + "error.USER-ERR-1": { + "other": "新电话号码必须与当前电话号码不同" + }, + "error.USER-ERR-10": { + "other": "验证码长度必须为6" + }, + "error.USER-ERR-11": { + "other": "无效的电话号码" + }, + "error.USER-ERR-12": { + "other": "用户名已存在" + }, + "error.USER-ERR-13": { + "other": "邮箱已存在" + }, + "error.USER-ERR-14": { + "other": "管理员用户不能被删除" + }, + "error.USER-ERR-15": { + "other": "拥有组织的用户不能被删除" + }, + "error.USER-ERR-16": { + "other": "拥有部署资源的用户不能被删除" + }, + "error.USER-ERR-17": { + "other": "拥有账单的用户不能被删除" + }, + "error.USER-ERR-2": { + "other": "新电话号码已经存在于sso服务中" + }, + "error.USER-ERR-3": { + "other": "禁止更改电话号码" + }, + "error.USER-ERR-4": { + "other": "更新电话号码失败" + }, + "error.USER-ERR-5": { + "other": "禁止频繁发送手机验证码" + }, + "error.USER-ERR-6": { + "other": "发送手机验证码失败" + }, + "error.USER-ERR-7": { + "other": "手机验证码已过期或不存在" + }, + "error.USER-ERR-8": { + "other": "手机验证码无效" + }, + "error.USER-ERR-9": { + "other": "验证码不能为空" + } +} \ No newline at end of file diff --git a/common/i18n/zh-HK/err_git.json b/common/i18n/zh-HK/err_git.json index ec16e9ac2..2f40c55ca 100644 --- a/common/i18n/zh-HK/err_git.json +++ b/common/i18n/zh-HK/err_git.json @@ -86,6 +86,9 @@ "error.GIT-ERR-34": { "other": "转移倉庫失敗" }, + "error.GIT-ERR-35": { + "other": "在 xnet 啟用的倉庫中使用 git 失敗" + }, "error.GIT-ERR-4": { "other": "查找提交失敗" }, diff --git a/common/i18n/zh-HK/err_user.json b/common/i18n/zh-HK/err_user.json new file mode 100644 index 000000000..f695c2686 --- /dev/null +++ b/common/i18n/zh-HK/err_user.json @@ -0,0 +1,56 @@ +{ + "error.USER-ERR-0": { + "other": "需要電話號碼" + }, + "error.USER-ERR-1": { + "other": "新電話號碼必須與當前電話號碼不同" + }, + "error.USER-ERR-10": { + "other": "驗證碼長度必須為6" + }, + "error.USER-ERR-11": { + "other": "無效的電話號碼" + }, + "error.USER-ERR-12": { + "other": "用戶名已存在" + }, + "error.USER-ERR-13": { + "other": "電郵已存在" + }, + "error.USER-ERR-14": { + "other": "管理員用戶不能被刪除" + }, + "error.USER-ERR-15": { + "other": "擁有組織的用戶不能被刪除" + }, + "error.USER-ERR-16": { + "other": "擁有部署資源的用戶不能被刪除" + }, + "error.USER-ERR-17": { + "other": "擁有賬單的用戶不能被刪除" + }, + "error.USER-ERR-2": { + "other": "新電話號碼已經存在於sso服務中" + }, + "error.USER-ERR-3": { + "other": "禁止更改電話號碼" + }, + "error.USER-ERR-4": { + "other": "更新電話號碼失敗" + }, + "error.USER-ERR-5": { + "other": "禁止頻繁發送手機驗證碼" + }, + "error.USER-ERR-6": { + "other": "發送手機驗證碼失敗" + }, + "error.USER-ERR-7": { + "other": "手機驗證碼已過期或不存在" + }, + "error.USER-ERR-8": { + "other": "手機驗證碼無效" + }, + "error.USER-ERR-9": { + "other": "驗證碼不能為空" + } +} \ No newline at end of file diff --git a/docs/error_codes_en.md b/docs/error_codes_en.md index 61f61810c..791036718 100644 --- a/docs/error_codes_en.md +++ b/docs/error_codes_en.md @@ -434,6 +434,14 @@ This document lists all the custom error codes defined in the project, categoriz - **Error Name:** `gitReplicateRepositoryFailed` - **Description:** replicate repository failed. This can be caused by network problems, authentication issues, or the specified repository does not exist. +--- + +### `GIT-ERR-35` + +- **Error Code:** `GIT-ERR-35` +- **Error Name:** `gitUsingGitInXnetRepository` +- **Description:** Using git in xnet-enabled repository error. Git operations are not supported in repositories enabled with xnet. + ## Invitation Errors ### `INVITATION-ERR-0` @@ -826,3 +834,35 @@ This document lists all the custom error codes defined in the project, categoriz - **Error Name:** `emailExists` - **Description:** The email address provided already exists in the system. +--- + +### `USER-ERR-14` + +- **Error Code:** `USER-ERR-14` +- **Error Name:** `adminUserCannotBeDeleted` +- **Description:** The admin user cannot be deleted. + +--- + +### `USER-ERR-15` + +- **Error Code:** `USER-ERR-15` +- **Error Name:** `userHasOrganizations` +- **Description:** The user who owns organizations cannot be deleted. + +--- + +### `USER-ERR-16` + +- **Error Code:** `USER-ERR-16` +- **Error Name:** `userHasDeployments` +- **Description:** The user who owns deployments cannot be deleted. + +--- + +### `USER-ERR-17` + +- **Error Code:** `USER-ERR-17` +- **Error Name:** `userHasBills` +- **Description:** The user who owns bills cannot be deleted. + diff --git a/docs/error_codes_zh.md b/docs/error_codes_zh.md index 94458f3ea..9e769efb9 100644 --- a/docs/error_codes_zh.md +++ b/docs/error_codes_zh.md @@ -434,6 +434,14 @@ - **错误名:** `gitReplicateRepositoryFailed` - **描述:** 转移仓库失败。这可能由网络问题、身份验证问题或指定的仓库不存在引起。 +--- + +### `GIT-ERR-35` + +- **错误代码:** `GIT-ERR-35` +- **错误名:** `gitUsingGitInXnetRepository` +- **描述:** 使用 git 操作 xnet 启用的仓库。 + ## Invitation 错误 ### `INVITATION-ERR-0` @@ -826,3 +834,35 @@ - **错误名:** `emailExists` - **描述:** 提供的电子邮件地址已存在于系统中。 +--- + +### `USER-ERR-14` + +- **错误代码:** `USER-ERR-14` +- **错误名:** `adminUserCannotBeDeleted` +- **描述:** 管理员用户不能被删除。 + +--- + +### `USER-ERR-15` + +- **错误代码:** `USER-ERR-15` +- **错误名:** `userHasOrganizations` +- **描述:** 拥有组织的用户不能被删除。 + +--- + +### `USER-ERR-16` + +- **错误代码:** `USER-ERR-16` +- **错误名:** `userHasDeployments` +- **描述:** 拥有部署资源的用户不能被删除。 + +--- + +### `USER-ERR-17` + +- **错误代码:** `USER-ERR-17` +- **错误名:** `userHasBills` +- **描述:** 拥有账单的用户不能被删除。 + diff --git a/user/handler/user.go b/user/handler/user.go index 23bcd8a11..81ed07a43 100644 --- a/user/handler/user.go +++ b/user/handler/user.go @@ -3,6 +3,7 @@ package handler import ( "context" "database/sql" + "encoding/json" "errors" "fmt" "log/slog" @@ -13,6 +14,7 @@ import ( "github.com/gin-gonic/gin" "go.temporal.io/sdk/client" "opencsg.com/csghub-server/api/httpbase" + "opencsg.com/csghub-server/builder/temporal" "opencsg.com/csghub-server/common/config" "opencsg.com/csghub-server/common/errorx" "opencsg.com/csghub-server/common/types" @@ -174,48 +176,56 @@ func (h *UserHandler) Delete(ctx *gin.Context) { // Check if operator can delete user isServerErr, err := h.c.CheckOperatorAndUser(ctx, operator, userName) if err != nil && isServerErr { + slog.Error("Check operator and user failed", slog.String("operator", operator), slog.String("user", userName), slog.Any("err", err)) httpbase.ServerError(ctx, fmt.Errorf("user cannot be deleted: %w", err)) return } if err != nil && !isServerErr { - httpbase.BadRequestWithExt(ctx, err) + slog.Error("Bad Request", slog.String("operator", operator), slog.String("user", userName), slog.Any("err", err)) + httpbase.BadRequestWithExt(ctx, errorx.ErrAdminUserCannotBeDeleted) return } // Check if user has organizations hasOrgs, err := h.c.CheckIfUserHasOrgs(ctx, userName) if err != nil { + slog.Error("Check if user has organizations failed", slog.String("user", userName), slog.Any("err", err)) httpbase.ServerError(ctx, fmt.Errorf("failed to check if user has organzitions, error: %w", err)) return } if hasOrgs { - httpbase.BadRequestWithExt(ctx, errorx.ReqParamInvalid(errors.New("users who own organizations cannot be deleted"), nil)) + slog.Error("User has organizations", slog.String("user", userName)) + httpbase.BadRequestWithExt(ctx, errorx.ErrUserHasOrganizations) return } // Check if user has running or building deployments hasDeployments, err := h.c.CheckIfUserHasRunningOrBuildingDeployments(ctx, userName) if err != nil { + slog.Error("Check if user has deployments failed", slog.String("user", userName), slog.Any("err", err)) httpbase.ServerError(ctx, fmt.Errorf("failed to check if user has deployments, error: %w", err)) return } if hasDeployments { - httpbase.BadRequestWithExt(ctx, errorx.ReqParamInvalid(errors.New("users who own deployments cannot be deleted"), nil)) + slog.Error("User has deployments", slog.String("user", userName)) + httpbase.BadRequestWithExt(ctx, errorx.ErrUserHasDeployments) return } // Check if user has bills, Saas only hasBills, err := h.c.CheckIfUserHasBills(ctx, userName) if err != nil { + slog.Error("Check if user has bills failed", slog.String("user", userName), slog.Any("err", err)) httpbase.ServerError(ctx, fmt.Errorf("failed to check if user has bills, error: %w", err)) return } if hasBills { - httpbase.BadRequestWithExt(ctx, errorx.ReqParamInvalid(errors.New("users who own bills cannot be deleted"), nil)) + slog.Error("User has bills", slog.String("user", userName)) + httpbase.BadRequestWithExt(ctx, errorx.ErrUserHasBills) return } //start workflow to delete user - workflowClient := workflow.GetWorkflowClient() + workflowClient := temporal.GetClient() workflowOptions := client.StartWorkflowOptions{ TaskQueue: workflow.WorkflowUserDeletionQueueName, } @@ -324,10 +334,17 @@ func (h *UserHandler) Casdoor(ctx *gin.Context) { jwtToken, signed, err := h.c.Signin(ctx.Request.Context(), code, state) if err != nil { slog.Error("Failed to signin", slog.Any("error", err), slog.String("code", code), slog.String("state", state)) + var customErr errorx.CustomError + if errors.As(err, &customErr) { + if handleConflictCustomError(ctx, customErr, h.signinFailureRedirectURL) { + return + } + } + errorMsg := url.QueryEscape(fmt.Sprintf("failed to signin: %v", err)) errorRedirectURL := fmt.Sprintf("%s?error_code=500&error_message=%s", h.signinFailureRedirectURL, errorMsg) slog.Info("redirecting to error page", slog.String("url", errorRedirectURL)) - ctx.Redirect(http.StatusMovedPermanently, errorRedirectURL) + ctx.Redirect(http.StatusFound, errorRedirectURL) return } @@ -344,7 +361,7 @@ func (h *UserHandler) Casdoor(ctx *gin.Context) { errorMsg := url.QueryEscape(errMsg) errorRedirectURL := fmt.Sprintf("%s?error_code=500&error_message=%s", h.signinFailureRedirectURL, errorMsg) slog.Info("redirecting to error page", slog.String("url", errorRedirectURL)) - ctx.Redirect(http.StatusMovedPermanently, errorRedirectURL) + ctx.Redirect(http.StatusFound, errorRedirectURL) return } codeSoulerEndpoint := h.codeSoulerVScodeRedirectURL @@ -366,7 +383,7 @@ func (h *UserHandler) Casdoor(ctx *gin.Context) { errorRedirectURL := fmt.Sprintf("%s?error_code=500&error_message=%s", h.signinFailureRedirectURL, errorMsg) slog.Info("redirecting to error page", slog.String("url", errorRedirectURL)) - ctx.Redirect(http.StatusMovedPermanently, errorRedirectURL) + ctx.Redirect(http.StatusFound, errorRedirectURL) return } // set jwt token in jwt query @@ -377,7 +394,7 @@ func (h *UserHandler) Casdoor(ctx *gin.Context) { } slog.Info("generate login redirect url", slog.Any("targetUrl", targetUrl)) - ctx.Redirect(http.StatusMovedPermanently, targetUrl) + ctx.Redirect(http.StatusFound, targetUrl) } func (h *UserHandler) getStarshipApiKey(ctx *gin.Context, userName, tokenName string) (string, error) { @@ -679,12 +696,11 @@ func (h *UserHandler) CloseAccount(ctx *gin.Context) { } if hasBills { httpbase.BadRequestWithExt(ctx, errorx.ReqParamInvalid(errors.New("users who own bills cannot be deleted"), nil)) - return } //start workflow to soft delete user - workflowClient := workflow.GetWorkflowClient() + workflowClient := temporal.GetClient() workflowOptions := client.StartWorkflowOptions{ TaskQueue: workflow.WorkflowUserDeletionQueueName, } @@ -797,3 +813,223 @@ func (e *UserHandler) ResetUserTags(ctx *gin.Context) { httpbase.OK(ctx, nil) } + +// SendSmsCode godoc +// @Security ApiKey +// @Summary generate sms verification code and send it by sms +// @Description generate sms verification code and send it by sms +// @Tags User +// @Accept json +// @Produce json +// @Param body body types.SendSMSCodeRequest true "SendSMSCodeRequest" +// @Success 200 {object} types.Response{data=types.SendSMSCodeResponse} "OK" +// @Failure 400 {object} types.APIBadRequest "Bad request" +// @Failure 500 {object} types.APIInternalServerError "Internal server error" +// @Router /user/sms-code [post] +func (e *UserHandler) SendSMSCode(ctx *gin.Context) { + currentUserUUID := httpbase.GetCurrentUserUUID(ctx) + var req types.SendSMSCodeRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + slog.Error("SendSMSCodeRequest failed", slog.Any("err", err)) + httpbase.ServerError(ctx, err) + return + } + resp, err := e.c.SendSMSCode(ctx, currentUserUUID, req) + if err != nil { + slog.Error("SendSMSCode failed", slog.Any("err", err)) + httpbase.ServerError(ctx, err) + return + } + httpbase.OK(ctx, resp) +} + +// SendPublicSMSCode godoc +// @Security ApiKey +// @Summary generate sms verification code and send it by sms (public endpoint) +// @Description generate sms verification code and send it by sms with scene parameter. Accessible to both logged-in and anonymous users. +// @Tags User +// @Accept json +// @Produce json +// @Param body body types.SendPublicSMSCodeRequest true "SendPublicSMSCodeRequest" +// @Success 200 {object} types.Response{data=types.SendSMSCodeResponse} "OK" +// @Failure 400 {object} types.APIBadRequest "Bad request" +// @Failure 500 {object} types.APIInternalServerError "Internal server error" +// @Router /user/public/sms-code [post] +func (e *UserHandler) SendPublicSMSCode(ctx *gin.Context) { + var req types.SendPublicSMSCodeRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + slog.Error("SendPublicSMSCodeRequest failed", slog.Any("err", err)) + httpbase.ServerError(ctx, err) + return + } + resp, err := e.c.SendPublicSMSCode(ctx, req) + if err != nil { + slog.Error("SendPublicSMSCode failed", slog.Any("err", err)) + httpbase.ServerError(ctx, err) + return + } + httpbase.OK(ctx, resp) +} + +// VerifyPublicSMSCode godoc +// @Security ApiKey +// @Summary verify sms verification code (public endpoint) +// @Description verify sms verification code with scene parameter. Accessible to both logged-in and anonymous users. +// @Tags User +// @Accept json +// @Produce json +// @Param body body types.VerifyPublicSMSCodeRequest true "VerifyPublicSMSCodeRequest" +// @Success 200 {object} types.Response{data=nil} "OK" +// @Failure 400 {object} types.APIBadRequest "Bad request" +// @Failure 500 {object} types.APIInternalServerError "Internal server error" +// @Router /user/public/sms-code/verify [post] +func (e *UserHandler) VerifyPublicSMSCode(ctx *gin.Context) { + var req types.VerifyPublicSMSCodeRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + slog.Error("VerifyPublicSMSCodeRequest failed", slog.Any("err", err)) + httpbase.ServerError(ctx, err) + return + } + err := e.c.VerifyPublicSMSCode(ctx, req) + if err != nil { + slog.Error("VerifyPublicSMSCode failed", slog.Any("err", err)) + httpbase.ServerError(ctx, err) + return + } + httpbase.OK(ctx, nil) +} + +// UpdatePhone godoc +// @Security ApiKey +// @Summary Update current user phone +// @Description Update current user phone +// @Tags User +// @Accept json +// @Produce json +// @Param body body types.UpdateUserPhoneRequest true "UpdateUserPhoneRequest" +// @Success 200 {object} types.Response{} "OK" +// @Failure 400 {object} types.APIBadRequest "Bad request" +// @Failure 500 {object} types.APIInternalServerError "Internal server error" +// @Router /user/phone [put] +func (e *UserHandler) UpdatePhone(ctx *gin.Context) { + currentUserUUID := httpbase.GetCurrentUserUUID(ctx) + var req types.UpdateUserPhoneRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + slog.Error("failed to update user's phone", slog.Any("err", err)) + httpbase.ServerError(ctx, err) + return + } + + err := e.c.UpdatePhone(ctx, currentUserUUID, req) + if err != nil { + slog.Error("failed to update user's phone", slog.Any("err", err)) + httpbase.ServerError(ctx, err) + return + } + + httpbase.OK(ctx, nil) +} + +func handleConflictCustomError(ctx *gin.Context, customErr errorx.CustomError, redirectURL string) bool { + errCode := customErr.Code() + + cField, cValue, ok := extractConflictInfo(customErr) + if !ok { + return false + } + + u, _ := url.Parse(redirectURL) + q := u.Query() + q.Set("error_code", strconv.Itoa(http.StatusConflict)) + q.Set("error_message_code", errCode) + q.Set("field_name", cField) + q.Set("field_value", cValue) + u.RawQuery = q.Encode() + slog.Info("redirecting to error page with conflict error", slog.String("url", u.String())) + ctx.Redirect(http.StatusFound, u.String()) + return true +} + +func extractConflictInfo(customErr errorx.CustomError) (field, value string, ok bool) { + errCtx := customErr.Context() + + switch { + case customErr.Is(errorx.ErrUsernameExists): + if username, exists := errCtx["username"]; exists { + if usernameStr, ok := username.(string); ok { + return "username", usernameStr, true + } + } + case customErr.Is(errorx.ErrEmailExists): + if email, exists := errCtx["email"]; exists { + if emailStr, ok := email.(string); ok { + return "email", emailStr, true + } + } + } + return "", "", false +} + +// ExportUserInfo godoc +// @Security ApiKey +// @Summary Export users info. Only Admin +// @Tags User +// @Accept json +// @Produce json +// @Param verify_status query string false "Verify status (e.g. 'none', 'pending', 'approved', 'rejected')" +// @Param search query string false "Search keyword (match username/email/phone)" +// @Param labels query []string false "Labels (e.g. vip,basic) - multiple values supported" +// @Param cursor query int64 false "Cursor for pagination" +// @Success 200 "OK - SSE stream: events are 'users' (single user JSON), 'error' (error message), 'end' (completion message)" +// @Failure 403 {object} types.APIForbidden "Forbidden - not admin" +// @Router /users/stream-export [get] +func (h *UserHandler) ExportUserInfo(ctx *gin.Context) { + search := ctx.Query("search") + _labels := ctx.QueryArray("labels") + labels := types.ParseLabels(_labels) + verifyStatus := ctx.Query("verify_status") + + req := types.UserIndexReq{ + Search: search, + VerifyStatus: types.VerifyStatus(verifyStatus), + Labels: labels, + Per: 300, + } + + ctx.Writer.Header().Set("Content-Type", "text/event-stream") + ctx.Writer.Header().Set("Cache-Control", "no-cache") + ctx.Writer.Header().Set("Connection", "keep-alive") + ctx.Writer.Header().Set("X-Accel-Buffering", "no") + ctx.Writer.WriteHeader(http.StatusOK) + ctx.Writer.Flush() + + data, err := h.c.StreamExportUsers(ctx.Request.Context(), req) + if err != nil { + slog.Error("stream export failed in component", slog.Any("error", err), slog.Any("req", req)) + select { + case data <- types.UserIndexResp{Error: err}: + case <-ctx.Request.Context().Done(): + } + } + + for resp := range data { + if resp.Error != nil { + ctx.SSEvent("error", resp.Error.Error()) + ctx.Writer.Flush() + return + } + + jsonData, err := json.Marshal(resp.Users) + if err != nil { + slog.Error("Failed to marshal users", slog.Any("error", err)) + ctx.SSEvent("error", errorx.ErrInternalServerError.Error()) + ctx.Writer.Flush() + return + } + ctx.SSEvent("users", string(jsonData)) + ctx.Writer.Flush() + } + + ctx.SSEvent("end", "export completed") + ctx.Writer.Flush() +}