SSO 与微信扫码登录

2022-12-30T14:11:02+08:00 | 21分钟阅读 | 更新于 2025-12-30T14:11:02+08:00

@

学习目标

  1. 理解 SSO(单点登录)的核心思想,能够独立讲清楚 OAuth2 授权码流程
  2. 掌握微信扫码登录的完整流程:构造授权 URL、处理回调、用 code 换 access_token、登录或注册
  3. 理解 state 参数在防 CSRF 攻击中的作用,并能在代码中正确使用
  4. 掌握长短 token(access_token / refresh_token)机制,知道为什么不能用自动刷新代替前端触发刷新
  5. 能在 JWT 无状态机制下实现"退出登录",理解 ssid 方案与降级策略
  6. 掌握配置模块的设计:来源、优先级、两次加载,能用 viper 读取本地配置和 etcd 远程配置
  7. 理解结构化日志的级别与最佳实践,能用 zap 打日志并抽象自己的 Logger 接口

前置知识(如果下面任意一点生疏,先回看对应章):

  • 第03章 JWT:知道三段结构、验签、middleware 校验。
  • 第03章 限流 / User-Agent 校验:理解"token 泄露"的风险。
  • 第04章 面向接口编程 / 依赖注入:本章 jwt.Handler 就是接口抽象的实践。
  • 基本 HTTP 重定向、Cookie 概念。

本章你会动手做的事

  • 在纸上/白板上独立画出 OAuth2 授权码流程的时序图(不看书),标出 code 与 access_token 各自的去向。
  • 给微信回调的 Callback 加上 state 校验,并用一个"攻击者用别人 code 绑账号"的场景验证它能拦住。
  • ssid + Redis 实现退出登录,并故意把 Redis 停掉,确认降级策略下已登录用户仍能访问。

一、SSO 与微信扫码登录

1.1 SSO 是什么

SSO(Single Sign-On,单点登录):用户在一个登录入口登录后,访问同一公司下所有互信的应用都不需要再次登录。

SSO 的常见实现有:

  • OAuth2:授权码模式(最常用,本文重点)
  • CAS:中央认证服务,多用于传统企业内网
  • JWT + 共享:把 token 通过 Cookie 共享给同根域下的子应用

微信扫码登录本质就是一个 OAuth2 授权码流程:你作为用户,授权 webook 这个第三方应用拿到了 access_token,webook 就认为你登录了。

类比:SSO 像公司的"一卡通"——你在前台刷脸领了一张门禁卡,之后进大楼、进食堂、进健身房都不用再证明身份。OAuth2 授权码模式是"领卡"最安全的流程:卡不是直接塞你手里,而是前台先给你一个临时排队号(code),你拿号去后台换了真正的卡(access_token)。

1.2 OAuth2 授权码流程(补充知识)

OAuth2 是一个授权框架,授权码模式(Authorization Code)是最安全的流程:

sequenceDiagram
    participant U as 用户
    participant W as webook(客户端)
    participant X as 微信(授权+资源服务器)
    U->>W: 1. 点击登录
    W->>X: 2. 重定向到微信授权页(带 client_id+redirect_uri+state)
    U->>X: 3. 扫码确认
    X->>W: 4. 重定向回 webook(带 code+state)
    W->>X: 5. 用 code 换 access_token
    X->>W: 6. 返回 access_token
    W->>X: 7. 用 token 拿用户信息
    X->>W: 8. 返回用户信息
    W->>U: 9. 登录成功

为什么要有 code 这一步? 因为 access_token 是敏感的,不能直接通过浏览器 URL 传递(会被 Referer 泄露)。所以先用浏览器传一个临时 code(短效、只能用一次),后端再用 code + secret 换 access_token,全程走服务端到服务端的安全通道。

1.3 微信扫码登录 API 详解

1.3.1 第一步:构造跳转到微信的 URL

跳转到微信时需要拼接这些参数:

参数说明
appid微信开放平台注册的 ID
redirect_uri微信跳回来的地址(必须与注册时填的域名一致)
response_type固定为 code
scope固定为 snsapi_login(登录权限)
state随机数,防 CSRF

1.3.2 第二步:微信跳回来,带 code

微信扫码确认后,会重定向到 redirect_uri?code=xxx&state=xxx

1.3.3 第三步:用 code 换 access_token

后端用 appid + secret + code 调用微信接口,换取真正的 access_token。

1.3.4 微信返回的字段

授权码部分:

  • access_token:后续访问微信 API 的凭证(短 token)
  • expires_in:access_token 的有效期
  • refresh_token:access_token 过期后用它换新的

ID 部分:

  • open_id:用户在你这个应用下的唯一 ID
  • union_id:用户在你这个公司下的唯一 ID

1.3.5 open_id 与 union_id 的区别

假设你们公司在微信注册了 A、B 两个产品。对用户张伟:

  • 张伟在 A 上有一个 open_id
  • 张伟在 B 上有另一个不同的 open_id
  • 但张伟在 A 和 B 上的 union_id同一个

