跳转到主要内容

Nest 使用笔记

第八章:TypeORM 集成——帖子服务的增删改查、分页、事务与迁移

打开 learnhub 的 PostService,在真实代码里讲透 TypeORM 的 Repository、QueryBuilder 分页、事务、404、所有权校验,以及生产必备的 migration 工作流。每段代码都能在 learnhub 里指到对应文件。

  • Nest
  • TypeORM
  • 数据层

前面几章里 Nest 的骨架已经搭起来了。这一章开始接数据层——用 TypeORM 把 MySQL 接进 learnhub,让帖子真正落库。和「写个 Article 跑通四条 SQL」不同,这一章直接打开 learnhub 已经在生产里用的 PostService,在真实代码里讲透四件初学最容易跳过、上线最容易踩的事:事务、分页、404、所有权,再加一套迁移(migration)工作流

先搞懂:TypeORM 是什么 / 为什么需要 / 企业级怎么用

TypeORM 是什么:Nest 生态最常用的 ORM(对象关系映射)库——把数据库表映射成一个 TS 类(Entity),字段是属性、列由 @Column 描述,操作数据库不写 SQL 字符串,而是调对象方法(repo.find()repo.save()repo.update()repo.delete()),底层自动生成并执行 SQL。

为什么需要它:手写裸 SQL 的痛点很真实——字符串没类型提示、字段名拼错只有运行时炸、动态条件拼接容易 SQL 注入、表一改满项目找 SQL 改。ORM 一次性解决:类型安全的查询 API、参数化查询天然防注入。但前提是你要懂它背后生成的 SQL——否则 N+1、冗余 JOIN、慢查询会悄悄咬人。

企业级怎么用(下面每一条,这一章都会在 learnhub 里真做一遍,不是空头支票):

  • 严格分 Controller(接 HTTP)→ Service(业务)→ Repository(数据访问) 三层,Service 注入 Repository 而不是直接碰库;
  • schema 用 migrations 管,生产绝不synchronize(会按 Entity 直接 ALTER/DROP 表丢数据)——本章第六步落地;
  • 写操作要考虑原子性:一个动作涉及多张表(发帖 + 绑标签)就包进事务——本章第四步 create 落地;
  • 查询警惕 N+1,列表接口必须有分页——本章第四步 findMany 落地;
  • 改删接口要做所有权校验(只能动自己的)——本章第四步 assertAuthor 落地。

这一章你会做出什么

  • 打开 learnhub 的 src/modules/post/post.service.ts,逐方法讲清楚它为什么这么写。
  • 在真实代码里吃透:事务创建(帖子 + 标签原子化)、QueryBuilder 分页 + 过滤NotFoundException所有权校验
  • 跑通一套迁移工作流migration:generate / run / revert,以及容器启动自动迁移。

前置:learnhub 的 MySQL 在跑(cd learnhub/docker && docker compose up mysql),仓库代码已拉到本地能打开看。

第一步:连接数据库——forRootAsync,配置走 env,synchronize 永远 false

新手写 TypeORM 连接,十有八九是这样的(别学这个):

// 反面教材:硬编码 + 生产定时炸弹(别学这个)
TypeOrmModule.forRoot({
  type: "mysql",
  host: "localhost",
  username: "root",
  password: "123456",      // 密码写进代码、提交进 git
  database: "nest_demo",
  synchronize: true,        // 生产会按 Entity 直接改表,丢数据
});

两个问题:配置硬编码(换环境就得改代码、密码泄露),synchronize: true(学习方便,生产会按 Entity 把表 ALTER/DROP 掉,数据说没就没)。learnhub 的真实写法是 forRootAsync + ConfigService 注入,配置全走环境变量:

// learnhub/src/app.module.ts
TypeOrmModule.forRootAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    type: "mysql",
    host: config.get<string>("mysql.host"),
    port: config.get<number>("mysql.port"),
    username: config.get<string>("mysql.username"),
    password: config.get<string>("mysql.password"),
    database: config.get<string>("mysql.database"),
    autoLoadEntities: true,   // 自动收集各模块 forFeature 注册的 Entity
    synchronize: false,       // 永远 false,靠 migration
    logging: config.get("nodeEnv") === "development", // 学习期打 SQL,生产关
    timezone: "+08:00",
    charset: "utf8mb4",
  }),
}),

