Skip to content

Commit 73432b2

Browse files
committed
feat: 实现邮箱绑定接口
1 parent be123f0 commit 73432b2

11 files changed

Lines changed: 311 additions & 3 deletions

File tree

backend/biz/user/handler/v1/auth.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package v1
33
import (
44
"fmt"
55
"log/slog"
6+
"net/http"
67

78
"github.com/GoYoko/web"
89
"github.com/google/uuid"
@@ -59,6 +60,10 @@ func NewAuthHandler(i *do.Injector) (*AuthHandler, error) {
5960
v1.GET("/status", web.BaseHandler(h.Status), auth.Check())
6061
v1.POST("/logout", web.BaseHandler(h.Logout), auth.Auth())
6162

63+
// 邮箱绑定接口
64+
v1.PUT("/email/bind-request", web.BindHandler(h.SendBindEmailVerification), auth.Auth())
65+
v1.GET("/email/verify", web.BindHandler(h.VerifyBindEmail))
66+
6267
return h, nil
6368
}
6469

@@ -315,3 +320,26 @@ func (h *AuthHandler) ResetPassword(c *web.Context, req domain.ResetUserPassword
315320

316321
return c.Success(nil)
317322
}
323+
324+
// SendBindEmailVerification 发送邮箱绑定验证邮件
325+
func (h *AuthHandler) SendBindEmailVerification(c *web.Context, req domain.SendBindEmailVerificationReq) error {
326+
user := middleware.GetUser(c)
327+
if user == nil {
328+
return errcode.ErrUnauthorized
329+
}
330+
331+
err := h.usecase.SendBindEmailVerification(c.Request().Context(), user.ID, &req)
332+
if err != nil {
333+
return err
334+
}
335+
return c.Success(nil)
336+
}
337+
338+
// VerifyBindEmail 验证邮箱绑定
339+
func (h *AuthHandler) VerifyBindEmail(c *web.Context, req domain.VerifyBindEmailReq) error {
340+
err := h.usecase.VerifyBindEmail(c.Request().Context(), req.Token)
341+
if err != nil {
342+
return err
343+
}
344+
return c.Redirect(http.StatusFound, h.config.Server.BaseURL)
345+
}

backend/biz/user/repo/user.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,8 @@ func (u *userRepo) ChangePassword(ctx context.Context, userID uuid.UUID, current
113113
func (u *userRepo) GetUserByEmail(ctx context.Context, emails []string) ([]*db.User, error) {
114114
return u.db.User.Query().WithTeams().Where(user.EmailIn(emails...)).All(ctx)
115115
}
116+
117+
// SetEmail implements domain.UserRepo.
118+
func (u *userRepo) SetEmail(ctx context.Context, userID uuid.UUID, email string) error {
119+
return u.db.User.UpdateOneID(userID).SetEmail(email).Exec(ctx)
120+
}

backend/biz/user/usecase/user.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"log/slog"
7+
"strings"
78
"time"
89

910
"github.com/google/uuid"
@@ -14,6 +15,7 @@ import (
1415
"github.com/chaitin/MonkeyCode/backend/db"
1516
"github.com/chaitin/MonkeyCode/backend/domain"
1617
"github.com/chaitin/MonkeyCode/backend/errcode"
18+
"github.com/chaitin/MonkeyCode/backend/pkg/crypto"
1719
"github.com/chaitin/MonkeyCode/backend/pkg/cvt"
1820
)
1921

@@ -139,3 +141,113 @@ func (u *UserUsecase) GetUserByEmail(ctx context.Context, emails []string) ([]*d
139141
})
140142
return result, nil
141143
}
144+
145+
// SendBindEmailVerification 发送邮箱绑定验证邮件
146+
func (u *UserUsecase) SendBindEmailVerification(ctx context.Context, userID uuid.UUID, req *domain.SendBindEmailVerificationReq) error {
147+
// 检查邮箱是否已被其他用户使用
148+
existingUsers, err := u.repo.GetUserByEmail(ctx, []string{req.Email})
149+
if err != nil && !db.IsNotFound(err) {
150+
return errcode.ErrDatabaseQuery.Wrap(err)
151+
}
152+
for _, eu := range existingUsers {
153+
if eu.ID == userID {
154+
return errcode.ErrEmailAlreadyBound
155+
}
156+
return errcode.ErrEmailTaken
157+
}
158+
159+
// 生成验证 token
160+
token, err := crypto.Simple(userID.String(), time.Now().Add(time.Hour*24))
161+
if err != nil {
162+
u.logger.ErrorContext(ctx, "generate bind email token failed", "error", err)
163+
return errcode.ErrInternalServer.Wrap(err)
164+
}
165+
166+
// 存储 token 到 Redis,格式:{token}:{email},有效期 24 小时
167+
key := fmt.Sprintf("bind_email_token:%s", userID.String())
168+
value := fmt.Sprintf("%s:%s", token, req.Email)
169+
if err := u.redis.Set(ctx, key, value, time.Hour*24).Err(); err != nil {
170+
u.logger.ErrorContext(ctx, "set redis key failed", "error", err)
171+
return errcode.ErrDatabaseOperation.Wrap(err)
172+
}
173+
174+
// 获取用户信息用于邮件发送
175+
user, err := u.repo.Get(ctx, userID)
176+
if err != nil {
177+
u.logger.ErrorContext(ctx, "get user failed", "error", err)
178+
return errcode.ErrDatabaseQuery.Wrap(err)
179+
}
180+
181+
// 异步发送邮件
182+
verifyURL := fmt.Sprintf("%s/api/v1/users/email/verify?token=%s", u.config.Server.BaseURL, token)
183+
go func() {
184+
if err := u.email.SendBindEmailVerification(context.Background(), req.Email, user.Name, verifyURL); err != nil {
185+
u.logger.ErrorContext(ctx, "send bind email verification mail failed", "error", err)
186+
}
187+
}()
188+
189+
return nil
190+
}
191+
192+
// VerifyBindEmail 验证邮箱绑定
193+
func (u *UserUsecase) VerifyBindEmail(ctx context.Context, token string) error {
194+
// 验证 token 的有效性(检查签名和过期时间)
195+
userIDStr, err := crypto.ValidateSimple(token)
196+
if err != nil {
197+
u.logger.WarnContext(ctx, "validate token failed", "error", err)
198+
return errcode.ErrEmailVerifyFailed.Wrap(err)
199+
}
200+
201+
userID, err := uuid.Parse(userIDStr)
202+
if err != nil {
203+
u.logger.WarnContext(ctx, "parse user id from token failed", "error", err)
204+
return errcode.ErrEmailVerifyFailed.Wrap(err)
205+
}
206+
207+
// 从 Redis 中取出存储的 token 和邮箱(一次性消费)
208+
key := fmt.Sprintf("bind_email_token:%s", userID.String())
209+
redisValue, err := u.redis.GetDel(ctx, key).Result()
210+
if err != nil {
211+
if err == redis.Nil {
212+
return errcode.ErrEmailVerifyFailed
213+
}
214+
u.logger.ErrorContext(ctx, "get redis key failed", "error", err)
215+
return errcode.ErrDatabaseOperation.Wrap(err)
216+
}
217+
218+
// 解析 Redis 中的值:{token}:{email}
219+
parts := strings.SplitN(redisValue, ":", 2)
220+
if len(parts) != 2 {
221+
u.logger.WarnContext(ctx, "invalid redis value format", "value", redisValue)
222+
return errcode.ErrEmailVerifyFailed
223+
}
224+
225+
storedToken := parts[0]
226+
email := parts[1]
227+
228+
// 验证 token 是否匹配(防止 token 替换)
229+
if storedToken != token {
230+
u.logger.WarnContext(ctx, "token mismatch")
231+
return errcode.ErrEmailVerifyFailed
232+
}
233+
234+
// 再次检查邮箱是否被其他用户占用(防止竞态条件)
235+
existingUsers, err := u.repo.GetUserByEmail(ctx, []string{email})
236+
if err != nil && !db.IsNotFound(err) {
237+
return errcode.ErrDatabaseQuery.Wrap(err)
238+
}
239+
for _, eu := range existingUsers {
240+
if eu.ID != userID {
241+
return errcode.ErrEmailTaken
242+
}
243+
}
244+
245+
// 更新用户邮箱
246+
if err := u.repo.SetEmail(ctx, userID, email); err != nil {
247+
u.logger.ErrorContext(ctx, "set email failed", "error", err)
248+
return errcode.ErrDatabaseOperation.Wrap(err)
249+
}
250+
251+
u.logger.InfoContext(ctx, "bind email success", "user_id", userID, "email", email)
252+
return nil
253+
}