所以跨产品识别同一个用户,要用 union_id

flowchart TD
    subgraph A[产品A]
        OA[open_id: aaa]
    end
    subgraph B[产品B]
        OB[open_id: bbb]
    end
    OA --> U[union_id: 同一个]
    OB --> U

1.4 设计与实现

1.4.1 提供两个接口

// web/wechat.go
type OAuth2WechatHandler struct {
    svc     wechat.Service       // 微信 OAuth2 服务
    userSvc service.UserService  // 用户服务
    jwt     jwt.Handler          // JWT 处理
}

func (h *OAuth2WechatHandler) RegisterRoutes(server *gin.Engine) {
    g := server.Group("/oauth2/wechat")
    g.GET("/authurl", h.AuthURL)    // 1. 获取微信授权 URL
    g.GET("/callback", h.Callback)  // 2. 处理微信回调
}

1.4.2 wechat.Service:构造授权 URL

// service/wechat/service.go
package wechat

import "github.com/google/uuid"

// Service 微信 OAuth2 服务抽象
type Service interface {
    // AuthURL 生成跳转到微信的授权 URL
    AuthURL(ctx context.Context, state string) (string, error)
    // Verify 用 code 换 access_token 和用户信息
    Verify(ctx context.Context, code string) (Result, error)
}

type Result struct {
    OpenId        string
    UnionId       string
    AccessToken   string
    RefreshToken  string
    ExpiresIn     int64
}

// 腾讯实现(构造 URL)
type WechatService struct {
    appId       string
    appSecret   string
    redirectURL string
}

func NewWechatService(appId, appSecret, redirectURL string) *WechatService {
    return &WechatService{appId: appId, appSecret: appSecret, redirectURL: redirectURL}
}

func (s *WechatService) AuthURL(ctx context.Context, state string) (string, error) {
    // 步骤 1:拼接微信授权 URL(appid + 回调 + 固定 response_type/scope + state)
    return fmt.Sprintf(
        "https://open.weixin.qq.com/connect/qrconnect?appid=%s&redirect_uri=%s"+
            "&response_type=code&scope=snsapi_login&state=%s",
        s.appId, url.QueryEscape(s.redirectURL), state), nil
}

1.4.3 AuthURL Handler

func (h *OAuth2WechatHandler) AuthURL(ctx *gin.Context) {
    // 步骤 1:生成 state,用 UUID 防止被猜
    state := uuid.New().String()
    // 步骤 2:把 state 写入 Cookie(JWT 形式),等回调时校验
    val := h.jwt.SetStateToken(ctx, state)
    ctx.SetCookie("jwt-state", val, 600, "/", "", false, true)

    // 步骤 3:构造微信授权 URL 返回给前端
    url, err := h.svc.AuthURL(ctx, state)
    if err != nil {
        ctx.JSON(http.StatusOK, web.Result{Code: 5, Msg: "系统错误"})
        return
    }
    ctx.JSON(http.StatusOK, web.Result{Data: url})
}

1.4.4 Callback Handler

func (h *OAuth2WechatHandler) Callback(ctx *gin.Context) {
    // 步骤 1:校验 state(防 CSRF)
    err := h.jwt.CheckState(ctx, ctx.Query("state"))
    if err != nil {
        ctx.JSON(http.StatusOK, web.Result{Code: 4, Msg: "非法请求"})
        return
    }
    // 步骤 2:用 code 换微信用户信息
    wechatRes, err := h.svc.Verify(ctx, ctx.Query("code"))
    if err != nil {
        ctx.JSON(http.StatusOK, web.Result{Code: 5, Msg: "系统错误"})
        return
    }
    // 步骤 3:FindOrCreate:新用户直接注册,老用户直接登录
    u, err := h.userSvc.FindOrOrCreate(ctx, wechatRes.UnionId)
    if err != nil {
        ctx.JSON(http.StatusOK, web.Result{Code: 5, Msg: "系统错误"})
        return
    }
    // 步骤 4:设置登录态(长短 token)
    if err = h.jwt.SetLoginToken(ctx, u.Id); err != nil {
        ctx.JSON(http.StatusOK, web.Result{Code: 5, Msg: "系统错误"})
        return
    }
    ctx.JSON(http.StatusOK, web.Result{Msg: "登录成功"})
}

1.5 state 参数:防 CSRF 攻击

1.5.1 攻击场景

理解 state 的核心是抓住"攻击者让你用他的临时授权码来绑定账号":

  1. 攻击者弄到一个绑定微信的临时授权码 A
  2. 正常用户登录成功
  3. 攻击者伪造页面诱导用户点击,攻击者带着正常用户的 Cookie + 攻击者的 code 去请求
  4. 结果:攻击者通过微信扫码登录,看到正常用户的数据
