🥰个人主页:会编程的土豆(欢迎来访)
💎作者简介:后端学习者
❄️个人专栏:数据结构与算法,数据库,leetcode
✨那些你一个人走过的夜路,终将化作照亮未来的光
系列:《影院票务 GO》功能详解 · 第 01 篇
对应代码:(代码在这个系列的文章中)templates/register.html→static/js/app.js→handlers/auth.go→dao/user.go→utils/password.go
数据库:sql/schema.sql中users表
前置
一、背景:为什么要有注册?
影院票务系统(TTMS)里,很多操作必须知道「你是谁」:
- 选座、下单要把座位绑到你的账号;
- 订单列表只显示自己的票;
- 评论、评分要记录发表人。
注册就是:在系统里新建一条用户记录,以后用「用户名 + 密码」登录。
本项目的注册特点:
- 前后端分离式交互:页面是 HTML 表单,提交走JSON API(不是传统 form POST 刷新整页)。
- 密码绝不存明文:每个用户随机8 位盐,存
MD5(密码+盐)。 - 新用户默认顾客:
role = 0(管理员只能种子数据或人工改库,注册接口不会创建管理员)。
二、用户视角:点「注册」后发生了什么?
打开http://localhost:8080/register(端口以config为准),你会看到:
- 用户名(必填)
- 密码(必填,HTML 限制至少 6 位)
- 昵称、手机(可选)
填完点「注册并登录」,前端脚本doRegister会:
- 阻止表单默认刷新(
e.preventDefault()); - 把字段打包成 JSON,
POST /api/register; - 注册成功后立刻再调
POST /api/login(自动登录); - 跳转到首页
/。
若用户名已存在或密码太短,浏览器alert弹出后端返回的中文错误信息。
三、请求链路总览
[浏览器 register.html] │ 用户提交 ▼ [app.js doRegister] │ fetch POST /api/register (JSON body) ▼ [handlers/routes.go] mux.HandleFunc("/api/register", a.APIRegister) ▼ [handlers/auth.go APIRegister] │ 校验参数 → 查重 → 哈希密码 → 组装 User ▼ [dao/user.go CreateUser] │ INSERT INTO users ... ▼ [MySQL users 表] │ 返回 LastInsertId ▼ [utils/json.go OK] → {"code":0,"message":"ok","data":{"id":3}} │ ▼ (前端继续) [app.js] POST /api/login → Session → location.href='/'路由注册在handlers/routes.go第 18 行:
mux.HandleFunc("/api/register", a.APIRegister)页面路由:
mux.HandleFunc("/register", a.PageRegister)PageRegister只负责渲染模板,不做业务:
func (a *App) PageLogin(w http.ResponseWriter, r *http.Request) { ... } func (a *App) PageRegister(w http.ResponseWriter, r *http.Request) { a.Render(w, "register.html", pageData{Title: "注册", User: a.currentUser(r)}) }四、前端:表单如何变成 JSON?
templates/register.html核心:
<form id="regForm" onsubmit="return doRegister(event)"> <label>用户名<input name="username" required /></label> <label>密码<input name="password" type="password" required minlength="6" /></label> ... </form>static/js/app.js中doRegister:
async function doRegister(e) { e.preventDefault(); const fd = new FormData(e.target); try { await api('/api/register', { method: 'POST', body: JSON.stringify({ username: fd.get('username'), password: fd.get('password'), nickname: fd.get('nickname'), phone: fd.get('phone'), }), }); await api('/api/login', { ... }); // 注册成功后自动登录 location.href = '/'; } catch (err) { alert(err.message); } return false; }公共封装api()做了三件事:
fetch时带Content-Type: application/json;credentials: 'same-origin'—— 为后续登录写 Cookie 做准备;- 解析统一格式
{code, message, data},code !== 0就抛错。
对应后端utils/json.go:
type APIResult struct { Code int `json:"code"` Message string `json:"message"` Data interface{} `json:"data,omitempty"` } func OK(w http.ResponseWriter, data interface{}) { JSON(w, 0, "ok", data) } func Fail(w http.ResponseWriter, message string) { JSON(w, 1, message, nil) }设计要点:业务失败时 HTTP 状态码仍是 200,靠code=1区分;前端只需看data.code,不必处理 4xx/5xx 分支(课设简化写法)。
五、后端 APIRegister:逐步拆解
完整代码在handlers/auth.go。我们按执行顺序读。
5.1 只允许 POST
if r.Method != http.MethodPost { utils.Fail(w, "方法不允许") return }若有人用浏览器直接访问/api/register(GET),会收到 JSON 错误,不会误插入数据。
5.2 解析 JSON 到匿名结构体
var req struct { Username string `json:"username"` Password string `json:"password"` Nickname string `json:"nickname"` Phone string `json:"phone"` } if err := utils.DecodeJSON(r, &req); err != nil { utils.Fail(w, "参数错误") return }DecodeJSON读r.Body并json.Decode。字段 tag 的json:"username"与前端 JSON 键名对应。
5.3 参数校验
req.Username = strings.TrimSpace(req.Username) if req.Username == "" || len(req.Password) < 6 { utils.Fail(w, "用户名不能为空,密码至少6位") return }- 去掉用户名首尾空格,避免
" alice "这种视觉重复账号; - 密码长度6是后端底线(前端
minlength="6"是第一道防线,后端必须再验,防绕过)。
5.4 用户名唯一性
exist, err := dao.GetUserByUsername(req.Username) if err != nil { utils.Fail(w, err.Error()) return } if exist != nil { utils.Fail(w, "用户名已存在") return }dao/user.go:
func GetUserByUsername(username string) (*models.User, error) { u := &models.User{} err := db.DB.QueryRow( `SELECT id,username,password,salt,nickname,phone,role,created_at FROM users WHERE username=?`, username, ).Scan(...) if errors.Is(err, sql.ErrNoRows) { return nil, nil // 没找到:不是错误,表示可以注册 } return u, err }sql.ErrNoRows时返回(nil, nil)是 Go + database/sql 的常见模式:「查无此人」≠ 数据库故障。
表结构里还有一层保险:
username VARCHAR(50) NOT NULL UNIQUE,即使并发两次相同用户名注册,第二条 INSERT 也会因唯一索引失败(本项目先查再插,课设足够;高并发应靠数据库约束 + 事务)。
5.5 昵称默认值
if req.Nickname == "" { req.Nickname = req.Username }用户没填昵称时,展示名等于登录名。
5.6 密码哈希:盐 + MD5
salt := utils.RandomSalt(8) u := &models.User{ Username: req.Username, Password: utils.HashPassword(req.Password, salt), Salt: salt, Nickname: req.Nickname, Phone: req.Phone, Role: models.RoleCustomer, // 常量 0 }utils/password.go:
func HashPassword(password, salt string) string { sum := md5.Sum([]byte(password + salt)) return hex.EncodeToString(sum[:]) } func RandomSalt(n int) string { const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" // crypto/rand 逐字符随机 ... }为什么要盐?若只存MD5(密码),两个用户密码相同则哈希相同;攻击者可用彩虹表批量反查。每人不同盐,同样密码哈希也不同。
种子数据验证方式(sql/seed.sql):
('admin', MD5(CONCAT('admin123', 'saltadmin')), 'saltadmin', ...),MySQL 的MD5(CONCAT(...))与 Go 的md5.Sum([]byte(password+salt))算法一致。
答辩提醒:MD5 已不适合生产密码存储(应 bcrypt/argon2);课设选 MD5 是为了算法简单、与 SQL 种子一致。详见blogs/13-密码与哈希MD5与盐.md。
5.7 写入数据库
if err := dao.CreateUser(u); err != nil { utils.Fail(w, "注册失败: "+err.Error()) return } utils.OK(w, map[string]interface{}{"id": u.ID})dao/user.go:
func CreateUser(u *models.User) error { res, err := db.DB.Exec( `INSERT INTO users(username,password,salt,nickname,phone,role) VALUES(?,?,?,?,?,?)`, u.Username, u.Password, u.Salt, u.Nickname, u.Phone, u.Role, ) if err != nil { return err } u.ID, _ = res.LastInsertId() return nil }LastInsertId()把数据库自增主键写回u.ID,响应里带给前端(前端当前没用 id,但 API 设计完整)。
models.User中密码字段带json:"-",即使将来把 User 序列化给前端,也不会泄露哈希:
type User struct { Password string `json:"-"` Salt string `json:"-"` Role int `json:"role"` ... }六、数据库表与字段含义
sql/schema.sql:
CREATE TABLE IF NOT EXISTS users ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password CHAR(32) NOT NULL COMMENT 'MD5(password+salt)', salt VARCHAR(16) NOT NULL, nickname VARCHAR(50) NOT NULL DEFAULT '', phone VARCHAR(20) NOT NULL DEFAULT '', role TINYINT NOT NULL DEFAULT 0, -- 0顾客 1管理员 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP );| 字段 | 注册时谁赋值 | 说明 |
|---|---|---|
| id | 数据库自增 | 用户唯一标识 |
| username | 用户输入 | 登录名 |
| password | HashPassword | 32 位十六进制 |
| salt | RandomSalt(8) | 明文存储,用于登录校验 |
| nickname | 用户或默认用户名 | 页面展示 |
| phone | 用户可选 | 联系方式 |
| role | 固定RoleCustomer=0 | 注册不能当管理员 |
| created_at | 数据库默认 | 注册时间 |
七、注册 vs 登录:别混淆
| 环节 | 注册/api/register | 登录/api/login |
|---|---|---|
| 密码处理 | 生成盐 → 哈希 →存入 | 读库盐 → 哈希输入 →比对 |
| Session | 不创建 | Sessions.Create写 Cookie |
| 返回 | {id} | {id, username, nickname, role} |
注册成功后前端主动调登录,用户无感;若只注册不登录,首页仍显示「登录/注册」链接。
登录校验用utils.CheckPassword:
func CheckPassword(password, salt, hashed string) bool { return HashPassword(password, salt) == hashed }同一套HashPassword,保证注册与登录算法一致。
八、常见错误与排查
| 现象 | 可能原因 | 怎么查 |
|---|---|---|
| alert「参数错误」 | JSON 格式不对、Body 空 | 浏览器 Network 看 Request Payload |
| 「用户名已存在」 | 重复注册 | SELECT * FROM users WHERE username='...' |
| 「注册失败: …」 | 数据库未启动、DSN 错 | 看main.go启动日志 |
| 注册成功但未登录 | 第二步 login 失败 | Network 看/api/login响应 |
| 密码 5 位也能提交? | 只绕过前端 | 后端应拒绝;本项目已校验len < 6 |
九、小结
- 页面
register.html收集字段,JS转 JSON POST 到/api/register。 - APIRegister校验 → 查重 →
RandomSalt+HashPassword→CreateUserINSERT。 - 新用户role 固定为 0(顾客),密码MD5(明文+盐)存库,明文永不落盘。
- 前端注册成功后自动 login,建立 Session(下一篇细讲)。
- 统一 JSON 响应
code/message/data,前后端约定清晰。
用一句话向学长描述:
「注册就是 handlers 收 JSON,dao 插 users 一行;密码先随机盐再 MD5,库里只有摘要。」
下一篇:02-登录退出与Session.md—— 登录如何写 Cookiettms_sid,store/session.go如何在内存里记住你。