为什么是 forRootAsync 而不是 forRoot:连接参数要从 ConfigService 拿(ConfigService 自己也是个 provider,要等 IoC 容器起来才能注入),forRoot 是静态同步的、拿不到。useFactory 把「等配置就绪 → 再建连接」这件事交给 Nest,工厂函数的参数就是 inject 里声明的依赖。

注意synchronize: false 是铁律。它一旦为 true,TypeORM 启动时会拿 Entity 和库里的表做 diff,少了的列加上、多了的列删掉——开发期方便,生产期等于把删表权限交给了代码。schema 变更只能走 migration,第六步讲。logging 在开发期打开,你能直接在终端看到每一步生成的 SQL,是学 TypeORM 最快的办法。

第二步:定义 Post 实体

实体就是把一张表写成一个类。learnhub 的帖子表:

// learnhub/src/modules/post/entities/post.entity.ts
@Entity("post")
export class Post {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ length: 100 })
  title: string;

  @Column({ type: "text" })
  content: string;

  @Column({ name: "view_count", type: "int", default: 0, comment: "浏览量(阶段5 改由 Redis 维护)" })
  viewCount: number;

  @Column({ name: "like_count", type: "int", default: 0 })
  likeCount: number;

  @Column({ default: false, comment: "是否置顶(仅 admin)" })
  pinned: boolean;

  @CreateDateColumn({ name: "create_time" })
  createTime: Date;

  @UpdateDateColumn({ name: "update_time" })
  updateTime: Date;

  // 下面四个是关系字段,第九章细讲,这章只用、不展开
  @ManyToOne(() => User, (u) => u.posts)
  author: User;
  @OneToMany(() => Comment, (c) => c.post)
  comments: Comment[];
  @ManyToMany(() => Tag, (t) => t.posts)
  @JoinTable({ name: "post_tag" })
  tags: Tag[];
}

几个要点:@Column({ name: "view_count" }) 是因为 TS 驼峰、MySQL 下划线,用 name 显式映射列名,避免歧义;@CreateDateColumn / @UpdateDateColumn 是 TypeORM 的魔法列,插入/更新时自动写时间,不用自己维护;comment 写进 DDL 注释,方便后人看库时理解字段。关系字段(author / comments / tags)这章只当它们「能用」,映射细节留给第九章。

第三步:注册实体、注入 Repository

实体要在模块里登记,Nest 才会把 Repository<Post> 造出来给你注入:

// learnhub/src/modules/post/post.module.ts
@Module({
  imports: [TypeOrmModule.forFeature([Post, Tag])], // 注册本模块用到的实体
  controllers: [PostController],
  providers: [PostService],
})
export class PostModule {}

forFeature([Post, Tag]) 干两件事:把 PostTag 登记进 autoLoadEntities 的收集清单,同时把 Repository<Post>Repository<Tag> 注册成可注入的 provider。然后在 service 构造函数里拿:

// learnhub/src/modules/post/post.service.ts
@Injectable()
export class PostService {
  constructor(
    @InjectRepository(Post) private readonly postRepo: Repository<Post>,
    @InjectRepository(Tag) private readonly tagRepo: Repository<Tag>,
    @InjectDataSource() private readonly dataSource: DataSource, // 事务要用
    // ...其它依赖:UserService / RankingService / AmqpService / SearchService
  ) {}

@InjectRepository(Post) 是「按实体拿它的 Repository」;@InjectDataSource() 拿到的是 DataSource 本身,事务要靠它(第四步 create 用)。注意 Service 注入的是 RepositoryDataSource不直接 import 任何 SQL——所有数据访问都经过这层,方便替换、测试、加日志。

第四步:Service——逐方法深讲(本章核心)

这是和「写个玩具 CRUD」差距最大的一节。把 PostService 五个方法一个一个拆开,看真实项目为什么这么写。

create:事务保住「帖子 + 标签」的原子性

发帖不只是插一条 post 记录,还要把选中的标签绑到中间表 post_tag 上。两步操作必须要么都成功、要么都回滚——否则标签绑了一半服务挂了,库里就留下一条没有标签、或标签错乱的帖子。learnhub 用 dataSource.transaction 包住它:

// learnhub/src/modules/post/post.service.ts
async create(dto: CreatePostDto, userId: number): Promise<Post> {
  const post = await this.dataSource.transaction(async (manager) => {
    const post = manager.create(Post, {
      title: dto.title,
      content: dto.content,
      author: { id: userId } as any,
    });
    if (dto.tagIds?.length) {
      post.tags = await manager.findBy(Tag, { id: In(dto.tagIds) });
    }
    return manager.save(post); // 一次 save:post + 关系 + 标签,全在一个事务里
  });
  // 事务提交成功后,再发领域事件、同步 ES(这些不属于帖子创建的原子范围)
  this.amqp.publish("post.published", { postId: post.id, title: post.title, authorId: userId });
  this.indexPost(post);
  return post;
}

关键点:transaction 的回调参数 manager 是一个事务专属的 EntityManager,回调里所有 manager.save(...) 走的是同一条事务连接,回调一抛错整体回滚。注意 manager.save(post) 一次保存就把 post 和它的 tags 关系一起落库(TypeORM 的级联保存),不需要自己再写中间表的 INSERT。

注意:领域事件(amqp.publish)和 ES 同步(indexPost)放在事务外面。这是个有意的设计——发 MQ 消息、写 ES 不属于「帖子创建」的原子范围,不该因为 MQ 暂时不可用就把已经成功的帖子创建也回滚。事务的边界要画在对的地方:只包住「必须一起成功或一起失败」的库操作。

findMany:QueryBuilder 分页 + 过滤

列表接口最容易写坏的就是 findAll 把整张表查回来——表一上万行,接口直接拖垮内存和带宽。learnhub 的列表是分页 + 关键词 + 标签/作者过滤,用 QueryBuilder 拼:

// learnhub/src/modules/post/post.service.ts
async findMany(q: QueryPostDto): Promise<PageResult<Post>> {
  const qb = this.postRepo
    .createQueryBuilder("p")
    .leftJoinAndSelect("p.author", "author")
    .leftJoinAndSelect("p.tags", "tag")
    .orderBy("p.pinned", "DESC")          // 置顶优先
    .addOrderBy("p.createTime", "DESC")
    .skip(q.skip)
    .take(q.take);

  if (q.keyword) {
    qb.andWhere("(p.title LIKE :kw OR p.content LIKE :kw)", { kw: `%${q.keyword}%` });
  }
  if (q.authorId) qb.andWhere("author.id = :aid", { aid: q.authorId });
  if (q.tagId) qb.andWhere("tag.id = :tid", { tid: q.tagId });

  const [list, total] = await qb.getManyAndCount();
  return PageResult.of(list, total, q.page, q.pageSize);
}

几件事值得讲:

  • leftJoinAndSelect 一次 JOIN 把 authortags 带出来,避免 N+1(否则查 10 条帖子、再各查一次作者,就是 11 条 SQL)。
  • skip(q.skip).take(q.take) 做分页,skip/take 来自 QueryPostDto 继承的 PageDto(page-1)*pageSize)。
  • getManyAndCount() 一次返回 [当前页数据, 总条数]——它内部会跑两条 SQL:一条 LIMIT 取数据,一条 COUNT(*) 取总数。total 是分页前端画页码要的,但它本身是一次全表计数,数据量极大时也是成本,超大数据量会改用游标分页(cursor)省掉 COUNT。
  • LIKE :kw 用参数绑定(:kw),不是字符串拼接,天然防 SQL 注入。

分页参数的校验和计算封在公共 DTO 里,各业务模块继承复用:

// learnhub/src/common/dto/page.dto.ts
export class PageDto {
  @IsOptional() @Type(() => Number) @IsInt() @Min(1)
  page: number = 1;
  @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100)
  pageSize: number = 10;
  @IsOptional() @IsString()
  keyword?: string;
  get skip(): number { return (this.page - 1) * this.pageSize; }
  get take(): number { return this.pageSize; }
}

// learnhub/src/common/dto/page-result.dto.ts
export class PageResult<T> {
  list: T[]; total: number; pageSize: number; page: number;
  static of<T>(list: T[], total: number, page: number, pageSize: number) { /* 赋值返回 */ }
}

QueryPostDto extends PageDto 只加帖子专属的 tagId / authorId。这样每个资源的列表接口都自动有了一致的分页校验和返回结构。

注意LIKE '%关键词%' 这种前后都加通配的模糊匹配不走索引,数据量大时会全表扫描。learnhub 的列表模糊查用 LIKE 是因为它是 MySQL 内建的简单方案;真要做海量全文检索,走第二十一章的 Elasticsearch(learnhub 的 search 接口就是 ES 实现的,贴标题加权 + 高亮)。

findOne:查不到就 404,不要返回 null