sequenceDiagram
    participant A as 攻击者
    participant V as 正常用户
    participant W as webook
    A->>V: 诱导点击(带着攻击者的 code)
    V->>W: 正常用户的 Cookie + 攻击者的 code
    Note over W: 若没校验 state, 把攻击者的微信绑到正常用户账号
    W-->>A: 攻击者用自己微信登录, 看到正常用户数据

1.5.2 state 如何解决

  • 生成 AuthURL 时,生成 state 并存起来(JWT 或 Session)
  • 微信跳回来时,把回调的 state 和我们存的 state 比较

如果不一致,说明有人伪造请求。

1.5.3 用 JWT 记录 state

用 JWT 记录 state 的好处:不用额外存 Redis,直接用 JWT 自带的 state 比较。注意要放在 Cookie 里,因为微信回调直接到后端,不经过前端。如果放 Header,前端代码需要主动加,但这里没法让前端加。

// 检查 state
func (h *JWTHandler) CheckState(ctx *gin.Context, state string) error {
    // 步骤 1:从 Cookie 取出 state token
    ck, err := ctx.Cookie("jwt-state")
    if err != nil {
        return fmt.Errorf("找不到 state cookie")
    }
    // 步骤 2:解析 JWT
    var claims StateClaims
    _, err = jwt.ParseWithClaims(ck, &claims, func(t *jwt.Token) (interface{}, error) {
        return h.stateKey, nil
    })
    if err != nil {
        return err
    }
    // 步骤 3:比对 state 是否一致
    if claims.State != state {
        return fmt.Errorf("state 不匹配")
    }
    return nil
}

现实情况:大部分公司都没处理 state。面试中刷亮点就要解释清楚:什么情况下不处理 state 会出问题(CORS 场景),以及如何解决。

⚠️ 新手必踩的坑:state 必须"存一份 + 比一次"。常见错误是只在 URL 里生成 state 却没在服务端留存,回调时无法比对——等于没防。正确做法是 AuthURL 时把 state 写进 Cookie(或 Session/Redis),Callback 时取出来和微信回传的 state 严格相等才放行。


二、长短 token 与登出

2.1 长短 token 的设计

在微信扫码登录里你已经看到:

  • access_token:短 token,用于访问资源,频繁使用,容易泄露
  • refresh_token:长 token,access_token 过期后用它换新的,使用频率低,不容易泄露

2.2 为什么需要两个 token

核心矛盾

  • token 越长寿,用户体验越好(不用频繁登录)
  • token 越长寿,泄露后危害越大

长短 token 的折中方案

  • 短 token 寿命短(如 30 分钟),即使泄露损失也小
  • 长 token 寿命长(如 7 天),但只在登录和刷新时用,不直接访问资源,泄露风险小
flowchart TD
    U[用户] -->|携带 access_token 访问资源| R[业务资源]
    R -. access_token 过期 .-> F[返回 401]
    F --> RF[前端用 refresh_token 换新的 access_token]
    RF --> U

2.3 改造已有代码

后端改造点:

  1. 登录成功时返回两个 token:access_token(短)+ refresh_token(长)
  2. 提供新的刷新 token 接口 refresh_token
  3. 去掉 JWT middleware 中的"自动刷新过期时间"机制

前端改造点:

  1. 收到 401 响应时,调用 refresh_token 拿新 token
  2. 用新 token 重试原请求
// 设置长短 token
func (h *JWTHandler) SetLoginToken(ctx *gin.Context, uid int64) error {
    // 步骤 1:生成 ssid,用于退出登录
    ssid := uuid.New().String()

    // 步骤 2:生成短 token(access_token,30 分钟)
    accessToken, err := h.genToken(uid, ssid, h.accessKey, 30*time.Minute)
    if err != nil {
        return err
    }
    ctx.Header("x-jwt-token", accessToken)

    // 步骤 3:生成长 token(refresh_token,7 天)
    refreshToken, err := h.genToken(uid, ssid, h.refreshKey, 7*24*time.Hour)
    if err != nil {
        return err
    }
    ctx.Header("x-refresh-token", refreshToken)
    return nil
}

2.4 refresh_token 接口

func (h *UserHandler) RefreshToken(ctx *gin.Context) {
    // 步骤 1:从 Header 拿长 token
    tokenStr := ctx.GetHeader("x-refresh-token")
    if tokenStr == "" {
        ctx.AbortWithStatus(http.StatusUnauthorized)
        return
    }
    // 步骤 2:解析长 token
    claims := &RefreshClaims{}
    _, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
        return h.jwt.refreshKey, nil
    })
    if err != nil || claims.ExpiresAt.Before(time.Now()) {
        ctx.AbortWithStatus(http.StatusUnauthorized)
        return
    }
    // 步骤 3:校验 ssid(用户是否已退出登录)
    err = h.jwt.CheckSession(ctx, claims.Ssid)
    if err != nil {
        ctx.AbortWithStatus(http.StatusUnauthorized)
        return
    }
    // 步骤 4:颁发新的短 token 和长 token
    err = h.jwt.SetLoginToken(ctx, claims.Uid)
    if err != nil {
        ctx.JSON(http.StatusOK, web.Result{Code: 5, Msg: "系统错误"})
        return
    }
    ctx.JSON(http.StatusOK, web.Result{Msg: "OK"})
}

