feat(数据库): 新增第四阶段前两课教学内容

This commit is contained in:
zhiye.sun
2026-08-12 17:23:12 +08:00
parent f1d9548646
commit a28c3b3168
12 changed files with 1291 additions and 18 deletions

View File

@@ -0,0 +1,420 @@
# 第 4-2 课Python 数据库事务与数据访问层
## 一、本课定位
第一课已经完成 PostgreSQL 连接、游标、参数化查询和资源释放。本课先系统复习事务的核心概念,再学习 Python/Psycopg 中的事务边界,以及如何把 SQL 从业务逻辑中分离。数据库基础不会展开成完整 SQL 课程,但事务是后续 ORM 和 Web 业务正确性的基础,必须讲清楚。
本课会写入远程练习数据库:示例创建专用表 `course_bank_account`,只重置并操作 `COURSE-` 前缀数据;练习使用另一张专用表 `course_wallet_account`,只操作 `PRACTICE-` 前缀数据。不要连接生产数据库。
## 二、本课目标
完成本课后,你将能够:
1. 说明事务是什么以及为什么需要事务;
2. 结合转账解释原子性、一致性、隔离性和持久性;
3. 区分提交、回滚、自动提交和事务失败状态;
4. 解释常见并发异常和事务隔离级别;
5. 解释 Psycopg 默认事务行为;
6. 使用连接上下文自动提交和回滚;
7. 理解为什么异常必须传播出事务上下文;
8. 使用 `executemany()` 批量执行同一条参数化 SQL
9. 使用 `FOR UPDATE` 锁定待修改数据;
10. 使用 Repository 隔离 SQL
11. 把业务规则放在 Service 中;
12. 让一次业务操作共享同一连接和事务;
13. 对照 JDBC、MyBatis 和 Spring 事务理解 Python实现。
## 三、事务是什么
事务Transaction是数据库中的一个工作单元它包含一条或多条操作这些操作应该作为一个不可分割的整体完成。
转账至少包含两条更新:
```text
账户A扣款200元
账户B入账200元
```
如果第一条成功、第二条失败,却保留了第一条结果,钱就凭空减少了。事务要求最终只能出现两种结果:
```text
全部成功 → 提交COMMIT
任一步失败 → 回滚ROLLBACK恢复到事务开始前
```
对应的SQL概念是
```sql
BEGIN;
UPDATE account SET balance = balance - 200 WHERE account_no = 'A';
UPDATE account SET balance = balance + 200 WHERE account_no = 'B';
COMMIT;
```
中间发生错误时执行:
```sql
ROLLBACK;
```
使用Psycopg时通常不需要手写`BEGIN`。驱动会按连接状态自动开始事务,代码负责正确划定提交和回滚边界。
## 四、事务的ACID特性
ACID是事务需要满足的四类核心性质。
### 4.1 原子性Atomicity
事务内的操作要么全部成功,要么全部失败。转账中的扣款和入账不能只保留其中一步。
本课的失败示例会先扣除50元再主动抛出异常。最终余额不变就是在验证原子性。
### 4.2 一致性Consistency
事务执行前后,数据都必须满足数据库约束和业务规则。例如:
- 余额不能小于0
- 转账前后两个账户的余额总额不应无故变化;
- 目标账户必须存在;
- 主键、非空和检查约束仍然成立。
一致性不是只靠数据库自动保证。数据库约束、事务、锁和Service中的业务校验要共同工作。
### 4.3 隔离性Isolation
多个事务并发执行时,一个事务不应随意看到另一个尚未完成事务的中间状态。
假设账户A余额为1000元两个请求同时转出800元。如果二者都先读取到1000再分别扣款就可能发生超额转账。事务隔离级别和行锁用于控制这种并发影响。
隔离不等于所有事务完全串行。隔离越强,并发冲突通常越少,但等待和资源成本可能越高。
### 4.4 持久性Durability
事务成功提交后,结果应该持久保存。程序退出或连接关闭后,再使用新连接查询,仍然能够看到已提交的余额。
本课使用独立连接回查成功转账结果,就是在直观验证持久性。
## 五、提交、回滚与事务状态
### 5.1 提交
`COMMIT`确认事务中的修改。提交成功后,其他事务才能按照隔离规则观察到这些结果,当前事务也不能再整体撤销。
### 5.2 回滚
`ROLLBACK`取消当前事务中尚未提交的修改。它不是反向执行一条新的补偿SQL而是让数据库放弃本次事务的未提交结果。
### 5.3 PostgreSQL事务失败状态
PostgreSQL事务中的某条SQL失败后当前事务通常进入失败状态。即使后面的SQL本身正确也会收到类似错误
```text
current transaction is aborted, commands ignored until end of transaction block
```
中文含义是当前事务已经失败在事务结束前忽略后续命令。此时必须回滚或让Psycopg的连接上下文因异常退出并自动回滚。
### 5.4 自动提交
自动提交Autocommit表示每条独立SQL完成后立即提交。它适合部分不需要多语句原子性的操作但无法把扣款和入账自动组合成一个事务。
Psycopg默认`autocommit=False`。本课保持默认行为,不开启自动提交。
## 六、隔离级别与并发异常
常见并发异常如下:
| 并发现象 | 含义 |
|---|---|
| 脏读Dirty Read | 读取到其他事务尚未提交的数据;对方回滚后,读到的数据从未真正成立 |
| 不可重复读Non-repeatable Read | 同一事务两次读取同一行,期间被其他事务提交修改,结果不同 |
| 幻读Phantom Read | 同一事务两次执行相同范围查询,结果行数量因其他事务提交而变化 |
| 丢失更新Lost Update | 两个事务基于同一旧值更新,后提交的结果覆盖前一个结果 |
SQL标准定义四个主要隔离级别
| 隔离级别 | 基本含义 | PostgreSQL说明 |
|---|---|---|
| `READ UNCOMMITTED` | 理论上允许读取未提交数据 | PostgreSQL内部按`READ COMMITTED`处理 |
| `READ COMMITTED` | 每条语句读取执行开始前已提交的数据 | PostgreSQL默认级别 |
| `REPEATABLE READ` | 事务内多次查询基于稳定快照 | 提交时仍可能出现并发冲突 |
| `SERIALIZABLE` | 尽量表现得像事务串行执行 | 冲突时可能要求应用重试事务 |
隔离级别不能代替所有业务并发控制。本课在默认`READ COMMITTED`下使用`SELECT ... FOR UPDATE`锁住即将修改的账户行。
## 七、JDBC与Psycopg事务对照
| Java常见写法 | Psycopg写法 | 含义 |
|---|---|---|
| `connection.setAutoCommit(false)` | 默认连接首次操作自动进入事务 | 开始事务工作 |
| `connection.commit()` | 正常离开连接`with` | 提交 |
| `connection.rollback()` | 异常离开连接`with` | 回滚 |
| `try-with-resources` | `with psycopg.connect(...)` | 管理连接生命周期 |
| Mapper/DAO | Repository | 封装SQL和结果转换 |
| Service | Service | 组织业务规则 |
| `@Transactional` | 外层连接/事务上下文 | 划定事务边界 |
Python没有Spring默认提供的声明式`@Transactional`。直接使用Psycopg时需要显式组织事务上下文后续SQLAlchemy和FastAPI课程会进一步统一Session生命周期。
## 八、Psycopg默认事务行为
Psycopg遵循DB-API习惯连接默认不是自动提交模式。包括`SELECT`在内的数据库操作通常都会启动事务。
```python
with psycopg.connect(**database_config) as connection:
connection.execute("UPDATE ...")
```
正常离开`with`时提交;块内异常传播出去时回滚;最后关闭连接。
### 8.1 异常不能在事务内部被吞掉
错误写法:
```python
with psycopg.connect(**database_config) as connection:
try:
connection.execute("UPDATE ...")
raise TransferError("后续失败")
except TransferError:
print("失败")
```
异常已经在`with`内部被捕获,连接上下文看到的是“正常结束”,可能提交前面的更新。
正确边界:
```python
try:
with psycopg.connect(**database_config) as connection:
connection.execute("UPDATE ...")
raise TransferError("后续失败")
except TransferError as error:
print(error)
```
异常先离开`with`,触发回滚,然后才由外层处理。
### 8.2 手动提交和回滚
连接上下文适合“整个代码块就是一个事务”的情况,也可以显式控制:
```python
connection = psycopg.connect(**database_config)
try:
connection.execute("UPDATE ...")
connection.execute("UPDATE ...")
connection.commit()
except Exception:
connection.rollback()
raise
finally:
connection.close()
```
这与JDBC手动事务非常接近。但只要业务边界能自然表达为代码块优先使用`with`,可以减少遗漏回滚或关闭连接的风险。
### 8.3 连接with与事务with的区别
本课使用:
```python
with psycopg.connect(**database_config) as connection:
...
```
它同时管理事务和连接生命周期。Psycopg还提供
```python
with connection.transaction():
...
```
后者只划定一个事务或保存点范围,不负责创建连接。它适合长连接或连接池场景。本阶段后续课程结合连接池时再深入使用。
## 九、Repository与Service职责
本课采用:
```text
main
↓ 创建连接并划定事务
Service
↓ 组织转账规则
Repository
↓ 执行参数化SQL
PostgreSQL
```
Repository不应在每个方法中调用`commit()`,否则扣款刚提交、入账却失败时,外层已经无法回滚整个业务操作。
```python
class AccountRepository:
def __init__(self, connection):
self.connection = connection
def change_balance(self, account_no, amount):
self.connection.execute(...)
# 这里不提交。
```
Service也不负责创建连接它复用同一个Repository从而保证多个SQL处于同一事务。
## 十、FOR UPDATE、锁与死锁
转账前先查询余额:
```sql
SELECT balance
FROM course_bank_account
WHERE account_no = %s
FOR UPDATE
```
`FOR UPDATE`会锁定选中的行,直到事务提交或回滚。它能防止两个并发事务同时读取相同旧余额后分别扣款。
这种“先锁定,再判断和修改”的做法属于悲观锁:代码假设并发冲突可能发生,因此提前取得排他性的行锁。
锁会持续到事务提交或回滚。事务范围过大,会让其他请求等待更久,因此事务中不应夹杂耗时的网络请求、人工操作或无关计算。
### 10.1 死锁
如果事务一先锁A再锁B事务二同时先锁B再锁A双方可能互相等待。数据库会检测死锁并中止其中一个事务。
降低死锁风险的常用办法:
- 多个事务按照统一顺序锁定资源,例如始终按账号升序;
- 缩短事务时间;
- 只锁真正需要修改的行;
- 应用捕获死锁或序列化失败,并按策略重试整个事务。
## 十一、批量操作
多条数据执行相同SQL时可以使用
```python
with connection.cursor() as cursor:
cursor.executemany(
"INSERT INTO course_bank_account VALUES (%s, %s, %s)",
accounts,
)
```
它比手工拼接多条SQL安全也明确表达“同一语句、不同参数”。它不等于无限制地一次提交海量数据生产中仍要根据数据量分批。
## 十二、完整示例
示例文件为[transaction_repository_example.py](./transaction_repository_example.py),包含:
- `AccountRepository`:建表、初始化、查询和更新;
- `TransferService`:金额检查、余额检查、扣款和入账;
- 成功事务转账200元并自动提交
- 失败事务先扣50元再抛出异常验证自动回滚
- 独立连接回查:证明提交和回滚的最终状态。
## 十三、准备配置
在第二课目录执行:
```powershell
Copy-Item .\config.example.toml .\config.toml
```
填写第一课使用的同一套专用练习数据库配置。`config.toml`已被项目`.gitignore`排除。
## 十四、运行方法与预期结果
激活已安装Psycopg的Conda环境
```powershell
conda activate python-test
python .\transaction_repository_example.py
```
关键结果应为:
```text
初始余额:
COURSE-A001小明余额1000.00
COURSE-A002小红余额500.00
成功转账 200 元后:
COURSE-A001小明余额800.00
COURSE-A002小红余额700.00
失败事务已回滚:模拟第二步失败,验证前一步更新会被回滚。
失败事务回滚后:
COURSE-A001小明余额800.00
COURSE-A002小红余额700.00
```
重复运行时,示例会先删除`COURSE-`前缀数据并重新初始化,因此结果保持一致。
## 十五、常见错误
### 15.1 Repository内部提交
这会破坏跨多个SQL的原子性。提交和回滚应由业务事务边界统一控制。
### 15.2 在with内部捕获业务异常
异常没有传播给连接上下文,可能导致错误提交。先让异常离开事务块,再在外层捕获。
### 15.3 失败后继续使用同一事务
PostgreSQL语句失败后当前事务通常进入失败状态。在回滚前继续执行SQL会收到`current transaction is aborted`一类错误。
### 15.4 使用float表示金额
二进制浮点数可能产生精度误差。课程金额使用`Decimal("200.00")`,数据库使用`NUMERIC(12, 2)`
### 15.5 先查询再更新却没有锁
单用户测试可能正常,但并发时可能发生余额覆盖或超额扣款。本课使用`FOR UPDATE`锁定账户行。
### 15.6 清理范围过大
不要使用无条件`DELETE``TRUNCATE`。示例和练习只删除指定前缀的课程数据。
## 十六、课堂练习
打开[practice.py](./practice.py),实现独立的钱包转账练习。题目已经明确:
1. 配置读取;
2. Repository方法
3. Service业务规则
4. 批量初始化;
5. 成功事务;
6. 失败回滚;
7. 回查、输出和异常处理。
## 十七、本课小结
- 事务把多条数据库操作组织成一个工作单元;
- ACID分别是原子性、一致性、隔离性和持久性
- 提交确认修改,回滚取消尚未提交的修改;
- PostgreSQL事务中的SQL失败后通常必须先回滚才能继续
- 隔离级别控制并发事务互相可见的范围;
- 事务边界应该覆盖完整业务操作;
- Repository封装SQL但不擅自提交
- Service组织业务规则但复用外部连接
- 异常必须先离开事务上下文才能触发自动回滚;
- `executemany()`适合相同SQL的多组参数
- `FOR UPDATE`用于锁定即将修改的数据;
- 金额应使用`Decimal`与数据库`NUMERIC`
## 十八、验收标准
- 能使用转账说明为什么需要事务;
- 能结合示例解释ACID四个特性
- 能区分提交、回滚和自动提交;
- 能简要说明脏读、不可重复读、幻读和丢失更新;
- 能解释连接上下文何时提交、何时回滚;
- 能说明为什么Repository不能随意提交
- 标准示例重复运行且结果一致;
- 练习的成功转账同时更新两个钱包;
- 模拟失败后第一条更新被完整回滚;
- SQL全部参数化
- 只操作课程专用表和指定前缀数据;
- 未提交真实`config.toml`