backend/domain/email.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ import "context"
55
// EmailSender 邮件发送接口
66
type EmailSender interface {
77
SendResetPasswordEmail(ctx context.Context, to, username, resetURL string) error
8+
SendBindEmailVerification(ctx context.Context, to, username, verifyURL string) error
89
}

backend/domain/user.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ type UserUsecase interface {
1919
ChangePassword(ctx context.Context, userID uuid.UUID, req *ChangePasswordReq, isReset bool) error
2020
SendResetPasswordEmail(ctx context.Context, req *ResetUserPasswordEmailReq) error
2121
GetUserByEmail(ctx context.Context, emails []string) ([]*User, error)
22+
SendBindEmailVerification(ctx context.Context, userID uuid.UUID, req *SendBindEmailVerificationReq) error
23+
VerifyBindEmail(ctx context.Context, token string) error
2224
}
2325

2426
type UserRepo interface {
@@ -28,6 +30,7 @@ type UserRepo interface {
2830
PasswordLogin(ctx context.Context, req *TeamLoginReq) (*db.User, error)
2931
ChangePassword(ctx context.Context, uid uuid.UUID, currentPassword, newPassword string, isReset bool) error
3032
GetUserByEmail(ctx context.Context, emails []string) ([]*db.User, error)
33+
SetEmail(ctx context.Context, userID uuid.UUID, email string) error
3134
}
3235

3336
type User struct {
@@ -38,9 +41,10 @@ type User struct {
3841
Role consts.UserRole `json:"role"`
3942
Status consts.UserStatus `json:"status"`
4043
IsBlocked bool `json:"is_blocked"`
41-
Token string `json:"token,omitempty"`
42-
Identities []*UserIdentity `json:"identities"`
43-
Team *Team `json:"team,omitempty"`
44+
Token string `json:"token,omitempty"`
45+
Identities []*UserIdentity `json:"identities"`
46+
Team *Team `json:"team,omitempty"`
47+
HasPassword bool `json:"has_password"`
4448
}
4549

4650
func (u *User) From(src *db.User) *User {
@@ -55,6 +59,7 @@ func (u *User) From(src *db.User) *User {
5559
u.Role = src.Role
5660
u.Status = consts.UserStatusActive
5761
u.IsBlocked = src.IsBlocked
62+
u.HasPassword = src.Password != ""
5863
u.Identities = cvt.Iter(src.Edges.Identities, func(_ int, i *db.UserIdentity) *UserIdentity {
5964
return cvt.From(i, &UserIdentity{})
6065
})
@@ -155,3 +160,13 @@ type CursorReq struct {
155160
Cursor string `query:"cursor"`
156161
Limit int `query:"limit"`
157162
}
163+
164+
// SendBindEmailVerificationReq 发送邮箱绑定验证邮件请求
165+
type SendBindEmailVerificationReq struct {
166+
Email string `json:"email" validate:"required,email"` // 要绑定的邮箱地址
167+
}
168+
169+
// VerifyBindEmailReq 验证邮箱请求
170+
type VerifyBindEmailReq struct {
171+
Token string `query:"token" validate:"required"` // 验证 token
172+
}

backend/errcode/errcode.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ var (
8989
ErrResetPasswordFailed = web.NewErr(http.StatusOK, 10608, "err-reset-password-failed")
9090
ErrWalletInsufficient = web.NewErr(http.StatusOK, 10609, "err-wallet-insufficient")
9191
ErrAccountOverdraft = web.NewErr(http.StatusOK, 10610, "err-account-overdraft")
92+
ErrEmailVerifyFailed = web.NewErr(http.StatusOK, 10611, "err-email-verify-failed")
93+
ErrEmailAlreadyBound = web.NewErr(http.StatusOK, 10612, "err-email-already-bound")
94+
ErrEmailTaken = web.NewErr(http.StatusOK, 10613, "err-email-taken")
9295

9396
// captcha 模块
9497
ErrCreateCaptchaFailed = web.NewErr(http.StatusOK, 10700, "err-create-captcha-failed")

backend/errcode/locale.en.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,15 @@ other = "Insufficient wallet balance"
129129
[err-account-overdraft]
130130
other = "Account overdraft"
131131

132+
[err-email-verify-failed]
133+
other = "Email verification failed"
134+
135+
[err-email-already-bound]
136+
other = "Email already bound"
137+
138+
[err-email-taken]
139+
other = "Email already taken by another user"
140+
132141
[err-deposit-failed]
133142
other = "Deposit failed"
134143

backend/errcode/locale.zh.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,15 @@ other = "余额不足"
136136
[err-account-overdraft]
137137
other = "账户超支"
138138

139+
[err-email-verify-failed]
140+
other = "邮箱验证失败"
141+
142+
[err-email-already-bound]
143+
other = "该邮箱已绑定"
144+
145+
[err-email-taken]
146+
other = "该邮箱已被使用"
147+
139148
[err-deposit-failed]
140149
other = "充值失败"
141150

backend/pkg/email/smtp.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,21 @@ func (c *EmailClient) SendResetPasswordEmail(ctx context.Context, to, username,
4343
return c.Send("Reset Your Password", to, buf.String())
4444
}
4545

46+
func (c *EmailClient) SendBindEmailVerification(ctx context.Context, to, username, verifyURL string) error {
47+
tmpl, err := template.New("bind_email").Parse(string(templates.BindEmail))
48+
if err != nil {
49+
return err
50+
}
51+
var buf bytes.Buffer
52+
if err := tmpl.Execute(&buf, map[string]string{
53+
"user": username,
54+
"verify_url": verifyURL,
55+
}); err != nil {
56+
return err
57+
}
58+
return c.Send("Verify Your Email", to, buf.String())
59+
}
60+
4661
type Smtp struct {
4762
cfg *config.Config
4863
}

0 commit comments

Comments
 (0)