2.5 为什么必须前端触发刷新?

面试常问:能不能在 JWT 中间件里发现短 token 过期就自动刷新?

不能。如果后端自动刷新,就和原来的"自动续期"机制一样了,违背了长短 token 的初衷。长短 token 的核心假设是:短 token 频繁使用容易泄露,所以寿命要短;长 token 只在登录和刷新时用,不容易泄露。如果中间件自动刷新长 token,长 token 也会变成"频繁使用",泄露风险上升。

2.6 长 token 泄露了怎么办?

但凡长 token 泄露,能做的事已经不多:

  1. 在长 token 里编码登录时的信息(如 User-Agent),校验时比对
  2. 长 token 只能用一次:每次刷新时颁发新的长 token,旧的作废(缓解但不能根治)

2.7 JWT 退出登录

Session 退出登录很简单:删 Cookie + 删 Redis 里的 Session 数据。

JWT 退出登录很麻烦:JWT 是无状态的,签发后无法主动作废(除非生成新 token)。多实例部署下,退出登录的请求落到实例 0,后续请求落到实例 1,实例 1 必须能感知到。

解决方案:用 Redis 记录"已退出登录的会话"

2.8 ssid 方案

思路:每次登录生成一个 ssid(Session ID),写入长短 token。退出登录时把 ssid 加入 Redis 黑名单。校验时检查 ssid 是否在黑名单中。

// 登录时生成 ssid
ssid := uuid.New().String()
// 写入 access_token 和 refresh_token 的 claims

// 退出登录时,把 ssid 加入 Redis
func (h *JWTHandler) Logout(ctx *gin.Context, ssid string) error {
    // 步骤 1:标记 ssid 不可用,过期时间和长 token 一致(7 天)
    return h.cmd.Set(ctx, fmt.Sprintf("users:ssid:%s", ssid),
        1, 7*24*time.Hour).Err()
}

// 校验时检查
func (h *JWTHandler) CheckSession(ctx *gin.Context, ssid string) error {
    // 步骤 1:查 Redis 黑名单
    cnt, err := h.cmd.Exists(ctx, fmt.Sprintf("users:ssid:%s", ssid)).Result()
    if err != nil {
        // 降级策略:Redis 挂了就不严格校验,保证登录用户可用
        return nil
    }
    // 步骤 2:在黑名单里则拒绝
    if cnt > 0 {
        return errors.New("会话已失效")
    }
    return nil
}
flowchart TD
    L[登录] --> G[生成 ssid 写入 token]
    O[退出登录] --> B[ssid 加入 Redis 黑名单]
    V[后续请求校验] --> C{Redis 里 ssid 存在?}
    C -- 存在 --> R[拒绝: 会话失效]
    C -- 不存在 --> OK[放行]
    C -- Redis 挂了 --> OK

⚠️ 新手必踩的坑:退出登录要让客户端也清掉 token。只把 ssid 写进 Redis 黑名单还不够——如果客户端仍带着旧 token 且没走到 Redis 校验(比如某些静态资源或中间件顺序问题),可能绕过。标准做法同时:① 服务端把 ssid 加入黑名单(让后续 JWT 校验失败);② 告诉客户端把长短 token 置为非法值。两层一起上才稳。

2.9 降级策略(面试加分点)

Redis 崩溃时,要不要严格执行 ssid 校验?

不严格执行。理由:

  • 相比退出登录这种少数情况,绝大多数已登录用户是正常的
  • 如果严格执行,Redis 一挂,所有登录用户全部无法通过校验,业务雪崩
  • 降级策略:Redis 挂了就跳过 ssid 校验,保证大多数用户可用

2.10 退出登录后的清理

退出登录时:

  1. 把 ssid 加入 Redis 黑名单
  2. 把客户端 Cookie/Header 里的长短 token 设为非法值,让客户端在到达 Redis 之前就因 JWT 校验失败而返回 401

2.11 重构:抽出 JwtHandler

随着功能增加,JWT 相关代码散落在 UserHandler、WechatHandler、JWTLoginMiddlewareBuilder 各处,屎山味道。重构方案:

// internal/jwt/handler.go
package jwt

type Handler interface {
    // 设置登录态 token(写入响应头)
    SetLoginToken(ctx *gin.Context, uid int64) error
    // 生成短 token
    GenerateToken(uid int64, ssid string) (string, error)
    // 校验短 token
    ParseToken(tokenStr string) (*Claims, error)
    // 设置/校验 state(用于 OAuth2)
    SetStateToken(ctx *gin.Context, state string) string
    CheckState(ctx *gin.Context, state string) error
    // 退出登录
    Logout(ctx *gin.Context, ssid string) error
    CheckSession(ctx *gin.Context, ssid string) error
}

