docs(website): 添加简记memo产品原型和UI设计规范文档
- 新增「简记memo」一体化小程序产品原型设计文档 - 新增简记memo完整版UI视觉设计规范和界面细节 - 添加IDEA项目配置文件.gitignore - 创建404页面HTML文件,包含响应式布局和错误提示 - 添加关于页面HTML文件,展示品牌介绍和团队信息 - 实现AES加解密工具函数,支持请求体加密 - 添加用户协议页面基础框架
This commit is contained in:
+982
@@ -0,0 +1,982 @@
|
||||
// 小程序用户接口
|
||||
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, "微信绑定成功")
|
||||
}
|
||||
Reference in New Issue
Block a user