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

🥰个人主页:会编程的土豆(欢迎来访)

💎作者简介:后端学习者

❄️个人专栏:数据结构与算法数据库leetcode

那些你一个人走过的夜路,终将化作照亮未 来的光

系列:《影院票务 GO》功能详解 · 第 01 篇

对应代码:(代码在这个系列的文章中)templates/register.htmlstatic/js/app.jshandlers/auth.godao/user.goutils/password.go

数据库:sql/schema.sqlusers

前置

一、背景:为什么要有注册?

影院票务系统(TTMS)里,很多操作必须知道「你是谁」:

  • 选座、下单要把座位绑到你的账号;
  • 订单列表只显示自己的票;
  • 评论、评分要记录发表人。

注册就是:在系统里新建一条用户记录,以后用「用户名 + 密码」登录。

本项目的注册特点:

  1. 前后端分离式交互 :页面是 HTML 表单,提交走 JSON API(不是传统 form POST 刷新整页)。
  2. 密码绝不存明文 :每个用户随机 8 位盐 ,存 MD5(密码+盐)
  3. 新用户默认顾客role = 0(管理员只能种子数据或人工改库,注册接口不会创建管理员)。

二、用户视角:点「注册」后发生了什么?

打开 http://localhost:8080/register(端口以 config 为准),你会看到:

  • 用户名(必填)
  • 密码(必填,HTML 限制至少 6 位)
  • 昵称、手机(可选)

填完点 「注册并登录」 ,前端脚本 doRegister 会:

  1. 阻止表单默认刷新(e.preventDefault());
  2. 把字段打包成 JSON,POST /api/register
  3. 注册成功后 立刻再调 POST /api/login(自动登录);
  4. 跳转到首页 /

若用户名已存在或密码太短,浏览器 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.jsdoRegister

复制代码
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() 做了三件事:

  1. fetch 时带 Content-Type: application/json
  2. credentials: 'same-origin' ------ 为后续登录写 Cookie 做准备;
  3. 解析统一格式 {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
}

DecodeJSONr.Bodyjson.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

九、小结

  1. 页面 register.html 收集字段,JS 转 JSON POST 到 /api/register
  2. APIRegister 校验 → 查重 → RandomSalt + HashPasswordCreateUser INSERT。
  3. 新用户 role 固定为 0(顾客) ,密码 MD5(明文+盐) 存库,明文永不落盘。
  4. 前端注册成功后 自动 login,建立 Session(下一篇细讲)。
  5. 统一 JSON 响应 code/message/data,前后端约定清晰。

用一句话向学长描述:

「注册就是 handlers 收 JSON,dao 插 users 一行;密码先随机盐再 MD5,库里只有摘要。」


下一篇02-登录退出与Session.md ------ 登录如何写 Cookie ttms_sidstore/session.go 如何在内存里记住你。

相关推荐
夏贰四1 小时前
数据资产平台如何落地企业数据分级合规管控?数据资产平台怎样满足行业数据监管审查要求?
大数据·数据库·人工智能
Ivanqhz1 小时前
Rust collect() 浅析
开发语言·后端·rust
吴声子夜歌1 小时前
MongoDB 4.2——聚合框架
数据库·mongodb
go不是csgo1 小时前
从模板到五道 LeetCode:完全背包的 Golang 教学笔记
笔记·leetcode·golang
卷无止境1 小时前
python进阶:类方法与静态方法的分野,classmethod 和 staticmethod 到底该怎么选
后端·python
墨神谕1 小时前
认识一下Unicode(统一码)
android·java·数据库
Wang's Blog2 小时前
Go-Zero项目开发33: IM实时通信前后端对接与微服务基础设施实践
开发语言·微服务·golang·go-zero
AINative软件工程2 小时前
LLM 应用的分层可观测性工程实践:从 Span 到 Prompt Diff,三层 Trace 让 AI 系统真正可调试
后端·ai编程
卷无止境2 小时前
继承与多态:Python OOP里最容易被用错的两把梯子
后端·python