Skip to content

Commit 32e9f6f

Browse files
committed
refactor: 简化StatusBadge组件并优化N+1查询
- 重构StatusBadge组件,统一状态颜色和标签映射 - 优化库存列表N+1查询,使用批量查询替代循环查询 - 修复删除会话时保留当前会话 - 更新Redis缓存失效策略文档 - 优化库存分页逻辑
1 parent 4fcb44b commit 32e9f6f

File tree

9 files changed

+336
-184
lines changed

9 files changed

+336
-184
lines changed

app/api/inventory.py

Lines changed: 55 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -235,30 +235,30 @@ def _add_specification(item_dict: dict) -> dict:
235235
return item_dict
236236

237237

238-
def _add_user_names(db: Session, item_dict: dict) -> dict:
239-
"""Add user names to inventory response dict"""
240-
# Get borrower name
241-
if item_dict.get("borrower_id"):
242-
borrower = db.get(User, item_dict["borrower_id"])
243-
item_dict["borrower_name"] = borrower.full_name or borrower.username if borrower else None
244-
else:
245-
item_dict["borrower_name"] = None
238+
# def _add_user_names(db: Session, item_dict: dict) -> dict:
239+
# """Add user names to inventory response dict"""
240+
# # Get borrower name
241+
# if item_dict.get("borrower_id"):
242+
# borrower = db.get(User, item_dict["borrower_id"])
243+
# item_dict["borrower_name"] = borrower.full_name or borrower.username if borrower else None
244+
# else:
245+
# item_dict["borrower_name"] = None
246246

247-
# Get last borrower name
248-
if item_dict.get("last_borrower_id"):
249-
last_borrower = db.get(User, item_dict["last_borrower_id"])
250-
item_dict["last_borrower_name"] = last_borrower.full_name or last_borrower.username if last_borrower else None
251-
else:
252-
item_dict["last_borrower_name"] = None
247+
# # Get last borrower name
248+
# if item_dict.get("last_borrower_id"):
249+
# last_borrower = db.get(User, item_dict["last_borrower_id"])
250+
# item_dict["last_borrower_name"] = last_borrower.full_name or last_borrower.username if last_borrower else None
251+
# else:
252+
# item_dict["last_borrower_name"] = None
253253

254-
# Get created by name
255-
if item_dict.get("created_by_id"):
256-
created_by = db.get(User, item_dict["created_by_id"])
257-
item_dict["created_by_name"] = created_by.full_name or created_by.username if created_by else None
258-
else:
259-
item_dict["created_by_name"] = None
254+
# # Get created by name
255+
# if item_dict.get("created_by_id"):
256+
# created_by = db.get(User, item_dict["created_by_id"])
257+
# item_dict["created_by_name"] = created_by.full_name or created_by.username if created_by else None
258+
# else:
259+
# item_dict["created_by_name"] = None
260260

261-
return item_dict
261+
# return item_dict
262262

263263

264264
# ==================== Named Routes (BEFORE /{id}) ====================
@@ -757,11 +757,41 @@ def norm_field(field):
757757
else:
758758
items = db.exec(base.order_by(order_expr)).all()
759759

