✨ 忘记密码 & 重发邮件

This commit is contained in:
sugarBecomer
2025-11-11 14:08:07 +08:00
parent 20b92b7d1c
commit 3993fa9b32
10 changed files with 361 additions and 22 deletions
+3 -3
View File
@@ -10,8 +10,8 @@ port = 8090
url = "https://devocr.likedream.xyz"
openapiPath= "/api.json"
swaggerPath= "/swagger"
emailConfirmBaseUrl = "https://dev.likedream.xyz/confirm/"
emailConfirmBaseUrl = "http://localhost:3200/confirm/"
emailResetPasswordUrl = "http://localhost:3200/reset-password/"
[env]
# 可选值: dev, prod
mode = "dev"
@@ -30,7 +30,7 @@ smtpUsername = "demo@likedream.xyz"
smtpPassword = "baca122..."
# 发件人邮箱和显示名称
fromEmail = "demo@likedream.xyz"
fromName = "今日校园系统"
fromName = "若梦系统"
[s3]
+26
View File
@@ -33,3 +33,29 @@ type UserConfirmReq struct {
g.Meta `path:"/user/confirm" method:"POST" tags:"UserLogin" summary:"邮箱二次验证"`
Token string `json:"token" v:"required"`
}
type ResendVerificationReq struct {
g.Meta `path:"/user/resendVerification" method:"POST" tags:"UserLogin" summary:"重新发送验证邮件"`
Email string `json:"email" v:"required|email|regex:^.*@qq\\.com$#邮箱不能为空|邮箱格式不正确|必须是QQ邮箱"`
CaptchaKey string `json:"captchaKey" v:"required#验证码不能不能为空"`
Dots []captcha.ConfirmData `json:"dots" v:"required#验证码不能为空"`
}
type ForgotPasswordReq struct {
g.Meta `path:"/user/forgotPassword" method:"POST" tags:"UserLogin" summary:"忘记密码-发送重置密码邮件"`
Email string `json:"email" v:"required|email|regex:^.*@qq\\.com$#邮箱不能为空|邮箱格式不正确|必须是QQ邮箱"`
CaptchaKey string `json:"captchaKey" v:"required#验证码不能不能为空"`
Dots []captcha.ConfirmData `json:"dots" v:"required#验证码不能为空"`
}
type ForgetResetPasswordReq struct {
g.Meta `path:"/user/forgetResetPassword" method:"POST" tags:"UserLogin" summary:"忘记密码-重置密码"`
Token string `json:"token" v:"required#token不能为空"`
NewPassword string `json:"newPassword" v:"required|length:6,20#密码不能为空|密码长度应在6-20之间"`
ConfirmPassword string `json:"confirmPassword" v:"required|same:newPassword#确认密码不能为空|两次输入的密码不一致"`
}
type ResetPasswordInfoReq struct {
g.Meta `path:"/user/resetPassword/info" method:"POST" tags:"UserLogin" summary:"获取重置密码用户信息"`
Token string `json:"token" v:"required#token不能为空"`
}
+19 -18
View File
@@ -3,22 +3,23 @@ package cst
import "time"
const (
ConfigOcrServerUrl = "ocr.url"
ConfigEnvMode = "env.mode"
ConfigDatabaseType = "database.type"
ConfigDatabaseDsn = "database.dsn"
ConfigServerPort = "server.port"
ConfigEmailConfirmBaseUrl = "server.emailConfirmBaseUrl"
ConfigAuthorizationKey = "server.authorization"
ConfigJwtKey = "server.jwt"
ConfigRuoliCaptchaBaseUrl = "ruoliCaptcha.baseUrl"
ConfigS3AccessKey = "s3.accessKey"
ConfigS3SecretKey = "s3.secretKey"
ConfigS3BaseUrl = "s3.baseUrl"
ConfigS3Bucket = "s3.bucket"
ConfigCpdailyAppVersion = "cpdaily.appVersion"
ConfigCpdailySystemVersion = "cpdaily.systemVersion"
ConfigCpdailyCalVersion = "cpdaily.calVersion"
ConfigOcrServerUrl = "ocr.url"
ConfigEnvMode = "env.mode"
ConfigDatabaseType = "database.type"
ConfigDatabaseDsn = "database.dsn"
ConfigServerPort = "server.port"
ConfigEmailConfirmBaseUrl = "server.emailConfirmBaseUrl"
ConfigEmailResetPasswordUrl = "server.emailResetPasswordUrl"
ConfigAuthorizationKey = "server.authorization"
ConfigJwtKey = "server.jwt"
ConfigRuoliCaptchaBaseUrl = "ruoliCaptcha.baseUrl"
ConfigS3AccessKey = "s3.accessKey"
ConfigS3SecretKey = "s3.secretKey"
ConfigS3BaseUrl = "s3.baseUrl"
ConfigS3Bucket = "s3.bucket"
ConfigCpdailyAppVersion = "cpdaily.appVersion"
ConfigCpdailySystemVersion = "cpdaily.systemVersion"
ConfigCpdailyCalVersion = "cpdaily.calVersion"
ConfigRedisUrl = "redis.url"
@@ -35,8 +36,8 @@ const (
const (
// 应用程序常量
AppName = "今日校园系统"
AppCopyright = "© 2024 今日校园系统. 保留所有权利."
AppName = "若梦系统"
AppCopyright = "© 2025 若梦系统. 保留所有权利."
)
var (
+2
View File
@@ -3,6 +3,7 @@ package cst
const (
RedisUserInfo = "userinfo:"
RedisVerifyCode = "verifycode:"
RedisResetPassword = "resetpassword:"
RedisCpUserLogin = "cpuserlogin:"
RedisCpUserAuthorize = "cpuser:authorize:"
)
@@ -10,6 +11,7 @@ const (
var (
RedisUserInfoExpire = int64(86400)
RedisVerifyCodeExpire = int64(86400)
RedisResetPasswordExpire = int64(3600) // 重置密码链接1小时有效
RedisCpUserLoginExpire = int64(180)
RedisCpUserAuthorizeExpire = int64(1800)
)
@@ -13,4 +13,8 @@ type IUserLoginV1 interface {
Login(ctx context.Context, req *v1.UserLoginReq) (*utils.Response[any], error)
Register(ctx context.Context, req *v1.UserRegisterReq) (*utils.Response[any], error)
Confirm(ctx context.Context, req *v1.UserConfirmReq) (*utils.Response[any], error)
ResendVerification(ctx context.Context, req *v1.ResendVerificationReq) (*utils.Response[any], error)
ForgotPassword(ctx context.Context, req *v1.ForgotPasswordReq) (*utils.Response[any], error)
ResetPassword(ctx context.Context, req *v1.ForgetResetPasswordReq) (*utils.Response[any], error)
GetResetPasswordInfo(ctx context.Context, req *v1.ResetPasswordInfoReq) (*utils.Response[any], error)
}
@@ -66,3 +66,43 @@ func (c *UserLoginV1) Register(ctx context.Context, req *v1.UserRegisterReq) (re
func (c *UserLoginV1) Confirm(ctx context.Context, req *v1.UserConfirmReq) (*utils.Response[any], error) {
return service.Global.UserLogin.Confirm(ctx, req), nil
}
func (c *UserLoginV1) ResendVerification(ctx context.Context, req *v1.ResendVerificationReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
client := redis.GetClient()
dotsStr := client.GetDel(ctx, req.CaptchaKey).Val()
if dotsStr == "" {
return utils.Fail(10003, "验证码错误"), nil
}
var dots captcha.ConfirmData
err = json.Unmarshal([]byte(dotsStr), &dots)
utils.OkOrPanic(err)
if !captcha.VerifyCaptcha(dots, req.Dots) {
return utils.Fail(10003, "验证码错误"), nil
}
return service.Global.UserLogin.ResendVerification(ctx, req), nil
}
func (c *UserLoginV1) ForgotPassword(ctx context.Context, req *v1.ForgotPasswordReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
client := redis.GetClient()
dotsStr := client.GetDel(ctx, req.CaptchaKey).Val()
if dotsStr == "" {
return utils.Fail(10003, "验证码错误"), nil
}
var dots captcha.ConfirmData
err = json.Unmarshal([]byte(dotsStr), &dots)
utils.OkOrPanic(err)
if !captcha.VerifyCaptcha(dots, req.Dots) {
return utils.Fail(10003, "验证码错误"), nil
}
return service.Global.UserLogin.ForgotPassword(ctx, req), nil
}
func (c *UserLoginV1) ResetPassword(ctx context.Context, req *v1.ForgetResetPasswordReq) (*utils.Response[any], error) {
return service.Global.UserLogin.ResetPassword(ctx, req), nil
}
func (c *UserLoginV1) GetResetPasswordInfo(ctx context.Context, req *v1.ResetPasswordInfoReq) (*utils.Response[any], error) {
return service.Global.UserLogin.GetResetPasswordInfo(ctx, req), nil
}
+46
View File
@@ -101,6 +101,29 @@ func (s *EmailServiceV1) SendTaskNotificationEmail(ctx context.Context, toEmail,
return utils.SuccessWithMsg("通知邮件发送成功")
}
// SendResetPasswordEmail 发送重置密码邮件
func (s *EmailServiceV1) SendResetPasswordEmail(ctx context.Context, toEmail, username string, token string) *utils.Response[any] {
if s.config.SMTPUsername == "" || s.config.SMTPPassword == "" {
logger.Logger.Error("邮件服务未配置")
return utils.FailBusiness("邮件服务未配置")
}
subject := fmt.Sprintf("重置%s账户密码", cst2.AppName)
body, err := s.renderResetPasswordEmailTemplate(username, token)
if err != nil {
logger.Logger.Error("邮件模板渲染失败")
return utils.FailBusiness(fmt.Sprintf("邮件模板渲染失败: %v", err))
}
err = s.sendEmail(ctx, toEmail, subject, body)
if err != nil {
logger.Logger.Error("邮件发送失败")
return utils.FailBusiness(fmt.Sprintf("邮件发送失败: %v", err))
}
return utils.SuccessWithMsg("重置密码邮件发送成功")
}
// sendEmail 发送邮件的底层方法
func (s *EmailServiceV1) sendEmail(ctx context.Context, to, subject, body string) error {
@@ -190,3 +213,26 @@ func (s *EmailServiceV1) renderTaskNotificationTemplate(taskName string, status
return buf.String(), nil
}
// renderResetPasswordEmailTemplate 渲染重置密码邮件模板
func (s *EmailServiceV1) renderResetPasswordEmailTemplate(username string, token string) (string, error) {
// 单独解析重置密码模板
tmpl, err := template.ParseFS(templateFiles, "templates/layout.gohtml", "templates/reset_password.gohtml")
if err != nil {
return "", fmt.Errorf("解析重置密码邮件模板失败: %v", err)
}
get, err := g.Cfg().Get(gctx.New(), cst2.ConfigEmailResetPasswordUrl)
if err != nil {
return "", err
}
data := NewResetPasswordTemplateData(username, cst2.AppName, cst2.AppCopyright, get.String()+token)
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, "reset_password.gohtml", data)
if err != nil {
return "", fmt.Errorf("渲染重置密码邮件模板失败: %v", err)
}
return buf.String(), nil
}
+24
View File
@@ -64,12 +64,36 @@ func NewTaskNotificationTemplateData(taskName string, status email.TaskStatus, d
}
}
// ResetPasswordTemplateData 重置密码邮件模板数据
type ResetPasswordTemplateData struct {
BaseTemplateData
Username string `json:"username"` // 用户名
ResetURL string `json:"reset_url"` // 重置密码链接
}
// NewResetPasswordTemplateData 创建重置密码邮件模板数据
func NewResetPasswordTemplateData(username, appName, copyright string, resetURL string) *ResetPasswordTemplateData {
return &ResetPasswordTemplateData{
BaseTemplateData: BaseTemplateData{
Title: "重置密码 - " + appName,
HeaderIcon: "🔒",
HeaderTitle: "重置您的密码",
HeaderColor: "#f56565",
AppName: appName,
AppCopyright: copyright,
},
Username: username,
ResetURL: resetURL,
}
}
// TemplateType 模板类型枚举
type TemplateType string
const (
TemplateTypeSignup TemplateType = "signup" // 注册模板
TemplateTypeTaskNotification TemplateType = "task_notification" // 任务通知模板
TemplateTypeResetPassword TemplateType = "reset_password" // 重置密码模板
)
// GetTemplatePath 获取模板文件路径
@@ -0,0 +1,59 @@
{{- /*gotype: likedream-goframe/internal/service/email.ResetPasswordTemplateData*/ -}}
{{template "layout" .}}
{{define "extra-styles"}}
<style>
.warning-box {
background: linear-gradient(90deg, #fff5f5 0%, #ffe5e5 100%);
border-radius: 8px;
padding: 20px 24px;
border-left: 4px solid #f56565;
margin: 24px 0;
}
.warning-box p {
margin: 8px 0;
font-size: 14px;
color: #c53030;
}
.info-box {
background: linear-gradient(90deg, #f0f9ff 0%, #e0f2fe 100%);
border-radius: 8px;
padding: 20px 24px;
border-left: 4px solid #3b82f6;
margin: 24px 0;
}
</style>
{{end}}
{{define "content"}}
<h2>亲爱的 <span class="highlight-text">{{.Username}}</span>,您好!</h2>
<p>我们收到了您重置<strong>{{.AppName}}</strong>账户密码的请求。请点击下面的链接重置您的密码:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{.ResetURL}}"
style="background-color: #f56565; color: white; padding: 12px 24px;
text-decoration: none; border-radius: 4px; display: inline-block;">
重置密码
</a>
</div>
<p>如果按钮无法点击,请复制以下链接到浏览器地址栏:</p>
<p style="word-break: break-all; color: #666;">{{.ResetURL}}</p>
<div class="info-box">
<p style="margin: 0; color: #1e40af;"><strong>⏰ 重要提示:链接有效期为 1 小时</strong></p>
</div>
<div class="warning-box">
<p style="margin: 0;"><strong>⚠️ 安全提醒</strong></p>
<p>如果您没有请求重置密码,请忽略此邮件。您的账户仍然是安全的。</p>
<p>请勿将此链接分享给任何人!</p>
</div>
<hr>
<p>如果您在重置密码过程中遇到任何问题,请随时联系我们的技术支持团队。</p>
<p><strong>祝您使用愉快!</strong> 🔒</p>
{{end}}
@@ -84,11 +84,14 @@ func (s *UserLoginServiceV1) Register(ctx context.Context, req *v1.UserRegisterR
// 注册成功后发送欢迎邮件(异步发送,不影响注册结果)
go func() {
ctx2 := context.Background()
client := redis.GetClient()
token := make([]byte, 32)
_, _ = rand.Read(token)
tokenStr := base64.URLEncoding.EncodeToString(token)
err2 := redis.GetClient().SetEx(ctx2, cst2.RedisVerifyCode+tokenStr, req.Email, time.Duration(cst2.RedisVerifyCodeExpire)*time.Second).Err()
err2 := client.SetEx(ctx2, cst2.RedisVerifyCode+tokenStr, req.Email, time.Duration(cst2.RedisVerifyCodeExpire)*time.Second).Err()
utils.OkOrPanic(err2, oops.WithContext(ctx2).Wrap)
// 保存邮箱到 token 的映射,方便重新发送时删除旧 token
client.SetEx(ctx2, cst2.RedisVerifyCode+"email:"+req.Email, tokenStr, time.Duration(cst2.RedisVerifyCodeExpire)*time.Second)
// 创建新的context避免原context被取消
emailService := email.NewV1()
emailService.SendSignupEmail(ctx2, req.Email, req.Username, tokenStr)
@@ -110,6 +113,8 @@ func (s *UserLoginServiceV1) Confirm(ctx context.Context, req *v1.UserConfirmReq
if emailStr == "" {
return utils.FailParam("非法的验证")
}
// 删除邮箱对应的 token 映射
client.Del(ctx, cst2.RedisVerifyCode+"email:"+emailStr)
// 查询用户
user := db.Global.SysUser.Query().Where(sysuser.EmailEQ(emailStr)).OnlyX(ctx)
db.Global.SysUser.UpdateOne(user).SetVerify(true).SaveX(ctx)
@@ -118,3 +123,135 @@ func (s *UserLoginServiceV1) Confirm(ctx context.Context, req *v1.UserConfirmReq
r.Response.Header().Set(cst2.AuthorizationKey, utils.GenerateToken(user.ID))
return utils.SuccessWithMsg("验证成功")
}
func (s *UserLoginServiceV1) ResendVerification(ctx context.Context, req *v1.ResendVerificationReq) *utils.Response[any] {
// 查询数据库中是否存在该邮箱的未激活用户
user, err := db.Global.SysUser.Query().Where(
sysuser.EmailEQ(req.Email),
sysuser.VerifyEQ(false),
).Only(ctx)
// 如果用户存在且未激活,则重新发送激活邮件
if err == nil && user != nil {
go func() {
ctx2 := context.Background()
client := redis.GetClient()
// 获取并删除旧的 token(如果存在)
oldTokenKey := cst2.RedisVerifyCode + "email:" + req.Email
oldToken := client.GetDel(ctx2, oldTokenKey).Val()
if oldToken != "" {
// 删除旧 token 对应的验证码记录
client.Del(ctx2, cst2.RedisVerifyCode+oldToken)
}
// 生成新的 token
token := make([]byte, 32)
_, _ = rand.Read(token)
tokenStr := base64.URLEncoding.EncodeToString(token)
// 设置新的 token 到 Redis
err2 := client.SetEx(ctx2, cst2.RedisVerifyCode+tokenStr, req.Email, time.Duration(cst2.RedisVerifyCodeExpire)*time.Second).Err()
utils.OkOrPanic(err2, oops.WithContext(ctx2).Wrap)
// 保存邮箱到 token 的映射,方便下次删除
client.SetEx(ctx2, oldTokenKey, tokenStr, time.Duration(cst2.RedisVerifyCodeExpire)*time.Second)
// 发送激活邮件
emailService := email.NewV1()
emailService.SendSignupEmail(ctx2, req.Email, user.Username, tokenStr)
}()
}
// 无论用户是否存在,都返回成功,避免泄露用户信息
return utils.SuccessWithMsg("如果该邮箱存在未激活账户,验证邮件已重新发送")
}
func (s *UserLoginServiceV1) ForgotPassword(ctx context.Context, req *v1.ForgotPasswordReq) *utils.Response[any] {
// 查询数据库中是否存在该邮箱的已激活用户
user, err := db.Global.SysUser.Query().Where(
sysuser.EmailEQ(req.Email),
sysuser.VerifyEQ(true),
).Only(ctx)
// 如果用户存在且已激活,则发送重置密码邮件
if err == nil && user != nil {
go func() {
ctx2 := context.Background()
client := redis.GetClient()
// 获取并删除旧的 token(如果存在)
oldTokenKey := cst2.RedisResetPassword + "email:" + req.Email
oldToken := client.GetDel(ctx2, oldTokenKey).Val()
if oldToken != "" {
// 删除旧 token 对应的重置密码记录
client.Del(ctx2, cst2.RedisResetPassword+oldToken)
}
// 生成新的 token
token := make([]byte, 32)
_, _ = rand.Read(token)
tokenStr := base64.URLEncoding.EncodeToString(token)
// 设置新的 token 到 Redis
err2 := client.SetEx(ctx2, cst2.RedisResetPassword+tokenStr, req.Email, time.Duration(cst2.RedisResetPasswordExpire)*time.Second).Err()
utils.OkOrPanic(err2, oops.WithContext(ctx2).Wrap)
// 保存邮箱到 token 的映射,方便下次删除
client.SetEx(ctx2, oldTokenKey, tokenStr, time.Duration(cst2.RedisResetPasswordExpire)*time.Second)
// 发送重置密码邮件
emailService := email.NewV1()
emailService.SendResetPasswordEmail(ctx2, req.Email, user.Username, tokenStr)
}()
}
// 无论用户是否存在,都返回成功,避免泄露用户信息
return utils.SuccessWithMsg("如果该邮箱已注册,重置密码邮件已发送")
}
func (s *UserLoginServiceV1) ResetPassword(ctx context.Context, req *v1.ForgetResetPasswordReq) *utils.Response[any] {
client := redis.GetClient()
emailStr := client.GetDel(ctx, cst2.RedisResetPassword+req.Token).Val()
if emailStr == "" {
return utils.FailParam("无效或已过期的重置密码链接")
}
// 删除邮箱对应的 token 映射
client.Del(ctx, cst2.RedisResetPassword+"email:"+emailStr)
// 查询用户
user, err := db.Global.SysUser.Query().Where(sysuser.EmailEQ(emailStr)).Only(ctx)
if ent.IsNotFound(err) {
return utils.FailParam("用户不存在")
}
// 更新密码
db.Global.SysUser.UpdateOne(user).SetPassword(utils.PasswordBcryptHash(req.NewPassword)).SaveX(ctx)
return utils.SuccessWithMsg("密码重置成功,请使用新密码登录")
}
func (s *UserLoginServiceV1) GetResetPasswordInfo(ctx context.Context, req *v1.ResetPasswordInfoReq) *utils.Response[any] {
client := redis.GetClient()
// 使用 Get 而不是 GetDel,不删除 token
emailStr := client.Get(ctx, cst2.RedisResetPassword+req.Token).Val()
if emailStr == "" {
return utils.FailParam("无效或已过期的重置密码链接")
}
// 查询用户
user, err := db.Global.SysUser.Query().Where(sysuser.EmailEQ(emailStr)).Only(ctx)
if ent.IsNotFound(err) {
return utils.FailParam("无效或已过期的重置密码链接")
}
type ResetPasswordInfoRes struct {
Username string `json:"username"` // 用户名
Email string `json:"email"` // 邮箱
}
// 返回用户信息
return utils.SuccessWithData[any](&ResetPasswordInfoRes{
Username: user.Username,
Email: user.Email,
})
}