用户模块讲义

用户模块

user表结构设计

sql 复制代码
DROP TABLE IF EXISTS `user`;

CREATE TABLE `user` (
    `id` bigint(20) NOT NULL AUTO_INCREMENT,
    `user_id` bigint(20) NOT NULL,
    `username` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
    `password` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
    `email` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
    `gender` tinyint(4) NOT NULL DEFAULT '0',
    `create_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
    `update_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_username` (`username`) USING BTREE,
    UNIQUE KEY `idx_user_id` (`user_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
  1. 整句拆解
SQL 片段 含义
DROP TABLE IF EXISTS user`` 如果表 user 已经存在,则先删除它。这是为了在重新执行建表脚本时避免冲突,常用于开发和测试环境。
CREATE TABLE user(...) 创建名为 user 的新表,括号内是表的字段定义和约束。
ENGINE=InnoDB 指定使用 InnoDB 存储引擎。这是 MySQL 默认且最常用的引擎,支持事务、行级锁和外键。
DEFAULT CHARSET=utf8mb4 指定字符集为 utf8mb4 。它是 utf8 的完整实现,支持存储表情符号(如 😊)和所有 Unicode 字符,是目前最推荐的字符集。
COLLATE=utf8mb4_general_ci 指定排序规则为 utf8mb4_general_cici 表示 Case Insensitive (大小写不敏感),即查询时 'abc''ABC' 会被视为相同。

英语动词collate,在数据库领域,它专门用来指字符串的比较和排序规则。

  1. 字段详解
字段名 类型 约束 含义 为什么这样设计?
id bigint(20) NOT NULL AUTO_INCREMENT 主键 ID,自增 数据库内部自增主键 ,用于保证每行数据的物理唯一性,与业务无关。AUTO_INCREMENT 让 MySQL 自动分配一个递增的数字。
user_id bigint(20) NOT NULL 用户 ID 业务层面的用户唯一标识 。为什么不直接用 id?因为 id 是自增的,会暴露用户总量和增长趋势,且不适合作为分布式环境下的业务 ID。user_id 通常由分布式 ID 生成器(如雪花算法)生成,保证全局唯一且不连续。
username varchar(64) NOT NULL 用户名 长度 64 足够容纳常见用户名,且建立唯一索引,确保用户名不重复。
password varchar(64) NOT NULL 密码 长度 64 足够存储加密后的密码(如 bcrypt 或 MD5 加盐后的十六进制字符串通常为 32 或 64 位)。
email varchar(64) DEFAULT NULL 邮箱 允许为空,因为用户注册时可能不提供邮箱,或者留作后续补充。
gender tinyint(4) NOT NULL DEFAULT '0' 性别 tinyint 占用 1 字节,存储 0/1/2 表示未知/男/女,非常高效。默认 0 表示未设置。
create_time timestamp DEFAULT CURRENT_TIMESTAMP 创建时间 自动记录插入数据时的当前时间,无需代码手动赋值。
update_time timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP 更新时间 自动记录数据每次更新时的当前时间,非常适合追踪记录的最后修改时刻。
  1. 索引说明
索引名 类型 字段 作用
PRIMARY KEY (id) 主键索引 id 保证每行数据物理唯一,且查询速度最快。
idx_username 指定唯一索引 username 确保用户名全局唯一 ,并且使用 USING BTREE指定 B+树索引,加速 WHERE username = ? 的查询。
idx_user_id 指定唯一索引 user_id 确保业务用户 ID 全局唯一 ,并且加速通过 user_id 查询用户的操作。

主键索引是唯一的,所以不用指定索引名,唯一索引不是唯一的,所以需要指定索引名,唯一索引只负责确保字段值不重复。

  1. 为什么不直接用 id 作为业务用户 ID?

这是很多初学者的疑问。区分 iduser_id 的设计,体现了"主键用于数据库内部,业务 ID 用于对外暴露"的最佳实践。

对比维度 id(自增主键) user_id(分布式业务 ID)
生成方式 数据库自动递增 应用层生成(如雪花算法)
是否连续 连续递增 全局唯一但趋势递增
是否暴露给用户 ❌ 从不暴露 ✅ 对外使用(如 API 路径 /user/123456
安全性 低,容易猜测用户数量 高,难以遍历和猜测
分布式友好 否,分库分表后无法保证全局唯一 是,天生适合分布式环境

在 API 响应中,通常返回 user_id 而不是 id,这样既保护了数据库内部结构,也提升了系统安全性。

分布式ID生成器

分布式ID的特点

  • 全局唯一性:不能出现有重复的ID标识,这是基本要求。
  • 递增性:确保生成ID对于用户或业务是递增的。
  • 高可用性:确保任何时候都能生成正确的ID。
  • 高性能性:在高并发的环境下依然表现良好。

不仅仅是用于用户ID,实际互联网中有很多场景需要能够生成类似MySQL自增ID这样不断增大,同时又不会重复的id。以支持业务中的高并发场景。

比较典型的场景有:电商促销时短时间内会有大量的订单涌入到系统,比如每秒10w+;明星出轨时微博短时间内会产生大量的相关微博转发和评论消息。在这些业务场景下将数据插入数据库之前,我们需要给这些订单和消息先分配一个唯一ID,然后再保存到数据库中。对这个id的要求是希望其中能带有一些时间信息,这样即使我们后端的系统对消息进行了分库分表,也能够以时间顺序对这些消息进行排序。

雪花算法(Snowflake)

它是Twitter开源的由64位整数组成分布式ID,性能较高,并且在单机上递增。

位分配说明

  1. 第一位占用1bit,其值始终是0,没有实际作用。
  2. 时间戳占用41bit,单位为毫秒,总共可以容纳约69年的时间。这里的时间戳只是相对于某个时间的增量,比如系统上线是2020-07-01,那么timestamp当作是从2020-07-01 00:00:00.000的偏移量。
  3. 工作机器id占用10bit,其中高位5bit是数据中心ID,低位5bit是工作节点ID,最多可以容纳1024个节点。
  4. 序列号占用12bit,用来记录同毫秒内产生的不同id。每个节点每毫秒从0开始不断累加,最多可以累加到4095,同一毫秒一共可以产生4096个ID。

容量计算

同一毫秒的ID数量 = 1024 × 4096 = 4194304

Snowflake的Go实现

1. bwmarrin/snowflake

github.com/bwmarrin/sn... 是一个相当轻量化的snowflake的Go实现。

go 复制代码
package main

import (
    "fmt"
    "time"
    "github.com/bwmarrin/snowflake"
)

var node *snowflake.Node

func Init(startTime string, machineID int64) (err error) {
    var st time.Time
    st, err = time.Parse("2006-01-02", startTime)
    if err != nil {
        return
    }
    snowflake.Epoch = st.UnixNano() / 1000000
    node, err = snowflake.NewNode(machineID)
    return
}

func GenID() int64 {
    return node.Generate().Int64()
}

func main() {
    if err := Init("2020-07-01", 1); err != nil {
        fmt.Println("init failed, err:", err)
        return
    }
    id := GenID()
    fmt.Println(id)
}

2. sony/sonyflake

sony/sonyflake 是Sony公司的一个开源项目,基本思路和snowflake差不多,不过位分配上有不同:

这里的时间只用了39个bit,但时间的单位变成了10ms,所以理论上比41位表示的时间还要久(174年)。

Sequence ID和之前的定义⼀一致, Machine ID其实就是节点id。 sonyflake库有以下配置参数:

go 复制代码
type Settings struct {
    StartTime      time.Time
    MachineID      func() (uint16, error)
    CheckMachineID func(uint16) bool
}
  • StartTime:和Epoch类似,如果不设置,默认从2014-09-01 00:00:00 +0000 UTC开始。
  • MachineID:可由用户自定义函数,如果不定义,默认将本机IP的低16位作为machine id。
  • CheckMachineID:由用户提供的检查MachineID是否冲突的函数。
使用示例
go 复制代码
package main

import (
    "fmt"
    "time"
    "github.com/sony/sonyflake"
)

var (
    sonyFlake     *sonyflake.Sonyflake
    sonyMachineID uint16
)

func getMachineID() (uint16, error) {
    return sonyMachineID, nil
}

// Init 需传入当前的机器ID
func Init(startTime string, machineId uint16) (err error) {
    sonyMachineID = machineId
    var st time.Time
    st, err = time.Parse("2006-01-02", startTime)
    if err != nil {
        return err
    }
    settings := sonyflake.Settings{
        StartTime: st,
        MachineID: getMachineID,
    }
    sonyFlake = sonyflake.NewSonyflake(settings)
    return
}

// GenID 生成id
func GenID() (id uint64, err error) {
    if sonyFlake == nil {
        err = fmt.Errorf("sonyflake not initied")
        return
    }
    id, err = sonyFlake.NextID()
    return
}

func main() {
    if err := Init("2020-07-01", 1); err != nil {
        fmt.Printf("Init failed, err:%v\n", err)
        return
    }
    id, _ := GenID()
    fmt.Println(id)
}

登录注册流程

注册流程

复制代码
提交注册信息 → 参数校验 → 入库 → 注册成功

登录流程

复制代码
提交登录信息 → 参数校验 → 查询数据库 → 登录成功 → 下发Token

用户认证

HTTP是一个无状态的协议,一次请求结束后,下次再发送服务器就不知道这个请求是谁发来的了(同一个IP不代表同一个用户)。在Web应用中,用户的认证和鉴权是非常重要的一环,实践中有多种可用方案,并且各有千秋。

在Web应用发展的初期,大部分采用基于Cookie-Session的会话管理方式,逻辑如下:

  1. 客户端使用用户名、密码进行认证
  2. 服务端验证用户名、密码正确后生成并存储Session,将SessionID通过Cookie返回给客户端
  3. 客户端访问需要认证的接口时在Cookie中携带SessionID
  4. 服务端通过SessionID查找Session并进行鉴权,返回给客户端需要的数据

存在的问题:

  • 服务端需要存储Session,并且由于Session需要经常快速查找,通常存储在内存或内存数据库中,同时在线用户较多时需要占用大量的服务器资源。
  • 当需要扩展时,创建Session的服务器可能不是验证Session的服务器,所以还需要将所有Session单独存储并共享。
  • 由于客户端使用Cookie存储SessionID,在跨域场景下需要进行兼容性处理,同时这种方式也难以防范CSRF攻击。

Token认证模式

鉴于基于Session的会话管理方式存在上述多个缺点,基于Token的无状态会话管理方式诞生了。所谓无状态,就是服务端可以不再存储信息,甚至是不再存储Session,逻辑如下:

  1. 客户端使用用户名、密码进行认证
  2. 服务端验证用户名、密码正确后生成Token返回给客户端
  3. 客户端保存Token,访问需要认证的接口时在URL参数或HTTP Header中加入Token
  4. 服务端通过解码Token进行鉴权,返回给客户端需要的数据

基于 Token 的会话管理理⽅方式有效解决了了基于 Session 的会话管理理⽅方式带来的问题。

  • 服务端不需要存储和用户鉴权有关的信息,鉴权信息会被加密到Token中,服务端只需要读取Token中包含的鉴权信息即可
  • 避免了共享Session导致的不易扩展问题
  • 不需要依赖Cookie,有效避免Cookie带来的CSRF攻击问题
  • 使用CORS可以快速解决跨域问题

JWT介绍

JWT(JSON Web Token)是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准(RFC7519)。JWT本身没有定义任何技术实现,它只是定义了一种基于Token的会话管理的规则,涵盖Token需要包含的标准内容和Token的生成过程,特别适用于分布式站点的单点登录(SSO)场景。

一个JWT Token就像这样:

复制代码
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoyODAxODcyNzQ4ODMyMzU4NSwiZXhwIjoxNjE0NTQwMjkxLCJpc3MiOiJibHVlYmVsbCJ9.1k_ZrAtYGCeZhK3iupHxP1kgjBJzQTVTtX0izYFx9wU

它是由.分隔的三部分组成,这三部分依次是:

  • 头部(Header)
  • 负载(Payload)
  • 签名(Signature)

头部和负载以JSON形式存在,这就是JWT中的JSON。三部分的内容都分别单独经过了Base64编码,以.拼接成一个JWT Token。

JWT的Header中存储了所使用的加密算法和Token类型。

json 复制代码
{
    "alg": "HS256",
    "typ": "JWT"
}

Payload

Payload表示负载,也是一个JSON对象。JWT规定了7个官方字段供选用:

  • iss(issuer):签发人
  • exp(expiration time):过期时间
  • sub(subject):主题
  • aud(audience):受众
  • nbf(Not Before):生效时间
  • iat(Issued At):签发时间
  • jti(JWT ID):编号

除了官方字段,开发者也可以自己指定字段和内容,例如:

json 复制代码
{
    "sub": "1234567890",
    "name": "John Doe",
    "admin": true
}

注意:JWT默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。这个JSON对象也要使用Base64URL算法转成字符串。

Signature

Signature部分是对前两部分的签名,防止数据篡改。

首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用Header里面指定的签名算法(默认是HMAC SHA256),按照下面的公式产生签名:

scss 复制代码
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

JWT优缺点

JWT拥有基于Token的会话管理方式所拥有的一切优势,不依赖Cookie,使得其可以防止CSRF攻击,也能在禁用Cookie的浏览器环境中正常运行。

而JWT的最大优势是服务端不再需要存储Session,使得服务端认证鉴权业务可以方便扩展,避免存储Session所需要引入的Redis等组件,降低了系统架构复杂度。

但这也是JWT最大的劣势,由于有效期存储在Token中,JWT Token一旦签发,就会在有效期内一直可用,无法在服务端废止。当用户进行登出操作,只能依赖客户端删除掉本地存储的JWT Token,如果需要禁用用户,单纯使用JWT就无法做到了。

基于JWT实现认证实践

Access Token 与 Refresh Token

前面讲的Token,都是Access Token,也就是访问资源接口时所需要的Token。还有另外一种Token,Refresh Token。通常情况下,Refresh Token的有效期会比较长,而Access Token的有效期比较短。当Access Token由于过期而失效时,使用Refresh Token就可以获取到新的Access Token,如果Refresh Token也失效了,用户就只能重新登录了。

改进后的会话管理流程

在JWT的实践中,引入Refresh Token,将会话管理流程改进如下:

  1. 客户端使用用户名密码进行认证
  2. 服务端生成有效时间较短的Access Token(例如10分钟),和有效时间较长的Refresh Token(例如7天)
  3. 客户端访问需要认证的接口时,携带Access Token
  4. 如果Access Token没有过期,服务端鉴权后返回给客户端需要的数据
  5. 如果携带Access Token访问需要认证的接口时鉴权失败(例如返回401错误),则客户端使用Refresh Token向刷新接口申请新的Access Token
  6. 如果Refresh Token没有过期,服务端向客户端下发新的Access Token
  7. 客户端使用新的Access Token访问需要认证的接口

后端需要对外提供一个刷新Token的接口,前端需要实现一个当Access Token过期时自动请求刷新Token接口获取新Access Token的拦截器。

Gin框架使用JWT

JWT-Go库的基本使用详见:李文周博客 - JWT in Gin

鉴权中间件开发

go 复制代码
const (
    ContextUserIDKey = "userID"
)

var (
    ErrorUserNotLogin = errors.New("当前⽤用户未登录")
)

// JWTAuthMiddleware 基于JWT的认证中间件
func JWTAuthMiddleware() func(c *gin.Context) {
    return func(c *gin.Context) {
        // 客户端携带Token有三种⽅方式 1.放在请求头 2.放在请求体 3.放在URI
        // 这⾥里里假设Token放在Header的中
        // 这⾥里里的具体实现⽅方式要依据你的实际业务情况决定
        authHeader := c.Request.Header.Get("Auth")
        if authHeader == "" {
            ResponseErrorWithMsg(c, CodeInvalidToken,
            c.Abort()
            "请求头缺少Auth Token")
            return
        }
        mc, err := utils.ParseToken(authHeader)
            if err != nil {
            ResponseError(c, CodeInvalidToken)
            c.Abort()
            return
        }

        // 将当前请求的username信息保存到请求的上下⽂文c上
        c.Set(ContextUserIDKey, mc.UserID)
        c.Next() // 后续的处理理函数可以⽤用过c.Get("userID")来获取当前请求的⽤用户信息
    }
}

生成access token和refresh token

go 复制代码
// GenToken ⽣成access token 和 refresh token
func GenToken(userID int64) (aToken, rToken string, err error) {
    // 创建⼀个我们⾃己的声明
    c := MyClaims{
            userID, // 自定义字段
            jwt.StandardClaims{ ExpiresAt: time.Now().Add(TokenExpireDuration).Unix(), // 过期时间
            Issuer: "bluebell", // 签发⼈人
        },
    }

    // 加密并获得完整的编码后的字符串串token
    aToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256,c).SignedString(mySecret)
    
    // refresh token 不不需要存任何⾃自定义数据
    rToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.StandardClaims{
        ExpiresAt: time.Now().Add(time.Second * 30).Unix(), // 过期时间
        Issuer: "bluebell", // 签发⼈
    }).SignedString(mySecret)
    // 使⽤用指定的secret签名并获得完整的编码后的字符串串token
    return
}

