+ {item.description} +
+{item.description}
+打开页面后自动开始计算
+{item.description}
-打开页面后自动开始计算
-- {item.description} -
-您的登录验证码为:%s,5分钟内有效,请勿将验证码泄露给他人。
", code), - ) - - // 构建异步邮件发送任务 - payload := SendEmailPayload{ - To: user.Email, - Subject: emailSubject, - Body: emailBody, - } - payloadBytes, _ := json.Marshal(payload) - _, err = task.DispatchTask(ctx, task.TaskTypeSendEmail, payloadBytes, "system") - if err != nil { - c.JSON(http.StatusOK, util.Err("投递验证邮件发送任务失败,请重试")) + if err := sendEmailVerificationCode(ctx, user.Email, "login", "login_email"); err != nil { + c.JSON(http.StatusOK, util.Err(err.Error())) return } } // 脱敏邮箱并返回错误,提示前端需要输入验证码 maskedEmail := util.MaskEmail(user.Email) - c.JSON(http.StatusOK, util.Err("need_email_code:"+maskedEmail)) + c.JSON(http.StatusOK, util.Err(errNeedEmailCodePrefix+maskedEmail)) return } // 校验验证码 - codeKey := fmt.Sprintf("email_code:login:%s", user.Email) - var storedCode string - if err := db.GetJSON(ctx, codeKey, &storedCode); err != nil { - c.JSON(http.StatusOK, util.Err("验证码错误或已过期")) + if !verifyEmailCode(ctx, user.Email, "login", req.Code) { + c.JSON(http.StatusOK, util.Err(errEmailCodeInvalidOrExpired)) return } - if storedCode != req.Code { - c.JSON(http.StatusOK, util.Err("验证码错误或已过期")) - return - } - - // 验证成功,删除验证码 - _ = db.Redis.Del(ctx, db.PrefixedKey(codeKey)).Err() } session := sessions.Default(c) @@ -232,7 +254,7 @@ func Login(c *gin.Context) { if isPlaintext { if err := user.SetEncryptedPassword(req.Password); err == nil { if err := db.DB(ctx).Model(&user).Update("password", user.Password).Error; err != nil { - c.JSON(http.StatusOK, util.Err("升级密码安全算法失败,请重试")) + c.JSON(http.StatusOK, util.Err(errPasswordUpgradeFailed)) return } needChangePassword = true @@ -247,15 +269,15 @@ func Login(c *gin.Context) { return } if err := setLoginSession(c, &user); err != nil { - c.JSON(http.StatusOK, util.Err("无法保存会话信息,请重试")) + c.JSON(http.StatusOK, util.Err(errSaveSessionFailed)) return } - // 检查是否有未完成的 OAuth/OIDC 绑定 - pendingSourceID := session.Get("pending_oauth_source_id") - pendingExternalID := session.Get("pending_oauth_external_id") - pendingExternalUsername := session.Get("pending_oauth_external_username") - pendingEmail := session.Get("pending_oauth_email") + // 检查是否有未完成 of OAuth/OIDC 绑定 + pendingSourceID := session.Get(oauth.PendingOAuthSourceIDKey) + pendingExternalID := session.Get(oauth.PendingOAuthExternalIDKey) + pendingExternalUsername := session.Get(oauth.PendingOAuthExternalUsernameKey) + pendingEmail := session.Get(oauth.PendingOAuthEmailKey) if pendingSourceID != nil && pendingExternalID != nil { var sourceID uint64 @@ -281,10 +303,10 @@ func Login(c *gin.Context) { }) } // 清除 pending 信息 - session.Delete("pending_oauth_source_id") - session.Delete("pending_oauth_external_id") - session.Delete("pending_oauth_external_username") - session.Delete("pending_oauth_email") + session.Delete(oauth.PendingOAuthSourceIDKey) + session.Delete(oauth.PendingOAuthExternalIDKey) + session.Delete(oauth.PendingOAuthExternalUsernameKey) + session.Delete(oauth.PendingOAuthEmailKey) _ = session.Save() } @@ -304,7 +326,7 @@ func Login(c *gin.Context) { // @Router /api/v1/user/register [post] func Register(c *gin.Context) { if !isRegistrationEnabled() || !isPasswordRegisterEnabled() { - c.JSON(http.StatusOK, util.Err("管理员关闭了注册")) + c.JSON(http.StatusOK, util.Err(errRegistrationDisabled)) return } @@ -322,11 +344,11 @@ func Register(c *gin.Context) { req.Code = strings.TrimSpace(req.Code) if req.Username == "" || req.Password == "" { - c.JSON(http.StatusOK, util.Err("无效的参数")) + c.JSON(http.StatusOK, util.Err(errInvalidParams)) return } if len(req.Password) < 8 { - c.JSON(http.StatusOK, util.Err("密码长度不能少于 8 位")) + c.JSON(http.StatusOK, util.Err(errPasswordTooShort)) return } @@ -335,23 +357,14 @@ func Register(c *gin.Context) { // 邮箱注册验证校验 if isEmailRegisterVerificationEnabled() { if req.Email == "" || req.Code == "" { - c.JSON(http.StatusOK, util.Err("邮箱或验证码未填写")) + c.JSON(http.StatusOK, util.Err(errEmailOrCodeRequired)) return } - codeKey := fmt.Sprintf("email_code:register:%s", req.Email) - var storedCode string - if err := db.GetJSON(ctx, codeKey, &storedCode); err != nil { - c.JSON(http.StatusOK, util.Err("验证码错误或已过期")) + if !verifyEmailCode(ctx, req.Email, "register", req.Code) { + c.JSON(http.StatusOK, util.Err(errEmailCodeInvalidOrExpired)) return } - if storedCode != req.Code { - c.JSON(http.StatusOK, util.Err("验证码错误或已过期")) - return - } - - // 验证通过,删除 Redis 中的验证码 - _ = db.Redis.Del(ctx, db.PrefixedKey(codeKey)).Err() } user := model.User{ @@ -380,7 +393,7 @@ func Register(c *gin.Context) { } if err := setLoginSession(c, &user); err != nil { - c.JSON(http.StatusOK, util.Err("无法保存会话信息,请重试")) + c.JSON(http.StatusOK, util.Err(errSaveSessionFailed)) return } @@ -434,36 +447,36 @@ func ChangePassword(c *gin.Context) { req.NewPassword = strings.TrimSpace(req.NewPassword) if req.OldPassword == "" || req.NewPassword == "" { - c.JSON(http.StatusOK, util.Err("无效的参数")) + c.JSON(http.StatusOK, util.Err(errInvalidParams)) return } if len(req.NewPassword) < 8 { - c.JSON(http.StatusOK, util.Err("新密码长度不能少于 8 位")) + c.JSON(http.StatusOK, util.Err(errNewPasswordTooShort)) return } userObj, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey) if userObj == nil { - c.JSON(http.StatusUnauthorized, util.Err("请先登录")) + c.JSON(http.StatusUnauthorized, util.Err(errLoginRequired)) return } ctx := c.Request.Context() var dbUser model.User if err := db.DB(ctx).Where("id = ?", userObj.ID).First(&dbUser).Error; err != nil { - c.JSON(http.StatusOK, util.Err("未找到该用户")) + c.JSON(http.StatusOK, util.Err(errUserNotFound)) return } // 校验旧密码 if !dbUser.CheckPassword(req.OldPassword) { - c.JSON(http.StatusOK, util.Err("原密码不正确")) + c.JSON(http.StatusOK, util.Err(errOldPasswordIncorrect)) return } // 加密并更新为新密码 if err := dbUser.SetEncryptedPassword(req.NewPassword); err != nil { - c.JSON(http.StatusOK, util.Err("密码加密失败,请重试")) + c.JSON(http.StatusOK, util.Err(errPasswordEncryptFailed)) return } @@ -499,12 +512,12 @@ func SendEmailCode(c *gin.Context) { req.Email = strings.TrimSpace(req.Email) if req.Email == "" { - c.JSON(http.StatusOK, util.Err("邮箱地址不能为空")) + c.JSON(http.StatusOK, util.Err(errEmailRequired)) return } if req.Scene != "register" { - c.JSON(http.StatusOK, util.Err("不支持的验证场景")) + c.JSON(http.StatusOK, util.Err(errUnsupportedEmailScene)) return } @@ -517,47 +530,22 @@ func SendEmailCode(c *gin.Context) { return } if count > 0 { - c.JSON(http.StatusOK, util.Err("该邮箱已被注册")) + c.JSON(http.StatusOK, util.Err(errEmailAlreadyRegistered)) return } // 2. 校验 Redis 发送冷却时间 - cooldownKey := fmt.Sprintf("email_code:cooldown:%s", req.Email) + cooldownKey := getEmailCooldownKey(req.Email) var temp string err := db.GetJSON(ctx, cooldownKey, &temp) if err == nil { - c.JSON(http.StatusOK, util.Err("验证码发送频繁,请稍后再试")) + c.JSON(http.StatusOK, util.Err(errEmailCodeCooldown)) return } - // 3. 生成并缓存验证码 - code := generateVerificationCode() - codeKey := fmt.Sprintf("email_code:register:%s", req.Email) - if err := db.SetJSON(ctx, codeKey, code, 5*time.Minute); err != nil { - c.JSON(http.StatusOK, util.Err("生成验证码失败,请重试")) - return - } - _ = db.SetJSON(ctx, cooldownKey, "1", 60*time.Second) - - // 使用模板管理获取并渲染邮件标题和正文 - emailSubject, emailBody := model.RenderTemplate( - ctx, - "register_email", - map[string]any{"Code": code}, - "Wavelet 注册验证码", - fmt.Sprintf("您的注册验证码为:%s,5分钟内有效,请勿泄露给他人。
", code), - ) - - // 4. 投递异步邮件发送任务 - payload := SendEmailPayload{ - To: req.Email, - Subject: emailSubject, - Body: emailBody, - } - payloadBytes, _ := json.Marshal(payload) - _, err = task.DispatchTask(ctx, task.TaskTypeSendEmail, payloadBytes, "system") - if err != nil { - c.JSON(http.StatusOK, util.Err("投递验证邮件发送任务失败,请重试")) + // 3. 发送验证码 + if err := sendEmailVerificationCode(ctx, req.Email, "register", "register_email"); err != nil { + c.JSON(http.StatusOK, util.Err(err.Error())) return } @@ -595,14 +583,14 @@ func UpdateProfile(c *gin.Context) { userObj, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey) if userObj == nil { - c.JSON(http.StatusUnauthorized, util.Err("请先登录")) + c.JSON(http.StatusUnauthorized, util.Err(errLoginRequired)) return } ctx := c.Request.Context() var dbUser model.User if err := db.DB(ctx).Where("id = ?", userObj.ID).First(&dbUser).Error; err != nil { - c.JSON(http.StatusOK, util.Err("未找到该用户")) + c.JSON(http.StatusOK, util.Err(errUserNotFound)) return } @@ -610,7 +598,7 @@ func UpdateProfile(c *gin.Context) { req.Email = strings.TrimSpace(req.Email) if req.Email != "" && req.Email != dbUser.Email { if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") { - c.JSON(http.StatusOK, util.Err("邮箱格式不正确")) + c.JSON(http.StatusOK, util.Err(errEmailFormatInvalid)) return } @@ -620,7 +608,7 @@ func UpdateProfile(c *gin.Context) { return } if count > 0 { - c.JSON(http.StatusOK, util.Err("该邮箱已被其他账号绑定")) + c.JSON(http.StatusOK, util.Err(errEmailAlreadyBound)) return } } diff --git a/internal/apps/user/errs.go b/internal/apps/user/errs.go index 27b4e083..059a9c3c 100644 --- a/internal/apps/user/errs.go +++ b/internal/apps/user/errs.go @@ -17,4 +17,42 @@ limitations under the License. package user -const () +const ( + errBindParamsFailed = "参数绑定失败" + errInvalidParams = "无效的参数" + errPasswordLoginDisabled = "管理员关闭了密码登录" + errUsernameOrPasswordWrong = "用户名或密码错误" + errLoginEmailMissing = "该账号未绑定邮箱,请联系管理员绑定邮箱后再登录" + errNeedEmailCodePrefix = "need_email_code:" + errEmailCodeInvalidOrExpired = "验证码错误或已过期" + errPasswordUpgradeFailed = "升级密码安全算法失败,请重试" + errSaveSessionFailed = "无法保存会话信息,请重试" + errRegistrationDisabled = "管理员关闭了注册" + errPasswordTooShort = "密码长度不能少于 8 位" + errEmailOrCodeRequired = "邮箱或验证码未填写" + errNewPasswordTooShort = "新密码长度不能少于 8 位" + errLoginRequired = "请先登录" + errUserNotFound = "未找到该用户" + errOldPasswordIncorrect = "原密码不正确" + errPasswordEncryptFailed = "密码加密失败,请重试" + errEmailRequired = "邮箱地址不能为空" + errUnsupportedEmailScene = "不支持的验证场景" + errEmailAlreadyRegistered = "该邮箱已被注册" + errEmailCodeCooldown = "验证码发送频繁,请稍后再试" + errEmailFormatInvalid = "邮箱格式不正确" + errEmailAlreadyBound = "该邮箱已被其他账号绑定" + errRenderEmailTemplateFailed = "渲染验证邮件模板失败:%w" + errGenerateEmailCodeFailed = "生成验证码失败,请重试" + errDispatchEmailTaskFailed = "投递验证邮件发送任务失败,请重试" + errTokenNameRequired = "令牌名称不能为空" + errAccessTokenLimitReached = "已达到访问令牌最大创建数量限制" + errGenerateTokenFailed = "生成令牌失败" + errInvalidTokenID = "无效的令牌ID" + errTokenNotFoundOrForbidden = "令牌不存在或无权操作" + errTaskPayloadRequired = "任务参数不能为空" + errInvalidJSONFormat = "无效的 JSON 格式: %w" + errEmailTaskFieldsRequired = "to、subject、body 不能为空" + errParseEmailPayloadFailed = "解析邮件发送参数失败: %w" + errSMTPConfigIncomplete = "系统 SMTP 邮件服务配置不完整" + errSendMailFailed = "发送邮件失败: %w" +) diff --git a/internal/apps/user/tasks.go b/internal/apps/user/tasks.go index 0c1cfac0..e43b882d 100644 --- a/internal/apps/user/tasks.go +++ b/internal/apps/user/tasks.go @@ -44,12 +44,12 @@ type SendEmailHandler struct{} // 校验并标准化邮件发送参数,框架在 Admin 下发时自动调用 func (h *SendEmailHandler) ValidatePayload(payload []byte) ([]byte, error) { if len(payload) == 0 { - return nil, errors.New("任务参数不能为空") + return nil, errors.New(errTaskPayloadRequired) } var req SendEmailPayload if err := json.Unmarshal(payload, &req); err != nil { - return nil, fmt.Errorf("无效的 JSON 格式: %w", err) + return nil, fmt.Errorf(errInvalidJSONFormat, err) } req.To = strings.TrimSpace(req.To) @@ -57,7 +57,7 @@ func (h *SendEmailHandler) ValidatePayload(payload []byte) ([]byte, error) { req.Body = strings.TrimSpace(req.Body) if req.To == "" || req.Subject == "" || req.Body == "" { - return nil, errors.New("to、subject、body 不能为空") + return nil, errors.New(errEmailTaskFieldsRequired) } return json.Marshal(req) @@ -68,7 +68,7 @@ func (h *SendEmailHandler) Execute(ctx context.Context, payload []byte) (*task.T var req SendEmailPayload if err := json.Unmarshal(payload, &req); err != nil { task.AppendLog(ctx, "解析邮件发送参数失败: %v", err) - return nil, fmt.Errorf("解析邮件发送参数失败: %w", err) + return nil, fmt.Errorf(errParseEmailPayloadFailed, err) } task.AppendLog(ctx, "开始准备发送邮件到: %s, 主题: %s", req.To, req.Subject) @@ -94,7 +94,7 @@ func (h *SendEmailHandler) Execute(ctx context.Context, payload []byte) (*task.T } if smtpHost == "" || smtpPortVal == "" || smtpUsername == "" { - err := errors.New("系统 SMTP 邮件服务配置不完整") + err := errors.New(errSMTPConfigIncomplete) task.AppendLog(ctx, "发送失败: %v", err) return nil, err } @@ -117,7 +117,7 @@ func (h *SendEmailHandler) Execute(ctx context.Context, payload []byte) (*task.T err = mail.SendMailHTML(cfg, req.To, req.Subject, req.Body) if err != nil { task.AppendLog(ctx, "邮件发送失败: %v", err) - return nil, fmt.Errorf("发送邮件失败: %w", err) + return nil, fmt.Errorf(errSendMailFailed, err) } msg := fmt.Sprintf("邮件成功发送至: %s", req.To) diff --git a/internal/db/errs.go b/internal/db/errs.go new file mode 100644 index 00000000..b7c1f624 --- /dev/null +++ b/internal/db/errs.go @@ -0,0 +1,24 @@ +/* +Copyright 2026 Arctel.net + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package db + +const ( + errRedisHashSetFailed = "failed to set redis hash: %w" + errUnmarshalDataFailed = "failed to unmarshal data: %w" + errMarshalDataFailed = "failed to marshal data: %w" + errRedisKeySetFailed = "failed to set redis key: %w" +) diff --git a/internal/db/redis.go b/internal/db/redis.go index 99fe3e99..3987f55f 100644 --- a/internal/db/redis.go +++ b/internal/db/redis.go @@ -123,7 +123,7 @@ func HSetJSON[T any](ctx context.Context, hashKey, fieldKey string, data T) erro } if err := Redis.HSet(ctx, PrefixedKey(hashKey), fieldKey, jsonData).Err(); err != nil { - return fmt.Errorf("failed to set redis hash: %w", err) + return fmt.Errorf(errRedisHashSetFailed, err) } return nil @@ -141,7 +141,7 @@ func HGetJSON[T any](ctx context.Context, hashKey, fieldKey string, data *T) err } if err := json.Unmarshal([]byte(val), data); err != nil { - return fmt.Errorf("failed to unmarshal data: %w", err) + return fmt.Errorf(errUnmarshalDataFailed, err) } return nil @@ -158,7 +158,7 @@ func GetJSON[T any](ctx context.Context, key string, data *T) error { } if err := json.Unmarshal(val, data); err != nil { - return fmt.Errorf("failed to unmarshal data: %w", err) + return fmt.Errorf(errUnmarshalDataFailed, err) } return nil @@ -172,11 +172,11 @@ func GetJSON[T any](ctx context.Context, key string, data *T) error { func SetJSON[T any](ctx context.Context, key string, data T, expiration time.Duration) error { jsonData, err := json.Marshal(data) if err != nil { - return fmt.Errorf("failed to marshal data: %w", err) + return fmt.Errorf(errMarshalDataFailed, err) } if err := Redis.Set(ctx, PrefixedKey(key), jsonData, expiration).Err(); err != nil { - return fmt.Errorf("failed to set redis key: %w", err) + return fmt.Errorf(errRedisKeySetFailed, err) } return nil diff --git a/internal/logger/errs.go b/internal/logger/errs.go new file mode 100644 index 00000000..4b5dc948 --- /dev/null +++ b/internal/logger/errs.go @@ -0,0 +1,21 @@ +/* +Copyright 2026 Arctel.net + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package logger + +const ( + errCreateLogFileDirFailed = "[Logger] create log file dir err: %w" +) diff --git a/internal/logger/utils.go b/internal/logger/utils.go index e24e52cc..d2b1f91d 100644 --- a/internal/logger/utils.go +++ b/internal/logger/utils.go @@ -55,7 +55,7 @@ func initWriter() (zapcore.WriteSyncer, error) { logPath := logConfig.FilePath logDir := filepath.Dir(logPath) if err := os.MkdirAll(logDir, 0750); err != nil { - return nil, fmt.Errorf("[Logger] create log file dir err: %w", err) + return nil, fmt.Errorf(errCreateLogFileDirFailed, err) } // 配置日志轮转 diff --git a/internal/model/auth_source.go b/internal/model/auth_source.go index ae8c0d52..836d54d7 100644 --- a/internal/model/auth_source.go +++ b/internal/model/auth_source.go @@ -91,19 +91,19 @@ func (source *AuthSource) Normalize() { func (source *AuthSource) Validate() error { source.Normalize() if source.Name == "" { - return errors.New("认证源名称不能为空") + return errors.New(errAuthSourceNameRequired) } if !authSourceNamePattern.MatchString(source.Name) { - return errors.New("认证源名称只能包含字母、数字、短横线或下划线,且必须以字母或数字开头") + return errors.New(errAuthSourceNameInvalid) } if source.Type != AuthSourceTypeOIDC { - return errors.New("认证源类型仅支持 oidc") + return errors.New(errAuthSourceTypeUnsupported) } if source.OpenIDDiscoveryURL == "" { - return errors.New("OIDC 认证源必须配置 Discovery URL") + return errors.New(errAuthSourceDiscoveryURLRequired) } if source.IsActive && (source.ClientID == "" || source.ClientSecret == "") { - return errors.New("启用认证源前必须配置 Client ID 和 Client Secret") + return errors.New(errAuthSourceClientCredentialsRequired) } return nil } @@ -137,7 +137,7 @@ func GetActiveAuthSources() ([]AuthSource, error) { func GetAuthSourceByID(id uint64) (*AuthSource, error) { if id == 0 { - return nil, errors.New("认证源 ID 不能为空") + return nil, errors.New(errAuthSourceIDRequired) } var source AuthSource if err := db.DB(context.Background()).First(&source, "id = ?", id).Error; err != nil { @@ -150,7 +150,7 @@ func GetAuthSourceByID(id uint64) (*AuthSource, error) { func GetAuthSourceByName(name string) (*AuthSource, error) { name = strings.TrimSpace(name) if name == "" { - return nil, errors.New("认证源名称不能为空") + return nil, errors.New(errAuthSourceNameRequired) } var source AuthSource if err := db.DB(context.Background()).First(&source, "name = ?", name).Error; err != nil { @@ -169,7 +169,7 @@ func CreateAuthSource(source *AuthSource) error { func UpdateAuthSource(source *AuthSource, keepSecret bool) error { if source.ID == 0 { - return errors.New("认证源 ID 不能为空") + return errors.New(errAuthSourceIDRequired) } var current AuthSource if err := db.DB(context.Background()).First(¤t, "id = ?", source.ID).Error; err != nil { @@ -208,7 +208,7 @@ func ToggleAuthSource(id uint64, isActive bool) error { func DeleteAuthSource(id uint64) error { if id == 0 { - return errors.New("认证源 ID 不能为空") + return errors.New(errAuthSourceIDRequired) } return db.DB(context.Background()).Transaction(func(tx *gorm.DB) error { if err := tx.Where("auth_source_id = ?", id).Delete(&ExternalAccount{}).Error; err != nil { @@ -228,7 +228,7 @@ func FindExternalAccount(sourceID uint64, externalID string) (*ExternalAccount, func BindExternalAccount(account *ExternalAccount) error { if account.UserID == 0 || strings.TrimSpace(account.ExternalID) == "" { - return errors.New("外部账号绑定信息不完整") + return errors.New(errExternalAccountBindingIncomplete) } account.ExternalID = strings.TrimSpace(account.ExternalID) account.ExternalUsername = strings.TrimSpace(account.ExternalUsername) @@ -239,7 +239,7 @@ func BindExternalAccount(account *ExternalAccount) error { err := tx.Where("auth_source_id = ? AND external_id = ?", account.AuthSourceID, account.ExternalID).First(¤t).Error if err == nil { if current.UserID != account.UserID { - return errors.New("该外部账号已绑定到其他用户") + return errors.New(errExternalAccountAlreadyBoundToAnother) } return tx.Model(¤t).Updates(map[string]any{ "external_username": account.ExternalUsername, @@ -255,7 +255,7 @@ func BindExternalAccount(account *ExternalAccount) error { func ListExternalAccountsByUserID(userID uint64) ([]ExternalAccountView, error) { if userID == 0 { - return nil, errors.New("用户 ID 不能为空") + return nil, errors.New(errUserIDRequired) } var accounts []ExternalAccount if err := db.DB(context.Background()).Where("user_id = ?", userID).Order("id asc").Find(&accounts).Error; err != nil { @@ -296,7 +296,7 @@ func ListExternalAccountsByUserID(userID uint64) ([]ExternalAccountView, error) func DeleteExternalAccountForUser(id uint64, userID uint64) error { if id == 0 || userID == 0 { - return errors.New("绑定记录 ID 不能为空") + return errors.New(errExternalAccountBindingIDRequired) } return db.DB(context.Background()).Where("id = ? AND user_id = ?", id, userID).Delete(&ExternalAccount{}).Error } diff --git a/internal/model/errs.go b/internal/model/errs.go new file mode 100644 index 00000000..83aa71c6 --- /dev/null +++ b/internal/model/errs.go @@ -0,0 +1,43 @@ +/* +Copyright 2026 Arctel.net + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package model + +const ( + errRegistrationDisabled = "注册已关闭" + errDatabaseNotInitialized = "database not initialized" + errUsernameExists = "用户名已存在" + errEmailAlreadyBound = "该邮箱已被其他账号绑定" + errConfigIntParseFailed = "配置 %s 的值 '%s' 无法转换为整数: %w" + errConfigDecimalParseFailed = "配置 %s 的值 '%s' 无法转换为decimal: %w" + errConfigBoolParseFailed = "配置 %s 的值 '%s' 无法转换为布尔值: %w" + errParseMenuDisplayConfigFailed = "解析目录显示配置失败: %w" + errTemplateKeyRequired = "模板标识符不能为空" + errTemplateNameRequired = "模板名称不能为空" + errTemplateContentRequired = "模板内容不能为空" + errTemplateUnavailable = "模板 %s 不存在或不可用: %w" + errTemplateRenderFailed = "模板 %s 渲染失败: %w" + errAuthSourceNameRequired = "认证源名称不能为空" + errAuthSourceNameInvalid = "认证源名称只能包含字母、数字、短横线或下划线,且必须以字母或数字开头" + errAuthSourceTypeUnsupported = "认证源类型仅支持 oidc" + errAuthSourceDiscoveryURLRequired = "OIDC 认证源必须配置 Discovery URL" + errAuthSourceClientCredentialsRequired = "启用认证源前必须配置 Client ID 和 Client Secret" + errAuthSourceIDRequired = "认证源 ID 不能为空" + errExternalAccountBindingIncomplete = "外部账号绑定信息不完整" + errExternalAccountAlreadyBoundToAnother = "该外部账号已绑定到其他用户" + errUserIDRequired = "用户 ID 不能为空" + errExternalAccountBindingIDRequired = "绑定记录 ID 不能为空" +) diff --git a/internal/model/system_configs.go b/internal/model/system_configs.go index dc086e20..eebea932 100644 --- a/internal/model/system_configs.go +++ b/internal/model/system_configs.go @@ -85,7 +85,7 @@ func (sc *SystemConfig) GetByKey(ctx context.Context, key string) error { // 查数据库 database := db.DB(ctx) if database == nil { - return errors.New("database not initialized") + return errors.New(errDatabaseNotInitialized) } if err := database.Where("key = ?", key).First(sc).Error; err != nil { @@ -109,7 +109,7 @@ func GetIntByKey(ctx context.Context, key string) (int, error) { value, err := strconv.Atoi(sc.Value) if err != nil { - return 0, fmt.Errorf("配置 %s 的值 '%s' 无法转换为整数: %w", key, sc.Value, err) + return 0, fmt.Errorf(errConfigIntParseFailed, key, sc.Value, err) } return value, nil @@ -125,7 +125,7 @@ func GetDecimalByKey(ctx context.Context, key string, precision int32) (decimal. value, err := decimal.NewFromString(sc.Value) if err != nil { - return decimal.Zero, fmt.Errorf("配置 %s 的值 '%s' 无法转换为decimal: %w", key, sc.Value, err) + return decimal.Zero, fmt.Errorf(errConfigDecimalParseFailed, key, sc.Value, err) } // 裁剪到指定小数位数 @@ -141,7 +141,7 @@ func GetBoolByKey(ctx context.Context, key string) (bool, error) { value, err := strconv.ParseBool(sc.Value) if err != nil { - return false, fmt.Errorf("配置 %s 的值 '%s' 无法转换为布尔值: %w", key, sc.Value, err) + return false, fmt.Errorf(errConfigBoolParseFailed, key, sc.Value, err) } return value, nil @@ -160,7 +160,7 @@ func GetMenuDisplayConfig(ctx context.Context) (map[string]bool, error) { } if err := json.Unmarshal([]byte(sc.Value), &config); err != nil { - return nil, fmt.Errorf("解析目录显示配置失败: %w", err) + return nil, fmt.Errorf(errParseMenuDisplayConfigFailed, err) } return config, nil diff --git a/internal/model/templates.go b/internal/model/templates.go index 77fc0c1a..bd2e7b69 100644 --- a/internal/model/templates.go +++ b/internal/model/templates.go @@ -20,6 +20,7 @@ import ( "bytes" "context" "errors" + "fmt" "strings" "text/template" "time" @@ -55,13 +56,13 @@ func (t *Template) Normalize() { func (t *Template) Validate() error { t.Normalize() if t.Key == "" { - return errors.New("模板标识符不能为空") + return errors.New(errTemplateKeyRequired) } if t.Name == "" { - return errors.New("模板名称不能为空") + return errors.New(errTemplateNameRequired) } if t.Content == "" { - return errors.New("模板内容不能为空") + return errors.New(errTemplateContentRequired) } return nil } @@ -94,26 +95,16 @@ func (t *Template) Render(data any) (string, string, error) { return subject, bodyBuf.String(), nil } -// RenderTemplate 渲染模板的高级包装。如果读取或渲染失败,将使用 fallbackSubject 和 fallbackBody 进行解析和返回。 -func RenderTemplate(ctx context.Context, key string, data any, fallbackSubject, fallbackBody string) (string, string) { +// RenderTemplate 渲染指定模板。模板不存在或渲染失败时返回错误,由调用方决定如何处理。 +func RenderTemplate(ctx context.Context, key string, data any) (string, string, error) { var t Template - if err := db.DB(ctx).Where("key = ?", key).First(&t).Error; err == nil { - subject, body, err := t.Render(data) - if err == nil { - return subject, body - } + if err := db.DB(ctx).Where("key = ?", key).First(&t).Error; err != nil { + return "", "", fmt.Errorf(errTemplateUnavailable, key, err) } - // 降级使用传入的默认模板内容渲染 - tFallback := Template{ - Key: key + "_fallback", - Subject: fallbackSubject, - Content: fallbackBody, + subject, body, err := t.Render(data) + if err != nil { + return "", "", fmt.Errorf(errTemplateRenderFailed, key, err) } - subject, body, err := tFallback.Render(data) - if err == nil { - return subject, body - } - - return fallbackSubject, fallbackBody + return subject, body, nil } diff --git a/internal/model/users.go b/internal/model/users.go index 816ae18b..bb21836f 100644 --- a/internal/model/users.go +++ b/internal/model/users.go @@ -92,12 +92,15 @@ func (u *User) SetEncryptedPassword(password string) error { return nil } +func (u *User) IsPasswordEncrypted() bool { + return strings.HasPrefix(u.Password, "$2a$") || strings.HasPrefix(u.Password, "$2b$") || strings.HasPrefix(u.Password, "$2y$") +} + func (u *User) CheckPassword(password string) bool { if u.Password == "" || password == "" { return false } - isBcrypt := strings.HasPrefix(u.Password, "$2a$") || strings.HasPrefix(u.Password, "$2b$") || strings.HasPrefix(u.Password, "$2y$") - if isBcrypt { + if u.IsPasswordEncrypted() { return util.CheckPasswordHash(u.Password, password) } return u.Password == password @@ -132,7 +135,7 @@ func (u *User) CheckActive() error { func (u *User) CreateUser(ctx context.Context, tx *gorm.DB, oauthInfo *OAuthUserInfo) error { enabled, err := GetBoolByKey(ctx, ConfigKeyRegistrationEnabled) if err == nil && !enabled { - return errors.New("注册已关闭") + return errors.New(errRegistrationDisabled) } now := time.Now() @@ -158,7 +161,7 @@ func (u *User) CreateUser(ctx context.Context, tx *gorm.DB, oauthInfo *OAuthUser func (u *User) RegisterUser(ctx context.Context, tx *gorm.DB) error { enabled, err := GetBoolByKey(ctx, ConfigKeyRegistrationEnabled) if err == nil && !enabled { - return errors.New("注册已关闭") + return errors.New(errRegistrationDisabled) } // 检查用户名冲突 @@ -167,7 +170,7 @@ func (u *User) RegisterUser(ctx context.Context, tx *gorm.DB) error { return err } if count > 0 { - return errors.New("用户名已存在") + return errors.New(errUsernameExists) } // 检查邮箱冲突 @@ -177,7 +180,7 @@ func (u *User) RegisterUser(ctx context.Context, tx *gorm.DB) error { return err } if emailCount > 0 { - return errors.New("该邮箱已被其他账号绑定") + return errors.New(errEmailAlreadyBound) } } diff --git a/internal/router/frontend_embedded.go b/internal/router/frontend_embedded.go index 7e94ab56..657dbf60 100644 --- a/internal/router/frontend_embedded.go +++ b/internal/router/frontend_embedded.go @@ -1,8 +1,7 @@ //go:build embed_frontend /* -Copyright 2025 linux.do -Modified by Arctel.net, 2026 +Copyright 2026 Arctel.net Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/internal/router/router.go b/internal/router/router.go index 05a8bc70..5797d732 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -215,7 +215,6 @@ func Serve() { { systemConfigRouter.GET("", system_config.GetSystemConfig) systemConfigRouter.PUT("", system_config.UpdateSystemConfig) - systemConfigRouter.DELETE("", system_config.DeleteSystemConfig) } // Templates diff --git a/internal/storage/errs.go b/internal/storage/errs.go index dfb5061e..e91be4bd 100644 --- a/internal/storage/errs.go +++ b/internal/storage/errs.go @@ -1,6 +1,5 @@ /* -Copyright 2025 linux.do -Modified by Arctel.net, 2026 +Copyright 2026 Arctel.net Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -20,11 +19,21 @@ package storage type ErrS3InitializationFailed struct{} func (e ErrS3InitializationFailed) Error() string { - return "S3存储初始化失败" + return errS3InitializationFailed } type LocalCacheError struct{} func (e LocalCacheError) Error() string { - return "本地缓存错误" + return errLocalCache } + +const ( + errS3InitializationFailed = "S3存储初始化失败" + errLocalCache = "本地缓存错误" + errS3PutObjectFailed = "s3 put object failed: %w" + errS3GetObjectFailed = "s3 get object failed: %w" + errCDNRequestFailed = "cdn request failed: %w" + errCDNStatusFailed = "cdn returned status %d" + errS3DeleteObjectFailed = "s3 delete object failed: %w" +) diff --git a/internal/storage/s3.go b/internal/storage/s3.go index a0d4f930..f41292ee 100644 --- a/internal/storage/s3.go +++ b/internal/storage/s3.go @@ -146,7 +146,7 @@ func putObjectDefault(ctx context.Context, key string, body io.Reader, size int6 _, err := client.PutObject(ctx, input) if err != nil { span.SetStatus(codes.Error, fmt.Sprintf("S3 put object failed: %v", err)) - return fmt.Errorf("s3 put object failed: %w", err) + return fmt.Errorf(errS3PutObjectFailed, err) } return nil } @@ -181,7 +181,7 @@ func getObjectDefault(ctx context.Context, key string) (*ObjectInfo, error) { }) if err != nil { span.SetStatus(codes.Error, fmt.Sprintf("S3 get object failed: %v", err)) - return nil, fmt.Errorf("s3 get object failed: %w", err) + return nil, fmt.Errorf(errS3GetObjectFailed, err) } contentType := "application/octet-stream" @@ -223,13 +223,13 @@ func GetObjectViaProxy(ctx context.Context, key string) (*ObjectInfo, error) { resp, err := util.Request(ctx, http.MethodGet, url, nil, nil, nil) if err != nil { span.SetStatus(codes.Error, fmt.Sprintf("cdn request failed: %v", err)) - return nil, fmt.Errorf("cdn request failed: %w", err) + return nil, fmt.Errorf(errCDNRequestFailed, err) } if resp.StatusCode != http.StatusOK { resp.Body.Close() span.SetStatus(codes.Error, fmt.Sprintf("cdn returned status %d", resp.StatusCode)) - return nil, fmt.Errorf("cdn returned status %d", resp.StatusCode) + return nil, fmt.Errorf(errCDNStatusFailed, resp.StatusCode) } contentType := resp.Header.Get("Content-Type") @@ -265,7 +265,7 @@ func deleteObjectDefault(ctx context.Context, key string) error { }) if err != nil { span.SetStatus(codes.Error, fmt.Sprintf("S3 delete object failed: %v", err)) - return fmt.Errorf("s3 delete object failed: %w", err) + return fmt.Errorf(errS3DeleteObjectFailed, err) } return nil } diff --git a/internal/task/errs.go b/internal/task/errs.go new file mode 100644 index 00000000..153cd998 --- /dev/null +++ b/internal/task/errs.go @@ -0,0 +1,30 @@ +/* +Copyright 2026 Arctel.net + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package task + +const ( + errUnknownTaskType = "未知的任务类型: %s" + errCreateTaskExecutionFailed = "创建任务执行记录失败: %w" + errTaskEnqueueFailed = "任务入队失败: %w" + errTaskExecutionNotFound = "任务执行记录不存在: %w" + errRetryOnlyFailedTask = "只有失败的任务才能重试,当前状态: %s" + errTaskNotRetryable = "该任务不支持重试" + errTaskMaxRetryExceeded = "已达到最大重试次数 %d" + errCreateRetryExecutionFailed = "创建重试任务执行记录失败: %w" + errRetryTaskEnqueueFailed = "重试任务入队失败: %w" + errUnregisteredTaskHandler = "未注册的任务处理器: %s" +) diff --git a/internal/task/executor.go b/internal/task/executor.go index 585dfd0c..aee277ec 100644 --- a/internal/task/executor.go +++ b/internal/task/executor.go @@ -99,7 +99,7 @@ func AppendLog(ctx context.Context, format string, args ...interface{}) { func DispatchTask(ctx context.Context, taskType string, payload []byte, triggeredBy string) (string, error) { meta := GetTaskMeta(taskType) if meta == nil { - return "", fmt.Errorf("未知的任务类型: %s", taskType) + return "", fmt.Errorf(errUnknownTaskType, taskType) } // 生成唯一的 TaskID @@ -119,7 +119,7 @@ func DispatchTask(ctx context.Context, taskType string, payload []byte, triggere } if err := model.CreateTaskExecution(ctx, execution); err != nil { - return "", fmt.Errorf("创建任务执行记录失败: %w", err) + return "", fmt.Errorf(errCreateTaskExecutionFailed, err) } // 入队 Asynq @@ -137,7 +137,7 @@ func DispatchTask(ctx context.Context, taskType string, payload []byte, triggere execution.StartedAt = &now execution.FinishedAt = &now _ = model.UpdateTaskExecution(ctx, execution) - return "", fmt.Errorf("任务入队失败: %w", err) + return "", fmt.Errorf(errTaskEnqueueFailed, err) } return taskID, nil @@ -147,19 +147,19 @@ func DispatchTask(ctx context.Context, taskType string, payload []byte, triggere func RetryTask(ctx context.Context, id uint64) (string, error) { execution, err := model.GetTaskExecutionByID(ctx, id) if err != nil { - return "", fmt.Errorf("任务执行记录不存在: %w", err) + return "", fmt.Errorf(errTaskExecutionNotFound, err) } if execution.Status != model.TaskExecutionStatusFailed { - return "", fmt.Errorf("只有失败的任务才能重试,当前状态: %s", execution.Status) + return "", fmt.Errorf(errRetryOnlyFailedTask, execution.Status) } if !execution.Retryable { - return "", fmt.Errorf("该任务不支持重试") + return "", fmt.Errorf(errTaskNotRetryable) } if execution.RetryCount >= execution.MaxRetry { - return "", fmt.Errorf("已达到最大重试次数 %d", execution.MaxRetry) + return "", fmt.Errorf(errTaskMaxRetryExceeded, execution.MaxRetry) } // 生成新的 TaskID @@ -179,7 +179,7 @@ func RetryTask(ctx context.Context, id uint64) (string, error) { } if err := model.CreateTaskExecution(ctx, newExecution); err != nil { - return "", fmt.Errorf("创建重试任务执行记录失败: %w", err) + return "", fmt.Errorf(errCreateRetryExecutionFailed, err) } // 入队 Asynq @@ -196,7 +196,7 @@ func RetryTask(ctx context.Context, id uint64) (string, error) { newExecution.StartedAt = &now newExecution.FinishedAt = &now _ = model.UpdateTaskExecution(ctx, newExecution) - return "", fmt.Errorf("重试任务入队失败: %w", err) + return "", fmt.Errorf(errRetryTaskEnqueueFailed, err) } return newTaskID, nil @@ -224,7 +224,7 @@ func ProcessTask(ctx context.Context, t *asynq.Task) error { // 查找处理器 handler, ok := getHandler(t.Type()) if !ok { - err := fmt.Errorf("未注册的任务处理器: %s", t.Type()) + err := fmt.Errorf(errUnregisteredTaskHandler, t.Type()) logger.ErrorF(ctx, "[TaskExecutor] %v", err) span.SetStatus(codes.Error, err.Error()) return err diff --git a/internal/task/scheduler/errs.go b/internal/task/scheduler/errs.go new file mode 100644 index 00000000..27cbc96c --- /dev/null +++ b/internal/task/scheduler/errs.go @@ -0,0 +1,21 @@ +/* +Copyright 2026 Arctel.net + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package scheduler + +const ( + errLoadLocationFailed = "failed to load location: %w" +) diff --git a/internal/task/scheduler/scheduler.go b/internal/task/scheduler/scheduler.go index fbc0e12a..0a5669ba 100644 --- a/internal/task/scheduler/scheduler.go +++ b/internal/task/scheduler/scheduler.go @@ -48,7 +48,7 @@ func StartScheduler() error { schedulerOnce.Do(func() { location, locErr := time.LoadLocation("Asia/Shanghai") if locErr != nil { - err = fmt.Errorf("failed to load location: %w", locErr) + err = fmt.Errorf(errLoadLocationFailed, locErr) return } scheduler = asynq.NewScheduler( diff --git a/internal/util/cap/cap.go b/internal/util/cap/cap.go index 76790e2a..acd5be50 100644 --- a/internal/util/cap/cap.go +++ b/internal/util/cap/cap.go @@ -105,10 +105,10 @@ func jwtSign(payload []byte, secret []byte) string { func jwtVerify(token string, secret []byte) ([]byte, error) { parts := strings.Split(token, ".") if len(parts) != 3 { - return nil, errors.New("invalid token format") + return nil, errors.New(errInvalidTokenFormat) } if parts[0] != jwtHeaderB64 { - return nil, errors.New("invalid header") + return nil, errors.New(errInvalidHeader) } sigInput := parts[0] + "." + parts[1] @@ -122,7 +122,7 @@ func jwtVerify(token string, secret []byte) ([]byte, error) { } if !hmac.Equal(expectedSig, actualSig) { - return nil, errors.New("signature mismatch") + return nil, errors.New(errSignatureMismatch) } payload, err := b64urlDecode(parts[1]) @@ -195,25 +195,25 @@ func GenerateChallenge(secret []byte, conf ChallengeConfig, scope string) (*Chal func VerifyChallengeSolutions(token string, solutions []int, secret []byte, expectedScope string) (*ChallengePayload, error) { payloadBytes, err := jwtVerify(token, secret) if err != nil { - return nil, errors.New("invalid_token") + return nil, errors.New(errInvalidToken) } var payload ChallengePayload if err := json.Unmarshal(payloadBytes, &payload); err != nil { - return nil, errors.New("invalid_token") + return nil, errors.New(errInvalidToken) } if expectedScope != "" && payload.Scope != expectedScope { - return nil, errors.New("scope_mismatch") + return nil, errors.New(errScopeMismatch) } now := time.Now().UnixNano() / int64(time.Millisecond) if payload.Expires < now { - return nil, errors.New("expired") + return nil, errors.New(errExpired) } if len(solutions) != payload.Count { - return nil, errors.New("invalid_solutions") + return nil, errors.New(errInvalidSolutions) } tokenFnv := fnv1a(token) @@ -229,7 +229,7 @@ func VerifyChallengeSolutions(token string, solutions []int, secret []byte, expe hashHex := hex.EncodeToString(hashBytes[:]) if !strings.HasPrefix(hashHex, target) { - return nil, errors.New("invalid_solution") + return nil, errors.New(errInvalidSolution) } } diff --git a/internal/util/cap/errs.go b/internal/util/cap/errs.go new file mode 100644 index 00000000..7507c6af --- /dev/null +++ b/internal/util/cap/errs.go @@ -0,0 +1,28 @@ +/* +Copyright 2026 Arctel.net + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cap + +const ( + errInvalidTokenFormat = "invalid token format" + errInvalidHeader = "invalid header" + errSignatureMismatch = "signature mismatch" + errInvalidToken = "invalid_token" + errScopeMismatch = "scope_mismatch" + errExpired = "expired" + errInvalidSolutions = "invalid_solutions" + errInvalidSolution = "invalid_solution" +) diff --git a/internal/util/crypto.go b/internal/util/crypto.go index 16fd66a4..12894d13 100644 --- a/internal/util/crypto.go +++ b/internal/util/crypto.go @@ -54,28 +54,28 @@ func encryptBytes(signKey string, plaintext []byte) (string, error) { // 将 hex 编码的密钥转换为字节 key, err := hex.DecodeString(signKey) if err != nil { - return "", fmt.Errorf("invalid sign key: %w", err) + return "", fmt.Errorf(errInvalidSignKey, err) } if len(key) != 32 { - return "", errors.New("sign key must be 32 bytes (64 hex characters)") + return "", errors.New(errSignKeyLengthInvalid) } // 创建 AES cipher block, err := aes.NewCipher(key) if err != nil { - return "", fmt.Errorf("failed to create cipher: %w", err) + return "", fmt.Errorf(errCreateCipherFailed, err) } // 使用 GCM 模式(Galois/Counter Mode) gcm, err := cipher.NewGCM(block) if err != nil { - return "", fmt.Errorf("failed to create GCM: %w", err) + return "", fmt.Errorf(errCreateGCMFailed, err) } // 生成随机 nonce nonce := make([]byte, gcm.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - return "", fmt.Errorf("failed to generate nonce: %w", err) + return "", fmt.Errorf(errGenerateNonceFailed, err) } // 加密数据 @@ -90,34 +90,34 @@ func decryptBytes(signKey string, ciphertext string) ([]byte, error) { // 将 hex 编码的密钥转换为字节 key, err := hex.DecodeString(signKey) if err != nil { - return nil, fmt.Errorf("invalid sign key: %w", err) + return nil, fmt.Errorf(errInvalidSignKey, err) } if len(key) != 32 { - return nil, errors.New("sign key must be 32 bytes (64 hex characters)") + return nil, errors.New(errSignKeyLengthInvalid) } // 解码 base64 密文 data, err := Base64Decode(ciphertext) if err != nil { - return nil, fmt.Errorf("failed to decode ciphertext: %w", err) + return nil, fmt.Errorf(errDecodeCiphertextFailed, err) } // 创建 AES cipher block, err := aes.NewCipher(key) if err != nil { - return nil, fmt.Errorf("failed to create cipher: %w", err) + return nil, fmt.Errorf(errCreateCipherFailed, err) } // 使用 GCM 模式 gcm, err := cipher.NewGCM(block) if err != nil { - return nil, fmt.Errorf("failed to create GCM: %w", err) + return nil, fmt.Errorf(errCreateGCMFailed, err) } // 提取 nonce nonceSize := gcm.NonceSize() if len(data) < nonceSize { - return nil, errors.New("ciphertext too short") + return nil, errors.New(errCiphertextTooShort) } nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:] @@ -125,7 +125,7 @@ func decryptBytes(signKey string, ciphertext string) ([]byte, error) { // 解密数据 plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil) if err != nil { - return nil, fmt.Errorf("failed to decrypt: %w", err) + return nil, fmt.Errorf(errDecryptFailed, err) } return plaintext, nil diff --git a/internal/util/custom_types.go b/internal/util/custom_types.go index 145c9547..b817b437 100644 --- a/internal/util/custom_types.go +++ b/internal/util/custom_types.go @@ -29,7 +29,7 @@ type StringArray []string func (sa *StringArray) Scan(value interface{}) error { bytesValue, ok := value.([]byte) if !ok { - return fmt.Errorf("invalid value: %v", value) + return fmt.Errorf(errInvalidCustomValue, value) } return json.Unmarshal(bytesValue, sa) } diff --git a/internal/util/errs.go b/internal/util/errs.go new file mode 100644 index 00000000..2793e7d3 --- /dev/null +++ b/internal/util/errs.go @@ -0,0 +1,31 @@ +/* +Copyright 2026 Arctel.net + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +const ( + errCreateHTTPRequestFailed = "创建HTTP请求失败: %w" + errHTTPRequestFailed = "请求%s接口失败: %w" + errInvalidCustomValue = "invalid value: %v" + errInvalidSignKey = "invalid sign key: %w" + errSignKeyLengthInvalid = "sign key must be 32 bytes (64 hex characters)" + errCreateCipherFailed = "failed to create cipher: %w" + errCreateGCMFailed = "failed to create GCM: %w" + errGenerateNonceFailed = "failed to generate nonce: %w" + errDecodeCiphertextFailed = "failed to decode ciphertext: %w" + errCiphertextTooShort = "ciphertext too short" + errDecryptFailed = "failed to decrypt: %w" +) diff --git a/internal/util/http_clients.go b/internal/util/http_clients.go index 207a5acb..b18db98d 100644 --- a/internal/util/http_clients.go +++ b/internal/util/http_clients.go @@ -55,7 +55,7 @@ func SetHTTPClient(c *http.Client) { func Request(ctx context.Context, method, url string, body io.Reader, headers, cookies map[string]string) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, method, url, body) if err != nil { - return nil, fmt.Errorf("创建HTTP请求失败: %w", err) + return nil, fmt.Errorf(errCreateHTTPRequestFailed, err) } if cookies != nil { @@ -72,7 +72,7 @@ func Request(ctx context.Context, method, url string, body io.Reader, headers, c resp, err := httpClient.Do(req) if err != nil { - return nil, fmt.Errorf("请求%s接口失败: %w", url, err) + return nil, fmt.Errorf(errHTTPRequestFailed, url, err) } return resp, nil diff --git a/internal/util/mail/errs.go b/internal/util/mail/errs.go new file mode 100644 index 00000000..75c3690d --- /dev/null +++ b/internal/util/mail/errs.go @@ -0,0 +1,28 @@ +/* +Copyright 2026 Arctel.net + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package mail + +const ( + errDialTLSFailed = "dial tls failed: %w" + errSMTPClientCreationFailed = "smtp client creation failed: %w" + errSMTPAuthFailed = "smtp auth failed: %w" + errSMTPMailCommandFailed = "smtp mail command failed: %w" + errSMTPRcptCommandFailed = "smtp rcpt command failed: %w" + errSMTPDataCommandFailed = "smtp data command failed: %w" + errSMTPWritingBodyFailed = "smtp writing body failed: %w" + errSendMailFailed = "send mail failed: %w" +) diff --git a/internal/util/mail/mail.go b/internal/util/mail/mail.go index 43179d7c..02a0c0d3 100644 --- a/internal/util/mail/mail.go +++ b/internal/util/mail/mail.go @@ -69,38 +69,38 @@ func SendMailHTML(cfg Config, to string, subject, body string) error { dialer := &net.Dialer{Timeout: 5 * time.Second} conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig) if err != nil { - return fmt.Errorf("dial tls failed: %w", err) + return fmt.Errorf(errDialTLSFailed, err) } defer conn.Close() _ = conn.SetDeadline(time.Now().Add(10 * time.Second)) client, err := smtp.NewClient(conn, cfg.Host) if err != nil { - return fmt.Errorf("smtp client creation failed: %w", err) + return fmt.Errorf(errSMTPClientCreationFailed, err) } defer client.Close() if err = client.Auth(auth); err != nil { - return fmt.Errorf("smtp auth failed: %w", err) + return fmt.Errorf(errSMTPAuthFailed, err) } if err = client.Mail(cfg.Username); err != nil { - return fmt.Errorf("smtp mail command failed: %w", err) + return fmt.Errorf(errSMTPMailCommandFailed, err) } if err = client.Rcpt(to); err != nil { - return fmt.Errorf("smtp rcpt command failed: %w", err) + return fmt.Errorf(errSMTPRcptCommandFailed, err) } w, err := client.Data() if err != nil { - return fmt.Errorf("smtp data command failed: %w", err) + return fmt.Errorf(errSMTPDataCommandFailed, err) } defer w.Close() _, err = w.Write([]byte(message)) if err != nil { - return fmt.Errorf("smtp writing body failed: %w", err) + return fmt.Errorf(errSMTPWritingBodyFailed, err) } return nil @@ -109,7 +109,7 @@ func SendMailHTML(cfg Config, to string, subject, body string) error { // For standard port (587 / 25), use smtp.SendMail directly (handles STARTTLS automatically if server supports it) err := smtp.SendMail(addr, auth, cfg.Username, []string{to}, []byte(message)) if err != nil { - return fmt.Errorf("send mail failed: %w", err) + return fmt.Errorf(errSendMailFailed, err) } return nil