🥰个人主页:会编程的土豆(欢迎来访)
💎作者简介:后端学习者
✨那些你一个人走过的夜路,终将化作照亮未 来的光



系列:《影院票务 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 ------ 登录如何写 Cookie ttms_sid,store/session.go 如何在内存里记住你。