1. 为什么在 Sequelize 里写对 Model 数据类型比写对 SQL 还难?
刚接手一个老项目时,我遇到过这么个问题:前端传过来的手机号是字符串"13800138000",后端用Sequelize.INTEGER定义了phone字段,结果存进数据库变成2147483647——MySQL 的INT上限。更糟的是,这个字段在迁移脚本里被设为NOT NULL,但实际业务中允许空值,于是某天凌晨三点,用户注册接口批量报错SequelizeDatabaseError: Column 'phone' cannot be null,而日志里只显示“验证失败”,没人知道是类型定义和业务语义彻底脱节。
这不是个例。我在过去三年带过的 7 个 Node.js 后端项目里,超过 60% 的数据一致性事故、迁移失败、JSON 序列化异常、时间戳错乱,根源都出在 Model 层的数据类型声明上,而不是 SQL 本身。为什么?因为 Sequelize 的DataTypes不是 SQL 类型的简单映射,它是一套三层语义叠加系统:
- 第一层是数据库物理层(MySQL 的
TINYINT(1)vsBOOLEAN); - 第二层是ORM 逻辑层(
DataTypes.BOOLEAN在 PostgreSQL 里生成BOOLEAN,但在 SQLite 里生成TINYINT); - 第三层是JavaScript 运行时层(
DataTypes.DATEONLY存入时自动截断时间部分,但取出来仍是Date实例,只是getHours()永远返回 0)。
这三层之间没有强制对齐机制。你写DataTypes.STRING(10),Sequelize 不会校验你传入的字符串是否真超长;你写DataTypes.DECIMAL(5,2),它也不会阻止你传1234.567进去——直到 MySQL 报Out of range value。更隐蔽的是,同一份 Model 定义,在不同数据库驱动下行为可能完全不同。比如DataTypes.JSON在 PostgreSQL 里走原生JSONB,在 MySQL 5.7+ 走JSON类型,但在 MySQL 5.6 及以下,它默默退化成TEXT,连基本的 JSON 校验都失效。
所以,当你在 Model 里写下phone: { type: DataTypes.STRING },你真正承诺的不是“存字符串”,而是:
✅ 允许存储任意长度字符串(包括空字符串、纯空格、emoji);
✅ 读取时不做任何格式清洗(不会 trim,不会转数字);
✅ 迁移时生成VARCHAR(255)(默认长度),但你没显式声明255,就等于把未来扩展权交给了 Sequelize 的魔法常量;
❌ 不提供手机号格式校验(这是 Validator 的事,不是 DataType 的事);
❌ 不保证与其他服务的序列化兼容(比如 Java 微服务期望phone是 long,而你给的是 string)。
我见过最典型的误用,是把status字段定义为DataTypes.ENUM('active', 'inactive', 'pending'),却在业务代码里直接写user.status = 'archived'——Sequelize 不拦你,MySQL 也不拦你(如果字段是VARCHAR),但下游报表系统一查status NOT IN ('active','inactive','pending')就漏掉所有归档用户。这种错误不会在npm test里暴露,只会在线上数据看板里静默腐烂。
提示:Sequelize 的
DataTypes本质是类型契约声明,不是类型安全防护。它告诉你“我打算怎么用”,而不是“我禁止你怎么用”。真正的防护要靠组合:type+validate+set钩子 + 数据库CHECK约束。
2. DataTypes 全谱系拆解:从基础类型到高阶陷阱
Sequelize 的DataTypes模块表面看是静态常量集合,实则暗藏大量隐式转换规则与数据库方言差异。下面按使用频率和风险等级,逐个击穿。
2.1 基础标量类型:你以为的“直译”,其实是“妥协”
2.1.1STRING/CHAR/TEXT—— 长度陷阱与编码战争
// 常见写法(危险!) name: { type: DataTypes.STRING } // 看似合理(仍危险!) description: { type: DataTypes.TEXT } // 真正可控的写法 title: { type: DataTypes.STRING(100), validate: { len: [1, 100] // 前端/后端双重校验 } }, content: { type: DataTypes.TEXT('long') // 显式指定 TEXT 子类 }为什么DataTypes.STRING默认是VARCHAR(255)?因为 MySQL 的VARCHAR最大长度是 65535 字节,但255是历史遗留的“安全甜点”——既能覆盖绝大多数名称字段,又避免单行记录过大。但问题在于:255是字节数,不是字符数。在 utf8mb4 编码下,一个 emoji 占 4 字节,那么VARCHAR(255)最多存 63 个 emoji,而非 255 个字符。我曾在一个社交项目里,用户昵称存👍👍👍...(64 个),直接触发Data too long for column。
更致命的是TEXT类型。Sequelize 的DataTypes.TEXT在不同方言下生成不同物理类型:
| 方言 | 生成类型 | 最大长度 | 特性 |
|---|---|---|---|
| MySQL | TEXT | 65,535 字节 | 不支持索引(除非前缀) |
| PostgreSQL | TEXT | 1GB | 支持全文索引、LIKE加速 |
| SQLite | TEXT | 理论无限 | 实际受内存限制 |
所以,如果你在 MySQL 项目里定义bio: { type: DataTypes.TEXT },然后想对bio建索引,必须手动加indexes: [{ fields: ['bio'], using: 'fulltext' }],否则WHERE bio LIKE '%node%'会全表扫描。而同样代码在 PostgreSQL 里,CREATE INDEX CONCURRENTLY ON users USING GIN (bio gin_trgm_ops)就能生效。
注意:
DataTypes.CHAR(10)和DataTypes.STRING(10)在 MySQL 里物理存储完全不同。CHAR固定长度,不足补空格;STRING(即VARCHAR)变长存储。但 Sequelize不提供CHAR的 trim 行为控制——你存'abc '(带空格),取出来还是'abc '。必须自己写set钩子:set(val) { this.setDataValue('code', val?.trim?.()); }。
2.1.2INTEGER/BIGINT/DECIMAL—— 数值精度的三重幻觉
// 绝对禁止! age: { type: DataTypes.INTEGER }, price: { type: DataTypes.DECIMAL }, // 必须显式声明精度 age: { type: DataTypes.TINYINT.UNSIGNED, // 0-255,省空间 validate: { min: 0, max: 150 } }, price: { type: DataTypes.DECIMAL(10, 2), // 总10位,小数2位 get() { const val = this.getDataValue('price'); return val ? parseFloat(val) : null; // 强制转 JS number } }DataTypes.INTEGER的陷阱在于:它不对应单一 SQL 类型。在 MySQL 里是INTEGER(等同INT),在 PostgreSQL 里是INTEGER,但在 SQLite 里是INTEGER(但 SQLite 是动态类型,实际存啥都行)。更麻烦的是BIGINT:MySQL 的BIGINT是 64 位有符号整数(-9223372036854775808 到 9223372036854775807),但 JavaScript 的Number安全整数上限是2^53-1(约 9e15)。当你的订单 ID 是9223372036854775807,JS 里读出来可能是9223372036854776000——最后几位被四舍五入了。
解决方案只有两个:
- 永远用字符串存超大整数(如订单号、雪花ID),配合
DataTypes.STRING(20)+validate: { is: /^[0-9]+$/ }; - 用
DataTypes.DECIMAL代替BIGINT存金额,因为DECIMAL在 JS 里返回string或BigDecimal(取决于配置),避免精度丢失。
DECIMAL(p,s)的p(精度)和s(标度)必须严格匹配业务。比如商品价格,DECIMAL(10,2)支持99999999.99,但如果做促销要支持999999999.99(十亿级),就必须DECIMAL(11,2)。我见过一个电商项目,因DECIMAL(10,2)存不下1000000000.00,MySQL 直接截断成999999999.99,财务对账差 1 分钱,排查三天才发现是类型定义短了 1 位。
2.2 时间类型:时区、精度与序列化的罗生门
// 错误示范:混合使用 createdAt: { type: DataTypes.DATE }, updatedAt: { type: DataTypes.DATEONLY }, expiresAt: { type: DataTypes.DATE }, // 正确分层方案 createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW, get() { // 统一转成本地时区 ISO 字符串 const date = this.getDataValue('createdAt'); return date ? new Date(date).toISOString().slice(0, 19) : null; } }, dateOnly: { type: DataTypes.DATEONLY, // 存 '2023-10-05' set(val) { // 强制设为当天 00:00:00 UTC if (val) { const d = new Date(val); d.setUTCHours(0,0,0,0); this.setDataValue('dateOnly', d); } } }, timeOnly: { type: DataTypes.TIME, // 存 '14:30:00' validate: { isTime: true // Sequelize 内置校验 } }DataTypes.DATE是最混乱的类型。它在不同方言下行为差异极大:
- MySQL:生成
DATETIME,默认无时区,存2023-10-05 14:30:00; - PostgreSQL:生成
TIMESTAMP WITHOUT TIME ZONE,但 Sequelize 会自动转成 UTC 存储; - SQLite:生成
DATETIME,但 SQLite 本身无时区概念,全靠应用层处理。
最坑的是DataTypes.DATEONLY。它声称只存日期,但底层仍是DATE类型。问题在于:DATE在 MySQL 里是“本地时区日期”,而 Sequelize 的NOW默认返回 UTC 时间。所以defaultValue: DataTypes.NOW存进去的2023-10-05,在东八区服务器上,实际代表2023-10-05 00:00:00 UTC,即北京时间2023-10-05 08:00:00。如果你用这个字段做“今日订单统计”,WHERE date = CURDATE()可能漏掉早上的单。
解决方案是统一时区策略:
- 所有
DATE/DATEONLY字段,数据库层面设为UTC时区(MySQLSET time_zone = '+00:00'); - Model 层
defaultValue用Sequelize.fn('NOW')替代DataTypes.NOW,让数据库生成时间; - 读取时用
get钩子转成本地时区字符串,避免 JSDate构造函数的隐式时区转换。
2.3 复合类型:JSON、ENUM 与自定义类型的生存指南
2.3.1JSON—— 看似灵活,实则脆弱
// 危险:无约束的 JSON metadata: { type: DataTypes.JSON }, // 安全:带结构校验的 JSON settings: { type: DataTypes.JSONB, // PostgreSQL 专用,支持索引 validate: { isValidJson(value) { try { const parsed = JSON.parse(value); // 校验结构 if (typeof parsed.theme !== 'string') { throw new Error('theme must be string'); } if (!['light','dark'].includes(parsed.theme)) { throw new Error('theme must be light or dark'); } } catch (e) { throw new Error(`Invalid JSON: ${e.message}`); } } } }DataTypes.JSON的核心问题是:它不提供 Schema 校验。你存{ "theme": "dark", "fontSize": 16 },没问题;存{ "theme": "dark", "fontSize": "16px" },也没问题;但下游解析时,fontSize类型不一致会导致 UI 崩溃。更糟的是,MySQL 5.7+ 的JSON类型虽支持JSON_VALID()函数,但 Sequelize 的validate不会自动调用它。
DataTypes.JSONB是 PostgreSQL 的救星,它支持:
@>操作符(包含):WHERE settings @> '{"theme":"dark"}';#>>操作符(路径取值):SELECT settings#>>'{theme}' FROM users;- GIN 索引加速查询。
但JSONB不能跨数据库移植。所以我的经验是:简单键值对用DataTypes.HSTORE(PostgreSQL)或拆成独立字段;复杂嵌套结构,宁可建关联表,也不要赌 JSON 的长期可维护性。
2.3.2ENUM—— 静态列表的动态诅咒
// 危险:硬编码枚举 status: { type: DataTypes.ENUM('draft','published','archived') }, // 安全:可扩展枚举 status: { type: DataTypes.ENUM, values: ['draft','published','archived','reviewing'], validate: { isIn: [['draft','published','archived','reviewing']] } }DataTypes.ENUM的最大缺陷是数据库迁移不可逆。MySQL 的ALTER TABLE ADD ENUM VALUE语法不标准,PostgreSQL 的ALTER TYPE ADD VALUE在旧版本不支持并发。一旦线上表已有百万数据,新增枚举值要锁表数分钟。
更隐蔽的问题是:ENUM值在 Sequelize 里是字符串,但数据库里是序号。MySQL 存ENUM('a','b','c'),'a'存为1,'b'存为2。如果你导出 CSV 再导入,不小心把1当数字导入,MySQL 会把它当'a',导致数据错乱。
我的实践是:用DataTypes.STRING+validate.isIn替代ENUM,并在数据库加CHECK约束:
status: { type: DataTypes.STRING(20), validate: { isIn: [['draft','published','archived','reviewing']] }, // 迁移时手动加 CHECK // queryInterface.addConstraint('users', ['status'], { type: 'check', where: { status: ['draft','published','archived','reviewing'] } }); }这样既保持 ORM 层校验,又让数据库兜底,还能随时增删状态而不改表结构。
3. 数据类型选型决策树:从需求场景反推最优 DataType
选型不是查文档,而是解一道约束满足题。你需要同时满足:数据库物理限制、ORM 运行时行为、业务语义表达、未来扩展成本。下面是我用过的决策流程图(文字版)。
3.1 第一步:明确字段的“不可变契约”
问自己三个问题:
- 这个值是否会被其他系统直接读取?(如 BI 工具、Java 微服务)→ 如果是,优先选数据库原生强类型(
DECIMAL而非STRING存金额); - 这个值的业务含义是否会随时间变化?(如
status从 3 种变 10 种)→ 如果是,放弃ENUM,用STRING+CHECK; - 这个值是否需要数据库级计算或索引?(如
WHERE price > 100)→ 如果是,DECIMAL比STRING更高效,且避免CAST(price AS DECIMAL)的开销。
举个真实案例:某 SaaS 项目的plan_tier字段。初期只有'free'/'pro'/'enterprise',团队用了ENUM。半年后要加'trial'和'legacy',DBA 拒绝执行ALTER TABLE ADD ENUM,因为要锁表。最后只能:
- 新增
plan_tier_v2字段,类型STRING; - 写脚本把旧
ENUM值映射到新字段; - 改所有代码读新字段;
- 三个月后删旧字段。
总耗时 16 人日。如果一开始用STRING+CHECK,只需改一行迁移脚本。
3.2 第二步:对照方言矩阵,排除不兼容选项
Sequelize 的dialect配置决定了哪些DataTypes可用。必须查清目标方言支持情况:
| DataType | MySQL | PostgreSQL | SQLite | MSSQL | 是否推荐 |
|---|---|---|---|---|---|
JSON | ✅ 5.7+ | ✅ | ❌ | ✅ | 仅 PostgreSQL/MSSQL |
JSONB | ❌ | ✅ | ❌ | ❌ | PostgreSQL 专属 |
HSTORE | ❌ | ✅ | ❌ | ❌ | PostgreSQL 专属 |
GEOGRAPHY | ❌ | ✅ | ❌ | ✅ | 地理位置必选 |
VIRTUAL | ✅ | ✅ | ✅ | ✅ | 计算字段首选 |
例如,你要存经纬度,DataTypes.GEOGRAPHY在 PostgreSQL 里生成GEOGRAPHY(POINT,4326),支持ST_DWithin距离查询;在 MSSQL 里生成GEOGRAPHY,支持STDistance();但在 MySQL 里,它退化成TEXT,完全失去地理计算能力。所以跨方言项目,地理字段必须用DataTypes.GEOMETRY(WKT 格式字符串)+ 应用层计算,牺牲一点性能,换跨库一致性。
3.3 第三步:精度与性能的平衡点
不是所有字段都需要最高精度。DataTypes.STRING(255)是安全选择,但浪费空间;DataTypes.STRING(10)太窄,易出错。我的经验值:
| 业务字段 | 推荐类型 | 理由 |
|---|---|---|
| 用户名 | STRING(50) | GitHub 用户名最长 39 字符,留余量 |
| 邮箱 | STRING(254) | RFC 5321 规定邮箱地址最大 254 字节 |
| 手机号 | STRING(20) | 国际号码含国家码(+86),最长 15 位,加符号留余 |
| UUID | STRING(36) | 标准 UUID 格式xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
| 密码哈希 | STRING(255) | bcrypt 哈希固定 60 字符,但未来可能换算法 |
| HTML 内容 | TEXT('long') | 避免VARCHAR长度限制,支持大文本 |
特别注意TEXT的性能。MySQL 的TEXT列不存于主行,而存于溢出页,SELECT *时会额外 IO。所以不要把TEXT字段放常用查询的 Model 里。比如文章 Model,title和slug放主 Model,content单独建ArticleContent关联表,用belongsTo关联。这样首页列表SELECT title,slug FROM articles不拖慢。
3.4 第四步:运行时行为兜底方案
即使选对了DataType,JS 层仍可能出错。必须用钩子加固:
// 通用防错模板 const safeString = (maxLength = 255) => ({ type: DataTypes.STRING(maxLength), validate: { len: [0, maxLength], is: /^[^\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]*$/ // 过滤控制字符 }, set(val) { // 自动 trim + 转义 const cleaned = typeof val === 'string' ? val.trim().replace(/\u200B/g, '') // 去零宽空格 : ''; this.setDataValue('fieldName', cleaned); } }); // 使用 username: safeString(50), email: safeString(254)这个模板解决了三大痛点:
len校验防止超长插入;is正则过滤不可见控制字符(\u200B零宽空格是爬虫注入常用手段);set钩子自动trim,避免前后空格污染;replace清除零宽字符,防止“看似相同实则不同”的用户名冲突。
提示:
DataTypes的validate是同步校验,发生在save()前;set钩子是赋值时触发,影响setDataValue。两者必须配合使用,不能只依赖其一。
4. 生产环境避坑手册:那些只有踩过才懂的 DataType 陷阱
这些不是文档里的警告,而是凌晨三点救火后记下的血泪笔记。
4.1 迁移脚本里的类型变更:ALTER TABLE 的静默失败
Sequelize CLI 生成的迁移脚本,changeColumn默认用ALTER TABLE MODIFY COLUMN。但在 MySQL 里,MODIFY会重建整张表,锁表时间与数据量成正比。一个 500 万行的表,MODIFY phone VARCHAR(20)要锁表 12 分钟。
更糟的是,某些类型变更 MySQL 会静默失败。比如把VARCHAR(10)改成VARCHAR(5),如果现有数据有长度 8 的,MySQL 不报错,而是截断存入!我在一个金融项目里,把account_number从STRING(20)改成STRING(10),结果所有 15 位卡号被截成前 10 位,支付失败率飙升。
解决方案:
- 永远用
CHANGE COLUMN替代MODIFY COLUMN,并显式指定USING转换逻辑; - 变更前先查数据长度分布:
SELECT MAX(LENGTH(phone)) FROM users; - 关键字段变更,用两阶段迁移:
- 新增
phone_new VARCHAR(20)字段; - 脚本把
phone值复制到phone_new并校验; - 应用层双写
phone和phone_new; - 切读到
phone_new; - 删
phone字段。
- 新增
4.2defaultValue的时区迷宫:NOW、fn('NOW') 与 new Date()
// 三种写法,行为完全不同 createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW // Sequelize 生成 new Date(),本地时区 }, updatedAt: { type: DataTypes.DATE, defaultValue: Sequelize.fn('NOW') // 数据库生成 NOW(),数据库时区 }, syncedAt: { type: DataTypes.DATE, defaultValue: () => new Date() // 每次实例化 Model 时生成,本地时区 }DataTypes.NOW是最危险的。它在 Model 定义时执行new Date(),意味着所有实例共享同一个时间戳!如果你的服务器时区是 CST(UTC+8),DataTypes.NOW返回2023-10-05T08:00:00.000Z,但存进 MySQL 时,MySQL 按SYSTEM时区解释,可能变成2023-10-05 00:00:00。
正确做法:
- 时间戳字段,一律用
Sequelize.fn('NOW'),让数据库生成,时区由 DB 统一管理; - 需要 JS 层控制的,默认值用箭头函数
() => new Date(),确保每次新建实例都实时生成; - 绝对不要用
DataTypes.NOW,它已被 Sequelize 官方文档标记为“不推荐”。
4.3allowNull: false与defaultValue的冲突:空值黑洞
// 看似完美 email: { type: DataTypes.STRING(254), allowNull: false, defaultValue: '' }, // 实际效果:email 为空字符串时,Sequelize 认为已赋值,跳过 validate // 但业务要求 email 必须是有效邮箱,空字符串应报错allowNull: false只检查null和undefined,不检查空字符串''或空白字符串' '。所以defaultValue: ''会制造一个“合法但无效”的默认值。
修复方案:
allowNull: false必须配validate.isEmail;defaultValue设为Sequelize.UUIDV4或Sequelize.fn('uuid_generate_v4')(PostgreSQL),避免业务字段默认值;- 对必填字段,
defaultValue设为Sequelize literal('')并在 validate 中拦截:email: { type: DataTypes.STRING(254), allowNull: false, defaultValue: Sequelize.literal("''"), validate: { isEmail: true, notEmpty: true // 拦截空字符串 } }
4.4unique约束的隐形杀手:NULL 与大小写
MySQL 的UNIQUE索引对NULL值特殊处理:多个NULL值不违反唯一约束。所以email: { type: STRING, unique: true },允许存多条email = NULL的记录。这在用户注册流程里很危险——未填写邮箱的用户能重复注册。
更隐蔽的是大小写。MySQL 的utf8mb4_unicode_ci排序规则默认忽略大小写,所以UNIQUE(email)会让A@B.COM和a@b.com冲突。但 Sequelize 的validate.isEmail是区分大小写的,导致校验通过,入库时报唯一键冲突。
解决方案:
- 邮箱唯一性,用
LOWER(email)建函数索引(MySQL 8.0+):CREATE UNIQUE INDEX idx_email_lower ON users (LOWER(email)); - 或在 Model 层统一转小写:
email: { type: DataTypes.STRING(254), set(val) { this.setDataValue('email', val?.toLowerCase?.()); } }
5. 实战复盘:一个电商订单 Model 的 DataType 演进
最后用一个完整案例,展示如何把上述原则落地。某电商订单 Model,初始版本很“干净”:
// V1:天真版本 module.exports = (sequelize, DataTypes) => { const Order = sequelize.define('Order', { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, userId: { type: DataTypes.INTEGER }, total: { type: DataTypes.DECIMAL }, status: { type: DataTypes.ENUM('pending','paid','shipped','delivered','cancelled') } }); return Order; };上线两周后,问题爆发:
id是INTEGER,订单号超2147483647,MySQL 报错;total没精度,199.995存成199.99,财务对不上;status新增'refunded',DBA 拒绝改ENUM;userId是INTEGER,但用户表用 UUID,关联失败。
我们花了 3 天重构,最终 V3 版本:
// V3:生产就绪版 module.exports = (sequelize, DataTypes) => { const Order = sequelize.define('Order', { // 主键:UUID 字符串,避免整数溢出 id: { type: DataTypes.STRING(36), primaryKey: true, defaultValue: DataTypes.UUIDV4, validate: { isUUID: true, len: [36, 36] } }, // 用户ID:字符串,兼容各种ID生成策略 userId: { type: DataTypes.STRING(36), allowNull: false, validate: { notEmpty: true, len: [1, 36] } }, // 金额:DECIMAL(12,2),支持千亿级订单 total: { type: DataTypes.DECIMAL(12, 2), allowNull: false, validate: { min: 0.01, max: 9999999999.99 }, get() { // 强制返回 number,避免字符串比较 const val = this.getDataValue('total'); return val ? parseFloat(val) : 0; } }, // 状态:STRING + CHECK,支持动态扩展 status: { type: DataTypes.STRING(20), allowNull: false, defaultValue: 'pending', validate: { isIn: [['pending','paid','shipped','delivered','cancelled','refunded','archived']] } }, // 时间戳:数据库生成,UTC 时区 createdAt: { type: DataTypes.DATE, defaultValue: Sequelize.fn('NOW') }, updatedAt: { type: DataTypes.DATE, defaultValue: Sequelize.fn('NOW') } }, { // 数据库级 CHECK 约束,兜底 indexes: [ { fields: ['userId'] }, { fields: ['status'] } ], // PostgreSQL 专属:JSONB 存扩展字段 ...(sequelize.options.dialect === 'postgres' && { getterMethods: { // 读取时自动解析 JSONB metadata() { const raw = this.getDataValue('metadata'); return raw ? JSON.parse(raw) : {}; } } }) }); return Order; };关键改进点:
- 主键弃用
INTEGER,改用STRING(36)+UUIDV4,彻底解决 ID 溢出; total显式声明DECIMAL(12,2),并加min/max校验,金额精度零误差;status用STRING+validate.isIn+ 数据库CHECK,新增状态只需改代码,不动表;createdAt/updatedAt用Sequelize.fn('NOW'),时区由 DB 统一;userId放宽为STRING(36),兼容 UUID、雪花ID、手机号等多种 ID;- PostgreSQL 专属优化:
getterMethods解析JSONB,避免应用层重复解析。
这个 Model 经历了 18 个月、日均 50 万订单的考验,零数据类型相关故障。它的核心哲学是:用最保守的类型(STRING)承载业务语义,用最精确的类型(DECIMAL)承载数值计算,用数据库约束兜底,用钩子加固边界。
我在实际使用中发现,过度追求“类型精准”反而增加维护成本。比如坚持用ENUM管理状态,不如用STRING+ 文档约定 + CI 检查状态枚举值。因为业务状态是活的,数据库类型是死的,让活的去适应死的,代价太大。真正的稳健,是接受一定的“类型宽松”,再用多层校验去收敛。