模块设计

登录模块

登录令牌

介绍

Access Token 作为用户已登录的令牌(时间较短,一般来说 15-30 分钟)

Refresh Token 作为刷新令牌的密钥(时间长,15-30 天,如果过期了,要求用户重新登录)


❓为什么要这么做,一个 Access Token 不行吗?

如果只是一个 Access Token,当别人拿到 Access Token,就会模拟用户请求,十分的不安全(XSS 攻击)

加上 Refresh Token 后,哪怕 Access Token 被盗走了,也只能使用很短一段时间,之后就会失效,这样增加了安全性

同时将 Refresh Token 放到 HttpOnly Cookie 中,防止使用 js 恶意脚本读取,并且如果有恶意用户,我们只需要在 数据库 中修改用户的 Refresh Token 状态,那么这个用户就会被踢出,无法登录


❓既然这个这么 HttpOnly Cookie 这么安全,为什么不直接把 Access Token 放到 HttpOnly Cookie 中?

CSRF 风险:

  • 如果 Access Token 在 HttpOnly Cookie 里,浏览器会 自动带上它(和 refresh_token 一样)。

  • 那么任何第三方网站只要诱导用户点击一个请求(CSRF 攻击),这个请求就会带上 Access Token,后端就认为是合法的用户请求。

  • 结果:攻击者可能发起恶意请求(比如转账、删帖),用户毫不知情。

👉 所以,Access Token 更适合放在 Authorization Header,因为它不会被浏览器自动附加,必须前端代码显式添加。这样能防御 CSRF。

短时效 vs 长时效

  • Access Token 通常生命周期短(15–30 分钟),主要目的是减少风险暴露。

  • Refresh Token 生命周期长(7–30 天),所以必须 HttpOnly Cookie + 入库校验

  • 如果你把 Access Token 也放 Cookie,其实就跟 Refresh Token 没啥区别了,失去了它作为「轻量、短时效凭证」的意义。

登录流程为:

  • 用户输入账号密码 -> 后端比对账号密码 -> 成功了 -> 派发 Access Token 和 Refresh Token -> Refresh Token存入数据库,并将 Refresh Token 写入 HttpOnly Cookie(js 无法读取,防止 XSS 攻击),将 Access Token 返回前端

  • 前端收到 Access Token 后,存到 localStorage 中,之后的请求携带上这个

  • 如果请求出现 401 了,则需要前端重新请求刷新 Access Token