View File

@@ -0,0 +1,10 @@
# 复制本文件并重命名为 config.toml再填写本地练习数据库信息。
# config.toml 已加入项目 .gitignore不会被 Git 跟踪。
[postgresql]
host = "数据库主机"
port = 5432
dbname = "数据库名"
user = "用户名"
password = "密码"
connect_timeout = 10

View File

@@ -0,0 +1,130 @@
# 第 4-2 课练习:使用事务完成安全转账
#
# 本文件只提供题目,不包含导入、代码骨架、测试数据或参考答案。
# 本练习会创建 course_wallet_account 表,并只操作 PRACTICE- 前缀的数据。
# 请勿把表名改成现有业务表,也不要删除不属于本练习的数据。
# 第一部分:准备配置和异常
# 1. 导入 Decimal、Path、tomllib 和 psycopg。
# 2. 从 psycopg 导入 Connection。
# 3. 使用 Path(__file__).with_name("config.toml") 定义 CONFIG_PATH。
# 4. 定义 WalletError(Exception),类体只写 pass。
# 5. 实现 load_database_config(config_path),要求与第一课相同:
# - 文件不存在时抛出明确的 RuntimeError
# - 使用 tomllib 读取 [postgresql]
# - 配置节不是字典时抛出明确的 RuntimeError
# - 返回数据库配置字典。
# 第二部分:定义 WalletRepository
# 1. __init__(self, connection) 保存 Connection但不在 Repository 中创建连接。
# 2. create_table() 执行以下建表逻辑:
# - 表名 course_wallet_account
# - wallet_no VARCHAR(30) 主键;
# - owner_name VARCHAR(50) 非空;
# - balance NUMERIC(12, 2) 非空并且大于等于 0
# - 使用 CREATE TABLE IF NOT EXISTS。
# 3. reset_practice_wallets() 只删除 wallet_no LIKE 'PRACTICE-%' 的记录,
# 模式字符串必须作为参数传入,不得拼接 SQL。
# 4. add_wallets(wallets) 使用 with connection.cursor() 创建游标,
# 再调用 cursor.executemany() 批量新增。
# 5. find_balance_for_update(wallet_no) 使用参数化 SQL 和 FOR UPDATE
# - 找不到时抛出 WalletError("钱包不存在:{wallet_no}")
# - 找到时返回 Decimal 余额。
# 6. change_balance(wallet_no, amount) 使用 balance = balance + %s 更新余额;
# - cursor.rowcount 不等于 1 时抛出钱包不存在异常;
# - 本方法不调用 commit() 或 rollback()。
# 7. find_practice_wallets() 查询 PRACTICE- 前缀记录,按 wallet_no 排序并返回结果。
#
# Repository 边界提醒:
# - Repository 负责 SQL 和结果转换;
# - 不要在 add_wallets()、change_balance() 内提交事务;
# - 多个 Repository 操作需要由外层业务事务统一提交或回滚。
# 第三部分:定义 WalletTransferService
# 1. __init__(self, repository) 保存 WalletRepository。
# 2. transfer(self, source_no, target_no, amount) 按顺序执行:
# - amount <= 0 时抛出 WalletError("转账金额必须大于 0。")
# - 调用 find_balance_for_update(source_no),保存 source_balance
# - 调用 find_balance_for_update(target_no),确认目标钱包存在并锁定;
# - source_balance < amount 时抛出 WalletError("钱包余额不足。")
# - 调用 change_balance(source_no, -amount)
# - 调用 change_balance(target_no, amount)。
# 3. transfer() 不调用 commit() 或 rollback()。
# 第四部分:准备练习数据
# 1. 定义 prepare_data(database_config)。
# 2. 使用 with psycopg.connect(**database_config) as connection 管理事务。
# 3. 创建 WalletRepository(connection)。
# 4. 依次调用 create_table() 和 reset_practice_wallets()。
# 5. 调用 add_wallets() 批量新增:
# - ("PRACTICE-W001", "张三", Decimal("800.00"))
# - ("PRACTICE-W002", "李四", Decimal("300.00"))
# 6. 正常离开 with让初始化事务自动提交。
# 第五部分:完成成功事务
# 1. 定义 run_successful_transfer(database_config)。
# 2. 使用一个连接上下文创建 Repository 和 Service。
# 3. 调用:
# service.transfer("PRACTICE-W001", "PRACTICE-W002", Decimal("150.00"))
# 4. 正常离开 with不要手工调用 commit()。
# 5. 成功后两个余额应分别为 650.00 和 450.00。
# 第六部分:完成失败事务并验证回滚
# 1. 定义 run_failed_transfer(database_config)。
# 2. 在 try 中创建连接上下文和 Repository。
# 3. 先调用:
# repository.change_balance("PRACTICE-W001", Decimal("-50.00"))
# 4. 紧接着抛出 WalletError("模拟入账失败。")。
# 5. 在连接上下文外捕获 WalletError并输出
# “失败事务已回滚:模拟入账失败。”
# 6. 不要在 except 前捕获并吞掉异常,否则连接上下文无法自动回滚。
# 7. 回查后余额必须仍为 650.00 和 450.00,而不是 600.00 和 450.00。
# 第七部分:查询与 main() 输出
# 1. 定义 query_wallets(database_config),使用独立连接查询并返回练习钱包。
# 2. 定义 print_wallets(title, wallets),按以下格式逐行输出:
# “PRACTICE-W001张三余额800.00”。
# 3. main() 依次调用:
# - load_database_config(CONFIG_PATH)
# - prepare_data(database_config)
# - print_wallets("初始余额:", query_wallets(database_config))
# - run_successful_transfer(database_config)
# - print_wallets("成功转账 150 元后:", query_wallets(database_config))
# - run_failed_transfer(database_config)
# - print_wallets("失败事务回滚后:", query_wallets(database_config))。
# 4. 分别捕获配置异常和 psycopg.Error并输出中文场景说明。
# 5. 添加程序入口判断并调用 main()。
# 预期关键输出:
# 初始余额:
# PRACTICE-W001张三余额800.00
# PRACTICE-W002李四余额300.00
# 成功转账 150 元后:
# PRACTICE-W001张三余额650.00
# PRACTICE-W002李四余额450.00
# 失败事务已回滚:模拟入账失败。
# 失败事务回滚后:
# PRACTICE-W001张三余额650.00
# PRACTICE-W002李四余额450.00
# 自查清单:
# 1. Repository 是否复用外部传入的 Connection
# 2. Repository 和 Service 是否都没有自行提交事务?
# 3. 扣款和入账是否处于同一个连接上下文?
# 4. 失败异常是否离开连接 with 后才被捕获?
# 5. 是否使用 FOR UPDATE 锁定待修改账户?
# 6. 批量初始化是否使用 executemany()
# 7. 所有值是否通过参数化查询传入?
# 8. 是否只清理 PRACTICE- 前缀的练习数据?
# 最终验收标准:
# 1. practice.py 通过语法检查并能重复运行;
# 2. 初始化、成功转账和失败回滚的余额符合预期;
# 3. 失败事务没有保留第一条扣款更新;
# 4. Repository 只负责数据访问Service 负责业务规则;
# 5. 外层连接上下文控制事务提交和回滚;
# 6. SQL 全部参数化,不拼接业务数据;
# 7. 只操作本课专用表和 PRACTICE- 前缀数据;
# 8. config.toml 与真实连接信息没有进入 Git。

