03 Sequelize 模型与关联

本章练习把新增的 admin_user 和 Sakila 现有表映射成 Sequelize Model。重点是准确描述真实数据库,而不是让 Sequelize 自动创造结构。

1. 初始化顺序

推荐:

创建 Sequelize 实例
  -> 初始化全部 Model
  -> 建立全部 Association
  -> sequelize.authenticate()
  -> 启动 HTTP Server

所有 Model 初始化后再建立关联,可以减少循环 import 和“关联目标尚未初始化”的问题。

export function initializeModels(sequelize: Sequelize): void {
  initAdminUserModel(sequelize);
  initActorModel(sequelize);
  initFilmModel(sequelize);
  // TODO:其余 Sakila Model

  initializeAssociations();
}

2. AdminUser 类骨架

import {
  CreationOptional,
  DataTypes,
  InferAttributes,
  InferCreationAttributes,
  Model,
  type Sequelize,
} from "sequelize";

export class AdminUser extends Model<
  InferAttributes<AdminUser>,
  InferCreationAttributes<AdminUser>
> {
  declare id: CreationOptional<string>;
  declare username: string;
  declare passwordHash: string;
  declare displayName: string;
  declare email: string | null;
  declare accountStatus: CreationOptional<AdminAccountStatus>;
  declare tokenVersion: CreationOptional<number>;
  declare failedLoginCount: CreationOptional<number>;
  declare lockedUntil: Date | null;
  declare lastLoginAt: Date | null;
  declare passwordChangedAt: CreationOptional<Date>;
  declare createdAt: CreationOptional<Date>;
  declare updatedAt: CreationOptional<Date>;
}

为什么 id 是 string:MySQL BIGINT 可能超过 JavaScript 安全整数。为什么 CreationOptional:数据库会生成主键或默认值,创建实例时可不传,不代表读取后它是 undefined

declare id: string 只约束 TypeScript,不能改变 mysql2 的运行时返回值。连接配置需要明确并实际测试 dialectOptions.supportBigNumbersdialectOptions.bigNumberStrings;在 DTO 和 JWT 边界仍使用 String(admin.id) 统一类型。不要先调用 Number(admin.id),否则超出安全整数范围时精度已经丢失。尤其要测试普通查询与自增插入后返回的 ID 类型是否一致。

declare id: string 只约束 TypeScript,不能改变 mysql2 的运行时返回值。连接配置需要明确并实际测试 dialectOptions.supportBigNumbersdialectOptions.bigNumberStrings;在 DTO 和 JWT 边界仍使用 String(admin.id) 统一类型。不要先调用 Number(admin.id),否则超出安全整数范围时精度已经丢失。尤其要测试普通查询与自增插入后返回的 ID 类型是否一致。

3. AdminUser.init() 骨架

export function initAdminUserModel(
  sequelize: Sequelize,
): typeof AdminUser {
  AdminUser.init(
    {
      id: {
        type: DataTypes.BIGINT.UNSIGNED,
        primaryKey: true,
        autoIncrement: true,
      },
      username: {
        type: DataTypes.STRING(50),
        allowNull: false,
        // TODO:是否在 Model 重复 unique 约束?说明数据库约束才是最终保护
      },
      passwordHash: {
        type: DataTypes.STRING(255),
        allowNull: false,
        field: "password_hash",
      },
      displayName: {
        // TODO
      },
      email: {
        // TODO
      },
      accountStatus: {
        // TODO
      },
      tokenVersion: {
        // TODO
      },
      failedLoginCount: {
        // TODO
      },
      lockedUntil: {
        // TODO
      },
      lastLoginAt: {
        // TODO
      },
      passwordChangedAt: {
        // TODO
      },
      createdAt: {
        // TODO:DATETIME(3) 和 field
      },
      updatedAt: {
        // TODO:DATETIME(3) 和 field
      },
    },
    {
      sequelize,
      tableName: "admin_user",
      freezeTableName: true,
      // createdAt/updatedAt 已作为普通属性映射到数据库列,
      // 值由 DDL 中的 DEFAULT 和 ON UPDATE 维护。
      timestamps: false,
      defaultScope: {
        attributes: {
          exclude: ["passwordHash"],
        },
      },
      scopes: {
        withPassword: {
          attributes: {
            include: ["passwordHash"],
          },
        },
      },
    },
  );

  return AdminUser;
}

调用具名 scope 时,不要想当然地认为它只是在 defaultScope 上“加回”一个字段;scope 的替换、合并方式与调用方式有关。这里保留了一个需要亲自验证的练习点:打开开发环境 SQL 日志,确认 withPassword 生成的 SELECT 是否包含密码字段。正式实现时更推荐为登录查询写明确的字段白名单。

defaultScope 只是降低误查密码摘要的概率,不是安全边界。unscoped()、Raw Query 或其他 scope 仍可能查出该字段;即使登录查询取得了摘要,也只能把 Model 交给认证逻辑,不能直接 res.json(model),响应必须经过 DTO 白名单转换。