代码

  1. 登录成功后:

    1. 生成 access_tokenrefresh_token

    2. refresh_token 通过 Set-Cookie 写到响应头里(右侧代码)

    3. access_token 返回在响应体里,前端拿到后放 Pinia / localStorage

    c.SetCookie(
        "refresh_token",
        refreshToken,
        7*24*3600, // 有效期 7 天
        "/",       // 路径
        "yourdomain.com", // domain
        true,      // secure(生产必须 true,只能 https 下用)
        true,      // httpOnly(防止 JS 获取)
    )
  2. 前端登录成功后:

    1. 拿到 access_token 存到 Pinia(或 localStorage)

    // store/auth.ts
    import { defineStore } from 'pinia'
    
    export const useAuthStore = defineStore('auth', {
      state: () => ({
        accessToken: '' as string | null
      }),
      actions: {
        setAccessToken(token: string) {
          this.accessToken = token
        },
        clear() {
          this.accessToken = null
        }
      }
    })
    
  3. 前端请求接口时:

    1. access_token 放到请求头里:

    // plugins/axios.ts
    import axios from 'axios'
    import { useAuthStore } from '~/store/auth'
    
    export default defineNuxtPlugin(() => {
      const auth = useAuthStore()
    
      const api = axios.create({
        baseURL: '/api',
        withCredentials: true // 必须开,否则 Cookie 不会带上
      })
    
      // 请求拦截器
      api.interceptors.response.use(
      response => response,
      async error => {
        const originalRequest = error.config;
    
        if (error.response?.status === 401 && !originalRequest._retry) {
          if (!isRefreshing) {
            isRefreshing = true;
            try {
              const res = await api.post("/auth/refresh"); 
              const newToken = res.data.accessToken;
              // 更新内存里的 token
              window.localStorage.setItem("accessToken", newToken);
    
              isRefreshing = false;
              onRefreshed(newToken);
            } catch (err) {
              isRefreshing = false;
              // 刷新失败,跳转登录
              window.location.href = "/login";
              return Promise.reject(err);
            }
          }
    
          return new Promise(resolve => {
            subscribeTokenRefresh((newToken: string) => {
              originalRequest._retry = true;
              originalRequest.headers["Authorization"] = `Bearer ${newToken}`;
              resolve(api(originalRequest));
            });
          });
        }
    
        return Promise.reject(error);
      }
    );
    
  4. 后端刷新接口:

    1. 从 Cookie 里取 refresh_token

    2. 查数据库校验

    3. 如果合法 → 签发新 access_token 并返回

    func RefreshHandler(c *gin.Context) {
        refreshToken, err := c.Cookie("refresh_token")
        if err != nil {
            c.JSON(http.StatusUnauthorized, gin.H{"error": "No refresh token"})
            return
        }
    
        // 验证 refreshToken 合法性(查数据库)
        userID, err := VerifyRefreshToken(refreshToken)
        if err != nil {
            c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid refresh token"})
            return
        }
    
        // 签发新的 accessToken
        newAccessToken, _ := GenerateAccessToken(userID)
    
        c.JSON(http.StatusOK, gin.H{
            "access_token": newAccessToken,
        })
    }
    

路由鉴权

前端:

  1. 创建中间件

// middleware/auth.ts
export default defineNuxtRouteMiddleware((to, from) => {
  const authStore = useAuthStore()
  if (!authStore.accessToken) {
    return navigateTo('/login')
  }
})
  1. 在需要使用的页面:

// index.vue
definePageMeta({
  middleware: 'auth'
})

后端:

后端也需要对需要用户登录的接口进行验证

浏览器指纹(设备码)

在浏览器中很难实现唯一设备码,所以只能模拟实现:

使用第三方库 FingerprintJS.js 生成设备码,但是可能不是唯一的,如用户清除缓存或者使用隐身模式可能会发生变化

import FingerprintJS from '@fingerprintjs/fingerprintjs'

const fp = await FingerprintJS.load()
const result = await fp.get()
const deviceId = result.visitorId // 生成的唯一 ID

数据库表设计

用户信息

Table:user 用户基础信息表

字段

主键

外键关联

类型

索引

唯一

空值

描述

id

bigint

主键 key

fuzzy_user_id

varchar

加密 id

username

varchar

唯一用户名

nickname

varchar

昵称

avatar

varchar

头像

email

varchar

邮箱

phone

int

电话号

password

varchar

加密后的密码

bio

text

个人简介

user_type

varchar

用户类型

gender

tinyint

性别(0:未知,1:男,2:女)

birthday

datetime

生日

country

varchar

国家

country_code

int

国家代码

language

varchar

语言

device_id

varchar

用户设备 id

status

tinyint

状态(0:正常,1:禁用)

created_at

datetime

创建时间

updated_at

datetime

更新时间

CREATE TABLE users (
    id INT PRIMARY KEY AUTO_INCREMENT,
    fuzzy_user_id VARCHAR(255) NOT NULL,
    user_name VARCHAR(255) NOT NULL UNIQUE,
    nick_name VARCHAR(255) DEFAULT '',
    avatar VARCHAR(255) DEFAULT '',
    email VARCHAR(255) DEFAULT '',
    phone INT DEFAULT 0,
    password VARCHAR(255) DEFAULT '',
    bio TEXT,
    user_type TINYINT UNSIGNED DEFAULT 1,
    gender TINYINT UNSIGNED DEFAULT 0 COMMENT '0未知,1男,2女',
    birthday DATETIME DEFAULT NULL,
    country VARCHAR(255) DEFAULT '',
    country_code INT DEFAULT 0,
    language VARCHAR(255) DEFAULT '',
    device_id VARCHAR(255) DEFAULT '',
    status TINYINT UNSIGNED DEFAULT 0,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- 创建索引
CREATE INDEX idx_users_id ON users(id);
CREATE INDEX idx_users_fuzzy_user_id ON users(fuzzy_user_id);
CREATE INDEX idx_users_username ON users(user_name);

Table:userextension 用户扩展信息表

字段

主键

外键关联

类型

索引

唯一

空值

描述

id

bigint

主键 key

user_id

user.id

bigint

外键关联 user 表

last_login_at

datetime

上次登录时间

last_active_at

datetime

上次活跃时间

reset_password_at

datetime

重置密码时间

item_likes

bigint

帖子被喜欢数(我的帖子被别人喜欢数量)

item_dislikes

bigint

帖子被不喜欢数

item_shares

bigint

帖子被分享数

item_count

bigint

发布帖子数

item_collects

bigint

帖子被收藏数

first_item_post_at

datetime

第一次发布帖子时间

last_item_post_at

datetime

最后一次发布帖子时间

comment_count

bigint

评论数

dislike_count

bigint

不喜欢数量

like_count

bigint

喜欢数量

share_count

bigint

我分享的数量

collect_count

bigint

我收藏的数量

following_count

bigint

关注数量

follower_count

bigint

粉丝数量

update_name_at

bigint

上次更新用户名时间

has_shop

tinyint

是否有店铺

device_id

varchar

用户设备 id

interests

varchar

用户兴趣

created_at

datetime

创建时间

updated_at

datetime

更新时间

CREATE TABLE user_extensions (
    id INT PRIMARY KEY AUTO_INCREMENT,
    user_id INT NOT NULL,
    last_login_at DATETIME DEFAULT NULL,
    last_active_at DATETIME DEFAULT NULL,
    reset_password_at DATETIME DEFAULT NULL,
    item_likes INT DEFAULT 0 COMMENT '我的帖子被喜欢数',
    item_dislikes INT DEFAULT 0 COMMENT '我的帖子被不喜欢数量',
    item_shares INT DEFAULT 0 COMMENT '我的帖子被分享量',
    item_count INT DEFAULT 0 COMMENT '我发布的帖子数量',
    item_collects INT DEFAULT 0 COMMENT '我的帖子被收藏量',
    first_item_post_at DATETIME DEFAULT NULL COMMENT '第一次发帖子时间',
    last_item_post_at DATETIME DEFAULT NULL COMMENT '最后一次发帖子时间',
    comment_count INT DEFAULT 0 COMMENT '我的评论数量',
    dislike_count INT DEFAULT 0 COMMENT '我不喜欢的数量',
    like_count INT DEFAULT 0 COMMENT '我喜欢的数量',
    share_count INT DEFAULT 0 COMMENT '我分享的数量',
    collect_count INT DEFAULT 0 COMMENT '我收藏的数量',
    following_count INT DEFAULT 0 COMMENT '我的关注数',
    follower_count INT DEFAULT 0 COMMENT '我的粉丝数',
    has_shop TINYINT UNSIGNED DEFAULT 0 COMMENT '是否有店铺,0没有,1有',
    interests VARCHAR(255) DEFAULT '' COMMENT '感兴趣的方向',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    
    -- 外键约束
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE
);

-- 创建索引
CREATE INDEX idx_user_extensions_id ON user_extensions(id);
CREATE INDEX idx_user_extensions_user_id ON user_extensions(user_id);

Table:user_refresh_tokens 用户登录Token 表

字段

主键

外键关联

类型

索引

唯一

空值

默认值

描述

id

bigint

主键 key

user_id

user.id

bigint

外键关联 user 表

token

varchar

Refresh Tokens 数据

status

tinyint

1

Token 状态,1 有效,0 失效

expire_at

datetime

过期时间

device_id

varchar

用户设备 id

created_at

datetime

创建时间

updated_at

datetime

更新时间

Table:user_devices 用户设备表

字段

主键

外键关联

类型

索引

唯一

空值

默认值

描述

id

bigint

主键 key

user_id

user.id

bigint

外键关联 user 表

device_id

varchar

Refresh Tokens 数据

device_name

VARCHAR

设备描述(PC/Mobile/浏览器信息)

ip_address

varchar

last_login

datetime

status

tinyint

1

1=有效,0=失效/踢出

created_at

datetime

创建时间

updated_at

datetime

更新时间

帖子信息

Table:posts 帖子主表

字段

主键

类型

索引

唯一

空值

默认值

描述

id

bigint

主键 key

fuzzy_id

varchar

user_id

bigint

用户 id

title

varchar

标题

content

TEXT

内容

post_type

ENUM('image', 'video', 'mixed')

帖子类型(图、视频、图文混合)

status

tinyint

1

0:草稿;1:正常状态;2:审核中;4:隐藏;5:下架;6:禁用

country

varchar

国家

language

varchar

语言

user_modified_at

datetime

用户修改时间

is_public

tinyint

是否是公开的;0:私密;1:公开

publish_at

datetime

上传时间

hash_tag

varchar

所属标签,使用 ","分割

music_name

varchar

先为 空

music_id

int

先为 0

allow_comment

tinyint

是否可以评论

is_comment

tinyint

是否是评论视频,用户可以在评论下使用视频评论

need_pay

tinyint

是否是付费视频

price

int

付费视频

allow_download

tinyint

是否可以下载

cover_url

varchar

视频封面

created_at

datetime

updated_at

datetime

Table:post_resource_groups 帖子资源分组表

存储帖子里不同类型的分组(图集 / 视频 / 混合段落):

字段

主键

类型

索引

唯一

空值

默认值

描述

id

bigint

fuzzy_id

varchar

post_id

biting

对应的帖子 id

group_type

ENUM('image', 'video', 'mixed')

对应的类型

order_index

int

分组顺序(在同一个帖子中,这个组的位置)

created_at

datetime

updated_at

datetime

Table:post_resources 帖子资源表

存储帖子里不同类型的分组(图集 / 视频 / 混合段落):

字段

主键

类型

索引

唯一

空值

默认值

描述

id

bigint

fuzzy_id

varchar

group_id

biting

对应的资源组 id

type

ENUM('image', 'video')

文件路径或 CDN URL

cover_url

varchar

封面图

order_index

int

组内顺序

duration

int

视频时长

created_at

datetime

updated_at

datetime