解析access token

go 复制代码
// ParseToken 解析JWT
func ParseToken(tokenString string) (claims *MyClaims, err error) {
    // 解析token
    var token *jwt.Token
    claims = new(MyClaims)
    token, err = jwt.ParseWithClaims(tokenString, claims, keyFunc)
    if err != nil {
        return
    }
    return
    if !token.Valid { // 校验token
        err = errors.New("invalid token")
    }
}

refresh token

go 复制代码
// RefreshToken 刷新AccessToken
func RefreshToken(aToken, rToken string) (newAToken, newRToken string, err error) {
    // refresh token⽆无效直接返回
    if _, err = jwt.Parse(rToken, keyFunc); err != nil {
        return
    }

    // 从旧access token中解析出claims数据
    var claims MyClaims
    _, err = jwt.ParseWithClaims(aToken, &claims, keyFunc)
    v,_ := err.(*jwt.ValidationError)
    
    // 当access token是过期错误 并且 refresh token没有过期时就创建⼀一个新的access token
    if v.Errors == jwt.ValidationErrorExpired {
        return GenToken(claims.UserID)
    }
    return
}

参考链接:

相关推荐
Sinclair21 小时前
安企CMS的安装-源码编译安装
运维·后端·go
wear工程师1 天前
Go 的 map 并发读写,面试里别上来就答「sync.Map」
面试·go
HokKeung2 天前
用 Cobra 写一个像样的 Go 命令行工具
go·命令行
黑贝是条狗2 天前
一个从零到一,全部由AI实现的开放平台
go·ai vibe coding
ArixBit2 天前
从目录分层到交付验证:我的 Go 后端工程实践
go
ArixBit2 天前
用 Go 手撸 Agent 框架 #9:把最小 Agent 抽成 Runtime
go·agent
江畔柳前堤2 天前
Go04-控制结构
大数据·人工智能·面试·职场和发展·go·业界资讯
用户6757049885023 天前
别再乱用了!var arr []int 和 arr := []int{} 给整懵了?不就声明个空数组,项目居然崩了?
后端·go
江畔柳前堤4 天前
GO01-Go 语言与主流编程语言深度对比
开发语言·人工智能·后端·微服务·云原生·golang·go