Alex
用户资料选填字段:数据库建模与前后端更新契约
- 数据库
- API
- 前端
注册和改资料谁都会写,真正容易踩坑的是:生日可以清空、性别可以「未设置」、手机号选填但填了要唯一——这类字段一多,表结构、更新接口和前端提交方式就开始互相扯皮。
本文按四条线串起来:表怎么建 → 更新接口怎么约定「没改」和「清空」→ 前端怎么只提交改动 → NULL 到底要不要全面禁止。
1. 数据库:别存会自己过期的字段
1.1 只持久化「源数据」
用户表里同时放 birthday 和 age 是典型反面教材:年龄每天都在变,存下来的数字第二天就过期,统计和展示都会对不上。
只存 birthday。 年龄放在查询或接口层算——用当前日期减生日,按业务需要取整或精确到日即可,不必在 DDL 里再占一列。
1.2 一张够用的 users 表示例
CREATE TABLE `users` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`email` VARCHAR(255) NOT NULL COMMENT '邮箱,唯一标识',
`name` VARCHAR(100) NOT NULL COMMENT '用户姓名',
`gender` TINYINT NOT NULL DEFAULT 0 COMMENT '0-未设置/保密, 1-男, 2-女',
`birthday` DATE DEFAULT NULL COMMENT '生日,选填',
UNIQUE KEY `uk_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;| 字段 | 取舍 |
|---|---|
email、name | NOT NULL,email 加唯一索引 |
gender | NOT NULL DEFAULT 0,用 0 表示「未设置」,见第 4 节 |
birthday | 允许 NULL,表示用户从未填写或已清空 |
2. 更新接口:「没传」和「传了空」不是一回事
资料页最常见的线上事故:用户只改了昵称,请求里没带生日,后端却把库里已有的生日清掉了。 根因是没约定清楚:空值究竟是「用户要清空」还是「这次请求根本没动这个字段」。
2.1 PATCH 语义:字段在不在请求体里说了算
| 请求体 | 后端行为 |
|---|---|
| 没有某个 key | 不更新该列 |
有 key,值为 null 或约定好的「清空哨兵」 | 写入 NULL 或业务上的空状态 |
| 有 key,值为正常内容 | 覆盖 |
后端实现上常见两种写法:动态 SQL 只拼变更列;或在 Go/Java 里用指针、Optional 区分「未出现」和「显式 null」。契约要在文档里写死,否则联调各猜各的。
2.2 PUT 语义:全量覆盖,前端背锅
有的团队嫌动态 SQL 麻烦,更新接口一律全量替换:请求体里缺什么列,就按缺失去理解(或 DTO 里未赋值的字段走默认值)。这样后端简单,但前端每次提交必须带上当前用户的完整快照,漏一个选填字段就可能被抹掉。
即使用 PUT,email、name 等必填项也必须在 DTO 层做强校验(@NotBlank、非空字符串等),防止前端漏传把核心字段写成空。全量风格不是「可以不校验」的借口。
2.3 怎么选
| 方案 | 适合 |
|---|---|
| PATCH + 字段级语义 | 资料表单字段多、用户常只改一两项 |
| PUT + 全量快照 | 接口少、团队小、前端能保证拉全量再提交 |
没有绝对更优,只有和前端能否稳定做 Diff、后端是否愿意维护「部分更新」之间的权衡。
3. 前端:只把改过的字段交给后端
采用 PATCH 时,前端需要知道哪些字段被用户动过。工程里两条常见路子,都不难,但要在项目里统一,别一个页面用 Hook Form、另一个手写全量 POST。
3.1 表单库的 dirty 状态(优先)
React Hook Form、VeeValidate 等会在输入时标记 dirtyFields。提交时筛一遍即可:
const patchData = {};
Object.keys(dirtyFields).forEach((key) => {
patchData[key] = getValues(key);
});
await api.patch(`/users/${id}`, patchData);用户没碰过的字段不会进请求体,和后端「缺 key 不更新」的约定自然对齐。
3.2 自己 diff 初始值和当前值
没有表单库时,在提交前对比 original 和 current,深度相等则跳过。Lodash 的 isEqual 够用;字段少也可以手写浅比较。注意:日期、枚举、空字符串和 null 要在对比前归一化,否则 diff 出一堆假变更。
3.3 清空选填字段怎么传
「用户点了清除生日」必须让后端收到显式清空信号:例如 birthday: null,或团队约定的 "" / 删除标记字段。只从 patch 对象里省略 birthday 在 PATCH 语义下等于没改,这是联调时最高频的误解之一。
4. NULL:能躲就躲,不能躲就认
结论:不要为了「规范」把所有列都改成 NOT NULL;按字段语义分三类处理。
4.1 为什么很多团队讨厌 NULL
- 三值逻辑 —
WHERE gender != 1筛不到gender IS NULL的行;NOT IN子查询里一旦出现 NULL,结果可能整段变空。写 SQL 的人得时刻记得IS NULL。 - 索引与优化器 — 大量 NULL 时,优化器对选择性、覆盖索引的估计会变复杂(具体因引擎和版本而异,但排查成本是实打实的)。
- 应用层 — JSON 里没有
undefined和 SQLNULL的一一对应,前后端容易在「没设置」和「传了 null」之间吵一轮。
所以很多规范会写:状态、枚举、计数优先 NOT NULL DEFAULT 0 或 DEFAULT ''。
4.2 什么时候必须留 NULL
| 场景 | 原因 |
|---|---|
选填且唯一(如 phone) | 多个 NULL 不违反 UNIQUE;多个 '' 会撞唯一约束 |
| 业务上要区分「零」和「没有」 | 例如成绩 0 是考了零分,NULL 是缺考,用 0 顶替会拉歪平均分 |
性别、账号状态这类没有「零分」歧义的字段,用 0 表示未设置通常比 NULL 省心。
4.3 和接口语义的对应关系
| 存储 | 接口展示 | 用户操作 |
|---|---|---|
gender = 0 | 「未设置」或「保密」 | 选具体性别则写 1/2 |
birthday IS NULL | 不展示或「未填写」 | 清空则 PATCH birthday: null |
phone IS NULL | 未绑定手机 | 填写后 UNIQUE 生效 |
5. 总结
| 主题 | 做法 |
|---|---|
| 表结构 | 只存 birthday,不存 age;必填列 NOT NULL |
| 状态类选填 | NOT NULL DEFAULT 0 + 明确枚举(如 gender) |
| 唯一选填 | 允许 NULL(如 phone) |
| 更新契约 | PATCH:缺 key 不改、null 清空;PUT:全量 + 必填校验 |
| 前端 | dirty 或 diff,清空字段必须显式传 null |
日常开发再记两条:
- 联调前先对齐一张表:每个选填字段「没传 / 传 null / 传空字符串」分别会怎样,写进接口文档,比事后捞数据省事。
- 如果线上已经出过「改昵称把生日弄没了」,优先查是 PATCH 语义没统一,还是前端提交成了全量却漏字段——两种病因,修法不一样。