// Redis 实现
type RedisJWTHandler struct {
    cmd         redis.Cmdable
    accessKey   []byte
    refreshKey  []byte
    stateKey    []byte
}

关键点

  • 把所有 JWT 操作内聚到 jwt.Handler
  • UserHandler、WechatHandler、JWTLoginMiddlewareBuilder 都依赖 jwt.Handler 接口
  • jwt.Handler 不直接写响应,让调用者根据返回值自己写(保持职责单一)

2.12 为什么不用 user id 代替 ssid?

面试衍生:能不能用 user id 标识"已退出登录"?

可以,但要注意:

  1. 登录时要删除 user id 的记录
  2. 要处理"一边登录一边退出登录"的并发问题(用户登录会清掉记录,使退出登录失效)

所以不建议用 user id,建议用 UUID 生成的 ssid。每次登录生成新的 ssid,互不影响。

2.13 布隆过滤器方案(面试方案)

另一种"记录已退出登录会话"的方案是布隆过滤器

  • 退出登录时把 ssid 哈希到比特数组的 N 个位置置 1
  • 校验时检查这些位置是否都为 1

缺点

  • 布隆过滤器有假阳性(说存在但实际不存在),所以还是要 Redis 兜底
  • 布隆过滤器本身也要用 Redis 实现,至少访问一次 Redis

所以改进有限,主要作为面试亮点。

布隆过滤器原理

选取 N 个哈希函数,计算业务值的哈希,将比特数组对应位置置 1。查询时:

  • 如果任一位置为 0 → 一定不存在
  • 如果所有位置都为 1 → 可能存在(假阳性)
flowchart LR
    S[ssid] --> H1[hash1]
    S --> H2[hash2]
    S --> H3[hash3]
    H1 --> B1[bit 3]
    H2 --> B2[bit 7]
    H3 --> B3[bit 12]

三、配置模块

3.1 配置来源

来源适用场景
启动参数命令行工具,如 mockgen -source=xxx
环境变量和实例有关的参数,如权重、分组
配置文件当前环境的通用配置,如数据库连接
远程配置中心除启动最少配置外的所有配置

个人建议

  • 少用启动参数(新人门槛高)
  • 少用环境变量(要登录机器才知道值)
  • 优先配置文件
  • 大规模微服务集群再引入远程配置中心

3.2 来源的优先级

如果多个来源都有同一配置项,用哪个?取决于哪个更"权威"。个人实践

命令行 > 环境变量 > 配置文件 > 远程配置中心

理由:命令行是我主动输入的;环境变量是我自己设置的;配置文件可能是同事写的;远程配置中心是公共的。

3.3 两次加载

使用远程配置中心时一般有两次加载:

第一次:加载最基本的配置

  • 远程配置的连接信息(连上配置中心)
  • 日志相关配置(确保后续能输出日志)

第二次:完全加载

  • 读取所有依赖配置(数据库、Redis 等)
  • 用于初始化各种组件
  • 如果远程配置中心能覆盖第一次的配置,则覆盖并重新初始化(如重连日志平台)
flowchart TD
    S[启动] --> L1[第一次加载: 连配置中心+日志]
    L1 --> L2[第二次加载: 全量配置]
    L2 --> I[初始化 DB/Redis/...]
    I --> R[若远程覆盖, 重连日志平台]

3.4 viper:读取本地配置

viper 是 Go 里 star 数最高的配置库:

go get github.com/spf13/viper

3.4.1 基本用法

viper.SetConfigName("config")  // 文件名(不含扩展名)
viper.SetConfigType("yaml")    // 文件类型
viper.AddConfigPath("./config") // 查找路径
viper.AddConfigPath(".")       // 也可以多个路径
err := viper.ReadInConfig()
if err != nil {
    panic(err)
}

注意 working directory:viper 从 Go 的 working directory 开始定位。如果在 GoLand 中跑,要确认 working directory 设置正确(一般是项目根目录)。

3.4.2 推荐写法:UnmarshalKey 到结构体

type DBConfig struct {
    DSN string `mapstructure:"dsn"`  // 直接用 DSN,不拆账号密码
}

func InitDB() *gorm.DB {
    // 步骤 1:定义目标结构体
    var cfg DBConfig
    // 步骤 2:按 key 反序列化
    err := viper.UnmarshalKey("db", &cfg)
    if err != nil {
        panic(err)
    }
    // 步骤 3:用 DSN 连库
    db, err := gorm.Open(mysql.Open(cfg.DSN), &gorm.Config{})
    if err != nil {
        panic(err)
    }
    return db
}

为什么不直接 viper.GetString("db.dsn")?因为:把相关配置收到一个结构体里更清晰;后续扩展字段方便;类型安全。

3.4.3 默认值

// 方式一:viper.SetDefault
viper.SetDefault("db.dsn", "localhost:3306")

