Compare commits

...

7 Commits

Author SHA1 Message Date
7604c353fb chore: .gitignore忽略部署产物(*.tar.gz, *.sql.gz) 2026-08-12 12:05:06 +08:00
d32a3dce49 chore: UniApp OTA静默更新 + 项目分析报告
1. UniApp OTA更新优化
   - 移除进度条和下载中Toast(静默后台下载)
   - 安装成功后3秒自动重启,无用户感知
   - 失败时仅console.error,不弹窗打扰用户

2. 项目分析报告
   - 新增PROJECT_ANALYSIS_REPORT.md
   - 涵盖技术栈、数据模型、核心业务流程、潜在痛点和架构建议
2026-08-12 12:04:51 +08:00
d59f732a0e fix: 数据库会话事务安全兜底
get_db() 依赖注入增加 try/except/rollback/finally:
- 请求处理中发生异常时自动 rollback
- 无论成功或异常,finally 中确保 close 释放连接
- 防止异常导致连接泄漏或脏事务残留
2026-08-12 12:04:45 +08:00
3a6ab7d756 security: 补全剩余端点鉴权 + 移除硬编码管理员后门
1. 鉴权补全
   - orders.py: create_order 补全 Depends(get_current_user)
   - print.py: print_execute 和 update_printer_config 补全鉴权
   - records.py: update_record 和 delete_record 补全鉴权

2. 安全加固
   - auth_service.py: 移除硬编码超级管理员(IRIS/123321)后门
   - 所有用户统一通过MOM sys_user scrypt密码验证登录
2026-08-12 12:04:40 +08:00
8c54a38f55 security: API鉴权补全 + SQL拼接隐患消除
1. materials.py
   - get_material_groups和get_material_items补全Depends(get_current_user)
   - 移除TYPE_FILTER="1=1"死代码及4处f-string SQL拼接
   - 全部SQL改为纯参数化text()查询

2. notifications.py
   - list_notifications废弃user_id查询参数(越权漏洞)
   - user_id强制从JWT Token解析,防止篡改参数偷看他人通知
   - mark_notification_read补全鉴权
2026-08-12 12:03:12 +08:00
69f3e35d14 fix: Dashboard统计数据修复 + 生产环境SECRET_KEY强制校验
1. Dashboard统计Bug修复
   - Task统计改用TASK_STATUS_PENDING/WIP/COMPLETED大写常量
   - 旧代码使用小写"pending"/"in_progress"永远匹配不到数据
   - 修复后tasks_pending/tasks_in_progress/tasks_completed返回真实值

2. 生产环境SECRET_KEY强制校验
   - 新增model_validator:DEBUG=False且SECRET_KEY为默认值时抛出ValueError
   - 阻止使用默认密钥部署到生产环境
2026-08-12 12:03:07 +08:00
cc199081f9 perf: CTE任务树加载器 + MOM跨库查询缓存
消除两个核心N+1性能瓶颈:

1. CTE任务树加载器 (task_tree_loader.py)
   - PostgreSQL Recursive CTE一次性加载完整任务树
   - 无论树深度多大,仅2条SQL(CTE + records selectinload)
   - set_committed_value安全注入,避免Session脏数据
   - 修复add_task_record双重加载问题
   - 移除get_all_tasks中冗余的selectinload(child_tasks)

2. MOM跨库查询缓存 (mom_cache.py)
   - 零依赖TTL内存缓存(threading.RLock + time.monotonic)
   - 参数化ANY(:user_ids)替代OR拼接LIKE(防注入)
   - get_all_products中3次调用共享缓存,2h TTL内零跨库查询
2026-08-12 12:03:02 +08:00
16 changed files with 817 additions and 180 deletions

4
.gitignore vendored
View File

@ -44,6 +44,10 @@ backend/data/
*.log *.log
*.tmp *.tmp
# ===== 部署产物 =====
*.tar.gz
*.sql.gz
# ===== 排除独立项目/个人文档 ===== # ===== 排除独立项目/个人文档 =====
track1.0.md track1.0.md
PROJECT_STATUS.md PROJECT_STATUS.md

396
PROJECT_ANALYSIS_REPORT.md Normal file
View 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 自动生成,基于对代码库的静态分析。建议结合实际运行数据进一步验证上述发现。*

