React + NestJS购物平台练习【3】数据设计与实现
搭建完前端与后端的基础结构后,我们就可以开始着手于数据库设计了。
1. 设计基础实体
在设计我们的购物平台的过程中,实体及其关系是核心的数据模型。我们从业务逻辑的角度来看,每个实体的意义和作用,这样可以更好地理解其在系统中的角色。
这里,我们以“用户”、“商品”、“订单”等为主线,讲解如何建立这些实体以及它们之间的关系,并举例说明其在业务场景中的应用。
1.1. 用户
用户实体是系统的核心,因为大部分操作都需要绑定到用户。每个用户都有其唯一的 ID、用户名、邮箱和密码,这些信息用于身份验证和授权。
示例,一个用户
John Doe
创建了账号,并通过邮箱[email protected]
登录系统。数据库会在Users
表中新增一条记录,保存 John 的邮箱、加密密码和创建时间。
1.1.1. 代码实现分析
让我们深入分析 Users
实体的代码实现:
1 | import { |
这部分使用了 TypeORM 的装饰器来定义表结构:
@Entity()
装饰器将这个类标记为一个数据库实体@PrimaryGeneratedColumn('uuid')
表示自动生成UUID
作为主键@Column({ unique: true })
为用户名和邮箱添加了唯一性约束,防止重复注册@CreateDateColumn()
会自动记录实体的创建时间@UpdateDateColumn()
会自动记录实体的更新时间
用户表的字段及其业务含义如下:
id
:主键,用于唯一标识每个用户,类型为UUID
,确保安全性和唯一性username
:用户名,系统中用于显示的名称,通常在用户之间是唯一的email
:用户的邮箱,通常作为主要联系手段,同时也是登录的标识之一password
:用户密码,以加密方式存储,用于用户验证created_at
和updated_at
:创建时间和更新时间,记录用户注册和资料更新的时间
1.1.2. 关联关系分析
Users
实体与其他实体之间建立了多个关联关系:
1 | import { Orders } from './orders.entity'; |
这些关联展示了用户实体在系统中的核心地位:
- 用户-订单关系(
@OneToMany
)- 一个用户可以有多个订单
- 这种一对多的关系允许系统追踪用户的所有购买历史
- 用户-购物车关系(
@OneToOne
)- 每个用户只能有一个购物车
- 一对一的关系确保购物车数据的独立性和安全性
- 用户-地址关系(
@OneToMany
)- 用户可以保存多个收货地址
- 方便用户在下单时快速选择收货地址
- 用户-支付关系(
@OneToMany
)- 记录用户的所有支付记录
- 用于追踪交易历史和财务统计
1.1.3. 用户角色
在开发应用程序时,管理用户的权限和访问控制是至关重要的。这是因为不同类型的用户可能需要访问系统的不同功能。
例如,一个购物平台可能有以下几种角色:
- 管理员:可以管理用户、查看所有订单、编辑商品等
- 普通用户:只能查看商品、下订单、查看个人资料等
- 游客:仅限浏览,不进行任何交互
在这种场景下,我们需要为用户管理系统添加一个角色字段,以便区分不同的用户类型。
角色字段通常有助于完成以下任务:
- 不同权限管理:每种角色都有不同的权限。管理员可能有权访问系统的所有数据,而普通用户只能查看他们自己的数据。通过角色字段,我们可以在数据库中存储每个用户的角色信息
- 角色控制的用户界面:角色字段可以帮助系统为不同角色的用户显示不同的页面或功能。例如,管理员可以访问管理控制台,而普通用户只能看到他们的订单历史
- 安全性增强:通过角色字段,系统可以在后端进行权限验证,确保不同角色的用户只能执行允许他们执行的操作,避免了恶意操作或权限滥用
为了明确区分不同角色的值,通常我们会使用枚举(enum
)来为角色提供固定的值。
enum
是一种特殊的类,用来表示一组固定的常量。
通过将 role
字段设置为枚举类型,我们可以确保角色值仅限于我们定义的固定值,这些值会自动被 TypeORM 映射到数据库字段中。
1 | // 添加角色的枚举类型 |
1.2. 商品
商品实体是平台中与交易直接相关的核心实体,它承载了商品的基本信息、库存管理、定价等重要功能。一个设计良好的商品实体不仅要满足基本的展示需求,还要支持库存管理、订单处理等复杂业务场景。
商品表的作用是提供系统中可供购买的商品清单,记录商品价格和库存。在库存管理方面,当库存减少到零时,商品需要标记为“缺货”或“下架”。
示例,商家上架了一款新商品“智能手表”,库存数量为 100,售价为 200 元,商品类别为“电子产品”。这款商品的信息会存储在
Products
表中,供用户选择和购买。
1.2.1. 代码实现分析
让我们详细分析 Products
实体的代码实现:
1 | import { |
代码实现的特点:
- 使用
text
类型存储描述,支持长文本内容 - 使用
decimal
类型存储价格,避免浮点数计算误差 stock
字段直接使用普通的数值类型,便于进行库存相关的计算
商品表的字段及其业务含义如下:
id
:主键,用于唯一标识每个商品,类型为UUID
name
:商品名称,帮助用户识别商品description
:商品描述,用于展示商品的详细信息price
:商品价格,定义用户在购买该商品时的成本stock
:商品库存数量,代表当前商品的剩余数量created_at
和updated_at
:创建时间和更新时间,记录商品上架和更新的时间
1.2.2. 关联关系分析
Products
实体与其他实体建立了多个关联关系:
1 | import { Categories } from './categories.entity'; |
- 商品-类别关系 (
@ManyToOne
)- 一个商品属于一个类别
- 支持商品分类管理和分类检索
- 多对一的关系使得类别可以包含多个商品
- 商品-订单项关系 (
@OneToMany
)- 一个商品可以出现在多个订单中
- 通过
OrderItems
中间表存储具体的购买数量和价格 - 支持订单历史查询和销售统计
- 商品-购物车项关系 (
@OneToMany
)- 一个商品可以被加入多个用户的购物车
- 通过
CartItems
中间表记录购物车中的商品数量
- 商品-库存日志关系 (
@OneToMany
)- 记录商品库存变动历史
- 支持库存追踪和审计
1.3. 类别
类别实体是商品分类的基础,它帮助我们组织和管理商品,提供更好的浏览和检索体验。一个良好的类别系统能够帮助用户更快地找到所需商品,同时也便于商家进行商品管理。
当平台增加新类别“智能家居”时,它会被添加到
Categories
表中。用户在浏览智能家居产品时,只需点击此类别,即可看到所有相关商品。
1.3.1. 代码实现分析
1 | import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm'; |
类别表的字段及其业务含义如下:
id
:主键,唯一标识类别,类型为UUID
name
:类别名称,例如“电子产品”或“家具”description
:类别描述,进一步解释类别内容created_at
和updated_at
:创建和更新时间,记录类别的管理信息
1.3.2. 关联关系分析
Categories
实体主要与 Products
实体建立了一对多的关联关系:
1 | import { Products } from './products.entity'; |
- 类别-商品关系 (
@OneToMany
)- 一个类别可以包含多个商品
- 体现了分类组织的层次结构
- 支持按类别查询和统计商品
1.4. 订单
订单是电商系统中最核心的业务实体之一,它记录了交易的全过程,连接了用户、商品、支付等多个业务环节。一个完善的订单系统需要处理订单状态流转、支付流程、商品库存等复杂的业务场景。
当用户下单购买一部智能手表,总价为 200 元,订单状态为“待支付”。在用户支付成功后,订单状态更新为“已支付”。
1.4.1. 代码实现分析
1 | import { |
id
:订单的唯一标识符,使用UUID
类型total_price
:订单总价,使用decimal
类型确保精确计算status
:订单状态,用于追踪订单的处理流程created_at
和updated_at
:记录订单的创建和更新时间
1.4.2. 关联关系分析
Orders
实体与其他实体建立了多个关联关系:
1 | import { Users } from './users.entity'; |
这些关联关系支持了完整的订单业务流程:
- 订单-用户关系 (
@ManyToOne
)- 每个订单必须属于一个用户
- 支持用户订单历史查询
- 便于进行用户消费分析
- 订单-订单项关系 (
@OneToMany
)- 一个订单可以包含多个商品
- 通过
OrderItems
存储具体的商品信息和数量 - 支持订单明细查询和统计
1.5. 订单项
订单项实体记录了订单中每个商品的具体购买信息,包括数量、单价和总价等。它不仅连接了订单和商品,还保存了购买时的价格快照,这对于订单历史记录和财务核算都很重要。
1.5.1. 代码实现分析
1 | import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm'; |
id
:订单项的唯一标识符,使用UUID
类型quantity
:购买数量price
:商品单价的快照,使用decimal
类型total_price
:该项商品的总价,使用decimal
类型
1.5.2. 关联关系分析
OrderItems
实体与其他实体建立了多个多对一的关联关系:
-
订单项-订单关系 (
@ManyToOne
)1
2() => Orders, order => order.orderItems) (
order: Orders;- 多个订单项属于同一个订单
- 通过这个关系可以获取订单的完整商品清单
- 支持订单总价计算和商品统计
-
订单项-商品关系(
@ManyToOne
)1
2() => Products, product => product.orderItems) (
product: Products;- 记录该订单项对应的具体商品
- 支持商品销售统计和分析
- 可以追踪商品的购买历史
1.6. 购物车
每个用户都有一个购物车,用于暂存待购买的商品信息。
购物车实体记录了用户在线上选择和加入的商品,是下单前的临时存储。它与订单实体有着类似的结构,但服务于不同的业务场景。
1.6.1. 代码实现分析
1 | import { Entity, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, OneToOne, OneToMany } from 'typeorm'; |
代码实现的特点:
- 使用一对一关系与用户实体关联,确保每个用户只有一个购物车
- 包含基本的时间戳字段,跟踪购物车的创建和更新
购物车表的字段及其业务含义如下:
id
: 购物车的唯一标识符,使用UUID
类型created_at
和updated_at
: 记录购物车的创建和更新时间
1.6.2. 关联关系分析
Carts
实体与其他实体建立了以下关联关系:
-
购物车-用户关系(
@OneToOne
)1
2() => Users, user => user.cart) (
user: Users; -
购物车-购物车项关系(
@OneToMany
)1
2() => CartItems, cartItem => cartItem.cart) (
cartItems: CartItems[];- 一个购物车可以包含多个购物车项
- 通过这个关系可以获取购物车中的商品列表
1.7. 购物车项
购物车项实体记录了用户在购物车中选择的商品及其数量,它与订单项实体有着类似的结构。
用户在购物车中添加了两部智能手表,购物车项记录该商品 ID、数量为 2,以及关联的购物车 ID。
1.7.1. 代码实现分析
1 | import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm'; |
CartItems
实体的特点:
- 使用多对一关系连接购物车和商品实体
- 记录了商品的购买数量,用于计算购物车总价
1.7.2. 关联关系分析
-
购物车项-购物车关系(
@ManyToOne
)1
2() => Carts, cart => cart.cartItems) (
cart: Carts; -
购物车项-商品关系(
@ManyToOne
)1
2() => Products, product => product.cartItems) (
product: Products;
1.8. 支付
支付实体记录了用户在完成下单后的支付信息,包括支付金额、支付状态等。它与订单实体有着直接的关联关系。
1.8.1. 代码实现分析
1 | import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne } from 'typeorm'; |
Payments
实体的特点:
- 使用多对一关系连接订单和用户实体
- 记录了支付的金额和状态
- 包含支付的创建时间戳
1.8.2. 关联关系分析
-
支付-订单关系(
@ManyToOne
)1
2() => Orders, order => order.id) (
order: Orders;- 一笔订单可以有多个支付记录
- 通过这个关系可以获取支付所属的订单信息
-
支付-用户关系(
@ManyToOne
)1
2() => Users, user => user.payments) (
user: Users;- 一个用户可以有多笔支付记录
- 通过这个关系可以获取支付所属的用户信息
1.9. 地址
地址实体记录了用户的收货地址信息,包括街道、城市、州/省、邮编和国家等详细信息。这些信息在用户下单时需要用到,也可以用于生成发货标签等。
1.9.1. 代码实现分析
1 | import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne } from 'typeorm'; |
1.9.2. 关联关系分析
-
地址-用户关系(
@ManyToOne
)1
2() => Users, user => user.addresses) (
user: Users;- 一个用户可以有多个地址信息
- 通过这个关系可以获取地址所属的用户信息
1.10. 库存日志
库存日志实体记录了商品库存的变动历史,包括每次库存增减的数量和时间。这些记录对于库存管理、库存盘点和异常排查都非常重要。
1.10.1. 代码实现分析
1 | import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne } from 'typeorm'; |
InventoryLogs
实体的特点:
- 使用多对一关系与商品实体关联
- 记录了库存变动数量(
change
),正数表示入库,负数表示出库 - 包含时间戳,记录每次库存变动的具体时间点
1.10.2. 关联关系分析
-
库存日志-商品关系(
@ManyToOne
)1
2() => Products, product => product.inventoryLogs) (
product: Products;- 一个商品可以有多条库存变动记录
- 通过这个关系可以追踪特定商品的库存变动历史
2. 创建数据库迁移文件
在开发应用程序时,数据库模式的更新是一项常见而重要的工作,尤其是在应用不断演进的过程中。
假设我们在开发一个应用,并需要更新数据库的表结构,比如添加新字段、修改字段类型等。如何在不丢失现有数据的情况下进行这些修改呢?
TypeORM 可以帮助我们轻松处理数据库操作。它内置了强大的迁移功能,使我们能够定义数据库结构变更并自动应用到数据库中。
2.1. 添加自定义命令
首先,我们在 package.json
中添加一些脚本来管理 TypeORM 的迁移操作。这些脚本将自动构建项目并运行相应的迁移命令,方便我们快速执行迁移操作:
1 | { |
让我们一行一行地分析这些脚本的作用:
typeorm
:指定 TypeORM 的配置文件路径(src/config/data-source.ts
),它是所有 TypeORM 操作的基础migration:initialize
:生成InitialMigration
迁移文件migration:generate
:生成新的迁移文件- 用法:
yarn migration:generate src/migrations/xxx
- 用法:
migration:run
:执行所有还未运行的迁移,应用到数据库中migration:revert
:撤销上一次迁移,通常用于回滚数据库结构到上一个状态
2.2. 配置 TypeORM 数据源
接下来,我们需要设置 TypeORM 的数据库配置。
在项目的 src/config/data-source.ts
文件中,我们使用 DataSourceOptions
来定义数据库连接的详细信息。
这里我们还使用了 @nestjs/config
模块来动态读取环境变量,这样在不同环境中可以使用不同的数据库配置。
1 | import { ConfigService } from '@nestjs/config'; |
type
:指定数据库类型,这里我们通过环境变量DB_TYPE
来设置,默认为 MySQLhost
、port
、username
、password
、database
:这些参数是数据库连接的必要信息,都可以通过环境变量配置synchronize
:如果APP_ENV
为development
,则会启用同步模式,让 TypeORM 自动更新数据库表结构- 注意:生产环境下建议禁用此项
entities
和migrations
:指定实体和迁移文件的路径。TypeORM 会使用这些路径找到相关文件
2.3. 生成数据库迁移文件
当配置完成后,我们可以通过以下命令来生成用于初始化数据库的迁移文件:
1 | yarn migration:initialize |
这条命令会自动构建项目,并使用 TypeORM 生成一个迁移文件(位于 src/migrations
,且默认命名为 数字数字数字-InitialMigration.ts
)。
这个文件会包含创建、修改或删除表结构的 SQL 语句。例如,如果你添加了一个新的字段,生成的迁移文件就会包含一个 ALTER TABLE
语句。
2.4. 执行迁移
生成迁移文件后,使用以下命令将其应用到数据库:
1 | yarn migration:run |
此命令会检查并执行所有尚未在数据库中应用的迁移。这样可以确保你的数据库结构与最新的迁移文件一致。
2.5. 回滚迁移
在开发过程中,偶尔可能需要回滚上次迁移,例如在测试或调试时。使用以下命令可以撤销上一次迁移:
1 | yarn migration:revert |
TypeORM 会自动识别上次执行的迁移,并撤销相应的更改,恢复到之前的数据库结构。
3. 设置数据库索引
在开发应用程序时,尤其是当数据库中的数据量越来越大时,查询性能变得至关重要。
数据库索引是一种数据结构,它帮助数据库管理系统(DBMS)高效地检索数据。索引就像书籍中的目录,它可以快速定位到某个数据的位置,而不是扫描整个表。当我们对数据库表进行查询时,索引可以显著提高查询性能,尤其是在处理大量数据时。
在 MySQL 中,索引通常应用于那些需要频繁查询的字段。常见的索引类型有:
- 主键索引:自动为表的主键字段创建
- 唯一索引:确保每个值唯一
- 普通索引:加速数据检索,但不强制唯一性
- 全文索引:用于加速文本内容的检索
没有索引的情况下,数据库在执行查询时必须扫描整个表,逐行比较数据。这种方式在小型表中可能没什么问题,但在数据量较大时,会导致性能急剧下降。
假设我们的购物平台,数据库中存储了数百万条订单记录。当用户搜索特定订单时,如果没有适当的索引,系统可能会扫描整个订单表来找到匹配的记录。随着数据量增加,查询速度变得非常慢,甚至可能导致系统响应延迟。
使用索引后,数据库不再需要扫描整个表,而是通过索引快速定位到目标数据,从而显著提升查询速度。
在 NestJS 中,我们可以通过 TypeORM 为实体字段添加索引。TypeORM 提供了 @Index()
装饰器,允许我们在数据库表的字段上添加索引。
3.1. 用户
1 | () |
在 Users
实体中,我们使用了 @Index()
装饰器来为 created_at
字段添加索引。
在大多数应用程序中,created_at
字段通常用于按照时间排序或进行时间范围查询。
例如,用户可能需要查看某一时间段内的所有注册用户,或者获取最近创建的用户列表。
假设在应用程序中,用户经常执行以下查询:
- 查询某个时间段内注册的用户。
- 按照注册时间排序显示用户列表。
如果没有为 created_at
字段添加索引,数据库会需要扫描整个用户表来执行这些查询,这样会导致性能问题。添加索引后,数据库可以通过索引快速查找和排序数据,显著提高查询速度,尤其是在数据量较大的情况下。
3.1.1. 关系字段为何不需要索引?
这时候就有人问了:“难道像 orders
、cart
这些字段,用户就不会查询了吗?如果经常用到,为什么不为它们加个索引呢?”答案是:确实不需要。
在 TypeORM 中,关系字段(比如 orders
和 cart
)通常是外键字段,数据库会自动为这些字段创建索引。例如,在 Users
实体中,orders
字段是通过 @OneToMany
与 Orders
实体关联的。虽然我们没有显式地为 orders
字段添加索引,但在 Orders
表中,user
字段作为外键会自动被索引。
这意味着,当我们查询某个用户的所有订单时,数据库会直接利用 Orders
表中自动创建的 user
索引,来加速查询。而我们不需要额外为 orders
字段添加索引,TypeORM 和数据库已经为我们处理好了这部分的优化。
3.2. 商品
1 | () |
price
字段通常会用于以下几种查询:
- 按照价格范围查询产品,例如查找价格低于某个值的所有产品
- 按照价格排序,例如将产品列表按价格从低到高或从高到低排序
1 | () |
stock
字段通常用于以下查询场景:
- 查找库存数量为零或低于某个值的产品(例如“缺货”产品或“库存预警”产品)
- 按照库存数量排序,帮助商家快速查看库存最少的产品
1 | () |
所有的 created_at
字段都需要添加索引。
3.3. 支付
1 | () |
3.4. 订单
1 | () |
status
字段通常用于查询订单的状态,例如:
- 查询某一状态的订单(如“待处理”、“已发货”、“已完成”等)
- 按照订单状态统计订单数量
1 | () |
3.5. 订单项
1 | () |
quantity
字段通常在以下情况中用于查询:
- 查询具有特定数量的订单项(例如查询某种商品的批量购买情况)
- 按照数量对订单项进行筛选或排序
3.6. 类别
1 | () |
name
字段是分类的唯一标识,用于以下情况:
- 按类别名称搜索产品类别(例如,用户希望快速找到特定名称的类别)
- 在名称字段上进行排序或进行自动完成的匹配查询
1 | () |
3.7. 购物车
1 | () |
3.8. 地址
1 | () |
在地址系统中,postal_code
(邮政编码)字段可能用于以下场景:
- 按邮政编码筛选地址。例如,在电商系统中,按邮政编码筛选用户地址,以确定是否支持配送
- 邮政编码的批量统计或区域分析,例如确定特定邮政编码区域的用户密度
1 | () |
3.9. 库存日志
1 | () |
接下来,我们可以使用以下命令生成数据库迁移文件:
1 | yarn migration:generate src/migrations/CreateIndex |
然后使用以下命令应用迁移并更新数据库:
1 | yarn migration:run |