// 方式二(推荐):结构体字段设默认值
type DBConfig struct {
    DSN string `mapstructure:"dsn"`
}
func InitDB() *gorm.DB {
    cfg := DBConfig{DSN: "localhost:3306"}  // 默认值
    _ = viper.UnmarshalKey("db", &cfg)
    // 如果配置文件没有 db.dsn,cfg.DSN 仍是 localhost:3306
}

推荐方式二,因为默认值放在业务相应的地方。

3.4.4 直接传 io.Reader

viper.SetConfigType("yaml")
viper.ReadConfig(strings.NewReader(`
db:
  dsn: "root:root@tcp(localhost:3306)/webook"
`))

测试或本地调试时方便,不用写配置文件。

3.5 不同环境加载不同配置

公司一般有几个环境:

  • 开发环境(本地)
  • 测试环境(测试人员用)
  • 线上环境

viper 不内置这种支持。常见做法是启动时传入配置文件路径

// 启动时:go run . --config=./config/dev.yaml
func InitConfig() {
    configPath := flag.String("config", "config/dev.yaml", "配置文件路径")
    flag.Parse()
    viper.SetConfigFile(*configPath)
    if err := viper.ReadInConfig(); err != nil {
        panic(err)
    }
}

3.6 远程配置中心:etcd

3.6.1 为什么需要

配置文件的缺点:不够灵活,难以接入加密解密、权限控制、实时更新。所以可以用远程配置中心。

3.6.2 用 Docker Compose 启动 etcd

# docker-compose.yaml
services:
  etcd:
    image: bitnami/etcd:latest
    environment:
      - ALLOW_NONE_AUTHENTICATION=yes  # 开发环境不设密码
    ports:
      - "2379:2379"

3.6.3 用 viper 接入 etcd

import (
    "github.com/spf13/viper"
    _ "github.com/spf13/viper/remote"  // 别忘了匿名引入 remote 包
)

func InitConfig() {
    viper.SetConfigType("yaml")
    err := viper.AddRemoteProvider("etcd", "localhost:2379", "/webook")
    if err != nil {
        panic(err)
    }
    err = viper.ReadRemoteConfig()
    if err != nil {
        panic(err)
    }
}

3.6.4 在 etcd 中写入配置

# 直接读取 yaml 文件作为参数
etcdctl put /webook -- < config/dev.yaml

3.6.5 监听配置变更

viper.WatchConfig()              // 监听文件变更
viper.OnConfigChange(func(e fsnotify.Event) {
    fmt.Println("配置变更:", e.Name)
})

// 监听远程配置
viper.WatchRemoteConfig()

应用场景:用配置做功能开关。新功能上线时开启,出问题立刻关闭,不用重新部署。

3.7 较好的实践

3.7.1 使用抽象的配置 API

不要在业务代码里直接操作 etcd API,否则应用和 etcd 耦合。借 viper 的 API 屏蔽底层实现差异(文件、etcd、Nacos)。如果担心 viper 不够好,可以照 viper API 抄一个公司内部统一的配置 API。

3.7.2 配置操作限定在初始化过程中

把配置操作放在 IoC 和 main 函数中。这样从 viper 换到别的框架时,只改初始化过程,别的代码不动。

缺点:如果要在 Service 层监听配置项变更,就违背了这个原则。

3.8 升职加薪:在公司引入远程配置中心

可以为公司提供:

  • 统一配置接口
  • Web 界面
  • 权限控制(某部门只能读某路径下的配置)
  • 版本控制(快速回滚)
  • 环境控制(快速在环境间同步配置)
  • 变更流程和审批流程
  • 灰度发布

这些看起来很高级,实际上全是增删改查,前端写不出来而已。


四、日志模块

4.1 为什么需要日志

到现在我们都没用日志模块,缺点:

  • 无法确认系统状态,出问题都不知道
  • 出现问题时难以定位

4.2 日志级别

级别含义线上是否打印
DEBUG辅助排查一般不打印
INFO中性描述发生了什么打印
WARN不好的事,但可容忍打印
ERROR需要关注的事打印

注意:除非刷 KPI,否则不要引入很多级别。每多一级别,多一份学习成本。

4.3 什么时候打日志?打什么级别?

一个原则:如果你怀疑这个地方要不要打日志,那就打上。

个人习惯:

  • 用 AOP 机制记录跟第三方交互的请求/响应(数据库、缓存、RPC),用 DEBUG
  • 用 AOP 记录系统收到的请求和自己返回的响应,用 DEBUG
  • 开发阶段用 DEBUG 记录关键中间结果
  • 怀疑可能有问题但不严重的地方,记 WARN(频繁 WARN 才去看)
  • 不可能出问题或有人攻击的地方,记 ERROR

宁滥勿缺

4.4 接入 zap

go get -u go.uber.org/zap
// 初始化全局 Logger
logger, err := zap.NewDevelopment()
defer logger.Sync()
zap.ReplaceGlobals(logger)

4.4.1 基本打印