详情接口最常见的坑:查不到记录时返回 null,前端拿到一个 null 不知道是该报错还是没数据。learnhub 直接抛 NotFoundException,让全局异常过滤器统一回 404:

// learnhub/src/modules/post/post.service.ts
async findOne(id: number): Promise<Post> {
  const post = await this.postRepo.findOne({
    where: { id },
    relations: { author: true, tags: true, comments: { author: true } },
  });
  if (!post) throw new NotFoundException("帖子不存在");
  // 浏览量 +1 走 Redis(增量),由 RankingService 定时 flush 进库
  this.ranking.recordView(id).catch(() => undefined);
  return post;
}

relations: { author: true, tags: true, comments: { author: true } } 这种对象写法一次把作者、标签、评论(连带评论的作者)都带出来,等价于几个 JOIN,不会有 N+1。嵌套对象 comments: { author: true } 表示「评论要带,而且评论的作者也要带」。

思考:浏览量 +1 为什么不直接 post.viewCount++ 然后 save,而要走 Redis?——因为详情页是高并发接口,每次都 UPDATE post SET view_count = view_count + 1 会把这一行锁热,并发一上来数据库扛不住。learnhub 把增量先攒在 Redis 里,由定时任务批量 flush 进库(第五阶段 RankingService 干这事,后面的 Redis 章节会讲透)。这里的 .catch(() => undefined) 是降级:Redis 暂时挂了也不影响你看帖子详情,浏览量这次少计一次而已。

update / remove:所有权校验——只能动自己的

改和删最容易忘的就是权限:没有所有权校验的话,知道别人的帖子 id 就能改/删。learnhub 把校验抽成一个 assertAuthor,update 和 remove 都先调它:

// learnhub/src/modules/post/post.service.ts
async update(id: number, dto: UpdatePostDto, userId: number): Promise<Post> {
  const post = await this.assertAuthor(id, userId); // 先校验:本人或 admin
  Object.assign(post, { title: dto.title ?? post.title, content: dto.content ?? post.content });
  if (dto.tagIds) post.tags = await this.tagRepo.findBy({ id: In(dto.tagIds) });
  const saved = await this.postRepo.save(post);
  this.indexPost(saved); // 更新后同步 ES
  return saved;
}

async remove(id: number, userId: number): Promise<void> {
  await this.assertAuthor(id, userId);
  await this.postRepo.delete(id);
  this.searchService.removePost(id).catch(() => undefined); // ES 同步删除
}

/** 所有权校验:本人或 admin 才能操作 */
private async assertAuthor(id: number, userId: number): Promise<Post> {
  const post = await this.postRepo.findOne({ where: { id }, relations: { author: true } });
  if (!post) throw new NotFoundException("帖子不存在");
  const isAdmin = await this.isAdmin(userId);
  if (post.author.id !== userId && !isAdmin) {
    throw new ForbiddenException("只能操作自己的帖子");
  }
  return post;
}

ForbiddenException → 403,和 NotFoundException → 404 一样,交给全局异常过滤器统一处理。注意 assertAuthor 里查帖子时带了 relations: { author: true }——因为要拿 post.author.id 比对,不带出 author 就拿不到。「需要哪个关联就在查询时显式带上」是避 N+1 的基本原则:不是无脑全带(查列表带评论就浪费),也不是全不带(拿不到 author 又报错),而是按需

注意:这里 assertAuthor 做的是数据级权限(这条帖子是不是你的),和第十二章 RBAC 的功能级权限(你有没有 post:create 这个能力)是两回事。功能级权限在 Controller 上用 @RequirePermission 守(见第五步),数据级权限在 Service 里校验,两层都要。

第五步:Controller——路由、版本、权限标记

Service 写完,Controller 就薄了,只负责接 HTTP、转交给 Service:

// learnhub/src/modules/post/post.controller.ts
@ApiTags("帖子")
@ApiBearerAuth()
@UseInterceptors(ClassSerializerInterceptor)
@Controller({ path: "posts", version: "1" }) // → /api/v1/posts
export class PostController {
  constructor(private readonly postService: PostService) {}

  @Post()
  @RequirePermission("post:create") // 功能级权限:需要 post:create
  create(@Body() dto: CreatePostDto, @CurrentUser() user: RequestUser) {
    return this.postService.create(dto, user.userId);
  }

  @Public() // 列表公开,无需登录
  @Get()
  list(@Query() q: QueryPostDto) {
    return this.postService.findMany(q);
  }