View File

@@ -0,0 +1,3 @@
# 第二课继续使用 Psycopg 3不新增第三方框架。
# 若使用 Conda可以在课程环境中安装 psycopg 或 psycopg-c。
psycopg>=3,<4

View File

@@ -0,0 +1,214 @@
"""第 4-2 课示例:使用事务和 Repository 完成安全转账。"""
from decimal import Decimal
from pathlib import Path
import tomllib
import psycopg
from psycopg import Connection
from psycopg.rows import dict_row
CONFIG_PATH = Path(__file__).with_name("config.toml")
class TransferError(Exception):
"""表示转账过程中可以预期的业务失败。"""
def load_database_config(config_path: Path) -> dict[str, str | int]:
"""读取第二课本地 TOML 数据库配置。"""
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
class AccountRepository:
"""封装账户表 SQL但不自行提交或回滚事务。"""
def __init__(self, connection: Connection) -> None:
self.connection = connection
def create_table(self) -> None:
"""创建本课专用表;表已存在时保持不变。"""
self.connection.execute(
"""
CREATE TABLE IF NOT EXISTS course_bank_account (
account_no VARCHAR(30) PRIMARY KEY,
owner_name VARCHAR(50) NOT NULL,
balance NUMERIC(12, 2) NOT NULL CHECK (balance >= 0)
)
"""
)
def reset_course_accounts(self) -> None:
"""只清理 COURSE- 前缀的课程数据,避免影响其他记录。"""
self.connection.execute(
"DELETE FROM course_bank_account WHERE account_no LIKE %s",
("COURSE-%",),
)
def add_accounts(self, accounts: list[tuple[str, str, Decimal]]) -> None:
"""使用 executemany() 批量新增课程账户。"""
# executemany() 是 Cursor 的方法,因此显式创建并关闭游标。
with self.connection.cursor() as cursor:
cursor.executemany(
"""
INSERT INTO course_bank_account (account_no, owner_name, balance)
VALUES (%s, %s, %s)
""",
accounts,
)
def get_balance_for_update(self, account_no: str) -> Decimal:
"""查询并锁定账户,防止并发事务同时修改同一余额。"""
result = self.connection.execute(
"""
SELECT balance
FROM course_bank_account
WHERE account_no = %s
FOR UPDATE
""",
(account_no,),
).fetchone()
if result is None:
raise TransferError(f"账户不存在:{account_no}")
return result[0]
def change_balance(self, account_no: str, amount: Decimal) -> None:
"""使用数据库加法更新余额,并检查目标账户是否存在。"""
cursor = self.connection.execute(
"""
UPDATE course_bank_account
SET balance = balance + %s
WHERE account_no = %s
""",
(amount, account_no),
)
if cursor.rowcount != 1:
raise TransferError(f"账户不存在:{account_no}")
def find_course_accounts(self) -> list[dict[str, object]]:
"""按账号查询课程账户,并以字典行返回。"""
cursor = self.connection.cursor(row_factory=dict_row)
try:
cursor.execute(
"""
SELECT account_no, owner_name, balance
FROM course_bank_account
WHERE account_no LIKE %s
ORDER BY account_no
""",
("COURSE-%",),
)
return list(cursor.fetchall())
finally:
cursor.close()
class TransferService:
"""组织转账业务规则;事务由调用它的连接上下文统一管理。"""
def __init__(self, repository: AccountRepository) -> None:
self.repository = repository
def transfer(self, source_no: str, target_no: str, amount: Decimal) -> None:
"""在同一事务中完成扣款与入账。"""
if amount <= 0:
raise TransferError("转账金额必须大于 0。")
source_balance = self.repository.get_balance_for_update(source_no)
self.repository.get_balance_for_update(target_no)
if source_balance < amount:
raise TransferError("账户余额不足。")
self.repository.change_balance(source_no, -amount)
self.repository.change_balance(target_no, amount)
def print_accounts(title: str, accounts: list[dict[str, object]]) -> None:
"""输出当前课程账户余额。"""
print(title)
for account in accounts:
print(
f"{account['account_no']}{account['owner_name']}"
f"余额:{account['balance']}"
)
def prepare_data(database_config: dict[str, str | int]) -> None:
"""创建专用表并重置本课固定数据。"""
with psycopg.connect(**database_config) as connection:
repository = AccountRepository(connection)
repository.create_table()
repository.reset_course_accounts()
repository.add_accounts(
[
("COURSE-A001", "小明", Decimal("1000.00")),
("COURSE-A002", "小红", Decimal("500.00")),
]
)
def run_successful_transfer(database_config: dict[str, str | int]) -> None:
"""演示正常离开连接上下文时自动提交事务。"""
with psycopg.connect(**database_config) as connection:
service = TransferService(AccountRepository(connection))
service.transfer("COURSE-A001", "COURSE-A002", Decimal("200.00"))
def run_failed_transfer(database_config: dict[str, str | int]) -> None:
"""演示异常离开连接上下文时自动回滚整个事务。"""
try:
with psycopg.connect(**database_config) as connection:
repository = AccountRepository(connection)
# 先执行一条成功更新,再主动触发业务异常。
# 外层 with 会回滚,因此这 50 元扣款不会保留下来。
repository.change_balance("COURSE-A001", Decimal("-50.00"))
raise TransferError("模拟第二步失败,验证前一步更新会被回滚。")
except TransferError as error:
print(f"失败事务已回滚:{error}")
def query_accounts(
database_config: dict[str, str | int],
) -> list[dict[str, object]]:
"""使用独立连接回查已经提交的数据。"""
with psycopg.connect(**database_config) as connection:
return AccountRepository(connection).find_course_accounts()
def main() -> None:
"""依次演示初始化、提交、回滚和回查。"""
try:
database_config = load_database_config(CONFIG_PATH)
prepare_data(database_config)
print_accounts("初始余额:", query_accounts(database_config))
run_successful_transfer(database_config)
print_accounts("成功转账 200 元后:", query_accounts(database_config))
run_failed_transfer(database_config)
print_accounts("失败事务回滚后:", query_accounts(database_config))
except (OSError, tomllib.TOMLDecodeError, RuntimeError) as error:
print(f"配置读取失败:{error}")
except psycopg.Error as error:
print(f"数据库访问失败:{error}")
if __name__ == "__main__":
main()