​​​​​​功能详解 01 · 用户注册:从表单到数据库一行

发布时间:2026/7/28 1:27:15
​​​​​​功能详解 01 · 用户注册:从表单到数据库一行 个人主页会编程的土豆欢迎来访作者简介后端学习者❄️个人专栏数据结构与算法数据库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()把字段打包成 JSONPOST /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)}) }四、前端表单如何变成 JSONtemplates/register.html核心form idregForm onsubmitreturn doRegister(event) label用户名input nameusername required //label label密码input namepassword typepassword required minlength6 //label ... /formstatic/js/app.js中doRegisterasync 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/jsoncredentials: same-origin—— 为后续登录写 Cookie 做准备解析统一格式{code, message, data}code ! 0就抛错。对应后端utils/json.gotype 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靠code1区分前端只需看data.code不必处理 4xx/5xx 分支课设简化写法。五、后端 APIRegister逐步拆解完整代码在handlers/auth.go。我们按执行顺序读。5.1 只允许 POSTif r.Method ! http.MethodPost { utils.Fail(w, 方法不允许) return }若有人用浏览器直接访问/api/registerGET会收到 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是后端底线前端minlength6是第一道防线后端必须再验防绕过。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.gofunc 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 密码哈希盐 MD5salt : 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.gofunc 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(passwordsalt))算法一致。答辩提醒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.gofunc 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.sqlCREATE TABLE IF NOT EXISTS users ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password CHAR(32) NOT NULL COMMENT MD5(passwordsalt), 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用户输入登录名passwordHashPassword32 位十六进制saltRandomSalt(8)明文存储用于登录校验nickname用户或默认用户名页面展示phone用户可选联系方式role固定RoleCustomer0注册不能当管理员created_at数据库默认注册时间七、注册 vs 登录别混淆环节注册/api/register登录/api/login密码处理生成盐 → 哈希 →存入读库盐 → 哈希输入 →比对Session不创建Sessions.Create写 Cookie返回{id}{id, username, nickname, role}注册成功后前端主动调登录用户无感若只注册不登录首页仍显示「登录/注册」链接。登录校验用utils.CheckPasswordfunc 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校验 → 查重 →RandomSaltHashPassword→CreateUserINSERT。新用户role 固定为 0顾客密码MD5(明文盐)存库明文永不落盘。前端注册成功后自动 login建立 Session下一篇细讲。统一 JSON 响应code/message/data前后端约定清晰。用一句话向学长描述「注册就是 handlers 收 JSONdao 插 users 一行密码先随机盐再 MD5库里只有摘要。」下一篇02-登录退出与Session.md—— 登录如何写 Cookiettms_sidstore/session.go如何在内存里记住你。