  @Public()
  @Get("search") // 必须在 @Get(":id") 之前,否则 "search" 会被 :id 吃掉
  search(@Query("q") q: string, @Query("page") page = 1, @Query("size") size = 10) {
    return this.postService.search(q ?? "", Number(page), Number(size));
  }

  @Public()
  @Get(":id")
  detail(@Param("id") id: number) {
    return this.postService.findOne(id);
  }

  @Patch(":id")
  update(@Param("id") id: number, @Body() dto: UpdatePostDto, @CurrentUser() user: RequestUser) {
    return this.postService.update(id, dto, user.userId);
  }

  @Delete(":id")
  async remove(@Param("id") id: number, @CurrentUser() user: RequestUser) {
    await this.postService.remove(id, user.userId);
  }
}

几个真实项目才会注意的点:

  • @Controller({ path: "posts", version: "1" }) 配合全局的 URI 版本控制,路由是 /api/v1/posts。第十章 Prisma 会做一个 /api/v2/posts 对照——同一份业务、两套 ORM 共存,靠版本号区分。
  • @Public() 标记的接口(列表、详情、搜索)不走全局登录守卫;没标的(发帖、改、删)必须登录。@CurrentUser() 把 JWT 解出来的当前用户塞进来——登录和守卫的机制第十一章细讲,这里知道「带 token 才能调、用户信息从 @CurrentUser() 拿」就行。
  • 路由顺序@Get("search") 必须写在 @Get(":id") 前面。Nest 路由是按声明顺序匹配的,:id 是动态参数,会吃掉 search 这个字面量——写反了,访问 /api/v1/posts/search 会进 detail,拿 "search"Number()NaN,查不到返回 404,而且这个错误非常难发现。

第六步:迁移——生产怎么管 schema

这是旧版课程只在注释里提、从来不真做的一节。生产环境改表结构不能靠 synchronize,得靠迁移:每次 schema 变更写成一个带 up / down 的类,能正着执行、也能反着回滚,团队协作时按顺序重放。

为什么单独一份 data-source.ts

TypeORM 的迁移命令(typeorm migration:run)需要一个静态、可直接加载DataSource 实例。但 Nest 应用里的 DataSource 是在 forRootAsync 里、依赖 IoC 容器的,CLI 拿不到。所以得单独写一份不依赖 IoC 的:

// learnhub/src/data-source.ts
export default new DataSource({
  type: "mysql",
  host: process.env.MYSQL_HOST || "127.0.0.1",
  port: parseInt(process.env.MYSQL_PORT || "3306", 10),
  username: process.env.MYSQL_USER || "root",
  password: process.env.MYSQL_PASSWORD || "",
  database: process.env.MYSQL_DATABASE || "learnhub",
  charset: "utf8mb4",
  timezone: "+08:00",
  synchronize: false, // 永远 false
  entities: [User, Role, Permission, Post, Comment, Tag, ChatRoom, ChatMessage],
  migrations: [path.join(__dirname, "migrations", "*.{ts,js}")],
});

__dirname 在 dev(ts-node)下是 src/、编译后是 dist/,所以 migrations 用相对 __dirname 的 glob,开发和生产都能跑。这份文件的连接参数和 app.module.ts 里的 forRootAsync 读的是同一套环境变量,所以不会出现「应用连 A 库、迁移跑到 B 库」的事故。

迁移文件长什么样

learnhub 有三个迁移:Init(建全部表)、Seed(灌种子数据:admin 账号、角色权限)、AddChat(后来加聊天功能时补的表)。每个迁移是一个实现 MigrationInterface 的类:

// learnhub/src/migrations/1784362383603-Init.ts
export class Init1784362383603 implements MigrationInterface {
  name = "Init1784362383603";

  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`CREATE TABLE \`post\` (\`id\` int NOT NULL AUTO_INCREMENT, ...) ENGINE=InnoDB`);
    await queryRunner.query(`CREATE TABLE \`post_tag\` (\`postId\` int NOT NULL, \`tagId\` int NOT NULL, ...) ENGINE=InnoDB`);
    // ...更多建表、加外键
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`DROP TABLE \`post_tag\``);
    await queryRunner.query(`DROP TABLE \`post\``);
    // ...倒序回滚
  }
}

这个 Init 不是手写的——是 migration:generate 拿 Entity 和空库做 diff 自动生成的(你能看到那些 FK_c6fb082a3114f35d0cc27c518e0 一样的 hash 命名,就是自动生成的标志)。up 是正着执行(建表),down 是反着回滚(删表)。

