Alex

用户资料选填字段:数据库建模与前后端更新契约

  • 数据库
  • API
  • 前端

注册和改资料谁都会写,真正容易踩坑的是:生日可以清空、性别可以「未设置」、手机号选填但填了要唯一——这类字段一多,表结构、更新接口和前端提交方式就开始互相扯皮。

本文按四条线串起来:表怎么建 → 更新接口怎么约定「没改」和「清空」→ 前端怎么只提交改动 → NULL 到底要不要全面禁止


1. 数据库:别存会自己过期的字段

1.1 只持久化「源数据」

用户表里同时放 birthdayage 是典型反面教材:年龄每天都在变,存下来的数字第二天就过期,统计和展示都会对不上。

只存 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;
字段取舍
emailnameNOT NULLemail 加唯一索引
genderNOT 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,emailname 等必填项也必须在 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 初始值和当前值

没有表单库时,在提交前对比 originalcurrent,深度相等则跳过。Lodash 的 isEqual 够用;字段少也可以手写浅比较。注意:日期、枚举、空字符串和 null 要在对比前归一化,否则 diff 出一堆假变更。

3.3 清空选填字段怎么传

「用户点了清除生日」必须让后端收到显式清空信号:例如 birthday: null,或团队约定的 "" / 删除标记字段。只从 patch 对象里省略 birthday 在 PATCH 语义下等于没改,这是联调时最高频的误解之一。


4. NULL:能躲就躲,不能躲就认

结论:不要为了「规范」把所有列都改成 NOT NULL;按字段语义分三类处理。

4.1 为什么很多团队讨厌 NULL

  1. 三值逻辑WHERE gender != 1 筛不到 gender IS NULL 的行;NOT IN 子查询里一旦出现 NULL,结果可能整段变空。写 SQL 的人得时刻记得 IS NULL
  2. 索引与优化器 — 大量 NULL 时,优化器对选择性、覆盖索引的估计会变复杂(具体因引擎和版本而异,但排查成本是实打实的)。
  3. 应用层 — JSON 里没有 undefined 和 SQL NULL 的一一对应,前后端容易在「没设置」和「传了 null」之间吵一轮。

所以很多规范会写:状态、枚举、计数优先 NOT NULL DEFAULT 0DEFAULT ''

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

日常开发再记两条:

  1. 联调前先对齐一张表:每个选填字段「没传 / 传 null / 传空字符串」分别会怎样,写进接口文档,比事后捞数据省事。
  2. 如果线上已经出过「改昵称把生日弄没了」,优先查是 PATCH 语义没统一,还是前端提交成了全量却漏字段——两种病因,修法不一样。