// 小程序用户接口 package app import ( "context" "errors" "fmt" "simple-memo/global" "simple-memo/models" "simple-memo/utils" "time" "github.com/gin-gonic/gin" "go.uber.org/zap" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" ) // -------------------------- 1. 定义 Handler 接口 -------------------------- // UserHandler 用户模块接口定义 type UserHandler interface { Login(c *gin.Context) // 微信登录 Logout(c *gin.Context) // 退出登录 UserInfo(c *gin.Context) // 获取用户信息 EditUser(c *gin.Context) // 修改用户信息 SendEmailCode(c *gin.Context) // 发送邮箱验证码(支持多种场景) SetEmail(c *gin.Context) // 设置邮箱(绑定邮箱) EmailLogin(c *gin.Context) // 邮箱密码登录 EmailRegister(c *gin.Context) // 邮箱注册 ResetPassword(c *gin.Context) // 重置密码(未登录找回密码) ChangePassword(c *gin.Context) // 修改密码(已登录场景) CheckEmailExists(c *gin.Context) // 检查邮箱是否已注册 BindWechat(c *gin.Context) // 绑定微信(Web端,网页授权code) BindWechatMini(c *gin.Context) // 绑定微信(小程序端,wx.login code) } // -------------------------- 2. 实现结构体(依赖注入DB) -------------------------- // userHandler 接口实现结构体 type userHandler struct { db *gorm.DB // 注入数据库,方便测试 } // NewUserHandler 创建用户处理器(对外暴露) func NewUserHandler() UserHandler { return &userHandler{ db: global.DB, } } // -------------------------- 3. 请求结构体定义 -------------------------- // LoginReq 登录参数 type LoginReq struct { Code string `json:"code"` } type EditUserReq struct { Nickname string `json:"nickname"` Avatar string `json:"avatar"` Signature string `json:"signature"` } // -------------------------- 4. 接口实现 -------------------------- // Login 微信登录 func (h *userHandler) Login(c *gin.Context) { var req LoginReq if err := c.ShouldBindJSON(&req); err != nil { utils.Fail(c, "参数解析失败") return } if req.Code == "" { utils.Fail(c, "code不能为空") return } // 调用微信接口获取 openid wxResp, err := utils.MiniProgramCode2Session(req.Code) if err != nil { utils.Fail(c, "微信登录失败:"+err.Error()) return } // 查询或创建用户 var user models.User err = h.db.Where("openid = ?", wxResp.OpenID).First(&user).Error if errors.Is(err, gorm.ErrRecordNotFound) { // 创建新用户 user = models.User{ Openid: wxResp.OpenID, UserCode: utils.GenerateUserCode(), } err = h.db.Create(&user).Error } if err != nil { utils.Fail(c, "用户操作失败") return } // 生成 token token, expiresAt, err := utils.GenerateTokenWithExpire(user.ID) if err != nil { utils.Fail(c, "token生成失败") return } // 返回数据 utils.Ok(c, gin.H{ "token": token, "expiresAt": expiresAt, "expiresIn": utils.GetTokenExpireSeconds(), }) } // Logout 退出登录 func (h *userHandler) Logout(c *gin.Context) { // 从上下文获取用户ID和token(JWTAuth中间件已注入) userId, exists := c.Get("userID") if !exists { utils.Fail(c, "用户未登录") return } token, tokenExists := c.Get("token") if !tokenExists { utils.Fail(c, "获取token失败") return } // 解析 token 获取过期时间 claims, err := utils.ParseToken(token.(string)) if err != nil { utils.Fail(c, "token无效") return } // 计算剩余过期时间 ttl := time.Until(claims.ExpiresAt.Time) if ttl > 0 { // 将 token 加入 Redis 黑名单 ctx := context.Background() err = global.Redis.Set(ctx, "token_blacklist:"+token.(string), "1", ttl).Err() if err != nil { global.Logger.Error("添加token到黑名单失败", append(utils.LogContextFields(c), zap.Error(err))...) utils.Fail(c, "退出登录失败") return } global.Logger.Info("用户退出登录", append(utils.LogContextFields(c), zap.Uint("userID", userId.(uint)))...) } utils.Ok(c, "退出登录成功") } // UserInfo 获取用户信息 func (h *userHandler) UserInfo(c *gin.Context) { userId, exists := c.Get("userID") if !exists { utils.Fail(c, "用户未登录") return } var user models.User err := h.db.First(&user, userId).Error if err != nil { utils.Fail(c, "获取用户信息失败") return } // 返回脱敏用户信息 utils.Ok(c, gin.H{ "id": user.ID, "nickname": user.Nickname, "avatar": user.Avatar, "signature": user.Signature, "email": user.Email, "user_code": user.UserCode, "is_bind_wechat": user.Openid != "", // 是否绑定微信 "is_bind_email": user.Email != "", // 是否绑定邮箱 "createdAt": user.CreatedAt, }) } // EditUser 修改用户信息 func (h *userHandler) EditUser(c *gin.Context) { userId, exists := c.Get("userID") if !exists { utils.Fail(c, "用户未登录") c.Abort() return } var editUserReq EditUserReq if err := c.ShouldBindJSON(&editUserReq); err != nil { utils.Fail(c, "参数解析失败") return } // 如果 昵称或者头像为空,则不更新 if editUserReq.Nickname == "" && editUserReq.Avatar == "" && editUserReq.Signature == "" { utils.Ok(c, nil) return } // 构建更新数据(支持同时更新多个字段) data := make(map[string]any) if editUserReq.Nickname != "" { data["nickname"] = utils.FilterSensitive(editUserReq.Nickname) // 敏感词过滤 } if editUserReq.Avatar != "" { data["avatar"] = editUserReq.Avatar } if editUserReq.Signature != "" { data["signature"] = utils.FilterSensitive(editUserReq.Signature) // 敏感词过滤 } if len(data) == 0 { utils.Ok(c, nil) return } err := h.db.Model(&models.User{}).Where("id = ?", userId).Updates(data).Error if err != nil { utils.Fail(c, "用户信息更新失败") return } utils.Ok(c, nil) } // -------------------------- 4. 邮箱验证接口 -------------------------- // SendEmailCodeReq 发送邮箱验证码请求 type SendEmailCodeReq struct { Email string `json:"email" binding:"required,email"` Type string `json:"type" binding:"omitempty,oneof=register login bind reset_password change_email"` // type为空时:已登录状态下绑定邮箱 CaptchaId string `json:"captchaId"` // 图形验证码ID(注册/登录/找回密码场景必填) Captcha string `json:"captcha"` // 图形验证码(注册/登录/找回密码场景必填) } // SendEmailCode 发送邮箱验证码(支持多种场景) // type=register: 注册,检查邮箱未被注册 // type=login: 登录,检查邮箱已注册且有密码 // type=bind/空: 已登录状态下绑定邮箱,不检查邮箱状态 // type=reset_password: 找回密码,检查邮箱已注册且有密码 // type=change_email: 修改邮箱(已登录),检查新邮箱未被其他用户使用 func (h *userHandler) SendEmailCode(c *gin.Context) { var req SendEmailCodeReq if err := c.ShouldBindJSON(&req); err != nil { utils.Fail(c, "请输入有效的邮箱地址") return } ctx := context.Background() // 1. 获取客户端IP clientIP := c.ClientIP() if clientIP == "" { clientIP = c.GetHeader("X-Forwarded-For") } if clientIP == "" { clientIP = c.GetHeader("X-Real-IP") } switch req.Type { case "register": // 注册场景:检查邮箱未被注册,需要验证图形验证码 // 验证图形验证码 if req.CaptchaId == "" || req.Captcha == "" { utils.Fail(c, "请输入图形验证码") return } if !store.Verify(req.CaptchaId, req.Captcha, true) { utils.Fail(c, "图形验证码错误") return } var user models.User err := h.db.Where("email = ?", req.Email).First(&user).Error if err == nil { utils.Fail(c, "该邮箱已被注册") return } case "login": // 登录场景:检查邮箱已注册且有密码,需要验证图形验证码 // 验证图形验证码 if req.CaptchaId == "" || req.Captcha == "" { utils.Fail(c, "请输入图形验证码") return } if !store.Verify(req.CaptchaId, req.Captcha, true) { utils.Fail(c, "图形验证码错误") return } var user models.User err := h.db.Where("email = ?", req.Email).First(&user).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { utils.Fail(c, "该邮箱未注册") } else { utils.Fail(c, "查询用户失败") } return } if user.Password == "" { utils.Fail(c, "该邮箱未设置密码,请先注册") return } case "reset_password": // 找回密码场景:检查邮箱已注册,需要验证图形验证码 // 验证图形验证码 if req.CaptchaId == "" || req.Captcha == "" { utils.Fail(c, "请输入图形验证码") return } if !store.Verify(req.CaptchaId, req.Captcha, true) { utils.Fail(c, "图形验证码错误") return } var user models.User err := h.db.Where("email = ?", req.Email).First(&user).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { utils.Fail(c, "该邮箱未注册") } else { utils.Fail(c, "查询用户失败") } return } if user.Password == "" { utils.Fail(c, "该邮箱未设置密码,请先注册") return } case "change_email": // 修改邮箱场景:需要用户已登录,检查新邮箱未被其他用户使用,需要验证图形验证码 userId, exists := c.Get("userID") if !exists { utils.Fail(c, "用户未登录") return } // 验证图形验证码 if req.CaptchaId == "" || req.Captcha == "" { utils.Fail(c, "请输入图形验证码") return } if !store.Verify(req.CaptchaId, req.Captcha, true) { utils.Fail(c, "图形验证码错误") return } // 检查新邮箱是否已被其他用户使用 var existingUser models.User err := h.db.Where("email = ? AND id != ?", req.Email, userId).First(&existingUser).Error if err == nil { utils.Fail(c, "该邮箱已被其他用户使用") return } case "bind", "": // 绑定邮箱场景:需要用户已登录,不检查邮箱状态,不需要图形验证码 _, exists := c.Get("userID") if !exists { utils.Fail(c, "用户未登录") return } default: utils.Fail(c, "无效的type参数") return } // 2. IP级别的频率限制:同一IP每分钟最多发送5次 ipRateLimitKey := "email_ip_rate_limit:" + clientIP ipCount, err := global.Redis.Incr(ctx, ipRateLimitKey).Result() if err != nil { global.Logger.Error("IP频率限制计数失败", append(utils.LogContextFields(c), zap.Error(err))...) utils.Fail(c, "发送验证码失败") return } if ipCount == 1 { global.Redis.Expire(ctx, ipRateLimitKey, 60*time.Second) } else if ipCount > 5 { ttl, _ := global.Redis.TTL(ctx, ipRateLimitKey).Result() utils.Fail(c, fmt.Sprintf("当前IP发送过于频繁,请%d秒后再试", int(ttl.Seconds()))) return } // 3. 邮箱级别的频率限制:同一邮箱每60秒只能发送一次 emailRateLimitKey := "email_rate_limit:" + req.Email emailCount, err := global.Redis.Exists(ctx, emailRateLimitKey).Result() if err == nil && emailCount > 0 { ttl, _ := global.Redis.TTL(ctx, emailRateLimitKey).Result() utils.Fail(c, fmt.Sprintf("该邮箱发送太频繁,请%d秒后再试", int(ttl.Seconds()))) return } // 4. 邮箱每日发送次数限制:同一邮箱每天最多发送10次 now := time.Now() dayKey := "email_daily_limit:" + req.Email + ":" + now.Format("2006-01-02") dailyCount, err := global.Redis.Incr(ctx, dayKey).Result() if err != nil { global.Logger.Error("邮箱每日限制计数失败", append(utils.LogContextFields(c), zap.Error(err))...) utils.Fail(c, "发送验证码失败") return } if dailyCount == 1 { global.Redis.Expire(ctx, dayKey, 24*time.Hour) } else if dailyCount > 10 { utils.Fail(c, "该邮箱今日发送次数已达上限,请明天再试") return } // 5. IP每日发送次数限制:同一IP每天最多发送30次 ipDayKey := "email_ip_daily_limit:" + clientIP + ":" + now.Format("2006-01-02") ipDailyCount, err := global.Redis.Incr(ctx, ipDayKey).Result() if err != nil { global.Logger.Error("IP每日限制计数失败", append(utils.LogContextFields(c), zap.Error(err))...) utils.Fail(c, "发送验证码失败") return } if ipDailyCount == 1 { global.Redis.Expire(ctx, ipDayKey, 24*time.Hour) } else if ipDailyCount > 30 { utils.Fail(c, "当前IP今日发送次数已达上限,请明天再试") return } code := utils.GenerateRandomString(6) err = global.Redis.Set(ctx, "email_code:"+req.Email, code, 5*time.Minute).Err() if err != nil { global.Logger.Error("保存邮箱验证码失败", append(utils.LogContextFields(c), zap.Error(err))...) utils.Fail(c, "发送验证码失败") return } global.Redis.Set(ctx, emailRateLimitKey, "1", 60*time.Second) err = utils.SendEmailCode(req.Email, code) if err != nil { global.Logger.Error("发送邮件失败", append(utils.LogContextFields(c), zap.String("email", req.Email), zap.Error(err))...) global.Redis.Del(ctx, "email_code:"+req.Email) utils.Fail(c, "发送验证码失败") return } global.Logger.Info("验证码发送成功", append(utils.LogContextFields(c), zap.String("email", req.Email), zap.String("code", code))...) utils.Ok(c, "验证码已发送,请注意查收") } // SetEmailReq 设置邮箱请求 type SetEmailReq struct { Email string `json:"email" binding:"required,email"` Code string `json:"code" binding:"required,len=6"` } // SetEmail 设置邮箱(需验证验证码) func (h *userHandler) SetEmail(c *gin.Context) { userId, exists := c.Get("userID") if !exists { utils.Fail(c, "用户未登录") return } var req SetEmailReq if err := c.ShouldBindJSON(&req); err != nil { utils.Fail(c, "请输入有效的邮箱和验证码") return } ctx := context.Background() storedCode, err := global.Redis.Get(ctx, "email_code:"+req.Email).Result() if err != nil || storedCode != req.Code { utils.Fail(c, "验证码错误或已过期") return } global.Redis.Del(ctx, "email_code:"+req.Email) var existingUser models.User err = h.db.Where("email = ? AND id != ?", req.Email, userId).First(&existingUser).Error if err == nil { utils.Fail(c, "该邮箱已被其他用户使用") return } err = h.db.Model(&models.User{}).Where("id = ?", userId).Update("email", req.Email).Error if err != nil { global.Logger.Error("更新邮箱失败", append(utils.LogContextFields(c), zap.Error(err))...) utils.Fail(c, "设置邮箱失败") return } utils.Ok(c, "邮箱设置成功") } // -------------------------- 5. 网页端邮箱登录接口 -------------------------- // EmailRegisterReq 邮箱注册请求 type EmailRegisterReq struct { Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=6"` Code string `json:"code" binding:"required,len=6"` CaptchaId string `json:"captchaId"` // 图形验证码ID Captcha string `json:"captcha"` // 图形验证码 } // EmailLoginReq 邮箱登录请求 type EmailLoginReq struct { Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required"` CaptchaId string `json:"captchaId"` // 图形验证码ID Captcha string `json:"captcha"` // 图形验证码 } // EmailRegister 邮箱注册 func (h *userHandler) EmailRegister(c *gin.Context) { var req EmailRegisterReq if err := c.ShouldBindJSON(&req); err != nil { utils.Fail(c, "请输入有效的邮箱和密码") return } // 注:图形验证码在发送邮箱验证码时已验证,此处不再重复验证 // 验证邮箱验证码(必填) ctx := context.Background() storedCode, err := global.Redis.Get(ctx, "email_code:"+req.Email).Result() if err != nil || storedCode != req.Code { utils.Fail(c, "邮箱验证码错误或已过期") return } global.Redis.Del(ctx, "email_code:"+req.Email) var existingUser models.User err = h.db.Where("email = ?", req.Email).First(&existingUser).Error if err == nil { utils.Fail(c, "该邮箱已被注册") return } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { global.Logger.Error("密码加密失败", append(utils.LogContextFields(c), zap.Error(err))...) utils.Fail(c, "注册失败") return } userCode := utils.GenerateUserCode() user := models.User{ Email: req.Email, Password: string(hashedPassword), Nickname: "用户" + utils.GenerateRandomString(6), UserCode: userCode, } err = h.db.Create(&user).Error if err != nil { global.Logger.Error("创建用户失败", append(utils.LogContextFields(c), zap.Error(err))...) utils.Fail(c, "注册失败") return } token, expiresAt, err := utils.GenerateTokenWithExpire(user.ID) if err != nil { utils.Fail(c, "token生成失败") return } utils.Ok(c, gin.H{ "token": token, "expiresAt": expiresAt, "expiresIn": utils.GetTokenExpireSeconds(), "nickname": user.Nickname, "user_code": user.UserCode, "avatar": user.Avatar, }) } // EmailLogin 邮箱登录 func (h *userHandler) EmailLogin(c *gin.Context) { var req EmailLoginReq if err := c.ShouldBindJSON(&req); err != nil { utils.Fail(c, "请输入有效的邮箱和密码") return } // 验证图形验证码(必填) if req.CaptchaId == "" || req.Captcha == "" { utils.Fail(c, "请输入图形验证码") return } if !store.Verify(req.CaptchaId, req.Captcha, true) { utils.Fail(c, "图形验证码错误") return } var user models.User err := h.db.Where("email = ?", req.Email).First(&user).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { utils.Fail(c, "邮箱或密码错误") } else { utils.Fail(c, "登录失败") } return } if user.Password == "" { utils.Fail(c, "该邮箱未设置密码,请先注册") return } err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)) if err != nil { utils.Fail(c, "邮箱或密码错误") return } token, expiresAt, err := utils.GenerateTokenWithExpire(user.ID) if err != nil { utils.Fail(c, "登录失败") return } utils.Ok(c, gin.H{ "token": token, "expiresAt": expiresAt, "expiresIn": utils.GetTokenExpireSeconds(), "nickname": user.Nickname, "avatar": user.Avatar, "email": user.Email, }) } // CheckEmailExistsReq 检查邮箱是否存在请求 type CheckEmailExistsReq struct { Email string `json:"email" binding:"required,email"` } // CheckEmailExists 检查邮箱是否已注册 func (h *userHandler) CheckEmailExists(c *gin.Context) { var req CheckEmailExistsReq if err := c.ShouldBindJSON(&req); err != nil { utils.Fail(c, "请输入有效的邮箱地址") return } var user models.User err := h.db.Where("email = ?", req.Email).First(&user).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { utils.Ok(c, gin.H{ "exists": false, "hasPassword": false, }) return } utils.Fail(c, "查询失败") return } utils.Ok(c, gin.H{ "exists": true, "hasPassword": user.Password != "", }) } // ResetPasswordReq 重置密码请求(未登录找回密码场景) type ResetPasswordReq struct { Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=6"` ConfirmPassword string `json:"confirmPassword"` // 确认密码(可选,前端已验证) Code string `json:"code" binding:"required,len=6"` // 邮箱验证码(必填) } // ResetPassword 重置密码(未登录找回密码场景) func (h *userHandler) ResetPassword(c *gin.Context) { var req ResetPasswordReq if err := c.ShouldBindJSON(&req); err != nil { utils.Fail(c, "请输入有效的邮箱、密码和验证码") return } // 验证密码一致性(如果前端传递了 confirmPassword) if req.ConfirmPassword != "" && req.Password != req.ConfirmPassword { utils.Fail(c, "两次输入的密码不一致") return } ctx := context.Background() storedCode, err := global.Redis.Get(ctx, "email_code:"+req.Email).Result() if err != nil || storedCode != req.Code { utils.Fail(c, "验证码错误或已过期") return } global.Redis.Del(ctx, "email_code:"+req.Email) var user models.User err = h.db.Where("email = ?", req.Email).First(&user).Error if err != nil { utils.Fail(c, "该邮箱未注册") return } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { global.Logger.Error("密码加密失败", append(utils.LogContextFields(c), zap.Error(err))...) utils.Fail(c, "重置密码失败") return } err = h.db.Model(&user).Update("password", string(hashedPassword)).Error if err != nil { global.Logger.Error("更新密码失败", append(utils.LogContextFields(c), zap.Error(err))...) utils.Fail(c, "重置密码失败") return } utils.Ok(c, "密码重置成功") } // ChangePasswordReq 修改密码请求(已登录场景) type ChangePasswordReq struct { Password string `json:"password" binding:"required,min=6"` // 新密码 ConfirmPassword string `json:"confirmPassword" binding:"required"` // 确认密码 NewEmail string `json:"newEmail" binding:"omitempty,email"` // 新邮箱(可选) CaptchaId string `json:"captchaId" binding:"required"` // 图形验证码ID Captcha string `json:"captcha" binding:"required"` // 图形验证码 Code string `json:"code" binding:"omitempty,len=6"` // 邮箱验证码(修改邮箱时必填) } // ChangePassword 修改密码(已登录场景) // 支持两种场景: // 1. 只修改密码(不修改邮箱):只需图形验证码 // 2. 修改邮箱+密码:需要新邮箱的验证码 func (h *userHandler) ChangePassword(c *gin.Context) { userId, exists := c.Get("userID") if !exists { utils.Fail(c, "用户未登录") return } var req ChangePasswordReq if err := c.ShouldBindJSON(&req); err != nil { utils.Fail(c, "请输入有效的参数") return } // 1. 验证图形验证码(必填) if !store.Verify(req.CaptchaId, req.Captcha, true) { utils.Fail(c, "图形验证码错误") return } // 2. 验证密码一致性 if req.Password != req.ConfirmPassword { utils.Fail(c, "两次输入的密码不一致") return } ctx := context.Background() // 3. 如果提供了新邮箱,需要验证邮箱验证码 if req.NewEmail != "" { if req.Code == "" { utils.Fail(c, "修改邮箱需要邮箱验证码") return } storedCode, err := global.Redis.Get(ctx, "email_code:"+req.NewEmail).Result() if err != nil || storedCode != req.Code { utils.Fail(c, "邮箱验证码错误或已过期") return } global.Redis.Del(ctx, "email_code:"+req.NewEmail) // 检查新邮箱是否已被其他用户使用 var existingUser models.User err = h.db.Where("email = ? AND id != ?", req.NewEmail, userId).First(&existingUser).Error if err == nil { utils.Fail(c, "该邮箱已被其他用户使用") return } } // 4. 更新密码 hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { global.Logger.Error("密码加密失败", append(utils.LogContextFields(c), zap.Error(err))...) utils.Fail(c, "修改密码失败") return } // 构建更新数据 updateData := map[string]interface{}{ "password": string(hashedPassword), } // 如果提供了新邮箱,同时更新邮箱 if req.NewEmail != "" { updateData["email"] = req.NewEmail } err = h.db.Model(&models.User{}).Where("id = ?", userId).Updates(updateData).Error if err != nil { global.Logger.Error("更新用户信息失败", append(utils.LogContextFields(c), zap.Error(err))...) utils.Fail(c, "修改失败") return } // 5. 返回成功消息 if req.NewEmail != "" { utils.Ok(c, "邮箱和密码修改成功") } else { utils.Ok(c, "密码修改成功") } } // -------------------------- 6. 微信绑定接口 -------------------------- // BindWechatReq 绑定微信请求(Web端已登录) type BindWechatReq struct { Code string `json:"code" binding:"required"` // 微信网页授权code } // BindWechat 绑定微信(已登录场景,Web端使用) // 流程:用户在Web端已登录 → 扫描微信二维码 → 微信回传code → 调用此接口绑定openid func (h *userHandler) BindWechat(c *gin.Context) { userId, exists := c.Get("userID") if !exists { utils.Fail(c, "用户未登录") return } var req BindWechatReq if err := c.ShouldBindJSON(&req); err != nil { utils.Fail(c, "参数解析失败") return } if req.Code == "" { utils.Fail(c, "授权code不能为空") return } // 调用微信网页授权接口获取 openid wxResp, err := utils.WebCode2Session(req.Code) if err != nil { global.Logger.Error("微信网页授权失败", append(utils.LogContextFields(c), zap.Error(err))...) utils.Fail(c, "微信授权失败:"+err.Error()) return } if wxResp.OpenID == "" { utils.Fail(c, "获取微信用户信息失败") return } // 检查该 openid 是否已被其他用户绑定 var existingUser models.User err = h.db.Where("openid = ? AND id != ?", wxResp.OpenID, userId).First(&existingUser).Error if err == nil { utils.Fail(c, "该微信账号已被其他用户绑定") return } // 检查当前用户是否已绑定微信 var currentUser models.User err = h.db.First(¤tUser, userId).Error if err != nil { utils.Fail(c, "用户不存在") return } if currentUser.Openid != "" { utils.Fail(c, "您已绑定微信,请先解绑") return } // 绑定微信 openid 到当前用户 err = h.db.Model(&models.User{}).Where("id = ?", userId).Update("openid", wxResp.OpenID).Error if err != nil { global.Logger.Error("绑定微信失败", append(utils.LogContextFields(c), zap.Error(err))...) utils.Fail(c, "绑定微信失败") return } global.Logger.Info("微信绑定成功", append(utils.LogContextFields(c), zap.Uint("userID", userId.(uint)), zap.String("openid", wxResp.OpenID), )...) utils.Ok(c, "微信绑定成功") } // -------------------------- 7. 小程序微信绑定接口 -------------------------- // BindWechatMiniReq 绑定微信请求(小程序端已登录) type BindWechatMiniReq struct { Code string `json:"code" binding:"required"` // 小程序 wx.login() 返回的临时code } // BindWechatMini 小程序绑定微信(已登录场景) // 流程:小程序用户用邮箱注册并登录 → 点击"绑定微信" → wx.login() 获取code → 调用此接口绑定openid // // 与 Web 端 BindWechat 的关键区别: // // - Web 端:code 来自微信网页OAuth2.0扫码授权 → 调用 sns/oauth2/access_token 换取 → WebCode2Session // // - 小程序端:code 来自 wx.login() → 调用 sns/jscode2session 换取 → MiniProgramCode2Session // // 两者 code 完全不同,API 端点不同,不可混用! func (h *userHandler) BindWechatMini(c *gin.Context) { userId, exists := c.Get("userID") if !exists { utils.Fail(c, "用户未登录") return } var req BindWechatMiniReq if err := c.ShouldBindJSON(&req); err != nil { utils.Fail(c, "参数解析失败") return } if req.Code == "" { utils.Fail(c, "授权code不能为空,请先调用wx.login获取") return } // 调用小程序专用接口:sns/jscode2session wxResp, err := utils.MiniProgramCode2Session(req.Code) if err != nil { global.Logger.Error("小程序微信授权失败", append(utils.LogContextFields(c), zap.Error(err))...) utils.Fail(c, "微信授权失败,请稍后重试") return } if wxResp.OpenID == "" { utils.Fail(c, "获取微信用户信息失败") return } // 检查该 openid 是否已被其他用户绑定 var existingUser models.User err = h.db.Where("openid = ? AND id != ?", wxResp.OpenID, userId).First(&existingUser).Error if err == nil { utils.Fail(c, "该微信账号已被其他用户绑定") return } // 检查当前用户是否已绑定微信 var currentUser models.User err = h.db.First(¤tUser, userId).Error if err != nil { utils.Fail(c, "用户不存在") return } if currentUser.Openid != "" { utils.Fail(c, "您已绑定微信,请先解绑") return } // 更新 openid 和相关字段 updates := map[string]interface{}{ "openid": wxResp.OpenID, } // 若存在 unionid 一并保存(跨平台识别用) if wxResp.UnionID != "" { updates["unionid"] = wxResp.UnionID } err = h.db.Model(&models.User{}).Where("id = ?", userId).Updates(updates).Error if err != nil { global.Logger.Error("小程序绑定微信失败", append(utils.LogContextFields(c), zap.Error(err))...) utils.Fail(c, "绑定微信失败,请稍后重试") return } global.Logger.Info("小程序微信绑定成功", append(utils.LogContextFields(c), zap.Uint("userID", userId.(uint)), zap.String("openid", wxResp.OpenID), )...) utils.Ok(c, "微信绑定成功") }