命令、启动自动跑、和一条铁律

常用命令(learnhub 的 package.json 里封好了):

npm run migration:generate -- src/migrations/AddChat.ts   # diff Entity 和库,生成新迁移
npm run migration:run      # 执行所有没跑过的迁移
npm run migration:revert   # 回滚最后一次

线上每次重启容器都手动跑迁移太脆,learnhub 在容器入口里做了自动迁移:

# learnhub/docker/docker-entrypoint.sh
if [ "$RUN_MIGRATIONS_ON_BOOT" = "true" ]; then
  node node_modules/typeorm/cli.js migration:run -d dist/data-source.js || exit 1
fi
exec "$@"

RUN_MIGRATIONS_ON_BOOT=true 时,容器起来先跑迁移、再启动 Nest;迁移失败直接退出,不让带着旧 schema 的应用起来。

注意(铁律)migration:generate 生成的迁移一定要人眼 review 再 run。它是机器 diff,偶尔会误判——比如你只是调了个列顺序、或换了 ORM 版本,它可能生成「删掉整列再重建」的迁移,跑下去那一列的数据就没了。养成习惯:生成 → 看一眼 up 里有没有 DROP / 危险操作 → 再 run。

第七步:跑起来

cd learnhub
docker compose -f docker/docker-compose.yml up -d mysql   # 起 MySQL
npm install
npm run migration:run                                       # 建表 + 灌种子
npm run start:dev                                           # 起服务

种子数据里有 admin / admin123456(带全部权限)。发帖需要登录拿 token(登录机制第十一章讲,这里先用):

# 1. 登录拿 token(第十一章细讲)
curl -X POST http://localhost:3000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"admin123456"}'
# {"accessToken":"eyJ...","refreshToken":"eyJ..."}

# 2. 带 token 发帖(绑两个标签)
curl -X POST http://localhost:3000/api/v1/posts \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer eyJ..." \
  -d '{"title":"TypeORM 三种关系怎么记","content":"一对多看 owning side...","tagIds":[1,2]}'

# 3. 列表(分页,公开接口,不用 token)
curl 'http://localhost:3000/api/v1/posts?page=1&pageSize=10&keyword=关系'
# {"list":[...],"total":1,"page":1,"pageSize":10}

# 4. 详情(公开,浏览量 +1)
curl http://localhost:3000/api/v1/posts/1

start:dev 的终端里开着 logging,每一步生成的 SQL 都会打出来——发帖那条你能看到事务 BEGIN → INSERT post → INSERT post_tag → COMMIT,分页那条能看到 LIMIT + 一条 COUNT(*)。对着 SQL 学 TypeORM,比看文档快得多。

这一章的成果

  1. forRootAsync + ConfigService 接 MySQL,配置走 env、synchronize 永远 false。
  2. 在真实 PostService 里吃透四件上线必备:事务create 包住帖子+标签)、分页findMany 的 QueryBuilder + PageResult)、404findOneNotFoundException)、所有权assertAuthor 本人或 admin)。
  3. 跑通一套迁移工作流:独立 data-source.tsgenerate/run/revert、容器启动自动迁移,以及「生成的迁移要 review」这条铁律。
  4. 理解 Controller 层的功能级权限(@RequirePermission)和 Service 层的数据级权限(assertAuthor)是两道关。

常见问题

  • 启动报 EntityRepository/找不到实体forRoot 里要么 entities: [...] 列全,要么用 autoLoadEntities: true(learnhub 用这个),并确保对应模块在 AppModule.imports 里。
  • N+1 查询:列表/详情返回的数据缺关联,或终端 SQL 翻倍,就是 N+1。用 relations: {...} 或 QueryBuilder 的 leftJoinAndSelect 一次 JOIN 带出。判断方法:开 logging 看 SQL 条数。
  • synchronize 到底能不能开:开发期图方便可以,生产绝不能。它按 Entity 直接改库,会丢数据。生产一律走 migration。
  • migration:generate 提示「没有变化」:Entity 和库已经一致,自然没东西可生成。先改 Entity,再 generate。
  • 时区/中文乱码:连接配置加 timezone: '+08:00'charset: 'utf8mb4'(learnhub 都加了)。

下一章讲 表与表之间的关系——把这一章里点到为止的 author / comments / tags 展开讲透:一对一、一对多、多对多在 TypeORM 里怎么映射、谁是 owning side、leftJoinAndSelectrelations 各自的边界。