760+
# ================= 性能优化核心:批量查询用户(消除 N+1) =================
761+
# 遍历当前页的数据,收集所有需要查询的用户 ID
762+
user_ids = set()
763+
for item in items:
764+
if item.borrower_id:
765+
user_ids.add(item.borrower_id)
766+
if item.last_borrower_id:
767+
user_ids.add(item.last_borrower_id)
768+
if item.created_by_id:
769+
user_ids.add(item.created_by_id)
770+
771+
# 用一条 SQL IN 语句,一次性查出这 50 条数据对应的所有用户
772+
users_map = {}
773+
if user_ids:
774+
users = db.exec(select(User).where(User.id.in_(user_ids))).all()
775+
# 构建内存字典 { user_id: "姓名" },查找速度是 O(1)
776+
users_map = {u.id: (u.full_name or u.username) for u in users}
777+
778+
# 在内存中完成数据组装,绝不再向数据库发请求
779+
result_data = []
780+
for item in items:
781+
# 序列化单条数据
782+
item_dict = InventoryResponse.model_validate(item).model_dump()
783+
item_dict = _add_specification(item_dict)
784+
785+
# 直接从内存字典中塞入用户名字
786+
item_dict["borrower_name"] = users_map.get(item.borrower_id)
787+
item_dict["last_borrower_name"] = users_map.get(item.last_borrower_id)
788+
item_dict["created_by_name"] = users_map.get(item.created_by_id)
789+
790+
result_data.append(item_dict)
791+
# =========================================================================
792+
760793
result = {
761-
"data": [
762-
_add_user_names(db, _add_specification(InventoryResponse.model_validate(i).model_dump()))
763-
for i in items
764-
],
794+
"data": result_data,
765795
"total": total,
766796
"skip": skip,
767797
"limit": limit,

app/api/user_sessions.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717

1818
router = APIRouter(prefix="/sessions", tags=["Sessions"])
1919

20+
# 导入 get_current_session 用于获取当前会话
21+
from app.api.deps import get_current_session
22+
2023

2124
class SessionResponse(BaseModel):
2225
"""Session response model"""
@@ -87,13 +90,16 @@ def delete_session(
8790

8891
@router.delete("/")
8992
def delete_all_sessions(
93+
request: Request,
9094
db: Session = Depends(get_db),
91-
current_user: User = Depends(get_current_user)
95+
current_user: User = Depends(get_current_user),
96+
current_session: UserSession = Depends(get_current_session)
9297
):
93-
"""Delete all sessions for current user"""
98+
"""Delete all sessions for current user except the current session"""
9499
sessions = db.exec(
95100
select(UserSession)
96101
.where(UserSession.user_id == current_user.id)
102+
.where(UserSession.token_hash != current_session.token_hash)
97103
).all()
98104

99105
deleted_count = 0

app/models/user.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ class UserBase(SQLModel):
2525

2626
class User(UserBase, table=True):
2727
"""User database model"""
28+
__tablename__ = "users"
29+
2830
id: Optional[int] = Field(default=None, primary_key=True)
2931
password_hash: str
3032
created_at: datetime = Field(default_factory=datetime.utcnow)

docs/plans/gemini.md

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
### 1. 高危安全漏洞修复 (Security)
2+
3+
根据深度安全审计,当前库存模块存在多个越权(IDOR)和并发业务逻辑漏洞。
4+
5+
#### 1.1 修复库存详情接口的未授权访问 (IDOR)
6+
7+
`inventory.py` 中,`get_inventory` 接口完全没有使用认证依赖,任何未登录的外部访问者都可以读取库存数据。
8+
9+
**🔧 修复代码 (`app/api/inventory.py` 约777行):**
10+
11+
**Python**
12+
13+
```
14+
@router.get("/{inventory_id}", response_model=InventoryResponse)
15+
def get_inventory(
16+
inventory_id: int,
17+
db: Session = Depends(get_db),
18+
current_user: User = Depends(get_current_user) # 修复:添加当前用户认证依赖
19+
):
20+
"""Get inventory item by ID"""
21+
item = _get_by_id(db, inventory_id)
22+
# ... 后续逻辑保持不变
23+
```
24+
25+
#### 1.2 修复借用操作的竞态条件 (TOCTOU)
26+
27+
`borrow_item` 函数在检查库存状态和更新库存状态之间存在时间差,如果存在并发请求,会导致同一件物品被多个人同时借出。
28+
29+
**🔧 修复代码 (`app/api/inventory.py` 约833行):**
30+
需引入悲观锁(Pessimistic Locking),在查询时锁定该行数据:
31+
32+
**Python**
33+
34+
```
35+
from sqlmodel import select
36+
37+
@router.post("/{inventory_id}/borrow", response_model=InventoryResponse)
38+
def borrow_item(
39+
inventory_id: int,
40+
current_user: User = Depends(get_current_user),
41+
db: Session = Depends(get_db),
42+
):
43+
# 修复:使用 with_for_update() 锁定该行,防止并发修改
44+
statement = select(Inventory).where(Inventory.id == inventory_id).with_for_update()
45+
item = db.exec(statement).first()
46+
47+
if not item:
48+
raise HTTPException(status_code=404, detail="Inventory item not found")
49+
50+
if item.status != InventoryStatus.IN_STOCK:
51+
raise HTTPException(status_code=400, detail=f"Cannot borrow item with status: {item.status}")
52+
53+
# ... 执行借出逻辑与 db.commit()
54+
```
55+
56+
---
57+
58+
### 2. 严重性能瓶颈优化 (Performance)
59+
60+
#### 2.1 消除 N+1 查询风暴
61+
62+
在原代码的 `_add_user_names` 辅助函数中,针对列表中的每一项数据,都会发起最多 3 次 `db.get(User, id)` 查询。如果列表包含 100 条数据,将额外触发 300 次数据库查询。
63+
64+
**🔧 优化方案:改用批量映射获取用户信息**
65+
废弃原本针对单条记录查询的 `_add_user_names`,在 `list_inventory` 接口中统一进行批量抓取:
66+
67+
**Python**
68+
69+
```
70+
# 替换 list_inventory 中原有的列表推导式
71+
items = db.exec(base.order_by(order_expr).offset(skip).limit(limit)).all()
72+
73+
# 1. 收集当前页面所有涉及到的 user_id
74+
user_ids = set()
75+
for item in items:
76+
if item.borrower_id: user_ids.add(item.borrower_id)
77+
if item.last_borrower_id: user_ids.add(item.last_borrower_id)
78+
if item.created_by_id: user_ids.add(item.created_by_id)
79+
80+
# 2. 一次性查出所有 User
81+
users_map = {}
82+
if user_ids:
83+
users = db.exec(select(User).where(User.id.in_(user_ids))).all()
84+
users_map = {u.id: (u.full_name or u.username) for u in users}
85+
86+
# 3. 组装数据,不再进行额外的 DB 查询
87+
result_data = []
88+
for item in items:
89+
item_dict = _add_specification(InventoryResponse.model_validate(item).model_dump())
90+
item_dict["borrower_name"] = users_map.get(item.borrower_id)
91+
item_dict["last_borrower_name"] = users_map.get(item.last_borrower_id)
92+
item_dict["created_by_name"] = users_map.get(item.created_by_id)
93+
result_data.append(item_dict)
94+
```
95+
96+
#### 2.2 修复拼音排序导致的全表加载
97+
98+
当用户使用拼音排序时,原始代码逻辑是 `items = db.exec(base.order_by(order_expr)).all()`,即使设定了 `limit` 也会拉取全部数据到内存中,有极大的 OOM 内存溢出风险。
99+
由于代码中已经添加了 `pinyin_sort_field_map`,需确保 `offset``limit` 在所有排序场景下都生效:
100+
101+
**Python**
102+
103+
```
104+
# 修复 inventory.py 约 740 行左右的逻辑:
105+
if limit > 0:
106+
items = db.exec(base.order_by(order_expr).offset(skip).limit(limit)).all()
107+
else:
108+
items = db.exec(base.order_by(order_expr)).all()
109+
```
110+
111+
---
112+
113+
### 3. 前端质量与架构同步优化 (Frontend Architecture)
114+
115+
根据《代码质量.md》,由于后端 API 的调整(特别是去除了冗余查询和提升了响应速度),前端也应同步进行瘦身以避免页面卡顿:
116+
117+
1. **组件职责拆分** :当前 `Inventory.tsx` 长达近 900 行。应当将“新增入库”和“编辑库存”的弹窗(Modal)完全抽离为独立的组件,比如 `<AddInventoryModal />`。这能阻止弹窗内用户的输入导致整个大型数据表格重渲染。
118+
2. **引入现代数据获取库** :目前的 React 页面依然在使用 `useEffect` 和手动状态管理(`loading`, `data`, `error`)来拉取数据。建议将后端刚优化好的分页与缓存逻辑搭配 `@tanstack/react-query` 使用,以原生支持请求防抖与数据缓存。

docs/plans/redis_cache_optimization.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,63 @@ def invalidate_cas_info(cas_number: str) -> None:
177177
- 新建订单时,自动填充 CAS 对应的名称、类别、品牌
178178
- 库存列表中相同 CAS 号只查一次数据库
179179

180+
### 4.3 缓存失效策略
181+
182+
缓存的最大挑战是**缓存失效(Cache Invalidation)**。以下是针对不同字段的处理策略:
183+
184+
#### 缓存字段与失效规则
185+
186+
| 缓存字段 | 说明 | 更新时处理 |
187+
|----------|------|------------|
188+
| `name` | 中文名称 | 删除缓存 |
189+
| `english_name` | 英文名称 | 删除缓存 |
190+
| `brand` | 品牌 | 删除缓存 |
191+
| `category` | 分类 | 删除缓存 |
192+
| `alias` | 别名 | 删除缓存 |
193+
| `is_hazardous` | 是否危化品 | 删除缓存 |
194+
| `unit` | 单位 | 删除缓存 |
195+
| `specification` | 规格(初始值) | 删除缓存 |
196+
| `initial_quantity` | 初始数量(默认值参考) | 删除缓存 |
197+
| `price` | 价格(参考价) | 删除缓存 |
198+
199+
#### 失效策略:写入时删除(Write Invalidate)
200+
201+
```python
202+
def update_cas_info(cas_number: str, info: dict):
203+
"""更新 CAS 信息"""
204+
# 1. 更新数据库
205+
db.update(...)
206+
207+
# 2. 删除缓存(强制下次查询回源数据库)
208+
invalidate_cas_info(cas_number)
209+
210+
def create_order(cas_number: str, order_data: dict):
211+
"""创建订单时填充 CAS 信息"""
212+
# 1. 查询 CAS 信息(优先缓存)
213+
cas_info = get_cached_cas_info(cas_number)
214+
if not cas_info:
215+
cas_info = db.query(CASInfo).filter_by(cas_number=cas_number).first()
216+
if cas_info:
217+
# 2. 写入缓存
218+
cache_cas_info(cas_number, cas_info.to_dict())
219+
220+
# 3. 填充订单
221+
order_data['name'] = cas_info['name']
222+
order_data['brand'] = cas_info['brand']
223+
```
224+
225+
#### TTL 作为最终保障
226+
227+
```python
228+
CAS_INFO_TTL = 86400 # 24小时
229+
```
230+
231+
#### 需要失效缓存的场景
232+
233+
1. **库存编辑** - 修改了 CAS 对应的名称/品牌/分类
234+
2. **手动入库** - 新增 CAS 信息
235+
3. **批量导入** - 导入新的 CAS 数据
236+
180237
### 5.2 方案二:分类/品牌列表缓存
181238

182239
**目标**:缓存分类和品牌列表,快速加载下拉选项
@@ -321,12 +378,20 @@ def invalidate_user_cache(user_id: int) -> None:
321378
- [X] Session 缓存功能
322379
- [X] 设备管理后端 API
323380
- [X] 禁用用户时清理 Session 缓存
381+
- [X] 缓存失效策略文档(写入时删除 + TTL)
382+
383+
### 缓存失效策略
384+
385+
- [X] `invalidate_cas_info` 函数定义
386+
- [X] 缓存字段列表(name, english_name, brand, category, alias, is_hazardous, unit)
387+
- [X] 失效场景说明(库存编辑、手动入库、批量导入)
324388

325389
### 待实现
326390

327391
- [ ] CAS 基础信息缓存
328392
- [ ] 分类/品牌列表缓存
329393
- [ ] 用户权限缓存
394+
- [ ] 实现缓存失效调用(在库存编辑、手动入库、批量导入时)
330395

331396
---
332397

0 commit comments

Comments
 (0)