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



系列:《影院票务 GO》知识点博客 · 第 5 篇
对应文件:
models/models.go,以及dao/*里的 Scan、handlers/*里的 JSON/模板
写在前面
数据库有表,HTTP 有 JSON,浏览器有表单------三套「形状」描述同一件事。
Go 内存里也需要一种类型把字段捆在一起,这个类型就是 struct(结构体)。
学长可能问:
「为什么 User 要单独一个文件?
json:"-"是什么意思?Seat 里为什么用指针?」
本篇从零讲 struct、字段标签、常量、方法、指针接收者,并对照票务系统的核心模型。
读完后,打开 models/models.go 应能一行行对应到 MySQL 列和 API 字段。
1. 什么是结构体?
把多个不同含义的字段捆成一种新类型:
type User struct {
ID int64
Username string
Nickname string
Role int
}
可以读作:「用户有这些属性」
1.1 创建与访问
u := User{Username: "user", Nickname: "电影爱好者", Role: 0}
fmt.Println(u.Username) // user
u.Nickname = "新昵称" // 修改字段
对比其它复合类型:
| 类型 | 特点 | 例子 |
|---|---|---|
| 数组 / 切片 | 一堆同类元素 | []int{1,2,3} |
| map | 键值对,键类型统一 | map[string]int |
| struct | 固定字段名,类型可不同 | User, Order |
票务里「订单号 + 金额 + 状态」语义不同,用 struct 比用 []interface{} 清晰得多。
1.2 零值
未赋值的 struct 字段各有零值:0、""、nil(指针)、零时间等。
Scan 进 struct 前,常 u := &models.User{} 得到干净对象。
2. 本项目核心模型一览
models/models.go 是业务名词的代码词典。和数据库表的对应:
| 结构体 | 表 | 关键字段直觉 |
|---|---|---|
User |
users |
Role:0 顾客 / 1 管理员 |
MovieGroup |
movie_groups |
电影分类 |
Movie |
movies |
RatingAvg、GroupID 可空 |
Hall |
halls |
影厅行列数 |
Schedule |
schedules |
开场时间、票价 |
Seat |
seats |
Status、锁座字段 |
Order |
orders |
PayDeadline、SeatInfo |
Comment |
comments |
ParentID 回复 |
Rating |
ratings |
1--10 分 |
2.1 状态常量:拒绝魔法数字
const (
SeatFree = 0
SeatLocked = 1
SeatSold = 2
OrderPending = 0
OrderPaid = 1
OrderCancelled = 2
OrderTimeout = 3
)
数据库 TINYINT status 和 Go 常量数值一致,dao 里写:
if status == models.SeatSold {
return nil, errors.New("座位已售出")
}
答辩加分:
「状态用命名常量,读代码的人不用猜 2 是已售还是已取消。」
3. 完整 User 结构体与标签
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
Password string `json:"-"`
Salt string `json:"-"`
Nickname string `json:"nickname"`
Phone string `json:"phone"`
Role int `json:"role"`
CreatedAt time.Time `json:"created_at"`
}
func (u *User) IsAdmin() bool { return u.Role == RoleAdmin }
| 字段 | 数据库 | JSON API | 说明 |
|---|---|---|---|
| Password | 有 | 不输出 json:"-" |
绝不能下发前端 |
| Salt | 有 | 不输出 | 校验密码用,保密 |
| Role | TINYINT | 数字 0/1 | 前端可据此跳转 admin |
4. 字段标签 json:"xxx"
Nickname string `json:"nickname"`
Password string `json:"-"`
GroupName string `json:"group_name,omitempty"`
| 标签 | 含义 |
|---|---|
json:"id" |
序列化时键名为 id(蛇形或驼峰按约定) |
json:"-" |
忽略该字段 |
omitempty |
零值时不输出(减少 JSON 体积) |
utils.OK(w, user) 内部 json.NewEncoder(w).Encode(...) 会靠反射读标签。
拓展:
生态里还有 db:"column"(sqlx)、form:"username"(绑定表单)等。
本项目 SQL 手写 + Scan,没用 ORM 标签------字段顺序要和 SELECT 列顺序一致。
5. 指针字段与数据库 NULL
type Seat struct {
ID int64 `json:"id"`
ScheduleID int64 `json:"schedule_id"`
RowNo int `json:"row_no"`
ColNo int `json:"col_no"`
Status int `json:"status"`
LockUserID *int64 `json:"lock_user_id,omitempty"`
LockUntil *time.Time `json:"lock_until,omitempty"`
OrderID *int64 `json:"order_id,omitempty"`
}
对应 schema.sql:
lock_user_id BIGINT NULL,
lock_until DATETIME NULL,
order_id BIGINT NULL,
| Go | SQL |
|---|---|
nil 指针 |
NULL |
| 非 nil | 有值 |
dao 扫描时常用中转:
var lockUID sql.NullInt64
var lockUntil sql.NullTime
rows.Scan(..., &lockUID, &lockUntil, ...)
if lockUID.Valid {
s.LockUserID = &lockUID.Int64
}
*Movie.GroupID int64 同理:电影可以暂时不属于任何分组(group_id NULL)。
为什么不用 int64 + 0 表示空?
0 可能是合法 id(虽本项目 id 从 1 自增);NULL 语义是「未知 / 不适用」,和 0 不同。
6. Order 与展示字段
type Order struct {
ID int64 `json:"id"`
OrderNo string `json:"order_no"`
UserID int64 `json:"user_id"`
ScheduleID int64 `json:"schedule_id"`
MovieTitle string `json:"movie_title,omitempty"` // 非表列,JOIN 填充
HallName string `json:"hall_name,omitempty"`
StartTime time.Time `json:"start_time,omitempty"`
SeatInfo string `json:"seat_info"`
Amount float64 `json:"amount"`
Status int `json:"status"`
PayDeadline time.Time `json:"pay_deadline"`
CreatedAt time.Time `json:"created_at"`
PaidAt *time.Time `json:"paid_at,omitempty"`
}
两种字段来源:
- 表列直接映射 :
OrderNo,Amount,Status - 查询时 JOIN 带上 :
MovieTitle,HallName------方便列表页一次展示,不必再查
SeatInfo 是字符串 "3排5座,3排6座"------冗余但利于订单列表快速显示;精确座位仍以 seats 表为准(思考题会问)。
6.1 方法 StatusText
func (o *Order) StatusText() string {
switch o.Status {
case OrderPending:
return "待支付"
case OrderPaid:
return "已支付"
// ...
}
}
模板注册函数:
"statusText": func(status int) string {
o := models.Order{Status: status}
return o.StatusText()
},
HTML 里:{``{statusText .Status}}------行为挂在类型上,比散落 switch 好维护。
7. Comment 与「不在表里的字段」
type Comment struct {
ID int64 `json:"id"`
MovieID int64 `json:"movie_id"`
UserID int64 `json:"user_id"`
Nickname string `json:"nickname,omitempty"` // JOIN users
ParentID *int64 `json:"parent_id,omitempty"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
Replies []Comment `json:"replies,omitempty"` // 内存里组树,无此列
}
Replies []Comment:数据库没有 replies 列 。
dao 查出扁平列表后,在内存里按 ParentID 拼成树,再 JSON 给前端------struct 可以比表「宽」,但不能在 models 里写 SQL 拼树(那是 dao 的事)。
8. 值接收者 vs 指针接收者
func (u *User) IsAdmin() bool { return u.Role == RoleAdmin }
func (o *Order) StatusText() string { ... }
值接收者 (u User) |
指针接收者 (u *User) |
|
|---|---|---|
| 修改原对象 | 改副本 | 可以改原对象 |
| 大 struct | 拷贝成本高 | 更常见 |
| nil 接收者 | 较少 | 需注意 nil pointer |
本项目只读方法(IsAdmin、StatusText)用指针接收者,和 *User 调用方一致。
若方法需要改字段(如 func (u *User) SetNickname),必须用指针。
拓展:
Go 会自动对值取地址调指针方法(user.IsAdmin() 当 user 是值也能工作),但 nil 指针调用会 panic。
9. 匿名 struct:请求 DTO
Handler 里常见临时形状,不必进 models:
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.Fail(w, "参数错误")
return
}
小项目足够。企业可能拆 LoginRequest、CreateOrderRequest 文件------models 保持「持久化实体」,DTO 保持「接口契约」。
10. 结构体与分层的关系
MySQL 行 --Scan--> models.Xxx --JSON/模板--> 浏览器
<--Exec/UPDATE-- <--解析--
dao handlers
models 是中线:
- dao 负责填满 struct(Scan、组装树)
- handlers 负责把 struct 变成 HTML 或 JSON
- 不要在 models 里写 SQL------保持「哑数据 + 少量纯方法」
违反例子(不要学):
func (u *User) Save() error { return db.DB.Exec(...) } // 坏味道
11. 拓展:嵌入 struct(本项目几乎不用)
Go 支持嵌入做组合:
type AdminUser struct {
User
Permissions []string
}
课程项目保持独立 struct + 显式字段 ,避免「伪继承」迷惑初学者。
学长若问继承:答「Go 倾向组合;我们用 Role 字段 + IsAdmin(),不用嵌入层次。」
12. 类型选择对照表(和 schema 一致)
| 业务 | Go 类型 | SQL 类型 |
|---|---|---|
| id | int64 | BIGINT |
| 用户名 | string | VARCHAR |
| 密码哈希 | string | CHAR(32) |
| 角色/状态 | int | TINYINT |
| 票价/金额 | float64 | DECIMAL(Scan 转 float) |
| 时间 | time.Time | DATETIME |
| 可空外键 | *int64 | BIGINT NULL |
| 长简介 | string | TEXT |
金额用 float64 的局限:
DECIMAL 精确到分;float64 有误差。本项目金额量级课程够用;金融级要用 shopspring/decimal 或整数「分」存储。
13. 常见误区
-
「struct 字段顺序无所谓」
Scan顺序必须和SELECT列顺序一致。 -
「json 标签只影响输出不影响输入」
Decode 同样看标签。
-
「Password 小写就安全」
小写只是包内不可 export;JSON 仍可能泄漏,必须
json:"-"。 -
「Replies 可以持久化到一列 JSON」
可以但本项目用关系表 + 内存组树,更利于 SQL 查询单条评论。
14. 项目代码对照
| 你想理解 | 打开 |
|---|---|
| 全部模型定义 | models/models.go |
| User Scan | dao/user.go → GetUserByUsername |
| Seat 可空字段 | dao/schedule.go → ListSeats |
| Order JOIN 字段 | dao/order.go → scanOrder |
| JSON 输出 | utils/json.go + 各 API* |
| 模板里 IsAdmin | handlers/app.go + templates/*.html |
| 常量用在锁座 | dao/order.go → models.SeatSold |
数据流口述(电影详情页):
dao 查 Movie → models.Movie
handlers 塞进 pageData → Render 模板
用户评分 POST → 解析 JSON → models.Rating 字段 → dao.Insert
15. 小结口述稿
struct 把业务概念变成 Go 类型;models 包描述用户、电影、场次、座位、订单等。
状态和角色用命名常量,和数据库 TINYINT 数值一致。
json 标签控制 API 输出;密码和盐用
json:"-"隐藏。可空列用指针;JOIN 来的展示字段可以不在表里,Scan 时填入。
IsAdmin、StatusText等方法让模板和业务更可读;models 不写 SQL。
16. 思考题
- 为什么
SeatInfo用字符串存「3排5座」,同时还要有seats表? Comment.Replies []Comment不是数据库列,为什么可以出现在 struct?谁负责填充?- 若忘记
Password的json:"-",会有什么安全风险? - 把
Status改成字符串"pending"/"paid"在 Go 和 MySQL 里各有什么利弊? Movie.GroupID用*int64而不是int64,在 ScanNULL时会发生什么区别?
17. 下一篇预告
《06 · MySQL 入门:库、表、行、主键》 ------四级概念、InnoDB、utf8mb4、schema 与 seed,以及和 db.Init DSN 的对应关系