View File

@ -1,14 +1,12 @@
"""物料选择器 — 读 MOM material_base,按成品/半成品 category 手风琴分组""" """物料选择器 — 读 MOM material_base,按成品/半成品 category 手风琴分组"""
from fastapi import APIRouter, Query, HTTPException, status from fastapi import APIRouter, Query, HTTPException, status, Depends
from pydantic import BaseModel from pydantic import BaseModel
from app.core.mom_database import MomSessionLocal from app.core.mom_database import MomSessionLocal
from app.services.auth_service import get_current_user
from sqlalchemy import text from sqlalchemy import text
router = APIRouter(prefix="/materials", tags=["物料选择"]) router = APIRouter(prefix="/materials", tags=["物料选择"])
# 全量展示全部物料类别
TYPE_FILTER = "1=1"
# ============================================================ # ============================================================
# 响应模型 # 响应模型
@ -36,6 +34,7 @@ class MaterialItem(BaseModel):
@router.get("/groups", response_model=list[MaterialGroup]) @router.get("/groups", response_model=list[MaterialGroup])
def get_material_groups( def get_material_groups(
keyword: str = Query("", description="搜索(按名称/规格)"), keyword: str = Query("", description="搜索(按名称/规格)"),
current_user: dict = Depends(get_current_user),
): ):
""" """
按 category 分组汇总,前端渲染手风琴外层。 按 category 分组汇总,前端渲染手风琴外层。
@ -44,29 +43,23 @@ def get_material_groups(
db = MomSessionLocal() db = MomSessionLocal()
try: try:
if keyword.strip(): if keyword.strip():
sql = text( sql = text("""
f"""
SELECT category, COUNT(*) AS count SELECT category, COUNT(*) AS count
FROM material_base FROM material_base
WHERE is_enabled = TRUE WHERE is_enabled = TRUE
AND ({TYPE_FILTER})
AND (name ILIKE :kw OR spec_model ILIKE :kw) AND (name ILIKE :kw OR spec_model ILIKE :kw)
GROUP BY category GROUP BY category
ORDER BY category ORDER BY category
""" """)
)
result = db.execute(sql, {"kw": f"%{keyword.strip()}%"}) result = db.execute(sql, {"kw": f"%{keyword.strip()}%"})
else: else:
sql = text( sql = text("""
f"""
SELECT category, COUNT(*) AS count SELECT category, COUNT(*) AS count
FROM material_base FROM material_base
WHERE is_enabled = TRUE WHERE is_enabled = TRUE
AND ({TYPE_FILTER})
GROUP BY category GROUP BY category
ORDER BY category ORDER BY category
""" """)
)
result = db.execute(sql) result = db.execute(sql)
rows = result.fetchall() rows = result.fetchall()
@ -85,6 +78,7 @@ def get_material_items(
category: str = Query(..., description="物料分类"), category: str = Query(..., description="物料分类"),
keyword: str = Query("", description="分组内搜索"), keyword: str = Query("", description="分组内搜索"),
limit: int = Query(500, ge=1, le=9999), limit: int = Query(500, ge=1, le=9999),
current_user: dict = Depends(get_current_user),
): ):
""" """
获取指定 category 下的物料条目,前端展开手风琴时懒加载。 获取指定 category 下的物料条目,前端展开手风琴时懒加载。
@ -92,35 +86,29 @@ def get_material_items(
db = MomSessionLocal() db = MomSessionLocal()
try: try:
if keyword.strip(): if keyword.strip():
sql = text( sql = text("""
f"""
SELECT id, name, spec_model AS spec, category, material_type AS type, SELECT id, name, spec_model AS spec, category, material_type AS type,
COALESCE(unit, '') AS unit, is_enabled COALESCE(unit, '') AS unit, is_enabled
FROM material_base FROM material_base
WHERE is_enabled = TRUE WHERE is_enabled = TRUE
AND ({TYPE_FILTER})
AND category = :cat AND category = :cat
AND (name ILIKE :kw OR spec_model ILIKE :kw) AND (name ILIKE :kw OR spec_model ILIKE :kw)
ORDER BY name ORDER BY name
LIMIT :lim LIMIT :lim
""" """)
)
result = db.execute( result = db.execute(
sql, {"cat": category, "kw": f"%{keyword.strip()}%", "lim": limit} sql, {"cat": category, "kw": f"%{keyword.strip()}%", "lim": limit}
) )
else: else:
sql = text( sql = text("""
f"""
SELECT id, name, spec_model AS spec, category, material_type AS type, SELECT id, name, spec_model AS spec, category, material_type AS type,
COALESCE(unit, '') AS unit, is_enabled COALESCE(unit, '') AS unit, is_enabled
FROM material_base FROM material_base
WHERE is_enabled = TRUE WHERE is_enabled = TRUE
AND ({TYPE_FILTER})
AND category = :cat AND category = :cat
ORDER BY name ORDER BY name
LIMIT :lim LIMIT :lim
""" """)
)
result = db.execute(sql, {"cat": category, "lim": limit}) result = db.execute(sql, {"cat": category, "lim": limit})
rows = result.fetchall() rows = result.fetchall()

View File

@ -11,18 +11,26 @@ from app.models.notification import Notification
from app.models.task import Task from app.models.task import Task
from app.models.product import Product from app.models.product import Product
from app.schemas.notification import NotificationResponse, NotificationListResponse from app.schemas.notification import NotificationResponse, NotificationListResponse
from app.services.auth_service import get_current_user
router = APIRouter(prefix="/notifications", tags=["消息通知"]) router = APIRouter(prefix="/notifications", tags=["消息通知"])
@router.get("/", response_model=NotificationListResponse) @router.get("/", response_model=NotificationListResponse)
async def list_notifications( async def list_notifications(
user_id: str = Query(..., description="当前用户ID"),
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100), limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db), 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( count_stmt = select(func.count()).select_from(Notification).where(
Notification.user_id == user_id Notification.user_id == user_id
@ -80,6 +88,7 @@ async def list_notifications(
async def mark_notification_read( async def mark_notification_read(
notification_id: str, notification_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
): ):
"""标记单条通知为已读""" """标记单条通知为已读"""
nid = uuid.UUID(notification_id) nid = uuid.UUID(notification_id)

View File

@ -9,6 +9,7 @@ from sqlalchemy.orm import selectinload
from app.core.database import get_db from app.core.database import get_db
from app.models.production_order import ProductionOrder from app.models.production_order import ProductionOrder
from app.schemas.order import OrderCreate, OrderResponse from app.schemas.order import OrderCreate, OrderResponse
from app.services.auth_service import get_current_user
router = APIRouter(prefix="/orders", tags=["订单管理"]) router = APIRouter(prefix="/orders", tags=["订单管理"])
@ -27,7 +28,11 @@ async def list_orders(
@router.post("/", response_model=OrderResponse, status_code=201) @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()) order = ProductionOrder(**data.model_dump())
db.add(order) db.add(order)
await db.commit() await db.commit()

View File

@ -1,9 +1,10 @@
"""标签打印 API — 预览 / 执行 / 打印机配置""" """标签打印 API — 预览 / 执行 / 打印机配置"""
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.services.label_service import generate_preview_image, send_to_printer from app.services.label_service import generate_preview_image, send_to_printer
from app.services.print_config import PrintConfigManager from app.services.print_config import PrintConfigManager
from app.services.auth_service import get_current_user
router = APIRouter(prefix="/print", tags=["标签打印"]) router = APIRouter(prefix="/print", tags=["标签打印"])
@ -49,7 +50,10 @@ def print_preview(data: LabelPreviewRequest) -> dict:
@router.post("/execute") @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() payload = data.model_dump()
copies = payload.pop("copies", 1) copies = payload.pop("copies", 1)
@ -77,7 +81,10 @@ def get_printer_config() -> dict:
@router.post("/config") @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/端口)""" """更新打印机配置(IP/端口)"""
current = PrintConfigManager.get_config() current = PrintConfigManager.get_config()
current["label_printer"] = { current["label_printer"] = {

View File

@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
from app.models.task import TaskRecord from app.models.task import TaskRecord
from app.schemas.task import TaskRecordCreate, TaskRecordResponse from app.schemas.task import TaskRecordCreate, TaskRecordResponse
from app.services.auth_service import get_current_user
router = APIRouter(prefix="/records", tags=["任务记录"]) router = APIRouter(prefix="/records", tags=["任务记录"])
@ -25,6 +26,7 @@ async def update_record(
record_id: int, record_id: int,
data: TaskRecordCreate, data: TaskRecordCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
): ):
"""更新任务记录(备注+图片)""" """更新任务记录(备注+图片)"""
record = await _get_record_or_404(db, record_id) record = await _get_record_or_404(db, record_id)
@ -40,6 +42,7 @@ async def update_record(
async def delete_record( async def delete_record(
record_id: int, record_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
): ):
"""删除任务记录""" """删除任务记录"""
record = await _get_record_or_404(db, record_id) record = await _get_record_or_404(db, record_id)

View File

@ -1,5 +1,6 @@
"""核心配置 — Pydantic Settings 自动从 .env 读取""" """核心配置 — Pydantic Settings 自动从 .env 读取"""
import json import json
from pydantic import model_validator
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
@ -26,6 +27,17 @@ class Settings(BaseSettings):
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
return ["http://localhost:1420", "tauri://localhost"] 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: class Config:
env_file = ".env" env_file = ".env"
extra = "ignore" extra = "ignore"

View File

@ -19,6 +19,12 @@ AsyncSessionLocal = async_sessionmaker(
async def get_db() -> AsyncSession: async def get_db() -> AsyncSession:
"""FastAPI 依赖注入:每次请求获取一个数据库会话""" """FastAPI 依赖注入:每次请求获取一个数据库会话(带事务安全兜底)"""
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
yield session try:
yield session
except Exception:
await session.rollback()
raise
finally:
await session.close()

View File

@ -23,21 +23,7 @@ def login(username: str, password: str) -> LoginResponse:
"""登录 — 签发双 Token(Access + Refresh)""" """登录 — 签发双 Token(Access + Refresh)"""
db = MomSessionLocal() db = MomSessionLocal()
try: try:
# 1. 超级管理员硬编码(和 MOM 系统一致) # 1. 普通用户:LIKE '%/username' 模糊匹配 MOM sys_user 表
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 表
from sqlalchemy import text from sqlalchemy import text
result = db.execute( result = db.execute(
text( text(
@ -57,14 +43,14 @@ def login(username: str, password: str) -> LoginResponse:
user_id, full_username, department, role, password_hash = row user_id, full_username, department, role, password_hash = row
# 3. Werkzeug scrypt 密码验证 # 2. Werkzeug scrypt 密码验证
if not check_password_hash(password_hash, password): if not check_password_hash(password_hash, password):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误", detail="用户名或密码错误",
) )
# 4. 解析 display_name("张三/zhangsan01" → "张三") # 3. 解析 display_name("张三/zhangsan01" → "张三")
display_name = full_username.split("/")[0] if "/" in full_username else full_username display_name = full_username.split("/")[0] if "/" in full_username else full_username
token_data = { token_data = {

View File

@ -17,7 +17,7 @@ class DashboardStats(BaseModel):
async def get_dashboard_stats(db: AsyncSession) -> DashboardStats: async def get_dashboard_stats(db: AsyncSession) -> DashboardStats:
from app.models.product import Product 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_total = await db.scalar(select(func.count(Product.id)))
p_pending = await db.scalar(select(func.count(Product.id)).where(Product.status == "pending")) 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")) 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_total = await db.scalar(select(func.count(Task.id)))
t_pending = await db.scalar(select(func.count(Task.id)).where(Task.status == "pending")) 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 == "in_progress")) 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 == "completed")) t_done = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_COMPLETED))
return DashboardStats( return DashboardStats(
products_total=p_total or 0, products_total=p_total or 0,

View 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

View File

@ -46,35 +46,10 @@ def _task_to_response(task: Task) -> TaskResponse:
async def _load_task_tree(db: AsyncSession, product_id: uuid.UUID) -> list[TaskResponse]: async def _load_task_tree(db: AsyncSession, product_id: uuid.UUID) -> list[TaskResponse]:
"""递归加载产品下的完整任务树""" """使用 PostgreSQL Recursive CTE 一次性加载产品下完整任务树(消除 N+1)"""
# 先取顶层任务 from app.services.task_tree_loader import load_task_trees_by_product
result = await db.execute( tasks = await load_task_trees_by_product(db, product_id)
select(Task) return [_task_to_response(t) for t in tasks]
.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]
async def get_product_by_serial(db: AsyncSession, serial_number: str) -> ProductScanResponse: 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]: def _lookup_display_names(location_ids: list[str]) -> dict[str, str]:
"""批量查询 MOM sys_user,将 username 映射为真实姓名""" """批量查询 MOM sys_user,将 username 映射为真实姓名(带 2h TTL 缓存)"""
if not location_ids: from app.services.mom_cache import get_display_names
return {} return get_display_names(location_ids)
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()
async def get_all_products( async def get_all_products(

View File

@ -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: async def _get_task_with_children_recursive(db: AsyncSession, task_id: uuid.UUID) -> Task:
"""递归加载任务及其所有子孙任务""" """使用 PostgreSQL Recursive CTE 一次性加载任务及其所有子孙任务(消除 N+1)"""
result = await db.execute( from app.services.task_tree_loader import load_task_tree_by_root
select(Task) return await load_task_tree_by_root(db, task_id)
.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
def _to_flat_response(task: Task) -> TaskResponse: def _to_flat_response(task: Task) -> TaskResponse:
@ -326,7 +296,6 @@ async def get_all_tasks(
) -> TaskListResponse: ) -> TaskListResponse:
"""获取任务列表,可按产品/负责人筛选""" """获取任务列表,可按产品/负责人筛选"""
stmt = select(Task).options( stmt = select(Task).options(
selectinload(Task.child_tasks),
selectinload(Task.records), selectinload(Task.records),
selectinload(Task.product), selectinload(Task.product),
) )
@ -1085,5 +1054,6 @@ async def add_task_record(
await db.commit() await db.commit()
await db.refresh(record) await db.refresh(record)
# 重新加载 task 带上新 record # 手动追加新 record,避免二次加载整棵树(task 已在 L1051 由 CTE 完整加载)
return await get_task(db, task_id) task.records.append(record)
return _to_response(task)

View 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

View File

@ -96,7 +96,6 @@ export default {
return; return;
} }
// 弹窗询问用户是否更新
const content = description const content = description
? `发现新版本 ${newVersion}\n\n${description}\n\n是否立即更新?` ? `发现新版本 ${newVersion}\n\n${description}\n\n是否立即更新?`
: `发现新版本 ${newVersion},是否立即更新?`; : `发现新版本 ${newVersion},是否立即更新?`;
@ -109,63 +108,33 @@ export default {
success: (modalRes) => { success: (modalRes) => {
if (!modalRes.confirm) return; if (!modalRes.confirm) return;
// 初始反馈:告知用户已转入后台 // 🚀 静默更新:无进度条、无 toast、不监听 onProgressUpdate
uni.showToast({ title: '已转入后台下载...', icon: 'none', position: 'top' }); uni.downloadFile({
const downloadTask = uni.downloadFile({
url: wgtUrl, url: wgtUrl,
success: (downloadRes) => { success: (downloadRes) => {
if (downloadRes.statusCode !== 200) { if (downloadRes.statusCode !== 200) {
uni.showToast({ title: "下载失败", icon: "none" }); console.error("[OTA] 下载失败, statusCode:", downloadRes.statusCode);
return; return;
} }
// 安装阶段 — toast 轻提示
uni.showToast({ title: "正在安装...", icon: "none", position: "top" });
plus.runtime.install( plus.runtime.install(
downloadRes.tempFilePath, downloadRes.tempFilePath,
{ force: true }, { force: true },
() => { () => {
console.log("[OTA] WGT 安装成功"); console.log("[OTA] 安装成功,3秒后自动重启");
// 🚀 静默重启:toast 提示后自动重启
plus.nativeUI.toast("新版本已就绪,即将重启...");
setTimeout(() => { setTimeout(() => {
plus.runtime.restart(); plus.runtime.restart();
}, 2000); }, 3000);
}, },
(err) => { (err) => {
console.error("[OTA] 安装失败:", err.message); console.error("[OTA] 安装失败:", err.message);
uni.showToast({
title: "更新失败: " + (err.message || "未知错误"),
icon: "none",
duration: 4000,
});
} }
); );
}, },
fail: (err) => { fail: (err) => {
console.error("[OTA] 下载失败:", err.errMsg); 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,
});
}
});
}
}, },
}); });
}, },