feat(数据库): 完善第四阶段SQLAlchemy课程与综合项目
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
# 第4-5课:数据库综合项目——库存订单管理
|
||||
|
||||
## 一、本课定位
|
||||
|
||||
这是第四阶段的综合项目。本课不再单独讲一个API,而是把前四课知识组合成一个小型业务流程:客户下单后,系统创建订单与订单明细,同时扣减商品库存;只要其中任何一步失败,整张订单和全部库存修改都必须回滚。
|
||||
|
||||
项目使用三张独立练习表和`DBP-`、`DBO-`数据前缀,不操作其他课程或业务数据。
|
||||
|
||||
## 二、本课目标
|
||||
|
||||
完成本课后,你能够:
|
||||
|
||||
1. 使用SQLAlchemy 2.x映射三张存在关联关系的表;
|
||||
2. 使用Repository(仓储)封装数据访问;
|
||||
3. 使用Service(业务服务)组织下单规则;
|
||||
4. 在调用方统一控制事务提交和回滚;
|
||||
5. 使用`SELECT FOR UPDATE`降低并发扣减库存产生的超卖风险;
|
||||
6. 使用DTO承载三表联查结果;
|
||||
7. 使用聚合查询统计订单状态;
|
||||
8. 使用本地TOML配置安全连接PostgreSQL。
|
||||
|
||||
## 三、前置知识
|
||||
|
||||
- PostgreSQL主键、外键、约束和事务;
|
||||
- Psycopg连接与参数化查询;
|
||||
- SQLAlchemy的Engine、连接池和Session;
|
||||
- 声明式ORM模型及增删改查;
|
||||
- `ForeignKey`、`relationship()`、`join()`和DTO。
|
||||
|
||||
第四课练习即使尚未全部完成,也可以先运行本课标准示例;遇到关系映射或DTO不理解时,再回看第四课对应章节。
|
||||
|
||||
## 四、业务模型
|
||||
|
||||
```text
|
||||
Product(商品) 1 ──── N OrderItem(订单明细) N ──── 1 Order(订单)
|
||||
```
|
||||
|
||||
为什么需要订单明细表?因为一张订单可以包含多个商品,一个商品也可以出现在多张订单中。订单与商品本质上是多对多关系,`OrderItem`把它拆成两个一对多关系,并额外保存购买数量和成交单价。
|
||||
|
||||
成交单价必须保存在订单明细中。商品价格以后可能变化,但历史订单金额不能随商品当前价格改变。
|
||||
|
||||
## 五、项目分层
|
||||
|
||||
```text
|
||||
main() / 事务调用方
|
||||
↓ 创建同一个Session
|
||||
OrderService
|
||||
↓ 调用
|
||||
ProductRepository + OrderRepository
|
||||
↓ 操作
|
||||
SQLAlchemy ORM模型与PostgreSQL
|
||||
```
|
||||
|
||||
### 5.1 Repository
|
||||
|
||||
Repository负责查询、增加和修改数据库对象,但不决定什么时候提交:
|
||||
|
||||
```python
|
||||
class OrderRepository:
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
|
||||
def add(self, order):
|
||||
self.session.add(order)
|
||||
```
|
||||
|
||||
它与MyBatis项目中的Mapper/DAO职责相近,但操作的是SQLAlchemy的Session和ORM对象。
|
||||
|
||||
### 5.2 Service
|
||||
|
||||
Service负责业务规则:验证订单、查询并锁定商品、判断库存、扣减库存、计算金额、组装订单。
|
||||
|
||||
它不调用`commit()`。因为一个业务用例可能调用多个Repository,必须保证它们处于同一个事务。
|
||||
|
||||
### 5.3 事务调用方
|
||||
|
||||
```python
|
||||
with session_factory.begin() as session:
|
||||
service = create_order_service(session)
|
||||
service.place_order(...)
|
||||
```
|
||||
|
||||
正常离开`with`时提交;异常离开时回滚。`ProductRepository`和`OrderRepository`共享同一个Session,因此库存修改、订单主表和订单明细属于同一个数据库事务。
|
||||
|
||||
## 六、成功事务与失败事务
|
||||
|
||||
成功订单购买两个键盘和一个鼠标:
|
||||
|
||||
```text
|
||||
验证订单 → 锁定商品 → 扣减库存 → 创建明细 → 创建订单 → 提交
|
||||
```
|
||||
|
||||
失败订单先扣减一个键盘,随后发现鼠标库存不足:
|
||||
|
||||
```text
|
||||
锁定键盘 → 内存中扣减键盘 → 锁定鼠标 → 库存不足 → 抛出异常 → 全部回滚
|
||||
```
|
||||
|
||||
回滚必须撤销第一项商品的扣减,也不能留下不完整的订单。不能在处理每项商品后分别提交。
|
||||
|
||||
## 七、为什么使用FOR UPDATE
|
||||
|
||||
普通查询后再扣减库存存在并发窗口:两个事务可能同时读到库存5,并各自认为能够购买4件。
|
||||
|
||||
```python
|
||||
statement = (
|
||||
select(Product)
|
||||
.where(Product.product_code == product_code)
|
||||
.with_for_update()
|
||||
)
|
||||
```
|
||||
|
||||
PostgreSQL会把它转换为`SELECT ... FOR UPDATE`。当前事务结束前,其他需要修改同一行的事务通常需要等待。
|
||||
|
||||
这能解决本项目中的典型并发更新问题,但生产系统还需要考虑锁顺序、死锁重试、事务超时、幂等和高并发架构。本课只要求理解悲观锁的基本作用。
|
||||
|
||||
## 八、金额为什么使用Decimal
|
||||
|
||||
二进制浮点数`float`不能精确表示很多十进制小数,不适合直接保存货币金额。本项目统一使用:
|
||||
|
||||
- Python:`Decimal`;
|
||||
- PostgreSQL:`NUMERIC(10, 2)`或`NUMERIC(12, 2)`。
|
||||
|
||||
```python
|
||||
price=Decimal("399.00")
|
||||
```
|
||||
|
||||
使用字符串创建`Decimal`,避免先经过不精确的浮点数。
|
||||
|
||||
## 九、DTO三表联查
|
||||
|
||||
订单明细页面同时需要订单、商品和明细字段,不适合把某一个ORM实体直接当作查询结果。
|
||||
|
||||
```python
|
||||
statement = (
|
||||
select(
|
||||
Order.order_no,
|
||||
Product.product_name,
|
||||
OrderItem.quantity,
|
||||
OrderItem.unit_price,
|
||||
)
|
||||
.join(OrderItem, Order.id == OrderItem.order_id)
|
||||
.join(Product, OrderItem.product_id == Product.id)
|
||||
)
|
||||
```
|
||||
|
||||
查询结果再转换为`OrderDetailDTO`。这与MyBatis联表SQL映射到DTO/VO的做法非常接近,而且只查询页面真正需要的字段。
|
||||
|
||||
## 十、完整示例
|
||||
|
||||
标准示例位于:
|
||||
|
||||
```text
|
||||
inventory_order_example.py
|
||||
```
|
||||
|
||||
它包含:
|
||||
|
||||
- 三个ORM模型及数据库约束;
|
||||
- Repository/Service分层;
|
||||
- 成功下单事务;
|
||||
- 库存不足事务回滚;
|
||||
- 悲观锁库存查询;
|
||||
- 三表联查DTO;
|
||||
- 订单状态聚合;
|
||||
- 可重复执行的数据初始化。
|
||||
|
||||
## 十一、安装与配置
|
||||
|
||||
如果`python-test`环境已经完成前两课SQLAlchemy练习,不需要重复安装。确认版本:
|
||||
|
||||
```powershell
|
||||
conda activate python-test
|
||||
python -c "import sqlalchemy, psycopg; print(sqlalchemy.__version__); print(psycopg.__version__)"
|
||||
```
|
||||
|
||||
缺少依赖时执行:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
如果第五课继续使用第四课数据库配置,可以在第五课目录执行:
|
||||
|
||||
```powershell
|
||||
Copy-Item ..\4_4_SQLAlchemy关系映射与工程实践\config.toml .\config.toml
|
||||
```
|
||||
|
||||
也可以复制模板后自行填写:
|
||||
|
||||
```powershell
|
||||
Copy-Item config.example.toml config.toml
|
||||
```
|
||||
|
||||
真实配置只保存在被Git忽略的`config.toml`中,不写入环境变量、示例文件或Python代码。
|
||||
|
||||
## 十二、运行方法
|
||||
|
||||
```powershell
|
||||
cd D:\Code\Python\04_数据库\4_5_数据库综合项目
|
||||
conda activate python-test
|
||||
python inventory_order_example.py
|
||||
```
|
||||
|
||||
正常结果应包括:
|
||||
|
||||
```text
|
||||
初始库存:
|
||||
DBP-001|机械键盘|价格:399.00|库存:10
|
||||
DBP-002|无线鼠标|价格:199.00|库存:5
|
||||
成功订单提交后:
|
||||
DBP-001|机械键盘|价格:399.00|库存:8
|
||||
DBP-002|无线鼠标|价格:199.00|库存:4
|
||||
失败订单已回滚:商品库存不足:DBP-002
|
||||
失败订单回滚后:
|
||||
DBP-001|机械键盘|价格:399.00|库存:8
|
||||
DBP-002|无线鼠标|价格:199.00|库存:4
|
||||
```
|
||||
|
||||
随后会输出两条`DBO-001`订单明细和一条`CREATED|订单数量:1`。连续运行两次时输出应保持一致;数据库序列生成的内部ID继续增长属于正常现象。
|
||||
|
||||
## 十三、关键执行顺序
|
||||
|
||||
1. 读取本地TOML配置;
|
||||
2. 创建Engine、连接池和Session工厂;
|
||||
3. 创建缺失的练习表;
|
||||
4. 在一个事务中重置练习数据;
|
||||
5. 查询初始库存;
|
||||
6. 在一个事务中执行成功下单;
|
||||
7. 在另一个事务中模拟库存不足并自动回滚;
|
||||
8. 使用DTO查询订单明细;
|
||||
9. 使用聚合查询统计订单状态;
|
||||
10. 释放连接池。
|
||||
|
||||
## 十四、常见错误
|
||||
|
||||
### 14.1 Repository中直接commit
|
||||
|
||||
这会让成功处理的第一项商品提前提交,后续商品失败时无法完整回滚。
|
||||
|
||||
### 14.2 每个Repository创建自己的Session
|
||||
|
||||
不同Session通常意味着不同事务。订单新增与库存扣减必须共享调用方传入的同一个Session。
|
||||
|
||||
### 14.3 捕获异常后不再抛出
|
||||
|
||||
如果在事务`with`内部吞掉库存不足异常,上下文会误以为业务成功并提交。应让异常离开事务上下文,再在外层捕获。
|
||||
|
||||
### 14.4 使用float计算金额
|
||||
|
||||
可能产生精度问题。金额应使用`Decimal`和数据库`NUMERIC`。
|
||||
|
||||
### 14.5 只检查库存但不锁定
|
||||
|
||||
单人练习时看似正确,并发请求下可能超卖。本课使用`FOR UPDATE`锁定商品行。
|
||||
|
||||
### 14.6 直接用当前商品价格展示历史订单
|
||||
|
||||
商品后来改价会污染历史数据。订单明细应保存成交时的`unit_price`。
|
||||
|
||||
## 十五、课堂练习
|
||||
|
||||
练习位于`practice.py`,仍采用前几课的分步形式,只包含题目、预期结果、自查清单和验收标准。请先独立完成,每完成一部分都可以让我验证。
|
||||
|
||||
## 十六、本课小结
|
||||
|
||||
1. 真实业务写入通常跨越多张表,事务边界应围绕完整业务用例;
|
||||
2. Repository负责数据访问,Service负责业务规则,调用方负责事务;
|
||||
3. 多个Repository必须共享同一个Session才能处于同一个事务;
|
||||
4. `FOR UPDATE`可以在事务中锁定待修改库存;
|
||||
5. 金额使用`Decimal`与`NUMERIC`;
|
||||
6. 多表列表查询适合使用DTO;
|
||||
7. 失败事务必须既不保留订单,也不保留任何库存修改。
|
||||
|
||||
## 十七、验收标准
|
||||
|
||||
- 三张表的约束、外键和ORM关系正确;
|
||||
- 成功订单保存订单与明细并扣减库存;
|
||||
- 库存不足时整个事务回滚;
|
||||
- Repository、Service和事务职责清晰;
|
||||
- DTO联表查询及状态统计正确;
|
||||
- 程序可重复运行且不影响其他数据;
|
||||
- 配置文件不进入Git;
|
||||
- 能解释本项目与Java中Mapper/Service/`@Transactional`/DTO的对应关系。
|
||||
@@ -0,0 +1,7 @@
|
||||
[postgresql]
|
||||
host = "你的PostgreSQL服务器地址"
|
||||
port = 5432
|
||||
dbname = "python_test"
|
||||
user = "你的数据库用户名"
|
||||
password = "你的数据库密码"
|
||||
connect_timeout = 10
|
||||
@@ -0,0 +1,448 @@
|
||||
"""第4-5课标准示例:使用SQLAlchemy实现库存订单综合项目。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
|
||||
from sqlalchemy import (
|
||||
CheckConstraint,
|
||||
ForeignKey,
|
||||
Numeric,
|
||||
String,
|
||||
URL,
|
||||
create_engine,
|
||||
delete,
|
||||
func,
|
||||
select,
|
||||
)
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import (
|
||||
DeclarativeBase,
|
||||
Mapped,
|
||||
Session,
|
||||
mapped_column,
|
||||
relationship,
|
||||
sessionmaker,
|
||||
)
|
||||
|
||||
|
||||
CONFIG_PATH = Path(__file__).with_name("config.toml")
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""所有ORM模型共同继承的声明式基类。"""
|
||||
|
||||
|
||||
class OrderError(Exception):
|
||||
"""表示下单过程中可以预期的业务异常。"""
|
||||
|
||||
|
||||
class Product(Base):
|
||||
"""商品模型,保存价格和当前库存。"""
|
||||
|
||||
__tablename__ = "course_shop_product"
|
||||
__table_args__ = (
|
||||
CheckConstraint("price >= 0", name="ck_course_shop_product_price"),
|
||||
CheckConstraint("stock >= 0", name="ck_course_shop_product_stock"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
product_code: Mapped[str] = mapped_column(String(30), unique=True, nullable=False)
|
||||
product_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
price: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
|
||||
stock: Mapped[int] = mapped_column(nullable=False)
|
||||
|
||||
items: Mapped[list["OrderItem"]] = relationship(back_populates="product")
|
||||
|
||||
|
||||
class Order(Base):
|
||||
"""订单主表模型,保存客户、总金额和订单状态。"""
|
||||
|
||||
__tablename__ = "course_shop_order"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"total_amount >= 0",
|
||||
name="ck_course_shop_order_total_amount",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
order_no: Mapped[str] = mapped_column(String(30), unique=True, nullable=False)
|
||||
customer_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
total_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
|
||||
items: Mapped[list["OrderItem"]] = relationship(
|
||||
back_populates="order",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class OrderItem(Base):
|
||||
"""订单明细模型,连接订单和商品并保存成交单价。"""
|
||||
|
||||
__tablename__ = "course_shop_order_item"
|
||||
__table_args__ = (
|
||||
CheckConstraint("quantity > 0", name="ck_course_shop_order_item_quantity"),
|
||||
CheckConstraint(
|
||||
"unit_price >= 0",
|
||||
name="ck_course_shop_order_item_unit_price",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
order_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("course_shop_order.id"),
|
||||
nullable=False,
|
||||
)
|
||||
product_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("course_shop_product.id"),
|
||||
nullable=False,
|
||||
)
|
||||
quantity: Mapped[int] = mapped_column(nullable=False)
|
||||
unit_price: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
|
||||
|
||||
order: Mapped[Order] = relationship(back_populates="items")
|
||||
product: Mapped[Product] = relationship(back_populates="items")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OrderDetailDTO:
|
||||
"""向调用方返回的订单明细查询结果。"""
|
||||
|
||||
order_no: str
|
||||
customer_name: str
|
||||
product_code: str
|
||||
product_name: str
|
||||
quantity: int
|
||||
unit_price: Decimal
|
||||
line_amount: Decimal
|
||||
status: str
|
||||
|
||||
|
||||
def load_database_config(config_path: Path) -> dict:
|
||||
"""从本地TOML文件读取PostgreSQL配置。"""
|
||||
|
||||
if not config_path.exists():
|
||||
raise RuntimeError(
|
||||
"没有找到config.toml,请复制config.example.toml并填写数据库信息。"
|
||||
)
|
||||
|
||||
with config_path.open("rb") as config_file:
|
||||
config_data = tomllib.load(config_file)
|
||||
|
||||
database_config = config_data.get("postgresql")
|
||||
if not isinstance(database_config, dict):
|
||||
raise RuntimeError("config.toml中缺少[postgresql]配置节。")
|
||||
return database_config
|
||||
|
||||
|
||||
def create_database_url(database_config: dict) -> URL:
|
||||
"""使用URL.create()构造连接地址,避免手工拼接密码。"""
|
||||
|
||||
return URL.create(
|
||||
drivername="postgresql+psycopg",
|
||||
username=str(database_config["user"]),
|
||||
password=str(database_config["password"]),
|
||||
host=str(database_config["host"]),
|
||||
port=int(database_config["port"]),
|
||||
database=str(database_config["dbname"]),
|
||||
)
|
||||
|
||||
|
||||
class ProductRepository:
|
||||
"""封装商品表的数据访问操作。"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def find_by_code_for_update(self, product_code: str) -> Product:
|
||||
"""查询并锁定商品,防止并发下单时同时修改同一库存。"""
|
||||
|
||||
statement = (
|
||||
select(Product)
|
||||
.where(Product.product_code == product_code)
|
||||
.with_for_update()
|
||||
)
|
||||
product = self.session.scalar(statement)
|
||||
if product is None:
|
||||
raise OrderError(f"商品不存在:{product_code}")
|
||||
return product
|
||||
|
||||
def find_practice_products(self) -> list[Product]:
|
||||
"""查询本课练习商品。"""
|
||||
|
||||
statement = (
|
||||
select(Product)
|
||||
.where(Product.product_code.like("DBP-%"))
|
||||
.order_by(Product.product_code)
|
||||
)
|
||||
return list(self.session.scalars(statement))
|
||||
|
||||
|
||||
class OrderRepository:
|
||||
"""封装订单及订单明细的数据访问操作。"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def exists_by_order_no(self, order_no: str) -> bool:
|
||||
"""判断订单编号是否存在。"""
|
||||
|
||||
statement = select(Order.id).where(Order.order_no == order_no)
|
||||
return self.session.scalar(statement) is not None
|
||||
|
||||
def add(self, order: Order) -> None:
|
||||
"""把订单加入Session;事务提交仍由外层调用方负责。"""
|
||||
|
||||
self.session.add(order)
|
||||
|
||||
def find_order_details(self) -> list[OrderDetailDTO]:
|
||||
"""联表查询订单明细并转换为DTO。"""
|
||||
|
||||
statement = (
|
||||
select(
|
||||
Order.order_no,
|
||||
Order.customer_name,
|
||||
Product.product_code,
|
||||
Product.product_name,
|
||||
OrderItem.quantity,
|
||||
OrderItem.unit_price,
|
||||
Order.status,
|
||||
)
|
||||
.join(OrderItem, Order.id == OrderItem.order_id)
|
||||
.join(Product, OrderItem.product_id == Product.id)
|
||||
.where(Order.order_no.like("DBO-%"))
|
||||
.order_by(Order.order_no, OrderItem.id)
|
||||
)
|
||||
|
||||
details = []
|
||||
for row in self.session.execute(statement):
|
||||
details.append(
|
||||
OrderDetailDTO(
|
||||
order_no=row.order_no,
|
||||
customer_name=row.customer_name,
|
||||
product_code=row.product_code,
|
||||
product_name=row.product_name,
|
||||
quantity=row.quantity,
|
||||
unit_price=row.unit_price,
|
||||
line_amount=row.unit_price * row.quantity,
|
||||
status=row.status,
|
||||
)
|
||||
)
|
||||
return details
|
||||
|
||||
def count_orders_by_status(self) -> list[tuple[str, int]]:
|
||||
"""让数据库按状态统计本课订单数量。"""
|
||||
|
||||
statement = (
|
||||
select(Order.status, func.count(Order.id))
|
||||
.where(Order.order_no.like("DBO-%"))
|
||||
.group_by(Order.status)
|
||||
.order_by(Order.status)
|
||||
)
|
||||
return list(self.session.execute(statement).tuples())
|
||||
|
||||
|
||||
class OrderService:
|
||||
"""实现下单和扣减库存的业务规则。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
product_repository: ProductRepository,
|
||||
order_repository: OrderRepository,
|
||||
):
|
||||
self.product_repository = product_repository
|
||||
self.order_repository = order_repository
|
||||
|
||||
def place_order(
|
||||
self,
|
||||
order_no: str,
|
||||
customer_name: str,
|
||||
requests: list[tuple[str, int]],
|
||||
) -> None:
|
||||
"""在调用方提供的事务中创建订单并扣减库存。"""
|
||||
|
||||
if self.order_repository.exists_by_order_no(order_no):
|
||||
raise OrderError(f"订单已存在:{order_no}")
|
||||
if not requests:
|
||||
raise OrderError("订单至少需要一项商品。")
|
||||
|
||||
order_items = []
|
||||
total_amount = Decimal("0.00")
|
||||
|
||||
for product_code, quantity in requests:
|
||||
if quantity <= 0:
|
||||
raise OrderError("购买数量必须大于0。")
|
||||
|
||||
product = self.product_repository.find_by_code_for_update(product_code)
|
||||
if product.stock < quantity:
|
||||
raise OrderError(f"商品库存不足:{product_code}")
|
||||
|
||||
product.stock -= quantity
|
||||
order_items.append(
|
||||
OrderItem(
|
||||
product=product,
|
||||
quantity=quantity,
|
||||
unit_price=product.price,
|
||||
)
|
||||
)
|
||||
total_amount += product.price * quantity
|
||||
|
||||
order = Order(
|
||||
order_no=order_no,
|
||||
customer_name=customer_name,
|
||||
total_amount=total_amount,
|
||||
status="CREATED",
|
||||
items=order_items,
|
||||
)
|
||||
self.order_repository.add(order)
|
||||
|
||||
|
||||
def reset_and_add_products(session: Session) -> None:
|
||||
"""按外键依赖顺序清理并重建本课练习数据。"""
|
||||
|
||||
order_ids = select(Order.id).where(Order.order_no.like("DBO-%"))
|
||||
session.execute(delete(OrderItem).where(OrderItem.order_id.in_(order_ids)))
|
||||
session.execute(delete(Order).where(Order.order_no.like("DBO-%")))
|
||||
session.execute(delete(Product).where(Product.product_code.like("DBP-%")))
|
||||
|
||||
session.add_all(
|
||||
[
|
||||
Product(
|
||||
product_code="DBP-001",
|
||||
product_name="机械键盘",
|
||||
price=Decimal("399.00"),
|
||||
stock=10,
|
||||
),
|
||||
Product(
|
||||
product_code="DBP-002",
|
||||
product_name="无线鼠标",
|
||||
price=Decimal("199.00"),
|
||||
stock=5,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def create_order_service(session: Session) -> OrderService:
|
||||
"""使用同一个Session组装Repository和Service。"""
|
||||
|
||||
return OrderService(
|
||||
ProductRepository(session),
|
||||
OrderRepository(session),
|
||||
)
|
||||
|
||||
|
||||
def run_successful_order(session_factory: sessionmaker[Session]) -> None:
|
||||
"""执行成功订单,正常离开上下文后自动提交。"""
|
||||
|
||||
with session_factory.begin() as session:
|
||||
service = create_order_service(session)
|
||||
service.place_order(
|
||||
"DBO-001",
|
||||
"张三",
|
||||
[("DBP-001", 2), ("DBP-002", 1)],
|
||||
)
|
||||
|
||||
|
||||
def run_failed_order(session_factory: sessionmaker[Session]) -> None:
|
||||
"""执行库存不足订单,让整个事务自动回滚。"""
|
||||
|
||||
try:
|
||||
with session_factory.begin() as session:
|
||||
service = create_order_service(session)
|
||||
service.place_order(
|
||||
"DBO-002",
|
||||
"李四",
|
||||
[("DBP-001", 1), ("DBP-002", 99)],
|
||||
)
|
||||
except OrderError as error:
|
||||
print(f"失败订单已回滚:{error}")
|
||||
|
||||
|
||||
def query_products(session_factory: sessionmaker[Session]) -> list[Product]:
|
||||
"""使用独立Session查询商品,并在关闭前取得所需字段。"""
|
||||
|
||||
with session_factory() as session:
|
||||
products = ProductRepository(session).find_practice_products()
|
||||
# expire_on_commit=False且这里只读取标量字段,Session关闭后仍可用于输出。
|
||||
return products
|
||||
|
||||
|
||||
def print_products(title: str, products: list[Product]) -> None:
|
||||
"""按统一格式输出商品库存。"""
|
||||
|
||||
print(title)
|
||||
for product in products:
|
||||
print(
|
||||
f"{product.product_code}|{product.product_name}|"
|
||||
f"价格:{product.price}|库存:{product.stock}"
|
||||
)
|
||||
|
||||
|
||||
def print_order_results(session_factory: sessionmaker[Session]) -> None:
|
||||
"""查询并输出DTO明细和订单状态统计。"""
|
||||
|
||||
with session_factory() as session:
|
||||
repository = OrderRepository(session)
|
||||
|
||||
print("订单明细DTO:")
|
||||
for detail in repository.find_order_details():
|
||||
print(
|
||||
f"{detail.order_no}|{detail.customer_name}|{detail.product_code}|"
|
||||
f"{detail.product_name}|数量:{detail.quantity}|"
|
||||
f"单价:{detail.unit_price}|小计:{detail.line_amount}|"
|
||||
f"{detail.status}"
|
||||
)
|
||||
|
||||
print("订单状态统计:")
|
||||
for status, order_count in repository.count_orders_by_status():
|
||||
print(f"{status}|订单数量:{order_count}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""创建环境并依次验证成功事务、失败回滚和多表查询。"""
|
||||
|
||||
engine = None
|
||||
try:
|
||||
database_config = load_database_config(CONFIG_PATH)
|
||||
engine = create_engine(
|
||||
create_database_url(database_config),
|
||||
connect_args={
|
||||
"connect_timeout": int(database_config.get("connect_timeout", 10))
|
||||
},
|
||||
pool_size=5,
|
||||
max_overflow=5,
|
||||
pool_pre_ping=True,
|
||||
echo=False,
|
||||
)
|
||||
session_factory = sessionmaker(engine, expire_on_commit=False)
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
with session_factory.begin() as session:
|
||||
reset_and_add_products(session)
|
||||
|
||||
print_products("初始库存:", query_products(session_factory))
|
||||
|
||||
run_successful_order(session_factory)
|
||||
print_products("成功订单提交后:", query_products(session_factory))
|
||||
|
||||
run_failed_order(session_factory)
|
||||
print_products("失败订单回滚后:", query_products(session_factory))
|
||||
|
||||
print_order_results(session_factory)
|
||||
except (RuntimeError, KeyError, tomllib.TOMLDecodeError) as error:
|
||||
print(f"配置读取失败:{error}")
|
||||
except OrderError as error:
|
||||
print(f"订单业务失败:{error}")
|
||||
except SQLAlchemyError as error:
|
||||
print(f"数据库访问失败:{error}")
|
||||
finally:
|
||||
if engine is not None:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,178 @@
|
||||
# 第4-5课练习:数据库综合项目——库存订单管理
|
||||
#
|
||||
# 本文件只提供题目,不包含导入、代码骨架、测试数据代码或参考答案。
|
||||
# 练习会创建course_shop_product、course_shop_order和course_shop_order_item三张表,
|
||||
# 并只操作DBP-商品前缀及DBO-订单前缀的数据。
|
||||
# 请勿改用现有业务表,也不要删除不属于本练习的数据。
|
||||
|
||||
|
||||
# 第一部分:导入、配置与基础类型
|
||||
# 1. 导入dataclass、Decimal、Path和tomllib。
|
||||
# 2. 从sqlalchemy导入CheckConstraint、ForeignKey、Numeric、String、URL、
|
||||
# create_engine、delete、func和select。
|
||||
# 3. 从sqlalchemy.exc导入SQLAlchemyError。
|
||||
# 4. 从sqlalchemy.orm导入DeclarativeBase、Mapped、Session、mapped_column、
|
||||
# relationship、selectinload和sessionmaker。
|
||||
# 5. 使用Path(__file__).with_name("config.toml")定义CONFIG_PATH。
|
||||
# 6. 定义Base(DeclarativeBase)。
|
||||
# 7. 定义OrderError(Exception),用于表达商品不存在、库存不足等业务失败。
|
||||
# 8. 实现load_database_config(config_path),读取[postgresql]配置。
|
||||
# 9. 实现create_database_url(database_config),使用URL.create()创建连接地址。
|
||||
|
||||
|
||||
# 第二部分:定义三个ORM模型
|
||||
# 1. 定义Product(Base),表名course_shop_product:
|
||||
# - id:int主键;
|
||||
# - product_code:最长30字符,唯一且非空;
|
||||
# - product_name:最长100字符且非空;
|
||||
# - price:NUMERIC(10, 2)且非空;
|
||||
# - stock:int且非空;
|
||||
# - 使用CheckConstraint保证price和stock都大于等于0;
|
||||
# - items:与OrderItem建立双向一对多关系。
|
||||
# 2. 定义Order(Base),表名course_shop_order:
|
||||
# - id:int主键;
|
||||
# - order_no:最长30字符,唯一且非空;
|
||||
# - customer_name:最长100字符且非空;
|
||||
# - total_amount:NUMERIC(12, 2)且非空;
|
||||
# - status:最长20字符且非空;
|
||||
# - items:与OrderItem建立双向一对多关系;
|
||||
# - 配置cascade="all, delete-orphan"。
|
||||
# 3. 定义OrderItem(Base),表名course_shop_order_item:
|
||||
# - id:int主键;
|
||||
# - order_id:外键引用course_shop_order.id,非空;
|
||||
# - product_id:外键引用course_shop_product.id,非空;
|
||||
# - quantity:int且非空,使用CheckConstraint保证大于0;
|
||||
# - unit_price:NUMERIC(10, 2)且非空;
|
||||
# - order:与Order.items互为双向关系;
|
||||
# - product:与Product.items互为双向关系。
|
||||
|
||||
|
||||
# 第三部分:定义DTO
|
||||
# 1. 使用@dataclass(frozen=True)定义OrderDetailDTO。
|
||||
# 2. DTO包含order_no、customer_name、product_code、product_name、quantity、
|
||||
# unit_price、line_amount和status。
|
||||
# 3. DTO不继承Base,不承担数据库持久化职责。
|
||||
# 4. line_amount由查询结果中的unit_price乘以quantity得到。
|
||||
|
||||
|
||||
# 第四部分:实现ProductRepository
|
||||
# 1. 构造方法接收并保存外部传入的Session。
|
||||
# 2. find_by_code_for_update(product_code):
|
||||
# - 使用select(Product).where(...)查询商品;
|
||||
# - 调用with_for_update()锁定商品行;
|
||||
# - 找不到时抛出OrderError("商品不存在:{product_code}");
|
||||
# - 返回Product对象。
|
||||
# 3. find_practice_products()查询DBP-前缀商品并按商品编号排序。
|
||||
# 4. Repository中不得创建Session,不得调用commit()或rollback()。
|
||||
|
||||
|
||||
# 第五部分:实现OrderRepository
|
||||
# 1. 构造方法接收并保存外部传入的Session。
|
||||
# 2. exists_by_order_no(order_no)判断订单编号是否已经存在。
|
||||
# 3. add(order)调用session.add(order),但不提交事务。
|
||||
# 4. find_order_details()使用显式join查询订单、明细和商品:
|
||||
# - 只查询DBO-前缀订单;
|
||||
# - 只选择DTO所需字段;
|
||||
# - 按订单编号和明细ID排序;
|
||||
# - 把结果转换成OrderDetailDTO列表。
|
||||
# 5. count_orders_by_status()使用func.count()和group_by()统计各状态订单数。
|
||||
|
||||
|
||||
# 第六部分:实现OrderService下单业务
|
||||
# 1. 构造方法接收ProductRepository和OrderRepository。
|
||||
# 2. 定义place_order(order_no, customer_name, requests),其中requests是
|
||||
# “商品编号、购买数量”组成的列表。
|
||||
# 3. 订单编号已存在时抛出OrderError("订单已存在:{order_no}")。
|
||||
# 4. requests为空时抛出OrderError("订单至少需要一项商品。")。
|
||||
# 5. 逐项处理购买请求:
|
||||
# - 数量小于等于0时抛出OrderError("购买数量必须大于0。");
|
||||
# - 调用find_by_code_for_update()查询并锁定商品;
|
||||
# - 库存不足时抛出OrderError("商品库存不足:{product_code}");
|
||||
# - 商品库存减去购买数量;
|
||||
# - 使用商品当前价格创建OrderItem;
|
||||
# - 累加订单总金额。
|
||||
# 6. 创建status="CREATED"的Order并关联全部OrderItem。
|
||||
# 7. 调用OrderRepository.add(order),不在Service中提交事务。
|
||||
|
||||
|
||||
# 第七部分:准备数据和验证事务
|
||||
# 1. 定义reset_and_add_products(session):
|
||||
# - 先删除DBO-前缀订单对应的订单明细;
|
||||
# - 再删除DBO-前缀订单;
|
||||
# - 最后删除DBP-前缀商品;
|
||||
# - 新增DBP-001机械键盘,价格399.00,库存10;
|
||||
# - 新增DBP-002无线鼠标,价格199.00,库存5;
|
||||
# - 全过程不调用commit()。
|
||||
# 2. 定义run_successful_order(session_factory):
|
||||
# - 使用with session_factory.begin() as session管理事务;
|
||||
# - 创建两个Repository和OrderService;
|
||||
# - 创建订单DBO-001,客户张三,购买2个DBP-001和1个DBP-002;
|
||||
# - 正常离开with,让事务自动提交;
|
||||
# - 成功后键盘库存为8,鼠标库存为4,订单金额为997.00。
|
||||
# 3. 定义run_failed_order(session_factory):
|
||||
# - 在try中使用with session_factory.begin() as session;
|
||||
# - 创建订单DBO-002,先购买1个DBP-001,再购买99个DBP-002;
|
||||
# - 第二项因库存不足抛出OrderError;
|
||||
# - 在事务with外捕获OrderError并输出失败信息;
|
||||
# - 整个订单事务必须回滚,键盘库存仍为8,且DBO-002不能存在。
|
||||
|
||||
|
||||
# 第八部分:输出和main()流程
|
||||
# 1. 定义print_products(title, products),输出商品编号、名称、价格和库存。
|
||||
# 2. 定义print_order_details(details),输出DTO中的订单和明细信息。
|
||||
# 3. 定义print_order_counts(counts),输出“状态|订单数量:数字”。
|
||||
# 4. main()依次执行:
|
||||
# - 读取TOML配置并创建数据库URL;
|
||||
# - 创建一次Engine并启用pool_pre_ping;
|
||||
# - 使用sessionmaker(engine, expire_on_commit=False)创建Session工厂;
|
||||
# - 调用Base.metadata.create_all(engine);
|
||||
# - 在一个事务中重置数据并新增练习商品;
|
||||
# - 输出初始库存;
|
||||
# - 执行成功订单并输出扣减后的库存;
|
||||
# - 执行失败订单并输出回滚后的库存;
|
||||
# - 查询并输出订单DTO和状态统计;
|
||||
# - 分类捕获配置异常、OrderError和SQLAlchemyError;
|
||||
# - 在finally中调用engine.dispose()。
|
||||
# 5. 添加程序入口判断并调用main()。
|
||||
#
|
||||
# 预期关键输出:
|
||||
# 初始库存:
|
||||
# DBP-001|机械键盘|价格:399.00|库存:10
|
||||
# DBP-002|无线鼠标|价格:199.00|库存:5
|
||||
# 成功订单提交后:
|
||||
# DBP-001|机械键盘|价格:399.00|库存:8
|
||||
# DBP-002|无线鼠标|价格:199.00|库存:4
|
||||
# 失败订单已回滚:商品库存不足:DBP-002
|
||||
# 失败订单回滚后:
|
||||
# DBP-001|机械键盘|价格:399.00|库存:8
|
||||
# DBP-002|无线鼠标|价格:199.00|库存:4
|
||||
# 订单明细DTO:
|
||||
# DBO-001|张三|DBP-001|机械键盘|数量:2|单价:399.00|小计:798.00|CREATED
|
||||
# DBO-001|张三|DBP-002|无线鼠标|数量:1|单价:199.00|小计:199.00|CREATED
|
||||
# 订单状态统计:
|
||||
# CREATED|订单数量:1
|
||||
|
||||
|
||||
# 自查清单:
|
||||
# 1. 三个ORM模型是否建立了真实外键和双向对象关系?
|
||||
# 2. 金额是否全部使用Decimal和NUMERIC,而不是float?
|
||||
# 3. 商品查询是否使用FOR UPDATE锁定待扣减库存的记录?
|
||||
# 4. Repository和Service是否都没有自行提交事务?
|
||||
# 5. 一张订单的全部库存扣减和订单新增是否处于同一个事务?
|
||||
# 6. 失败订单中第一项库存扣减是否也被回滚?
|
||||
# 7. 订单明细查询是否使用join()并转换成DTO?
|
||||
# 8. 状态统计是否由数据库完成count()和group_by()?
|
||||
# 9. 程序是否只清理DBP-和DBO-前缀的练习数据?
|
||||
# 10. 配置是否来自被Git忽略的本地config.toml?
|
||||
|
||||
|
||||
# 最终验收标准:
|
||||
# 1. practice.py通过语法检查并能连续运行两次;
|
||||
# 2. 三张表的字段、约束、外键和关系映射正确;
|
||||
# 3. 成功订单正确保存订单、明细并扣减库存;
|
||||
# 4. 失败订单完全回滚,不保存订单且不改变任何库存;
|
||||
# 5. DTO联表查询结果和订单状态统计符合预期;
|
||||
# 6. Repository负责持久化,Service负责业务规则,调用方负责事务;
|
||||
# 7. SQLAlchemy查询使用2.x写法,不使用session.query();
|
||||
# 8. 所有练习数据与现有数据安全隔离;
|
||||
# 9. config.toml与真实连接信息没有进入Git。
|
||||
@@ -0,0 +1,2 @@
|
||||
SQLAlchemy>=2.0,<2.1
|
||||
psycopg[binary]>=3.2,<4.0
|
||||
Reference in New Issue
Block a user