// ERROR:传 error
zap.L().Error("发送短信失败",
    zap.Error(err),
    zap.String("biz", "login"))

// WARN:短信发送太频繁
zap.L().Warn("短信发送太频繁", zap.Error(err))

// INFO:用户首次登录
zap.L().Info("新用户注册", zap.Int64("uid", u.Id))

关键细节

  • 绝对不会把 error 暴露给前端
  • 敏感信息(手机号、密码)不能打日志
  • 跟第三方交互的 req/resp 含手机号,只有线上不开 DEBUG 时才可打

4.5 不使用 zap 包变量(保持依赖注入)

zap.L() 是包变量,大部分中小型应用没问题。但一旦同一项目要用多个 Logger(如 user 相关日志用更安全的存储),就得全改一遍。

// 推荐写法:依赖注入
type UserService struct {
    logger *zap.Logger
}
func NewUserService(repo repository.UserRepository, logger *zap.Logger) *UserService {
    return &UserService{repo: repo, logger: logger}
}

简化版(注了但没完全注):

type UserService struct {
    logger *zap.Logger
}
func NewUserService(repo repository.UserRepository) *UserService {
    return &UserService{
        repo:   repo,
        logger: zap.L(),  // 仍用全局,但封装在 New 里,将来改只改这里
    }
}

4.6 抽象日志 API

直接用 zap 会强耦合,将来想换就换不动。所以可以抽象自己的日志 API。

4.6.1 风格一:经典 printf 风格

type Logger interface {
    Debug(msg string, args ...any)
    Info(msg string, args ...any)
    Warn(msg string, args ...any)
    Error(msg string, args ...any)
}

要求用户在 msg 里留占位符。

4.6.2 风格二:zap 风格(参数有名字)

type LoggerV1 interface {
    Debug(msg string, fields ...Field)
    Info(msg string, fields ...Field)
    Warn(msg string, fields ...Field)
    Error(msg string, fields ...Field)
}
type Field struct {
    Key   string
    Value any
}
// 提供辅助方法 zap.String、zap.Int64 等

对日志分析更友好。

4.6.3 风格三:参数必须是偶数

// args 必须是偶数,假设参数都有名字
type LoggerV2 interface {
    Debug(msg string, args ...any)
    Info(msg string, args ...any)
    Warn(msg string, args ...any)
    Error(msg string, args ...any)
}

折中方案,但没编译器检查,用户不按套路出牌没办法。

4.6.4 选用哪种

  • 兼容性最好:Logger
  • 认同参数要有名字:LoggerV1
  • 有完善 code review:LoggerV2(否则不建议)

4.6.5 用 LoggerV1 封装 zap(适配器模式)

type ZapLogger struct {
    l *zap.Logger
}

func NewZapLogger(l *zap.Logger) *ZapLogger {
    return &ZapLogger{l: l}
}

func (z *ZapLogger) Debug(msg string, fields ...Field) {
    // 步骤 1:把自定义 Field 转成 zap.Field
    z.l.Debug(msg, z.toZapFields(fields)...)
}

func (z *ZapLogger) toZapFields(fields []Field) []zap.Field {
    // 步骤 2:逐个转换
    res := make([]zap.Field, 0, len(fields))
    for _, f := range fields {
        res = append(res, zap.Any(f.Key, f.Value))
    }
    return res
}

缺点:参数转换有额外内存分配和 CPU 消耗。这就是适配器模式

4.7 适配器模式 vs 装饰器模式

模式接口用途
装饰器同一接口加新特性,不改变接口
适配器不同接口把 A 接口适配到 B 接口

4.8 在系统出入口记录日志

  • 入口:系统收到请求、返回响应
  • 出口:调用第三方

4.8.1 利用 Gin middleware 打日志

type AccessLog struct {
    Method  string
    URL     string
    Body    string
    Resp    string
}

func AccessLogger(logFn func(l AccessLog)) gin.HandlerFunc {
    return func(ctx *gin.Context) {
        // 步骤 1:读取请求 body(并还原,避免后续读不到)
        body, _ := io.ReadAll(ctx.Request.Body)
        ctx.Request.Body = io.NopCloser(bytes.NewReader(body))

        // 步骤 2:包装 ResponseWriter,记录响应
        respWriter := &responseWriter{ResponseWriter: ctx.Writer}
        ctx.Writer = respWriter

        // 步骤 3:执行下一个
        ctx.Next()

        // 步骤 4:打日志
        logFn(AccessLog{
            Method: ctx.Request.Method,
            URL:    ctx.Request.URL.String(),
            Body:   string(body),
            Resp:   respWriter.data.String(),
        })
    }
}

漏洞:没控制住 AccessLog 大小,攻击者可传巨长 URL 或巨大请求。

⚠️ 新手必踩的坑:记录请求/响应要防巨量 body。上面中间件直接把整个 bodyresp 写进日志,攻击者传一个 100MB 的 POST body 就能把磁盘打满。生产要截断(如只保留前 1KB)并限制单条日志大小。