登录查询示意:

const admin = await AdminUser.scope("withPassword").findOne({
  where: { username },
});

普通查询不应携带密码摘要。

4. Sakila 时间戳不是 Sequelize 默认结构

Sakila 很多表只有:

last_update

而不是:

createdAt
updatedAt

因此 Actor 不能照搬 admin_user 的 timestamps 配置。

export class Actor extends Model<
  InferAttributes<Actor>,
  InferCreationAttributes<Actor>
> {
  declare actorId: CreationOptional<number>;
  declare firstName: string;
  declare lastName: string;
  declare lastUpdate: CreationOptional<Date>;
}

export function initActorModel(sequelize: Sequelize): typeof Actor {
  return Actor.init(
    {
      actorId: {
        // TODO:SMALLINT UNSIGNED、主键、自增和 field
      },
      firstName: {
        // TODO:真实长度、allowNull、field
      },
      lastName: {
        // TODO
      },
      lastUpdate: {
        // TODO:映射 last_update
      },
    },
    {
      sequelize,
      tableName: "actor",
      timestamps: false,
    },
  );
}

如果忘记 timestamps: false,Sequelize 可能查询并不存在的 createdAtupdatedAt

5. 第一阶段模型清单

按练习进度添加,不要一次完成所有表:

认证:AdminUser
Actor CRUD:Actor、FilmActor、Film
Customer:Customer、Address、City、Country、Store
Film:Language、Category、FilmCategory、Inventory
Rental:Rental、Staff
Payment:Payment

字段定义必须对照你本机 Sakila:

SHOW CREATE TABLE actor;
DESCRIBE actor;

不要根据字段名字猜类型和 NULL 规则。

6. Association 统一入口

export function initializeAssociations(): void {
  Customer.belongsTo(Address, {
    foreignKey: "addressId",
    as: "address",
  });

  Address.hasMany(Customer, {
    foreignKey: "addressId",
    as: "customers",
  });

  Film.belongsToMany(Actor, {
    through: FilmActor,
    foreignKey: "filmId",
    otherKey: "actorId",
    as: "actors",
  });

  Actor.belongsToMany(Film, {
    through: FilmActor,
    foreignKey: "actorId",
    otherKey: "filmId",
    as: "films",
  });

  // TODO:其余关联
}

使用 include 时 alias 必须一致:

include: [{ model: Address, as: "address" }]

不要一处写 address,另一处写 customerAddress

7. 中间表与复合主键

film_actorfilm_category 是多对多中间表。需要对照真实 DDL 定义:

查询演员电影时通常不需要把中间表字段返回 API:

through: {
  attributes: [],
}

但不要因为响应不需要就省略数据库 Model 的主键映射。

8. DECIMALTINYINT 与日期

DECIMAL

payment.amountfilm.rental_rate 等值可能以 string 返回。DTO 中先保留 string:

declare amount: string;

TINYINT

customer.active 在数据库可能是数字。可以在 DTO 显式转换为 Boolean,但要保持 Model 与真实数据库一致。

日期

Model 使用 Date,DTO 使用 ISO string:

lastUpdate: actor.lastUpdate.toISOString()

9. 不要隐藏 SQL

学习阶段建议开发环境开启日志:

const sequelize = new Sequelize({
  // TODO:连接配置
  logging: (sql) => logger.debug({ sql }, "sequelize query"),
});

生产环境是否记录每条 SQL 需考虑敏感参数、日志量和性能。不要把含有密码摘要或隐私字段的完整 SQL 直接写进日志。

10. Model 与 DTO 示例

export interface ActorDto {
  actorId: number;
  firstName: string;
  lastName: string;
  fullName: string;
  lastUpdate: string;
}

export function toActorDto(actor: Actor): ActorDto {
  return {
    actorId: actor.actorId,
    firstName: actor.firstName,
    lastName: actor.lastName,
    fullName: `${actor.firstName} ${actor.lastName}`,
    lastUpdate: actor.lastUpdate.toISOString(),
  };
}

fullName 是 API 派生字段,不一定要存入数据库或写成 Model getter。

11. 本章验收清单

12. 本章练习

  1. 完成 AdminUser.init() 中的 TODO,并与 DDL 逐字段对照。
  2. 完成 Actor Model,运行一次 findByPk() 并观察 SQL。
  3. 故意去掉 timestamps: false,记录产生的数据库错误后恢复。
  4. 验证普通 AdminUser 查询和 withPassword scope 的字段差异。
  5. 定义 FilmActor 复合主键并解释 foreignKey/otherKey 方向。
  6. 建立 Actor 与 Film 多对多关系,只返回电影字段而隐藏 through 字段。
  7. 检查 Payment amount 的运行时类型,并使 DTO 类型与它一致。
  8. 制造一个 alias 不匹配错误,阅读 Sequelize 的错误信息并解释原因。