🥰个人主页:会编程的土豆(欢迎来访)
💎作者简介:后端学习者
❄️个人专栏:数据结构与算法,数据库,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)) |
| HttpOnly | true(JS 读不到,防 XSS 偷 Cookie) |
重要区分:
- 认证(Authentication):登录时验证用户名密码 ——
APILogin。 - 识别(Identification):之后每次请求用 Cookie 找 Session ——
Sessions.Get。 - 鉴权(Authorization):识别后检查能不能做某操作 —— 第 03 篇的
requireAdmin。
本篇聚焦登录 / 退出 / Session 读写。
二、用户视角
登录
- 打开
/login,输入user/user123(或admin/admin123)。 - 点登录 → 页面跳转:
- 管理员 →
/admin - 顾客 →
/
- 管理员 →
- 顶栏显示昵称、「订单」「退出」;管理员还多一个「后台」。
退出
点「退出」→ 调/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/ | 全站有效 |
| HttpOnly | document.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:
- 没有 Cookie;
- Session ID 不在 map(服务端重启、被 Destroy、GC 清掉);
- 已过期。
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() } }NewSessionStore里go 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.RoleAdmin、RoleCustomer常量一致。
七、Session 存什么?为什么不存密码?
Session 存完整*models.User,包括Password、Salt字段——只在服务器内存。
每次Get直接返回该指针,handler 可立刻用u.ID、u.IsAdmin(),不必再查库。
权衡:
| 优点 | 缺点 |
|---|---|
| 快,无 DB 压力 | 改用户 role/昵称后 Session 内是旧数据,直到重新登录 |
| 实现简单 | 服务重启 Session 全丢,用户要重新登录 |
| 课设够用 | 多机部署不能共享内存 map |
生产环境常用Redis Session或JWT;本项目注释写明「内存 Session,适合课程项目」。
八、与页面/API 的配合
| 场景 | 是否必须登录 | 怎么判断 |
|---|---|---|
首页/ | 否 | currentUser可为 nil,仍显示片单 |
选座/seats/ | 是 | requireLogin→ 302/login |
| 下单 API | 是 | currentUser == nil→ JSON Fail |
| 顶栏昵称 | 可选 | 有 User 就显示 |
登录态贯穿所有带Cookie的请求;未登录访问公开页不会报错,只是User为空。
九、调试技巧
- Chrome DevTools → Application → Cookies:看是否有
ttms_sid。 - Network → 任意请求 → Request Headers:
Cookie: ttms_sid=...。 - 重启 Go 程序:内存 Session 清空,Cookie 仍在但
Get返回 nil —— 表现为「Cookie 在但显示未登录」。 - 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
十、小结
- 登录验证密码后,
SessionStore.Create生成随机 ID,内存 map + Set-Cookiettms_sid。 - 之后
Get(r)读 Cookie 查 map,得到*User;过期或无效返回nil。 - 退出
Destroy删 map 并MaxAge=-1清 Cookie。 - 前端
fetch必须credentials: 'same-origin'才能收发 Cookie。 - 内存 Session + 24h TTL + 10 分钟 GC,重启丢失——课设清晰,上线需换方案。
一句话:
「登录成功后服务器发一张『(sessionId) 号手环』给浏览器 Cookie;以后每次请求亮手环,内存里查对应用户。」
下一篇:03-顾客与管理员权限.md——role字段如何驱动页面与 API 的requireLogin/requireAdmin/apiUser。