4.8.2 GORM 打日志

GORM 自带日志配置,但用起来不方便:

gormLoggerFunc := glogger.NewFunc(func(sql string, args ...any) {
    zap.L().Debug("SQL", zap.String("sql", sql), zap.Any("args", args))
}, glogger.Config{...})

db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{
    Logger: gormLoggerFunc,
})

4.8.3 其他第三方

  • 腾讯云短信 SDK:没暴露 AOP 接口,手动打
  • redis.Cmdable:可以通过封装接口打,但有上百个方法,一个个实现要命
  • http.Client:没暴露 AOP 接口,手动打

没有AOP 机制且没有接口 = 垃圾。

4.9 打印日志技巧总结

  • 宁滥勿缺,宁多打不少打
  • 优先用 AOP 机制打
  • 没 AOP 用装饰器
  • 百万 QPS 之前,不要考虑打印日志的开销问题。打,狠狠打,往死里打!

4.10 升职加薪:为公司设计日志规范

要考虑:

  • 什么情况打什么级别
  • 什么情况打了什么日志就要告警
  • 怎么保证看到日志能快速定位问题并修复

在规范严谨和易于推行之间取平衡。过于严苛推行不了;过于容易效果有限。建议:找一个大厂的日志规范,结合公司工程素养做削减适配。

4.11 面试:日志作为可观测性的一环

日志属于可观测性(Observability)范畴。可观测性是改善性能和可用性的前提——必须先观测到问题,才能优化。

话术:

我在接手系统时发现性能和可用性很差。准备分析问题时,发现可观测性做得非常差——零散、缺乏标准、不够完备。为此我先执行了日志规范……


五、工程实践要点

5.1 安全相关

  • OAuth2 流程必须校验 state,否则有 CSRF 风险
  • 长 token 应该有保护:User-Agent 绑定 / 一次性使用
  • 退出登录必须用 ssid + Redis 黑名单
  • 敏感信息(手机号、密码)不能打日志

5.2 降级策略

  • Redis 挂了不严格校验 ssid(保证大多数用户可用)
  • 配置中心挂了用本地配置兜底(如果有)

5.3 配置管理

  • 优先配置文件
  • 大规模集群再上远程配置中心
  • 配置操作限定在初始化过程中
  • 用 viper API 屏蔽底层差异

5.4 日志

  • 宁滥勿缺
  • 优先 AOP / 装饰器
  • 百万 QPS 之前不考虑开销

自测题与动手练习

自测题(合上书能答出来,才算懂)

  1. OAuth2 授权码流程里,为什么浏览器先拿到的是 code 而不是直接拿 access_tokencodeaccess_token 分别走什么通道?
  2. open_idunion_id 的区别是什么?跨产品(A、B 两个 App)识别同一用户该用哪个?
  3. 不校验 state 会出什么安全问题?服务端要怎么"存 + 比"才能防住 CSRF?
  4. 为什么长短 token 方案里,刷新必须由前端触发、而不能在 middleware 里自动续期?
  5. JWT 是无状态的,怎么实现"退出登录"?ssid + Redis 黑名单方案的降级策略是什么、为什么必须降级?

动手练习(建议真做一遍)

  1. 画 + 讲 OAuth2:不看任何资料,独立画出授权码流程时序图,并讲清每一步是谁调用谁、参数怎么传。
  2. 实现并验证 state 校验:给 Callback 接上 CheckState;写一个测试,模拟"攻击者带别人 code + 正常用户 Cookie"请求,确认返回"非法请求"。
  3. ssid 退出演练:本地起 Redis,实现 Logout/CheckSession;退出登录后确认后续请求被拒;再手动 redis-cli shutdown,确认降级下已登录用户仍可用。

本章小结

第5章围绕"登录与运维基础设施"展开,从单点登录讲到日志系统,覆盖了一个 Go 后端核心的"非业务"能力。

关键回顾:

  1. SSO/OAuth2:微信扫码登录本质是 OAuth2 授权码流程,关键是 code→access_token 两步走,state 防 CSRF
  2. 长短 token:短 token 频繁使用易泄露,长 token 只在刷新时用,前端触发刷新而非后端自动续期
  3. JWT 退出登录:JWT 无状态无法主动作废,用 ssid + Redis 黑名单方案;Redis 挂了用降级策略
  4. 配置模块:来源(启动参数/环境变量/文件/远程中心),优先级(命令行 > 环境变量 > 文件 > 远程),两次加载(基础配置 + 全量配置)
  5. 日志:四大级别(DEBUG/INFO/WARN/ERROR),宁滥勿缺,优先 AOP,抽象自己的 Logger 接口(适配器模式封装 zap)

掌握这些内容,你已经具备了完整的登录鉴权能力和初步的可观测性建设能力,可以独立搭建一套生产可用的认证体系。下一章(第06章)进入发帖功能与缓存,把内容生产链路和缓存设计讲透。

About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!