功能详解 02 · 登录、退出与 Session:Cookie ttms_sid 如何认人
2026/7/28 1:27:07 网站建设 项目流程

🥰个人主页:会编程的土豆(欢迎来访)
💎作者简介:后端学习者
❄️个人专栏:数据结构与算法,数据库,leetcode
那些你一个人走过的夜路,终将化作照亮未来的光

系列:《影院票务 GO》功能详解 · 第 02 篇
对应代码:handlers/auth.go·store/session.go·handlers/app.go·static/js/app.js
前置:第 01 篇注册;知识点篇blogs/12-Cookie与Session登录态.md


一、背景:HTTP 无状态,网站怎么「记住你」?

HTTP 协议本身是无状态的:服务器处理完一次请求就「忘了」你是谁
但购票系统需要连续认人——从登录到选座、下单,不能每点一次按钮都重新输入密码。

常见方案:Session(会话)+ Cookie(浏览器存 Session ID)

本项目约定:

项目
Cookie 名ttms_sid
Cookie 值32 字节随机数的 hex 字符串(Session ID)
服务端存储内存map[string]*sessionData
有效期24 小时(NewSessionStore(24 * time.Hour)
HttpOnlytrue(JS 读不到,防 XSS 偷 Cookie)

重要区分

  • 认证(Authentication):登录时验证用户名密码 ——APILogin
  • 识别(Identification):之后每次请求用 Cookie 找 Session ——Sessions.Get
  • 鉴权(Authorization):识别后检查能不能做某操作 —— 第 03 篇的requireAdmin

本篇聚焦登录 / 退出 / Session 读写


二、用户视角

登录

  1. 打开/login,输入user/user123(或admin/admin123)。
  2. 点登录 → 页面跳转:
    • 管理员 →/admin
    • 顾客 →/
  3. 顶栏显示昵称、「订单」「退出」;管理员还多一个「后台」。

退出

点「退出」→ 调/api/logout→ 回首页,顶栏变回「登录 / 注册」。

刷新页面为什么仍登录?

因为浏览器自动带上 Cookiettms_sid,服务端用 ID 查内存 map,取出*models.User


三、完整流程图

3.1 登录成功

[浏览器 POST /api/login {username, password}] ▼ [APILogin] GetUserByUsername ▼ [CheckPassword] 比对 MD5(输入+库中salt) 与 库中password ▼ [Sessions.Create(w, user)] ├─ newID() → sessionId ├─ map[sessionId] = {User, ExpiresAt} └─ Set-Cookie: ttms_sid=sessionId; HttpOnly; MaxAge=86400 ▼ [utils.OK] 返回 {id, username, nickname, role} ▼ [app.js doLogin] role===1 ? '/admin' : '/'

3.2 后续任意请求

[浏览器 GET /orders Cookie: ttms_sid=abc...] ▼ [handler] a.currentUser(r) ▼ [Sessions.Get(r)] 读 Cookie → 查 map → 未过期则 *User ▼ [Render / 业务逻辑] 模板里 {{.User.Nickname}}

3.3 退出

[POST /api/logout] ▼ [Sessions.Destroy] ├─ delete(map, sessionId) └─ Set-Cookie: ttms_sid=; MaxAge=-1 (让浏览器删 Cookie) ▼ [utils.OK]

四、路由与 Handler

handlers/routes.go

mux.HandleFunc("/login", a.PageLogin) mux.HandleFunc("/api/login", a.APILogin) mux.HandleFunc("/api/logout", a.APILogout) mux.HandleFunc("/api/me", a.APIMe)

4.1 APILogin

handlers/auth.go

func (a *App) APILogin(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { utils.Fail(w, "方法不允许") return } var req struct { Username string `json:"username"` Password string `json:"password"` } if err := utils.DecodeJSON(r, &req); err != nil { utils.Fail(w, "参数错误") return } u, err := dao.GetUserByUsername(strings.TrimSpace(req.Username)) if err != nil { utils.Fail(w, err.Error()) return } if u == nil || !utils.CheckPassword(req.Password, u.Salt, u.Password) { utils.Fail(w, "用户名或密码错误") return } a.Sessions.Create(w, u) utils.OK(w, map[string]interface{}{ "id": u.ID, "username": u.Username, "nickname": u.Nickname, "role": u.Role, }) }

安全细节

  • 用户不存在和密码错误同一句话:「用户名或密码错误」—— 避免攻击者枚举哪些用户名已注册。
  • 校验通过后CreateSession;失败不写 Cookie。

4.2 APILogout

func (a *App) APILogout(w http.ResponseWriter, r *http.Request) { a.Sessions.Destroy(w, r) utils.OK(w, nil) }

4.3 APIMe —— 当前登录用户是谁?

func (a *App) APIMe(w http.ResponseWriter, r *http.Request) { u := a.currentUser(r) if u == nil { utils.Fail(w, "未登录") return } utils.OK(w, u) }

可用于前端 SPA 拉用户信息;本项目主要在服务端模板里注入User


五、SessionStore 源码精读

文件:store/session.go

5.1 数据结构与常量

const cookieName = "ttms_sid" type sessionData struct { User *models.User ExpiresAt time.Time } type SessionStore struct { mu sync.RWMutex data map[string]*sessionData ttl time.Duration secure bool }
  • data:Session ID → 用户 + 过期时间。
  • mu读写锁,多个 HTTP 请求并发访问 map 必须加锁,否则 race condition。
  • ttl:Session 寿命,App 初始化时传24 * time.Hour

handlers/app.go

func NewApp(cfg config.Config) *App { return &App{ Cfg: cfg, Sessions: store.NewSessionStore(24 * time.Hour), } }

5.2 Create —— 登录写 Session

func (s *SessionStore) Create(w http.ResponseWriter, user *models.User) { id := newID() s.mu.Lock() s.data[id] = &sessionData{User: user, ExpiresAt: time.Now().Add(s.ttl)} s.mu.Unlock() http.SetCookie(w, &http.Cookie{ Name: cookieName, Value: id, Path: "/", HttpOnly: true, MaxAge: int(s.ttl.Seconds()), }) }

newID()

func newID() string { b := make([]byte, 16) _, _ = rand.Read(b) return hex.EncodeToString(b) // 32 个 hex 字符 }

Cookie 字段解释

字段作用
Namettms_sid项目自定义,避免与别的站冲突
Path/全站有效
HttpOnlydocument.cookie读不到
MaxAge秒数,浏览器到期自动删

响应头类似:Set-Cookie: ttms_sid=a1b2c3...; Path=/; MaxAge=86400; HttpOnly

注意:Session 里存的是*models.User指针,含密码哈希字段——只在服务端内存,不发给浏览器。

5.3 Get —— 每次请求认人

func (s *SessionStore) Get(r *http.Request) *models.User { c, err := r.Cookie(cookieName) if err != nil || c.Value == "" { return nil } s.mu.RLock() defer s.mu.RUnlock() item, ok := s.data[c.Value] if !ok || time.Now().After(item.ExpiresAt) { return nil } return item.User }

三种「未登录」情况统一返回nil

  1. 没有 Cookie;
  2. Session ID 不在 map(服务端重启、被 Destroy、GC 清掉);
  3. 已过期。

handlers/app.go封装:

func (a *App) currentUser(r *http.Request) *models.User { return a.Sessions.Get(r) }

模板templates/header.html

{{if .User}} <span class="user">{{.User.Nickname}}</span> <button onclick="logout()">退出</button> {{else}} <a href="/login">登录</a> {{end}}

5.4 Destroy —— 退出

func (s *SessionStore) Destroy(w http.ResponseWriter, r *http.Request) { c, err := r.Cookie(cookieName) if err == nil { s.mu.Lock() delete(s.data, c.Value) s.mu.Unlock() } http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", MaxAge: -1}) }

服务端删 map + 浏览器删 Cookie,双保险

5.5 过期清理 gcLoop

func (s *SessionStore) gcLoop() { t := time.NewTicker(10 * time.Minute) for range t.C { now := time.Now() s.mu.Lock() for k, v := range s.data { if now.After(v.ExpiresAt) { delete(s.data, k) } } s.mu.Unlock() } }

NewSessionStorego s.gcLoop()启动后台 goroutine,防止过期 Session 占内存。
即使用户不退出,24 小时后Get也会判过期。

5.6 RefreshUser(扩展)

func (s *SessionStore) RefreshUser(r *http.Request, user *models.User) { // 更新 Session 内 User 指针并续期 }

改昵称等场景可刷新 Session;当前项目注册后未单独调用,了解即可。


六、前端:credentials 与跳转逻辑

static/js/app.js

async function api(url, options = {}) { const res = await fetch(url, { headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, credentials: 'same-origin', // 关键:同源请求携带 Cookie ...options, }); ... } async function doLogin(e) { e.preventDefault(); const fd = new FormData(e.target); try { const user = await api('/api/login', { method: 'POST', body: JSON.stringify({ username: fd.get('username'), password: fd.get('password') }), }); location.href = user.role === 1 ? '/admin' : '/'; } catch (err) { alert(err.message); } return false; } async function logout() { await api('/api/logout', { method: 'POST', body: '{}' }); location.href = '/'; }

credentials: 'same-origin'含义:

  • 默许fetch带 Cookie;
  • 设为same-origin后,对/api/login的响应Set-Cookie会被保存,后续/api/*和页面请求都会带上ttms_sid

登录后按role分流:

  • role === 1→ 管理后台;
  • role === 0→ 顾客首页。

models.RoleAdminRoleCustomer常量一致。


七、Session 存什么?为什么不存密码?

Session 存完整*models.User,包括PasswordSalt字段——只在服务器内存。

每次Get直接返回该指针,handler 可立刻用u.IDu.IsAdmin(),不必再查库。

权衡

优点缺点
快,无 DB 压力改用户 role/昵称后 Session 内是旧数据,直到重新登录
实现简单服务重启 Session 全丢,用户要重新登录
课设够用多机部署不能共享内存 map

生产环境常用Redis SessionJWT;本项目注释写明「内存 Session,适合课程项目」。


八、与页面/API 的配合

场景是否必须登录怎么判断
首页/currentUser可为 nil,仍显示片单
选座/seats/requireLogin→ 302/login
下单 APIcurrentUser == nil→ JSON Fail
顶栏昵称可选有 User 就显示

登录态贯穿所有带Cookie的请求;未登录访问公开页不会报错,只是User为空。


九、调试技巧

  1. Chrome DevTools → Application → Cookies:看是否有ttms_sid
  2. Network → 任意请求 → Request HeadersCookie: ttms_sid=...
  3. 重启 Go 程序:内存 Session 清空,Cookie 仍在但Get返回 nil —— 表现为「Cookie 在但显示未登录」。
  4. curl 模拟
    curl -c jar.txt -X POST http://localhost:8080/api/login -H "Content-Type: application/json" -d "{\"username\":\"user\",\"password\":\"user123\"}" curl -b jar.txt http://localhost:8080/api/me

十、小结

  1. 登录验证密码后,SessionStore.Create生成随机 ID,内存 map + Set-Cookiettms_sid
  2. 之后Get(r)读 Cookie 查 map,得到*User;过期或无效返回nil
  3. 退出Destroy删 map 并MaxAge=-1清 Cookie。
  4. 前端fetch必须credentials: 'same-origin'才能收发 Cookie。
  5. 内存 Session + 24h TTL + 10 分钟 GC,重启丢失——课设清晰,上线需换方案。

一句话:

「登录成功后服务器发一张『(sessionId) 号手环』给浏览器 Cookie;以后每次请求亮手环,内存里查对应用户。」


下一篇03-顾客与管理员权限.md——role字段如何驱动页面与 API 的requireLogin/requireAdmin/apiUser

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询