Compare commits
7 Commits
991d713777
...
7604c353fb
| Author | SHA1 | Date | |
|---|---|---|---|
| 7604c353fb | |||
| d32a3dce49 | |||
| d59f732a0e | |||
| 3a6ab7d756 | |||
| 8c54a38f55 | |||
| 69f3e35d14 | |||
| cc199081f9 |
4
.gitignore
vendored
4
.gitignore
vendored
@ -44,6 +44,10 @@ backend/data/
|
||||
*.log
|
||||
*.tmp
|
||||
|
||||
# ===== 部署产物 =====
|
||||
*.tar.gz
|
||||
*.sql.gz
|
||||
|
||||
# ===== 排除独立项目/个人文档 =====
|
||||
track1.0.md
|
||||
PROJECT_STATUS.md
|
||||
|
||||
396
PROJECT_ANALYSIS_REPORT.md
Normal file
396
PROJECT_ANALYSIS_REPORT.md
Normal file
@ -0,0 +1,396 @@
|
||||
# Track Production — 项目现状与业务逻辑分析报告
|
||||
|
||||
> **受众:** 高级开发工程师 / 系统架构师
|
||||
> **日期:** 2026-08-12
|
||||
> **版本:** v0.1.0
|
||||
|
||||
---
|
||||
|
||||
## 一、技术栈与架构
|
||||
|
||||
### 1.1 项目整体拓扑
|
||||
|
||||
```
|
||||
track/
|
||||
├── backend/ # Python FastAPI 后端服务
|
||||
├── frontend/ # React SPA(Tauri 桌面壳 + Web 管理端)
|
||||
└── track-uniapp/ # UniApp 移动端(iOS/Android/H5)
|
||||
```
|
||||
|
||||
### 1.2 后端技术栈
|
||||
|
||||
| 层面 | 技术 | 版本 |
|
||||
|------|------|------|
|
||||
| 框架 | FastAPI (ASGI) | 0.141 |
|
||||
| ASGI 服务器 | Uvicorn | 0.52 |
|
||||
| ORM | SQLAlchemy 2.0(异步) | 2.0.51 |
|
||||
| 数据库 | PostgreSQL(通过 asyncpg) | pg 15+ |
|
||||
| 迁移工具 | Alembic | 1.18 |
|
||||
| 认证 | python-jose (JWT) + Werkzeug scrypt | HS256 |
|
||||
| 密码验证 | passlib bcrypt + Werkzeug scrypt(MOM 对接) | — |
|
||||
| 二维码 | qrcode[pil] + Pillow | 8.2 / 12.3 |
|
||||
| 标签打印 | PIL 图像合成 + Socket TSPL 协议 | — |
|
||||
| 序列化 | Pydantic v2 | 2.13 |
|
||||
|
||||
**架构模式:分层架构 (Layered Architecture)**
|
||||
- `api/v1/endpoints/` — 路由/控制器层
|
||||
- `services/` — 业务逻辑服务层
|
||||
- `models/` — SQLAlchemy ORM 数据模型
|
||||
- `schemas/` — Pydantic 请求/响应 DTO
|
||||
- `core/` — 横切关注点(配置、数据库连接池、安全、时间工具)
|
||||
|
||||
### 1.3 前端技术栈
|
||||
|
||||
| 层面 | 技术 | 版本 |
|
||||
|------|------|------|
|
||||
| UI 框架 | React 19 + TypeScript 6.0 | 19.2 |
|
||||
| 桌面壳 | Tauri 2.x(Rust 原生窗口) | 2.11 |
|
||||
| 构建工具 | Vite 8.2 | — |
|
||||
| UI 库 | Ant Design 6 + Tailwind CSS 4 | 6.5 / 4.3 |
|
||||
| 状态管理 | Zustand 5 | 5.0 |
|
||||
| 路由 | React Router v7 | 7.18 |
|
||||
| HTTP 客户端 | Axios | 1.19 |
|
||||
| 扫码 | html5-qrcode(懒加载) | 2.3 |
|
||||
|
||||
**架构模式:SPA + 路由级代码分割(React.lazy)**
|
||||
|
||||
### 1.4 移动端 (track-uniapp)
|
||||
|
||||
基于 **UniApp (Vue)** 构建,目前包含 5 个页面:
|
||||
- 扫码干活 (`pages/scan/index`) — 核心工作入口
|
||||
- 我的任务 (`pages/tasks/index`) — 个人任务看板
|
||||
- 消息通知 (`pages/notify/index`)
|
||||
- 个人中心 (`pages/profile/index`)
|
||||
- 登录页 (`pages/login/login`)
|
||||
|
||||
底部 TabBar 4 个入口,与 PC 端 AppLayout 页面结构对应。
|
||||
|
||||
---
|
||||
|
||||
## 二、数据模型与实体关系
|
||||
|
||||
### 2.1 核心 E-R 图
|
||||
|
||||
```
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ production_orders│ 1──N │ products │
|
||||
│ - id (UUID PK) │ │ - id (UUID PK) │
|
||||
│ - order_no[UQ] │ │ - serial_number │
|
||||
│ - customer_info │ │ [16位HEX, UQ] │
|
||||
│ - status │ │ - order_id (FK) │←──── FK (nullable in latest)
|
||||
│ - created_at │ │ - material_id* │ * = 逻辑外键→MOM
|
||||
└──────────────────┘ │ - material_name │ (material_base)
|
||||
│ - spec_model │
|
||||
│ - category │
|
||||
│ - material_type │
|
||||
│ - overall_status │ 宏观: 备货/生产/测试/维修/在库
|
||||
│ - parent_product │──┐ 自引用 FK
|
||||
│ - current_location│ │ (持有者/仓库)
|
||||
│ - status │ │
|
||||
│ - created_at │ │
|
||||
└────────┬─────────┘ │
|
||||
│ 1 │
|
||||
│ │
|
||||
│ N │
|
||||
┌────────▼─────────┐ │
|
||||
│ tasks │◄─┘
|
||||
│ - id (UUID PK) │
|
||||
│ - product_id(FK) │
|
||||
│ - parent_task_id │──┐ 自引用 FK(无限嵌套)
|
||||
│ - task_name │ │
|
||||
│ - assignee_id* │ │ * = 逻辑外键→MOM sys_user
|
||||
│ - status │ │
|
||||
│ - task_type │ │ TRANSFER/SPAWN/RECOVERY
|
||||
│ - is_rework │ │
|
||||
│ - reject_reason │ │
|
||||
│ - remark │ │
|
||||
│ - received_at │ │
|
||||
│ - completed_at │ │
|
||||
│ - created_at │ │
|
||||
└──┬──────┬─────────┘ │
|
||||
│ N │ N │
|
||||
┌────────▼─┐ ┌──▼──────────┐ │
|
||||
│task_records│ │ task_logs │ │
|
||||
│- remark │ │ - action_type│ │
|
||||
│- images │ │ - operator_id│ │
|
||||
│- created │ │ - remark │ │
|
||||
└───────────┘ │ - created │ │
|
||||
└─────────────┘ │
|
||||
┌────────────────────────┐
|
||||
│ notifications │
|
||||
│ - user_id (目标用户) │
|
||||
│ - type (TRANSFER/REJECT│
|
||||
│ /COMMENT) │
|
||||
│ - task_id (FK→tasks) │
|
||||
│ - is_read │
|
||||
└────────────────────────┘
|
||||
┌────────────────────────┐
|
||||
│ product_messages │
|
||||
│ - product_id (FK) │
|
||||
│ - operator_id │
|
||||
│ - content │
|
||||
└────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 关键设计决策
|
||||
|
||||
1. **逻辑外键 vs 物理外键**:`material_id` 和 `assignee_id` 均使用逻辑外键(只存 ID,无 DB 级约束),指向外部 MOM 老系统——允许老系统数据独立演进,避免跨库约束。
|
||||
|
||||
2. **物料快照机制**:Product 表存储 `material_name/spec_model/category/material_type` 完整快照,创建时一次性写入。这意味着即使老系统后续修改物料数据,已经流转的产品标签不会受影响。
|
||||
|
||||
3. **任务树自引用**:Task 通过 `parent_task_id` 自引用实现无限层级嵌套。三种任务类型定义了分支行为:
|
||||
- `TRANSFER`:主线转交(主分支)
|
||||
- `SPAWN`:协助分支(不改变父任务状态)
|
||||
- `RECOVERY`:撤回后接力节点
|
||||
|
||||
4. **16 位 HEX 产品身份证**:通过 PostgreSQL SEQUENCE 单调递增生成,格式 `%016X`,理论上限 2^64(实际序列值)。
|
||||
|
||||
---
|
||||
|
||||
## 三、核心业务流程
|
||||
|
||||
### 3.1 任务生命周期状态机
|
||||
|
||||
```
|
||||
┌──────────┐
|
||||
│ PENDING │ 待接收
|
||||
└────┬─────┘
|
||||
│ receive
|
||||
▼
|
||||
┌──────────┐
|
||||
┌────────│ WIP │◄───────────────┐
|
||||
│ └────┬─────┘ │
|
||||
│ spawn │ transfer/complete │ recall
|
||||
│ (协助分支) │ (完工裂变转交) │ (撤回转交→CANCELED)
|
||||
▼ ▼ │
|
||||
┌──────────┐ ┌───────────┐ │
|
||||
│PENDING │ │ COMPLETED │ │
|
||||
│(SPAWN) │ │(终态) │ │
|
||||
└──┬───────┘ └───────────┘ │
|
||||
│ reject │
|
||||
▼ │
|
||||
┌──────────┐ ┌──────────────────────────────┘
|
||||
│ REJECTED │ │
|
||||
│ (终态) │ │
|
||||
└────┬─────┘ │
|
||||
│ 自动创建返工│
|
||||
▼ │
|
||||
┌──────────┐ │
|
||||
│ PENDING │ │
|
||||
│(REWORK) │──┘ 返工任务回到 WIP 循环
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
### 3.2 核心业务接口说明
|
||||
|
||||
| 接口 | 作用 | 关键逻辑 |
|
||||
|------|------|----------|
|
||||
| `POST /tasks/{id}/receive` | 工人确认接收 | PENDING→WIP,同步产品位置+宏观状态 |
|
||||
| `POST /tasks/{id}/transfer` | 完工裂变转交 | WIP→COMPLETED,支持多分支 `next_tasks`,裂变子任务挂在当前任务下 |
|
||||
| `POST /tasks/{id}/complete` | 旧版单步完结 | 保留兼容,内部委托到 transfer 逻辑 |
|
||||
| `POST /tasks/{id}/end` | 结束协助分支 | 仅 SPAWN 类型可用,不创建下游 |
|
||||
| `POST /tasks/{id}/reject` | 品质驳回 | →REJECTED,自动创建返工任务给上游 |
|
||||
| `POST /tasks/{id}/recall` | 撤回未接收的转交 | PENDING→CANCELED,创建 RECOVERY 接力 |
|
||||
| `POST /tasks/{id}/spawn` | 派发并行协助 | 父任务保持 WIP,创建 PENDING SPAWN 子任务 |
|
||||
| `GET /products/scan/{sn}` | 扫码查询 | 返回产品信息+完整递归任务树+人员姓名映射 |
|
||||
| `PATCH /products/scan/{sn}/status` | 更新宏观状态 | 权限校验:SUPER_ADMIN 或当前主线负责人 |
|
||||
| `POST /print/execute` | 物理标签打印 | 480×360 工业排版→二值化→TSPL→Socket 9100 |
|
||||
|
||||
### 3.3 权限模型
|
||||
|
||||
```
|
||||
角色层级:
|
||||
SUPER_ADMIN / SUPERVISOR → 上帝视角(所有任务可操作)
|
||||
operator (普通工人) → 仅操作分配给自己的任务
|
||||
|
||||
校验点:
|
||||
- 任务接收/驳回/转交/撤回:_check_permission(assignee_id, operator_id, role)
|
||||
- 宏观状态修改:需 SUPER_ADMIN 或当前主线任务(WIP/PENDING)的 assignee
|
||||
- 留言板:operator_id 由 Token 强制覆写,防止越权伪造
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、数据样本 (Data Shape)
|
||||
|
||||
### 4.1 扫码查询响应 (ProductScanResponse)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"serial_number": "000000000000001A",
|
||||
"external_serial": "CUST-SN-2024-0001",
|
||||
"order_id": "660e8400-e29b-41d4-a716-446655440002",
|
||||
"order_no": "ORD-2024-0881",
|
||||
"material_id": "MAT-32001",
|
||||
"material_name": "样品升降台V1J",
|
||||
"spec_model": "PH-B4V1J/类A",
|
||||
"category": "成品",
|
||||
"material_type": "装配件",
|
||||
"parent_product_id": null,
|
||||
"current_location_id": "zhangsan01",
|
||||
"overall_status": "生产",
|
||||
"status": "in_progress",
|
||||
"created_at": "2026-08-10T09:30:00+08:00",
|
||||
"assignee_names": {
|
||||
"zhangsan01": "张三",
|
||||
"lisi02": "李四",
|
||||
"wangwu03": "王五"
|
||||
},
|
||||
"task_tree": [
|
||||
{
|
||||
"id": "770e8400-...",
|
||||
"task_name": "装配",
|
||||
"assignee_id": "zhangsan01",
|
||||
"status": "WIP",
|
||||
"task_type": "TRANSFER",
|
||||
"is_rework": false,
|
||||
"received_at": "2026-08-10T09:45:00+08:00",
|
||||
"child_tasks": [
|
||||
{
|
||||
"id": "880e8400-...",
|
||||
"task_name": "接线",
|
||||
"assignee_id": "lisi02",
|
||||
"status": "PENDING",
|
||||
"task_type": "TRANSFER",
|
||||
"child_tasks": [],
|
||||
"records": []
|
||||
}
|
||||
],
|
||||
"records": [
|
||||
{
|
||||
"id": 1,
|
||||
"task_id": "770e8400-...",
|
||||
"remark": "已完成底座固定",
|
||||
"images": ["https://cdn.example.com/img/2024/photo1.jpg"],
|
||||
"created_at": "2026-08-10T10:15:00+08:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 任务完成请求 (transfer)
|
||||
|
||||
```json
|
||||
{
|
||||
"next_tasks": [
|
||||
{
|
||||
"task_name": "接线",
|
||||
"assignees": ["lisi02", "wangwu03"]
|
||||
},
|
||||
{
|
||||
"task_name": "质检",
|
||||
"assignees": ["virtual_warehouse"]
|
||||
}
|
||||
],
|
||||
"note": "装配工序完工,转接线双人并行 + 质检入库"
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 标签打印数据
|
||||
|
||||
```json
|
||||
{
|
||||
"serial_number": "000000000000001A",
|
||||
"material_name": "样品升降台V1J",
|
||||
"spec_model": "PH-B4V1J/类A",
|
||||
"order_no": "ORD-2024-0881",
|
||||
"copies": 2,
|
||||
"printer_ip": "192.168.9.221",
|
||||
"printer_port": 9100
|
||||
}
|
||||
```
|
||||
> 标签格式:480×360 px 工业标签 → 二值化 → TSPL BITMAP 指令
|
||||
|
||||
---
|
||||
|
||||
## 五、系统双库架构
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────┐
|
||||
│ Track 系统(本库) │
|
||||
│ PostgreSQL :5433 / track_production │
|
||||
│ 表: production_orders, products, tasks, │
|
||||
│ task_logs, task_records, notifications, │
|
||||
│ app_versions, product_messages │
|
||||
│ ORM: SQLAlchemy 2.0 Async │
|
||||
├────────────────────────────────────────────────┤
|
||||
│ MOM 老系统(只读) │
|
||||
│ PostgreSQL :5435 / inventory_system │
|
||||
│ 表: sys_user (用户), material_base (物料) │
|
||||
│ 连接: SQLAlchemy Sync + NullPool │
|
||||
│ 用途: 登录验证 + 物料手风琴选择器 + 姓名映射 │
|
||||
└────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、观察到的潜在架构问题与优化方向
|
||||
|
||||
### 6.1 数据一致性与可靠性
|
||||
|
||||
| 问题 | 严重度 | 说明 |
|
||||
|------|--------|------|
|
||||
| **Dashboard 统计值陈旧** | 中 | `get_dashboard_stats` 使用硬编码字符串 `"pending"/"in_progress"` 过滤,但任务模型实际使用 `PENDING/WIP` 等大写常量。当前实际查的是全量/0值。 |
|
||||
| **产品 status 字段语义模糊** | 中 | Product 有 `status`(产品自身状态)和 `overall_status`(宏观流转状态)两个状态字段,前者使用小写 `pending/in_progress/completed`,后者使用中文 `备货/生产/测试/维修/在库`,存在概念重叠和命名不一致。 |
|
||||
| **缺失数据库事务跨表保护** | 低 | `transfer_task` 涉及多条 INSERT(子任务+日志+通知+位置更新),使用多次 `flush()` + 最终 `commit()`,无显式 BEGIN/SAVEPOINT,但 SQLAlchemy autocommit 模式下能保证原子性。 |
|
||||
| **无软删除机制** | 低 | 任务仅状态流转(CANCELED),产品删除是硬删除(级联清理关联),无回收站/审计日志。 |
|
||||
|
||||
### 6.2 性能与查询优化
|
||||
|
||||
| 问题 | 严重度 | 说明 |
|
||||
|------|--------|------|
|
||||
| **递归任务树 N+1 查询** | 高 | `_load_task_tree` 和 `_load_children` 递归执行单条 SELECT,深度为 N 的任务树执行 N+1 次数据库查询。建议使用 PostgreSQL Recursive CTE 一次性加载整棵树。 |
|
||||
| **姓名映射逐次查询** | 中 | `_lookup_display_names` 每次用 OR 拼接 LIKE 查询 MOM 老系统,高频场景(产品列表每页 50 条)下调用多次。建议加 Redis 缓存或本地映射表。 |
|
||||
| **任务列表无总数** | 低 | `get_all_tasks` 返回的 `total` 是 `len(flat_tasks)`(即当前页条数),而非数据库真实总数,前端无法正确分页。 |
|
||||
| **产品列表复杂 JOIN** | 中 | `get_all_products` 为每个产品列表做了 3 次聚合子查询(macro_status、overall_names、main_assignees),数据量大时需关注性能。 |
|
||||
|
||||
### 6.3 安全性
|
||||
|
||||
| 问题 | 严重度 | 说明 |
|
||||
|------|--------|------|
|
||||
| **SECRET_KEY 硬编码** | 高 | `config.py` 默认值 `"change-me-in-production"`,虽然 `.env` 可覆盖,但缺少生产环境强校验。 |
|
||||
| **Material API 无鉴权** | 中 | `/materials/groups` 和 `/materials/items` 无 `Depends(get_current_user)`,任何人均可查询老系统物料库。 |
|
||||
| **通知查询无鉴权** | 中 | `/notifications/` 通过 Query 参数 `user_id` 过滤,可被任意篡改查看他人通知。应改为从 Token 解析当前用户。 |
|
||||
| **MOM 数据库密码明文** | 中 | `mom_database.py` 中连接字符串硬编码数据库密码。 |
|
||||
|
||||
### 6.4 代码质量
|
||||
|
||||
| 问题 | 严重度 | 说明 |
|
||||
|------|--------|------|
|
||||
| **print.py endpoint 为同步函数** | 低 | 标签预览/打印端点为同步 `def`,若耗时长会阻塞 event loop。建议改为 `async def` + `run_in_executor`。 |
|
||||
| **materials.py SQL 注入风险** | 中 | `TYPE_FILTER = "1=1"` 是 Python 常量注入到 SQL 字符串拼接,虽当前安全,但此模式不够防御性。 |
|
||||
| **重复的 `_task_to_response` 实现** | 低 | `product_service.py` 和 `task_service.py` 各自维护一套任务树序列化逻辑,不共享。 |
|
||||
| **app_version 模块独立但未接入 CI/CD** | 低 | `AppVersion` 表 + `/app_version` 端点支持 OTA WGT 升级,但目前无关联的打包/上传脚本。 |
|
||||
|
||||
### 6.5 架构演进建议
|
||||
|
||||
1. **引入消息队列**:当裂变转交产生多个子任务时,通知创建在同一个事务内——若通知发送失败会回滚整个转交。建议将通知发送解耦到消息队列。
|
||||
|
||||
2. **位置追踪精度**:当前 `current_location_id` 仅存储一个持有者,多路裂变后只能追踪第一个分支的负责人。建议引入专门的 `product_locations` 轨迹表。
|
||||
|
||||
3. **任务状态机形式化**:当前状态转换逻辑分散在 `task_service.py` 各方法中(多处 `if task.status != ...` 检查)。建议使用状态机模式(如 `transitions` 库)集中管理。
|
||||
|
||||
4. **API 版本化健全性**:当前 `/api/v1` 前缀已预留版本号,但部分接口响应模型在迭代中已发生变化(如 `task_tree` 替代 `top_level_tasks`),建议通过 `/api/v2` 或 Deprecation Header 管理 API 演进。
|
||||
|
||||
5. **前端测试覆盖**:当前 `frontend/` 无任何测试文件(`.test.ts`/`.spec.ts`),后端也无 `pytest` 目录,建议补充核心业务流程的集成测试。
|
||||
|
||||
---
|
||||
|
||||
## 七、项目亮点总结
|
||||
|
||||
1. ✅ **精巧的任务裂变模型**:TRANSFER/SPAWN/RECOVERY 三种任务基因 + 智能父节点继承算法,支持单线转交、并行协助、裂变分支、撤回接力等复杂工厂场景。
|
||||
|
||||
2. ✅ **双库隔离架构**:Track 本库存储流转数据,MOM 老系统只读对接——物理隔离保护老系统,同时通过物料快照机制避免数据漂移。
|
||||
|
||||
3. ✅ **工业级标签打印**:PIL 精确坐标排版 → 二值化 → TSPL 指令 → Socket 直连打标机,全程离线化,不依赖第三方打印服务。
|
||||
|
||||
4. ✅ **三端覆盖**:PC 管理端 (React+Tauri) + 移动端 (UniApp) + 扫码端 (html5-qrcode 懒加载),UI 架构通过路由级代码分割优化首屏加载。
|
||||
|
||||
5. ✅ **双 Token 认证**:Access Token (2h) + Refresh Token (7d),对接 MOM sys_user 的 scrypt 密码存储,不重复造用户系统。
|
||||
|
||||
---
|
||||
|
||||
*报告由 Claude Code 自动生成,基于对代码库的静态分析。建议结合实际运行数据进一步验证上述发现。*
|
||||
@ -1,14 +1,12 @@
|
||||
"""物料选择器 — 读 MOM material_base,按成品/半成品 category 手风琴分组"""
|
||||
from fastapi import APIRouter, Query, HTTPException, status
|
||||
from fastapi import APIRouter, Query, HTTPException, status, Depends
|
||||
from pydantic import BaseModel
|
||||
from app.core.mom_database import MomSessionLocal
|
||||
from app.services.auth_service import get_current_user
|
||||
from sqlalchemy import text
|
||||
|
||||
router = APIRouter(prefix="/materials", tags=["物料选择"])
|
||||
|
||||
# 全量展示全部物料类别
|
||||
TYPE_FILTER = "1=1"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 响应模型
|
||||
@ -36,6 +34,7 @@ class MaterialItem(BaseModel):
|
||||
@router.get("/groups", response_model=list[MaterialGroup])
|
||||
def get_material_groups(
|
||||
keyword: str = Query("", description="搜索(按名称/规格)"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
按 category 分组汇总,前端渲染手风琴外层。
|
||||
@ -44,29 +43,23 @@ def get_material_groups(
|
||||
db = MomSessionLocal()
|
||||
try:
|
||||
if keyword.strip():
|
||||
sql = text(
|
||||
f"""
|
||||
sql = text("""
|
||||
SELECT category, COUNT(*) AS count
|
||||
FROM material_base
|
||||
WHERE is_enabled = TRUE
|
||||
AND ({TYPE_FILTER})
|
||||
AND (name ILIKE :kw OR spec_model ILIKE :kw)
|
||||
GROUP BY category
|
||||
ORDER BY category
|
||||
"""
|
||||
)
|
||||
""")
|
||||
result = db.execute(sql, {"kw": f"%{keyword.strip()}%"})
|
||||
else:
|
||||
sql = text(
|
||||
f"""
|
||||
sql = text("""
|
||||
SELECT category, COUNT(*) AS count
|
||||
FROM material_base
|
||||
WHERE is_enabled = TRUE
|
||||
AND ({TYPE_FILTER})
|
||||
GROUP BY category
|
||||
ORDER BY category
|
||||
"""
|
||||
)
|
||||
""")
|
||||
result = db.execute(sql)
|
||||
|
||||
rows = result.fetchall()
|
||||
@ -85,6 +78,7 @@ def get_material_items(
|
||||
category: str = Query(..., description="物料分类"),
|
||||
keyword: str = Query("", description="分组内搜索"),
|
||||
limit: int = Query(500, ge=1, le=9999),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
获取指定 category 下的物料条目,前端展开手风琴时懒加载。
|
||||
@ -92,35 +86,29 @@ def get_material_items(
|
||||
db = MomSessionLocal()
|
||||
try:
|
||||
if keyword.strip():
|
||||
sql = text(
|
||||
f"""
|
||||
sql = text("""
|
||||
SELECT id, name, spec_model AS spec, category, material_type AS type,
|
||||
COALESCE(unit, '') AS unit, is_enabled
|
||||
FROM material_base
|
||||
WHERE is_enabled = TRUE
|
||||
AND ({TYPE_FILTER})
|
||||
AND category = :cat
|
||||
AND (name ILIKE :kw OR spec_model ILIKE :kw)
|
||||
ORDER BY name
|
||||
LIMIT :lim
|
||||
"""
|
||||
)
|
||||
""")
|
||||
result = db.execute(
|
||||
sql, {"cat": category, "kw": f"%{keyword.strip()}%", "lim": limit}
|
||||
)
|
||||
else:
|
||||
sql = text(
|
||||
f"""
|
||||
sql = text("""
|
||||
SELECT id, name, spec_model AS spec, category, material_type AS type,
|
||||
COALESCE(unit, '') AS unit, is_enabled
|
||||
FROM material_base
|
||||
WHERE is_enabled = TRUE
|
||||
AND ({TYPE_FILTER})
|
||||
AND category = :cat
|
||||
ORDER BY name
|
||||
LIMIT :lim
|
||||
"""
|
||||
)
|
||||
""")
|
||||
result = db.execute(sql, {"cat": category, "lim": limit})
|
||||
|
||||
rows = result.fetchall()
|
||||
|
||||
@ -11,18 +11,26 @@ from app.models.notification import Notification
|
||||
from app.models.task import Task
|
||||
from app.models.product import Product
|
||||
from app.schemas.notification import NotificationResponse, NotificationListResponse
|
||||
from app.services.auth_service import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["消息通知"])
|
||||
|
||||
|
||||
@router.get("/", response_model=NotificationListResponse)
|
||||
async def list_notifications(
|
||||
user_id: str = Query(..., description="当前用户ID"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""获取当前用户的通知列表(按时间倒序)"""
|
||||
"""
|
||||
获取当前用户的通知列表(按时间倒序)。
|
||||
|
||||
安全:user_id 强制从 JWT Token 解析,不接受查询参数,
|
||||
杜绝通过篡改 user_id 参数越权查看他人通知。
|
||||
"""
|
||||
user_id: str = current_user.get("username", "") or current_user.get("sub", "")
|
||||
|
||||
# 总数
|
||||
count_stmt = select(func.count()).select_from(Notification).where(
|
||||
Notification.user_id == user_id
|
||||
@ -80,6 +88,7 @@ async def list_notifications(
|
||||
async def mark_notification_read(
|
||||
notification_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""标记单条通知为已读"""
|
||||
nid = uuid.UUID(notification_id)
|
||||
|
||||
@ -9,6 +9,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.core.database import get_db
|
||||
from app.models.production_order import ProductionOrder
|
||||
from app.schemas.order import OrderCreate, OrderResponse
|
||||
from app.services.auth_service import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/orders", tags=["订单管理"])
|
||||
|
||||
@ -27,7 +28,11 @@ async def list_orders(
|
||||
|
||||
|
||||
@router.post("/", response_model=OrderResponse, status_code=201)
|
||||
async def create_order(data: OrderCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_order(
|
||||
data: OrderCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
order = ProductionOrder(**data.model_dump())
|
||||
db.add(order)
|
||||
await db.commit()
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
"""标签打印 API — 预览 / 执行 / 打印机配置"""
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.services.label_service import generate_preview_image, send_to_printer
|
||||
from app.services.print_config import PrintConfigManager
|
||||
from app.services.auth_service import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/print", tags=["标签打印"])
|
||||
|
||||
@ -49,7 +50,10 @@ def print_preview(data: LabelPreviewRequest) -> dict:
|
||||
|
||||
|
||||
@router.post("/execute")
|
||||
def print_execute(data: PrintExecuteRequest) -> dict:
|
||||
def print_execute(
|
||||
data: PrintExecuteRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""发送打印指令到物理打标机"""
|
||||
payload = data.model_dump()
|
||||
copies = payload.pop("copies", 1)
|
||||
@ -77,7 +81,10 @@ def get_printer_config() -> dict:
|
||||
|
||||
|
||||
@router.post("/config")
|
||||
def update_printer_config(data: PrinterConfigUpdate) -> dict:
|
||||
def update_printer_config(
|
||||
data: PrinterConfigUpdate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""更新打印机配置(IP/端口)"""
|
||||
current = PrintConfigManager.get_config()
|
||||
current["label_printer"] = {
|
||||
|
||||
@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.database import get_db
|
||||
from app.models.task import TaskRecord
|
||||
from app.schemas.task import TaskRecordCreate, TaskRecordResponse
|
||||
from app.services.auth_service import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/records", tags=["任务记录"])
|
||||
|
||||
@ -25,6 +26,7 @@ async def update_record(
|
||||
record_id: int,
|
||||
data: TaskRecordCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""更新任务记录(备注+图片)"""
|
||||
record = await _get_record_or_404(db, record_id)
|
||||
@ -40,6 +42,7 @@ async def update_record(
|
||||
async def delete_record(
|
||||
record_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""删除任务记录"""
|
||||
record = await _get_record_or_404(db, record_id)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"""核心配置 — Pydantic Settings 自动从 .env 读取"""
|
||||
import json
|
||||
from pydantic import model_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
@ -26,6 +27,17 @@ class Settings(BaseSettings):
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return ["http://localhost:1420", "tauri://localhost"]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_production_secret(self):
|
||||
"""生产环境强制校验:SECRET_KEY 禁止使用默认值"""
|
||||
if not self.DEBUG and self.SECRET_KEY == "change-me-in-production":
|
||||
raise ValueError(
|
||||
"生产环境 (DEBUG=False) 禁止使用默认 SECRET_KEY。"
|
||||
"请在 .env 中设置 SECRET_KEY 为至少 32 字符的随机值。"
|
||||
"示例: python -c \"import secrets; print(secrets.token_urlsafe(32))\""
|
||||
)
|
||||
return self
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
extra = "ignore"
|
||||
|
||||
@ -19,6 +19,12 @@ AsyncSessionLocal = async_sessionmaker(
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
"""FastAPI 依赖注入:每次请求获取一个数据库会话"""
|
||||
"""FastAPI 依赖注入:每次请求获取一个数据库会话(带事务安全兜底)"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
try:
|
||||
yield session
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
@ -23,21 +23,7 @@ def login(username: str, password: str) -> LoginResponse:
|
||||
"""登录 — 签发双 Token(Access + Refresh)"""
|
||||
db = MomSessionLocal()
|
||||
try:
|
||||
# 1. 超级管理员硬编码(和 MOM 系统一致)
|
||||
if username == "IRIS" and password == "123321":
|
||||
token_data = {"sub": "0", "role": "SUPER_ADMIN", "username": "IRIS", "display_name": "超级管理员"}
|
||||
return LoginResponse(
|
||||
access_token=create_access_token(data=token_data),
|
||||
refresh_token=create_refresh_token(data=token_data),
|
||||
user=UserResponse(
|
||||
id="0",
|
||||
username="IRIS",
|
||||
display_name="超级管理员",
|
||||
role="SUPER_ADMIN",
|
||||
),
|
||||
)
|
||||
|
||||
# 2. 普通用户:LIKE '%/username' 模糊匹配 MOM sys_user 表
|
||||
# 1. 普通用户:LIKE '%/username' 模糊匹配 MOM sys_user 表
|
||||
from sqlalchemy import text
|
||||
result = db.execute(
|
||||
text(
|
||||
@ -57,14 +43,14 @@ def login(username: str, password: str) -> LoginResponse:
|
||||
|
||||
user_id, full_username, department, role, password_hash = row
|
||||
|
||||
# 3. Werkzeug scrypt 密码验证
|
||||
# 2. Werkzeug scrypt 密码验证
|
||||
if not check_password_hash(password_hash, password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户名或密码错误",
|
||||
)
|
||||
|
||||
# 4. 解析 display_name("张三/zhangsan01" → "张三")
|
||||
# 3. 解析 display_name("张三/zhangsan01" → "张三")
|
||||
display_name = full_username.split("/")[0] if "/" in full_username else full_username
|
||||
|
||||
token_data = {
|
||||
|
||||
@ -17,7 +17,7 @@ class DashboardStats(BaseModel):
|
||||
|
||||
async def get_dashboard_stats(db: AsyncSession) -> DashboardStats:
|
||||
from app.models.product import Product
|
||||
from app.models.task import Task
|
||||
from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP, TASK_STATUS_COMPLETED
|
||||
|
||||
p_total = await db.scalar(select(func.count(Product.id)))
|
||||
p_pending = await db.scalar(select(func.count(Product.id)).where(Product.status == "pending"))
|
||||
@ -25,9 +25,9 @@ async def get_dashboard_stats(db: AsyncSession) -> DashboardStats:
|
||||
p_done = await db.scalar(select(func.count(Product.id)).where(Product.status == "completed"))
|
||||
|
||||
t_total = await db.scalar(select(func.count(Task.id)))
|
||||
t_pending = await db.scalar(select(func.count(Task.id)).where(Task.status == "pending"))
|
||||
t_progress = await db.scalar(select(func.count(Task.id)).where(Task.status == "in_progress"))
|
||||
t_done = await db.scalar(select(func.count(Task.id)).where(Task.status == "completed"))
|
||||
t_pending = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_PENDING))
|
||||
t_progress = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_WIP))
|
||||
t_done = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_COMPLETED))
|
||||
|
||||
return DashboardStats(
|
||||
products_total=p_total or 0,
|
||||
|
||||
154
backend/app/services/mom_cache.py
Normal file
154
backend/app/services/mom_cache.py
Normal file
@ -0,0 +1,154 @@
|
||||
"""
|
||||
MOM 跨库查询缓存模块 — 使用本地 TTL 缓存消除冗余跨库请求
|
||||
|
||||
解决的问题:
|
||||
1. _lookup_display_names 在 get_all_products 中被调用 3 次,每次都打开/关闭
|
||||
MOM 数据库连接,150 条产品的列表页 = 3 根管线查询。
|
||||
2. 同一批 username 在短时间内(用户翻页、多人同时访问)被反复查询。
|
||||
3. 旧实现用 OR 拼接 LIKE 条件,存在注入风险。
|
||||
|
||||
方案:python -m 内置模块(零依赖)实现线程安全 TTL 缓存 + 参数化 ANY 查询。
|
||||
|
||||
TTL: 2 小时(人员姓名不会频繁变动,可调)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from app.core.mom_database import MomSessionLocal
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 零依赖 TTL 缓存(线程安全)
|
||||
# ============================================================
|
||||
|
||||
class _TTLCache:
|
||||
"""线程安全的内存 TTL 缓存,用于 MOM 只读查询结果"""
|
||||
|
||||
def __init__(self, ttl_seconds: int = 7200) -> None:
|
||||
self._store: dict[str, str] = {}
|
||||
self._expiry: dict[str, float] = {}
|
||||
self._ttl = ttl_seconds
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def get_many(self, keys: list[str]) -> tuple[dict[str, str], list[str]]:
|
||||
"""
|
||||
批量获取 → (命中字典, 未命中 key 列表)。
|
||||
|
||||
内部自动清理过期条目。
|
||||
"""
|
||||
hits: dict[str, str] = {}
|
||||
missed: list[str] = []
|
||||
now = time.monotonic()
|
||||
|
||||
with self._lock:
|
||||
for k in keys:
|
||||
exp = self._expiry.get(k)
|
||||
if exp is not None and now < exp:
|
||||
hits[k] = self._store[k]
|
||||
else:
|
||||
missed.append(k)
|
||||
# 清理过期残留
|
||||
if k in self._store:
|
||||
del self._store[k]
|
||||
del self._expiry[k]
|
||||
|
||||
return hits, missed
|
||||
|
||||
def set_many(self, mapping: dict[str, str]) -> None:
|
||||
"""批量写入,所有 key 共享同一过期时间"""
|
||||
expiry = time.monotonic() + self._ttl
|
||||
with self._lock:
|
||||
for k, v in mapping.items():
|
||||
self._store[k] = v
|
||||
self._expiry[k] = expiry
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 全局缓存实例(2h TTL)
|
||||
# ============================================================
|
||||
|
||||
_user_name_cache = _TTLCache(ttl_seconds=7200)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 公开 API
|
||||
# ============================================================
|
||||
|
||||
def get_display_names(user_ids: list[str]) -> dict[str, str]:
|
||||
"""
|
||||
批量查询 MOM sys_user,将 username 映射为真实姓名(带 2h TTL 缓存)。
|
||||
|
||||
缓存穿透流程:
|
||||
1. 去重 → 从缓存批量读取
|
||||
2. 计算 miss 差集
|
||||
3. miss 非空时,用参数化 ANY(:user_ids) 查 MOM(1 条 SQL)
|
||||
4. 写回缓存
|
||||
5. 合并 hits + fresh 返回
|
||||
|
||||
参数:
|
||||
user_ids: 短用户名列表,如 ["zhangsan01", "lisi02"]
|
||||
|
||||
返回:
|
||||
{"zhangsan01": "张三", "lisi02": "李四"}
|
||||
不存在的 key 不会出现在返回字典中。
|
||||
|
||||
SQL 安全:
|
||||
使用 SPLIT_PART(username, '/', 2) = ANY(:user_ids) 参数化查询,
|
||||
杜绝旧实现中 OR 拼接 LIKE 的注入风险。
|
||||
"""
|
||||
if not user_ids:
|
||||
return {}
|
||||
|
||||
# 过滤特殊值 + 去重保序
|
||||
seen: set[str] = set()
|
||||
real_ids: list[str] = []
|
||||
for uid in user_ids:
|
||||
if uid and uid != "virtual_warehouse" and uid not in seen:
|
||||
seen.add(uid)
|
||||
real_ids.append(uid)
|
||||
|
||||
if not real_ids:
|
||||
return {}
|
||||
|
||||
# ── Step 1: 批量查缓存 ──
|
||||
hits, missed = _user_name_cache.get_many(real_ids)
|
||||
|
||||
# ── Step 2: 仅对 miss 查 MOM ──
|
||||
if missed:
|
||||
db = MomSessionLocal()
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
|
||||
# 参数化 ANY 查询 — 安全防注入
|
||||
# SPLIT_PART('张三/zhangsan01', '/', 2) = 'zhangsan01'
|
||||
# OR username = ANY(...) 兜底无斜杠的用户名(如 admin)
|
||||
sql = text("""
|
||||
SELECT username,
|
||||
SPLIT_PART(username, '/', 1) AS display_name
|
||||
FROM sys_user
|
||||
WHERE SPLIT_PART(username, '/', 2) = ANY(:user_ids)
|
||||
OR username = ANY(:user_ids)
|
||||
""")
|
||||
result = db.execute(sql, {"user_ids": missed})
|
||||
rows = result.fetchall()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# ── Step 3: 解析结果 + 写回缓存 ──
|
||||
fresh: dict[str, str] = {}
|
||||
for row in rows:
|
||||
full_username: str = row[0]
|
||||
display_name: str = row[1]
|
||||
# "张三/zhangsan01" → short="zhangsan01"
|
||||
short = full_username.split("/")[-1] if "/" in full_username else full_username
|
||||
fresh[short] = display_name
|
||||
|
||||
if fresh:
|
||||
_user_name_cache.set_many(fresh)
|
||||
|
||||
# ── Step 4: 合并 ──
|
||||
hits.update(fresh)
|
||||
|
||||
return hits
|
||||
@ -46,35 +46,10 @@ def _task_to_response(task: Task) -> TaskResponse:
|
||||
|
||||
|
||||
async def _load_task_tree(db: AsyncSession, product_id: uuid.UUID) -> list[TaskResponse]:
|
||||
"""递归加载产品下的完整任务树"""
|
||||
# 先取顶层任务
|
||||
result = await db.execute(
|
||||
select(Task)
|
||||
.options(selectinload(Task.child_tasks), selectinload(Task.records))
|
||||
.where(
|
||||
Task.product_id == product_id,
|
||||
Task.parent_task_id.is_(None),
|
||||
)
|
||||
.order_by(Task.created_at)
|
||||
)
|
||||
top_tasks = result.scalars().all()
|
||||
|
||||
# 递归加载每层子任务
|
||||
async def _load_children(t: Task):
|
||||
for child in t.child_tasks:
|
||||
child_result = await db.execute(
|
||||
select(Task)
|
||||
.options(selectinload(Task.child_tasks), selectinload(Task.records))
|
||||
.where(Task.id == child.id)
|
||||
)
|
||||
refreshed = child_result.scalar_one()
|
||||
t.child_tasks[t.child_tasks.index(child)] = refreshed
|
||||
await _load_children(refreshed)
|
||||
|
||||
for task in top_tasks:
|
||||
await _load_children(task)
|
||||
|
||||
return [_task_to_response(t) for t in top_tasks]
|
||||
"""使用 PostgreSQL Recursive CTE 一次性加载产品下完整任务树(消除 N+1)"""
|
||||
from app.services.task_tree_loader import load_task_trees_by_product
|
||||
tasks = await load_task_trees_by_product(db, product_id)
|
||||
return [_task_to_response(t) for t in tasks]
|
||||
|
||||
|
||||
async def get_product_by_serial(db: AsyncSession, serial_number: str) -> ProductScanResponse:
|
||||
@ -344,32 +319,9 @@ async def update_overall_status(
|
||||
|
||||
|
||||
def _lookup_display_names(location_ids: list[str]) -> dict[str, str]:
|
||||
"""批量查询 MOM sys_user,将 username 映射为真实姓名"""
|
||||
if not location_ids:
|
||||
return {}
|
||||
from app.core.mom_database import MomSessionLocal
|
||||
from sqlalchemy import text
|
||||
db = MomSessionLocal()
|
||||
try:
|
||||
# 过滤掉特殊值
|
||||
real_ids = [uid for uid in location_ids if uid and uid != "virtual_warehouse"]
|
||||
if not real_ids:
|
||||
return {}
|
||||
# 用 LIKE 模糊匹配批量查出
|
||||
conditions = " OR ".join([f"username LIKE '%/{uid}'" for uid in real_ids])
|
||||
result = db.execute(
|
||||
text(f"SELECT username, SPLIT_PART(username, '/', 1) as display_name FROM sys_user WHERE {conditions}")
|
||||
)
|
||||
mapping = {}
|
||||
for row in result:
|
||||
full_username = row[0]
|
||||
display_name = row[1]
|
||||
# 从 full_username 末尾提取短用户名: "张三/zhangsan01" → "zhangsan01"
|
||||
short = full_username.split("/")[-1] if "/" in full_username else full_username
|
||||
mapping[short] = display_name
|
||||
return mapping
|
||||
finally:
|
||||
db.close()
|
||||
"""批量查询 MOM sys_user,将 username 映射为真实姓名(带 2h TTL 缓存)"""
|
||||
from app.services.mom_cache import get_display_names
|
||||
return get_display_names(location_ids)
|
||||
|
||||
|
||||
async def get_all_products(
|
||||
|
||||
@ -125,39 +125,9 @@ async def _get_task_or_404(db: AsyncSession, task_id: uuid.UUID) -> Task:
|
||||
|
||||
|
||||
async def _get_task_with_children_recursive(db: AsyncSession, task_id: uuid.UUID) -> Task:
|
||||
"""递归加载任务及其所有子孙任务"""
|
||||
result = await db.execute(
|
||||
select(Task)
|
||||
.options(
|
||||
selectinload(Task.child_tasks),
|
||||
selectinload(Task.records),
|
||||
)
|
||||
.where(Task.id == task_id)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"任务不存在: {task_id}",
|
||||
)
|
||||
|
||||
# 递归加载每一层子任务
|
||||
async def _load_children(t: Task):
|
||||
for child in t.child_tasks:
|
||||
child_result = await db.execute(
|
||||
select(Task)
|
||||
.options(
|
||||
selectinload(Task.child_tasks),
|
||||
selectinload(Task.records),
|
||||
)
|
||||
.where(Task.id == child.id)
|
||||
)
|
||||
refreshed_child = child_result.scalar_one()
|
||||
t.child_tasks[t.child_tasks.index(child)] = refreshed_child
|
||||
await _load_children(refreshed_child)
|
||||
|
||||
await _load_children(task)
|
||||
return task
|
||||
"""使用 PostgreSQL Recursive CTE 一次性加载任务及其所有子孙任务(消除 N+1)"""
|
||||
from app.services.task_tree_loader import load_task_tree_by_root
|
||||
return await load_task_tree_by_root(db, task_id)
|
||||
|
||||
|
||||
def _to_flat_response(task: Task) -> TaskResponse:
|
||||
@ -326,7 +296,6 @@ async def get_all_tasks(
|
||||
) -> TaskListResponse:
|
||||
"""获取任务列表,可按产品/负责人筛选"""
|
||||
stmt = select(Task).options(
|
||||
selectinload(Task.child_tasks),
|
||||
selectinload(Task.records),
|
||||
selectinload(Task.product),
|
||||
)
|
||||
@ -1085,5 +1054,6 @@ async def add_task_record(
|
||||
await db.commit()
|
||||
await db.refresh(record)
|
||||
|
||||
# 重新加载 task 带上新 record
|
||||
return await get_task(db, task_id)
|
||||
# 手动追加新 record,避免二次加载整棵树(task 已在 L1051 由 CTE 完整加载)
|
||||
task.records.append(record)
|
||||
return _to_response(task)
|
||||
|
||||
176
backend/app/services/task_tree_loader.py
Normal file
176
backend/app/services/task_tree_loader.py
Normal file
@ -0,0 +1,176 @@
|
||||
"""
|
||||
共享 CTE 任务树加载器 — 使用 PostgreSQL Recursive CTE 一次性拉取完整任务树
|
||||
|
||||
解决问题:原 _load_task_tree / _get_task_with_children_recursive 使用
|
||||
Python 递归逐层 SELECT,N 个节点产生 N+1 次数据库查询。
|
||||
现在无论树深度多大,仅执行 2 条查询(CTE + records selectinload)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import noload, selectinload
|
||||
from sqlalchemy.orm.attributes import set_committed_value
|
||||
|
||||
from app.models.task import Task
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 内存树组装(O(N) 时间 / O(N) 空间)
|
||||
# ============================================================
|
||||
|
||||
def _build_tree_in_memory(tasks: list[Task]) -> dict[uuid.UUID, Task]:
|
||||
"""
|
||||
给定扁平 Task ORM 列表,在内存中通过哈希表组装嵌套树结构。
|
||||
|
||||
关键安全设计:
|
||||
- 使用临时字典 temp_children_map 暂存父子关系,绝对不直接操作 ORM 的 child_tasks。
|
||||
- 通过 set_committed_value 注入最终列表,告诉 SQLAlchemy 这是"已提交数据",
|
||||
避免 add_task_record 等场景中 db.commit() 时触发级联 UPDATE 污染数据库。
|
||||
|
||||
时间复杂度: O(N),空间复杂度: O(N)。
|
||||
"""
|
||||
if not tasks:
|
||||
return {}
|
||||
|
||||
# ── Pass 1: 临时字典存储关系(不触碰 ORM 属性)──
|
||||
temp_children_map: dict[uuid.UUID, list[Task]] = {t.id: [] for t in tasks}
|
||||
task_map: dict[uuid.UUID, Task] = {t.id: t for t in tasks}
|
||||
|
||||
# ── Pass 2: 挂载到临时字典 ──
|
||||
for t in tasks:
|
||||
pid = t.parent_task_id
|
||||
if pid is not None and pid in temp_children_map:
|
||||
temp_children_map[pid].append(t)
|
||||
|
||||
# ── Pass 3: 排序 + set_committed_value 安全注入 ──
|
||||
for t in tasks:
|
||||
children = temp_children_map[t.id]
|
||||
if children:
|
||||
children.sort(key=lambda x: x.created_at)
|
||||
# 关键:标记为已提交数据,SQLAlchemy 不会对其生成 UPDATE
|
||||
set_committed_value(t, 'child_tasks', children)
|
||||
|
||||
return task_map
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 公开 API:按单一任务 ID 加载子树
|
||||
# ============================================================
|
||||
|
||||
async def load_task_tree_by_root(
|
||||
db: AsyncSession, task_id: uuid.UUID
|
||||
) -> Task:
|
||||
"""
|
||||
使用 Recursive CTE 加载以 task_id 为根的完整任务子树。
|
||||
|
||||
返回: 根 Task ORM 对象(child_tasks 已递归填充)。
|
||||
|
||||
Raises:
|
||||
HTTPException(404): 根任务不存在。
|
||||
"""
|
||||
# ── Step 1: Recursive CTE — 收集所有子孙节点 ID ──
|
||||
# WITH RECURSIVE task_tree AS (
|
||||
# SELECT tasks.* FROM tasks WHERE tasks.id = :tid
|
||||
# UNION ALL
|
||||
# SELECT tasks.* FROM tasks
|
||||
# JOIN task_tree ON tasks.parent_task_id = task_tree.id
|
||||
# )
|
||||
anchor = (
|
||||
select(Task)
|
||||
.where(Task.id == task_id)
|
||||
.cte(name="task_tree", recursive=True)
|
||||
)
|
||||
task_tree_cte = anchor.union_all(
|
||||
select(Task).join(anchor, Task.parent_task_id == anchor.c.id)
|
||||
)
|
||||
|
||||
# ── Step 2: 批量加载所有任务 + 关联数据 ──
|
||||
stmt = (
|
||||
select(Task)
|
||||
.options(
|
||||
noload(Task.child_tasks), # 禁掉模型默认 selectinload,由内存树接管
|
||||
noload(Task.parent_task), # 组装树不需要 parent 引用
|
||||
selectinload(Task.records), # 🔥 一次性预加载所有进度记录
|
||||
selectinload(Task.product), # 🔥 一次性预加载产品引用
|
||||
)
|
||||
.where(Task.id.in_(select(task_tree_cte.c.id)))
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
all_tasks = result.unique().scalars().all()
|
||||
|
||||
if not all_tasks:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"任务不存在: {task_id}",
|
||||
)
|
||||
|
||||
# ── Step 3: 内存组装 ──
|
||||
task_map = _build_tree_in_memory(all_tasks)
|
||||
|
||||
# 根任务一定在 map 中(CTE anchor 保证了这一点)
|
||||
return task_map[task_id]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 公开 API:按产品 ID 加载所有任务树
|
||||
# ============================================================
|
||||
|
||||
async def load_task_trees_by_product(
|
||||
db: AsyncSession, product_id: uuid.UUID
|
||||
) -> list[Task]:
|
||||
"""
|
||||
使用 Recursive CTE 加载指定产品下的所有任务树。
|
||||
|
||||
返回: 顶层任务列表(parent_task_id IS NULL),每项的 child_tasks 已递归填充。
|
||||
若无任务则返回空列表。
|
||||
"""
|
||||
# ── Step 1: Recursive CTE ──
|
||||
# WITH RECURSIVE product_task_tree AS (
|
||||
# SELECT tasks.* FROM tasks
|
||||
# WHERE tasks.product_id = :pid AND tasks.parent_task_id IS NULL
|
||||
# UNION ALL
|
||||
# SELECT tasks.* FROM tasks
|
||||
# JOIN product_task_tree ON tasks.parent_task_id = product_task_tree.id
|
||||
# )
|
||||
anchor = (
|
||||
select(Task)
|
||||
.where(
|
||||
Task.product_id == product_id,
|
||||
Task.parent_task_id.is_(None),
|
||||
)
|
||||
.cte(name="product_task_tree", recursive=True)
|
||||
)
|
||||
task_tree_cte = anchor.union_all(
|
||||
select(Task).join(anchor, Task.parent_task_id == anchor.c.id)
|
||||
)
|
||||
|
||||
# ── Step 2: 批量加载 ──
|
||||
stmt = (
|
||||
select(Task)
|
||||
.options(
|
||||
noload(Task.child_tasks),
|
||||
noload(Task.parent_task),
|
||||
selectinload(Task.records),
|
||||
selectinload(Task.product),
|
||||
)
|
||||
.where(Task.id.in_(select(task_tree_cte.c.id)))
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
all_tasks = result.unique().scalars().all()
|
||||
|
||||
if not all_tasks:
|
||||
return []
|
||||
|
||||
# ── Step 3: 内存组装 ──
|
||||
_build_tree_in_memory(all_tasks)
|
||||
|
||||
# ── Step 4: 返回排序后的顶层任务 ──
|
||||
roots = [t for t in all_tasks if t.parent_task_id is None]
|
||||
roots.sort(key=lambda t: t.created_at)
|
||||
return roots
|
||||
@ -96,7 +96,6 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
// 弹窗询问用户是否更新
|
||||
const content = description
|
||||
? `发现新版本 ${newVersion}\n\n${description}\n\n是否立即更新?`
|
||||
: `发现新版本 ${newVersion},是否立即更新?`;
|
||||
@ -109,63 +108,33 @@ export default {
|
||||
success: (modalRes) => {
|
||||
if (!modalRes.confirm) return;
|
||||
|
||||
// 初始反馈:告知用户已转入后台
|
||||
uni.showToast({ title: '已转入后台下载...', icon: 'none', position: 'top' });
|
||||
|
||||
const downloadTask = uni.downloadFile({
|
||||
// 🚀 静默更新:无进度条、无 toast、不监听 onProgressUpdate
|
||||
uni.downloadFile({
|
||||
url: wgtUrl,
|
||||
success: (downloadRes) => {
|
||||
if (downloadRes.statusCode !== 200) {
|
||||
uni.showToast({ title: "下载失败", icon: "none" });
|
||||
console.error("[OTA] 下载失败, statusCode:", downloadRes.statusCode);
|
||||
return;
|
||||
}
|
||||
|
||||
// 安装阶段 — toast 轻提示
|
||||
uni.showToast({ title: "正在安装...", icon: "none", position: "top" });
|
||||
|
||||
plus.runtime.install(
|
||||
downloadRes.tempFilePath,
|
||||
{ force: true },
|
||||
() => {
|
||||
console.log("[OTA] WGT 安装成功");
|
||||
// 🚀 静默重启:toast 提示后自动重启
|
||||
plus.nativeUI.toast("新版本已就绪,即将重启...");
|
||||
console.log("[OTA] 安装成功,3秒后自动重启");
|
||||
setTimeout(() => {
|
||||
plus.runtime.restart();
|
||||
}, 2000);
|
||||
}, 3000);
|
||||
},
|
||||
(err) => {
|
||||
console.error("[OTA] 安装失败:", err.message);
|
||||
uni.showToast({
|
||||
title: "更新失败: " + (err.message || "未知错误"),
|
||||
icon: "none",
|
||||
duration: 4000,
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error("[OTA] 下载失败:", err.errMsg);
|
||||
uni.showToast({ title: "下载失败,请检查网络", icon: "none" });
|
||||
},
|
||||
});
|
||||
|
||||
// 🚀 核心性能优化:节流阀 — 每跨越 20% 才触发一次轻提示
|
||||
let lastProgress = 0;
|
||||
if (downloadTask && downloadTask.onProgressUpdate) {
|
||||
downloadTask.onProgressUpdate((res) => {
|
||||
const pct = res.progress;
|
||||
if (pct - lastProgress >= 20 && pct < 100) {
|
||||
lastProgress = pct;
|
||||
uni.showToast({
|
||||
title: `新版本下载中 ${pct}%`,
|
||||
icon: "none",
|
||||
position: "top",
|
||||
duration: 1200,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user