merge: 整改 Task 0-14 统一基线
This commit is contained in:
@@ -26,8 +26,11 @@ __pycache__/
|
|||||||
/wvp/*.sh
|
/wvp/*.sh
|
||||||
*.sh
|
*.sh
|
||||||
*.ps1
|
*.ps1
|
||||||
|
!scripts/verify.ps1
|
||||||
*.py
|
*.py
|
||||||
*.sql
|
*.sql
|
||||||
|
!server-go/migrations/
|
||||||
|
!server-go/migrations/*.sql
|
||||||
# ai-service(Python 推理服务)文件例外
|
# ai-service(Python 推理服务)文件例外
|
||||||
!ai-service/
|
!ai-service/
|
||||||
!ai-service/app/
|
!ai-service/app/
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
|
|
||||||
### 1.6 通知与实时推送
|
### 1.6 通知与实时推送
|
||||||
|
|
||||||
- WebSocket 网关位于 `/ws`,使用 JWT 鉴权
|
- WebSocket 网关位于 `/ws`,使用一次性 ticket 鉴权
|
||||||
- 广播事件:`telemetry.all`、`telemetry`、`alarm`、`alarm.recovery`
|
- 广播事件:`telemetry.all`、`telemetry`、`alarm`、`alarm.recovery`
|
||||||
- Web/APP/小程序均接入 WebSocket 实时订阅
|
- Web/APP/小程序均接入 WebSocket 实时订阅
|
||||||
- 登录后自动连接 WebSocket,登出自动断开
|
- 登录后自动连接 WebSocket,登出自动断开
|
||||||
@@ -64,12 +64,14 @@
|
|||||||
|
|
||||||
- 蚕匾(`trays`)、蚕种批次(`batches`,含品种/蚕种来源/批次号/检疫证明/龄期/入房/上蔟)、饲养记录(`rearing_records`)三表 + CRUD
|
- 蚕匾(`trays`)、蚕种批次(`batches`,含品种/蚕种来源/批次号/检疫证明/龄期/入房/上蔟)、饲养记录(`rearing_records`)三表 + CRUD
|
||||||
- Web「批次管理」页(批次 + 蚕匾 + 饲养记录);小程序蚕房详情展示蚕匾与当前批次
|
- Web「批次管理」页(批次 + 蚕匾 + 饲养记录);小程序蚕房详情展示蚕匾与当前批次
|
||||||
|
- 生物安全:种源与检疫链、消毒计划/执行、批次/蚕匾/样本二维码身份;小程序可扫码并现场提交消毒执行
|
||||||
|
|
||||||
### 1.9 AI 拍照巡检闭环(计划 #5/#6/#8/#9)
|
### 1.9 AI 拍照巡检闭环(计划 #5/#6/#8/#9)
|
||||||
|
|
||||||
- `ai-service/`:FastAPI + ONNX Runtime(默认 mock 模式;`POST /detect` 返回框/类别/置信度,`POST /stream-detect` 拉流抽帧骨架,`GET /metrics` 监控指标)
|
- `ai-service/`:FastAPI + ONNX Runtime(默认 mock 模式;`POST /detect` 返回框/类别/置信度、`modelVersion`、`isMock`、`abnormalProbability`,`POST /internal/stream-tasks` 受限拉流任务,`GET /metrics` 监控指标)
|
||||||
- 拍照上传 → AI 检测 → 风险评分(0-100 分,绿/黄/橙/红四级)→ 巡检记录(`inspection_records`,`Idempotency-Key` 幂等)
|
- 拍照上传 → AI 检测 → 风险评分(0-100 分,绿/黄/橙/红四级,只消费 AI 异常概率,缺失项不按 0 参与)→ 巡检记录(`inspection_records`,`Idempotency-Key` 幂等)
|
||||||
- 小程序「拍照巡检」页;Web「巡检记录」页(技术员复查)
|
- 小程序「拍照巡检」页;Web「巡检记录」页(技术员复查)
|
||||||
|
- 小程序离线巡检:拍照先入本地持久队列,联网后串行上传并复用同一幂等键;401 自动刷新后重试
|
||||||
|
|
||||||
### 1.10 知识库与阶段风险提示(计划 #10/#13)
|
### 1.10 知识库与阶段风险提示(计划 #10/#13)
|
||||||
|
|
||||||
@@ -82,6 +84,7 @@
|
|||||||
- LAMP 检测任务单 + 标准 5 步流程 + 结果照片 + 结果录入
|
- LAMP 检测任务单 + 标准 5 步流程 + 结果照片 + 结果录入
|
||||||
- qPCR Ct 值自动判读(阈值可配);SERS 光谱数据上传与光谱库;高光谱方式预留
|
- qPCR Ct 值自动判读(阈值可配);SERS 光谱数据上传与光谱库;高光谱方式预留
|
||||||
- 交叉验证:AI 结果 vs 检测结果(一致→确认诊断;不一致→建议专家会诊)
|
- 交叉验证:AI 结果 vs 检测结果(一致→确认诊断;不一致→建议专家会诊)
|
||||||
|
- 统一检测任务/样本/发病事件:`/detection-tasks`、`/samples`、`/disease-events`;橙色/红色巡检通过 Outbox 幂等创建待确认任务,阳性结果自动创建发病事件并关联溯源
|
||||||
- 多检测方式推荐引擎(设备条件/紧急程度/操作者水平/成本偏好)
|
- 多检测方式推荐引擎(设备条件/紧急程度/操作者水平/成本偏好)
|
||||||
- Web「分子检测」页 + 小程序 LAMP 录入
|
- Web「分子检测」页 + 小程序 LAMP 录入
|
||||||
|
|
||||||
@@ -89,6 +92,7 @@
|
|||||||
|
|
||||||
- 会诊单 + 病例快照(照片/AI/检测/环境/批次/天气)+ 状态机(受理/出方案/归档)+ 专家意见与防控方案
|
- 会诊单 + 病例快照(照片/AI/检测/环境/批次/天气)+ 状态机(受理/出方案/归档)+ 专家意见与防控方案
|
||||||
- Web「专家会诊」页
|
- Web「专家会诊」页
|
||||||
|
- 会诊治理:统一状态机、SLA 超时标记、专家意见版本;知识库草稿/审核/发布/撤回;防控效果报告
|
||||||
|
|
||||||
### 1.13 疫病溯源与区域统计(计划 #21/#22)
|
### 1.13 疫病溯源与区域统计(计划 #21/#22)
|
||||||
|
|
||||||
@@ -173,7 +177,7 @@ silk/
|
|||||||
│ ├─ cmd/server/main.go # 入口
|
│ ├─ cmd/server/main.go # 入口
|
||||||
│ ├─ internal/
|
│ ├─ internal/
|
||||||
│ │ ├─ config/ # 环境变量配置
|
│ │ ├─ config/ # 环境变量配置
|
||||||
│ │ ├─ database/ # GORM 数据库连接与自动迁移
|
│ │ ├─ database/ # GORM 数据库连接与版本化迁移
|
||||||
│ │ ├─ handler/ # HTTP handler(auth/device/alarm/video 等)
|
│ │ ├─ handler/ # HTTP handler(auth/device/alarm/video 等)
|
||||||
│ │ ├─ middleware/ # JWT 鉴权、RBAC 权限、CORS、限流、安全头
|
│ │ ├─ middleware/ # JWT 鉴权、RBAC 权限、CORS、限流、安全头
|
||||||
│ │ ├─ model/ # GORM 数据模型 + 权限种子数据
|
│ │ ├─ model/ # GORM 数据模型 + 权限种子数据
|
||||||
@@ -244,7 +248,7 @@ silk/
|
|||||||
| Alarms | `handler/alarm.go` | 告警生成、确认、解除、查询 |
|
| Alarms | `handler/alarm.go` | 告警生成、确认、解除、查询 |
|
||||||
| AlarmClip | `handler/alarm_clip.go` | 告警关联视频片段 |
|
| AlarmClip | `handler/alarm_clip.go` | 告警关联视频片段 |
|
||||||
| Control | `handler/control.go` | MQTT 控制命令下发 |
|
| Control | `handler/control.go` | MQTT 控制命令下发 |
|
||||||
| Notification | `handler/notification.go` | Redis 站内通知 |
|
| Notification | `handler/notification.go` | PostgreSQL 持久化通知与 Outbox 状态 |
|
||||||
| Video Camera | `handler/video_camera.go` | 摄像头管理、WVP 状态同步 |
|
| Video Camera | `handler/video_camera.go` | 摄像头管理、WVP 状态同步 |
|
||||||
| Video Stream | `handler/video_stream.go` | 实时点播、FLV 代理 |
|
| Video Stream | `handler/video_stream.go` | 实时点播、FLV 代理 |
|
||||||
| Video Record | `handler/video_record.go` | 录制启停、活跃录制查询 |
|
| Video Record | `handler/video_record.go` | 录制启停、活跃录制查询 |
|
||||||
@@ -346,6 +350,7 @@ Authorization: Bearer <accessToken>
|
|||||||
| `GET/POST/PATCH/DELETE /trays`、`/batches`、`/rearing-records` | 蚕匾/批次/饲养记录 |
|
| `GET/POST/PATCH/DELETE /trays`、`/batches`、`/rearing-records` | 蚕匾/批次/饲养记录 |
|
||||||
| `GET/POST/PATCH/DELETE /lamp-tests(/:id)` | 分子检测任务单(含步骤/结果照片/judge-qpcr/spectrum/cross-validation) |
|
| `GET/POST/PATCH/DELETE /lamp-tests(/:id)` | 分子检测任务单(含步骤/结果照片/judge-qpcr/spectrum/cross-validation) |
|
||||||
| `GET/POST/DELETE /spectrum-entries` | SERS 光谱库 |
|
| `GET/POST/DELETE /spectrum-entries` | SERS 光谱库 |
|
||||||
|
| `GET/POST/PATCH /detection-tasks(/:id)`、`/samples`、`/disease-events` | 统一检测任务、样本链路与发病事件 |
|
||||||
| `GET /weather/now`、`GET /weather/alerts` | 天气与高发病预警 |
|
| `GET /weather/now`、`GET /weather/alerts` | 天气与高发病预警 |
|
||||||
| `GET/POST /wechat/binding`、`/wechat/bind`、`/wechat/subscribe` | 微信订阅绑定/授权 |
|
| `GET/POST /wechat/binding`、`/wechat/bind`、`/wechat/subscribe` | 微信订阅绑定/授权 |
|
||||||
| `GET/POST/PATCH /consultations(/:id)`、`/:id/resolve`、`/:id/archive` | 专家会诊 |
|
| `GET/POST/PATCH /consultations(/:id)`、`/:id/resolve`、`/:id/archive` | 专家会诊 |
|
||||||
@@ -426,7 +431,7 @@ Payload 示例:
|
|||||||
|
|
||||||
### 10.1 PostgreSQL(业务数据)
|
### 10.1 PostgreSQL(业务数据)
|
||||||
|
|
||||||
保存用户、蚕房、设备、传感器、阈值、告警、摄像头、录像片段、审计日志、权限等业务数据。GORM 自动迁移建表,系统启动时自动初始化权限种子数据。
|
保存用户、蚕房、设备、传感器、阈值、告警、摄像头、录像片段、审计日志、权限等业务数据。系统启动时先执行 `server-go/migrations/` 下的版本化 SQL 迁移,再初始化权限种子数据;生产环境禁用 AutoMigrate。
|
||||||
|
|
||||||
```env
|
```env
|
||||||
PG=postgresql://postgres:pan@localhost:5432/silk
|
PG=postgresql://postgres:pan@localhost:5432/silk
|
||||||
@@ -442,9 +447,9 @@ IOTDB_URL=http://localhost:18081
|
|||||||
|
|
||||||
IoTDB 路径示例:`root.silk.telemetry.`sensor-001`.temperature`
|
IoTDB 路径示例:`root.silk.telemetry.`sensor-001`.temperature`
|
||||||
|
|
||||||
### 10.3 Valkey/Redis(缓存与通知)
|
### 10.3 Valkey/Redis(跨实例认证状态)
|
||||||
|
|
||||||
用于权限缓存(5 分钟有效期)、站内通知列表。
|
用于 JWT 吊销、登录限流等跨实例状态;通知与事件箱持久化在 PostgreSQL,Redis 不可用时登录/刷新/吊销和已认证接口保守失败,不回退单机内存。
|
||||||
|
|
||||||
### 10.4 Ceph S3(录像存储)
|
### 10.4 Ceph S3(录像存储)
|
||||||
|
|
||||||
@@ -499,7 +504,10 @@ C:\msys64\usr\bin\sshpass.exe -p "pan" C:\msys64\usr\bin\ssh.exe -o StrictHostKe
|
|||||||
| 变量 | 默认值 | 说明 |
|
| 变量 | 默认值 | 说明 |
|
||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
| `PG` | `postgresql://postgres:pan@localhost:5432/silk` | PostgreSQL 连接串 |
|
| `PG` | `postgresql://postgres:pan@localhost:5432/silk` | PostgreSQL 连接串 |
|
||||||
| `REDIS` | `redis://:pan@localhost:6379` | Valkey/Redis 连接串 |
|
| `APP_ENV` | `development` | 运行环境;生产环境应设为 `production` |
|
||||||
|
| `ALLOW_DEV_AUTOMIGRATE` | `false` | 仅开发环境可显式开启 AutoMigrate,生产忽略此开关 |
|
||||||
|
| `WS_ALLOWED_ORIGINS` | `http://localhost:5174,http://localhost:3000,...` | WebSocket 允许的 Origin,逗号分隔 |
|
||||||
|
| `REDIS` | `redis://:pan@localhost:6379` | Valkey/Redis 连接串;不可用时认证/限流接口返回 503 |
|
||||||
| `JWT_SECRET` | `silk-secret-please-change-me` | JWT 签名密钥 |
|
| `JWT_SECRET` | `silk-secret-please-change-me` | JWT 签名密钥 |
|
||||||
| `JWT_EXPIRES_IN` | `2h` | JWT 有效期 |
|
| `JWT_EXPIRES_IN` | `2h` | JWT 有效期 |
|
||||||
| `MQTT` | `mqtt://pan:pan@localhost:1883` | MQTT 连接串 |
|
| `MQTT` | `mqtt://pan:pan@localhost:1883` | MQTT 连接串 |
|
||||||
@@ -592,14 +600,16 @@ python -m venv .venv
|
|||||||
MODEL_MODE=mock .venv\Scripts\python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
MODEL_MODE=mock .venv\Scripts\python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
- 接口:`GET /health`、`POST /detect`(multipart 图片)、`POST /stream-detect`(摄像头流拉帧骨架)、`GET /metrics`(含 GPU 信息)
|
- 接口:`GET /health`、`POST /detect`(multipart 图片,返回 `abnormalProbability`)、`POST /internal/stream-tasks`(受限摄像头流拉帧任务)、`GET /metrics`(含 GPU 信息)
|
||||||
- 默认 mock 模式;训练恢复后放 `models/best.onnx` 并设 `MODEL_MODE=onnx`(`MODEL_LABELS` 可配类别)
|
- 默认 mock 模式且响应带 `isMock=true`;训练恢复后放 `models/best.onnx` 并设 `MODEL_MODE=onnx`(`MODEL_LABELS` 可配类别、`MODEL_VERSION` 必须显式配置)
|
||||||
- 开发服务器部署:`/home/pan/ai-service`(venv + `start.sh`,:8000),服务器 pip 源已配置清华镜像
|
- 开发服务器部署:`/home/pan/ai-service`(venv + `start.sh`,:8000),服务器 pip 源已配置清华镜像
|
||||||
|
|
||||||
### 12.7 开发服务器部署摘要
|
### 12.7 开发服务器部署摘要
|
||||||
|
|
||||||
- 部署规则(备份/回滚/冒烟)见根目录 `AGENTS.md`「部署到开发服务器」;SSH/SFTP 工具为 `scripts/devssh.py`(`PAN_SSH_PASS` 环境变量传密码)
|
- 部署规则(备份/回滚/冒烟)见根目录 `AGENTS.md`「部署到开发服务器」;SSH/SFTP 工具为 `scripts/devssh.py`(`PAN_SSH_PASS` 环境变量传密码)
|
||||||
- 服务清单:Go 后端 :3000、Web :5174、ai-service :8000、recorder-go :9090、PostgreSQL :5432、IoTDB :18081、VerneMQ :1883、Ceph RGW :7480、WVP :18080、ZLM :8081
|
- 服务清单:Go 后端 :3000、Web :5174、ai-service :8000、recorder-go :9090、PostgreSQL :5432、IoTDB :18081、VerneMQ :1883、Ceph RGW :7480、WVP :18080、ZLM :8081
|
||||||
|
- 可观测性:HTTP 请求统一 `X-Request-ID`;日志脱敏;`GET /api/v1/ops/metrics` 提供依赖指标;SLO/负载/备份恢复演练文档见 `docs/operations/`
|
||||||
|
- 验收门禁:需求追踪、核心端到端用例和发布清单见 `docs/acceptance/`
|
||||||
|
|
||||||
## 13. Git 注意事项
|
## 13. Git 注意事项
|
||||||
|
|
||||||
|
|||||||
+17
-2
@@ -5,7 +5,8 @@ FastAPI + ONNX Runtime 的蚕病检测推理服务。YOLO 训练挂起期间以
|
|||||||
## 接口
|
## 接口
|
||||||
|
|
||||||
- `GET /health` → `{"status":"ok","model":"mock|onnx"}`
|
- `GET /health` → `{"status":"ok","model":"mock|onnx"}`
|
||||||
- `POST /detect`(multipart 字段 `file`)→ `{"model":"mock","detections":[{"bbox":{x,y,w,h},"class":"healthy|sick","confidence":0.95}]}`
|
- `POST /detect`(multipart 字段 `file`)→ `{"model":"mock","modelVersion":"...","isMock":true,"status":"healthy|abnormal|unknown","abnormalProbability":0,"detections":[{"bbox":{x,y,w,h},"class":"healthy|sick","confidence":0.95}]}`
|
||||||
|
- `POST /internal/stream-tasks`(需 `X-Internal-Key`)→ 创建受限拉流任务,不再接受客户端任意 URL
|
||||||
|
|
||||||
## 本地运行
|
## 本地运行
|
||||||
|
|
||||||
@@ -17,6 +18,14 @@ python -m venv .venv
|
|||||||
MODEL_MODE=mock .venv/Scripts/python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
MODEL_MODE=mock .venv/Scripts/python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 质量检查
|
||||||
|
|
||||||
|
仓库根目录的 `scripts/verify.ps1` 会使用 `ai-service/.venv/Scripts/python.exe` 运行 pytest;如果该虚拟环境不存在,会回退到 `python`。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File scripts/verify.ps1
|
||||||
|
```
|
||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
| 变量 | 默认 | 说明 |
|
| 变量 | 默认 | 说明 |
|
||||||
@@ -24,10 +33,16 @@ MODEL_MODE=mock .venv/Scripts/python -m uvicorn app.main:app --host 0.0.0.0 --po
|
|||||||
| `MODEL_MODE` | `mock` | `mock` / `onnx` |
|
| `MODEL_MODE` | `mock` | `mock` / `onnx` |
|
||||||
| `MODEL_PATH` | `models/best.onnx` | ONNX 模型路径 |
|
| `MODEL_PATH` | `models/best.onnx` | ONNX 模型路径 |
|
||||||
| `MODEL_LABELS` | `healthy,sick` | 类别列表(逗号分隔) |
|
| `MODEL_LABELS` | `healthy,sick` | 类别列表(逗号分隔) |
|
||||||
|
| `MODEL_VERSION` | `mock-2026.08.14` / `best.onnx` | 模型版本;真实模型上线时必须显式配置 |
|
||||||
| `MOCK_CLASS` | `healthy` | mock 返回类别 |
|
| `MOCK_CLASS` | `healthy` | mock 返回类别 |
|
||||||
| `MOCK_CONFIDENCE` | `0.95` | mock 返回置信度 |
|
| `MOCK_CONFIDENCE` | `0.95` | mock 返回置信度 |
|
||||||
|
| `INTERNAL_API_KEY` | `silk-internal-2026` | 内部接口认证密钥 |
|
||||||
|
| `ALLOWED_STREAM_HOSTS` | `localhost,127.0.0.1,100.83.103.1` | 允许拉流的主机白名单 |
|
||||||
|
| `STREAM_TASK_MAX_WORKERS` | `2` | 流任务并发 worker 数 |
|
||||||
|
| `STREAM_TASK_MAX_FRAMES` | `10` | 单任务最大抽帧数 |
|
||||||
|
|
||||||
## 说明
|
## 说明
|
||||||
|
|
||||||
- 只做检测,风险评分在 Go 后端计算(#9:0.5×AI 置信度 + 0.2×环境 + 0.15×阶段 + 0.15×整齐度)。
|
- 只做检测并返回 `abnormalProbability`;风险评分在 Go 后端计算(#9:0.5×AI 异常概率 + 0.2×环境 + 0.15×阶段 + 0.15×整齐度,缺失项归一化)。
|
||||||
|
- `healthy` 高置信度不贡献异常概率;空检测或 `unknown` 返回 `unknown`,不自动视为健康。
|
||||||
- ONNX 后处理按 YOLOv8 常见输出格式实现(含 letterbox 与 NMS),训练产物出来后需用真实模型校准验证。
|
- ONNX 后处理按 YOLOv8 常见输出格式实现(含 letterbox 与 NMS),训练产物出来后需用真实模型校准验证。
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ MODEL_MODE = os.getenv("MODEL_MODE", "mock")
|
|||||||
MODEL_PATH = os.getenv("MODEL_PATH", "models/best.onnx")
|
MODEL_PATH = os.getenv("MODEL_PATH", "models/best.onnx")
|
||||||
# YOLO 类别(二分类训练基线:healthy/sick;7 类病种扩展后再调整)
|
# YOLO 类别(二分类训练基线:healthy/sick;7 类病种扩展后再调整)
|
||||||
MODEL_LABELS = os.getenv("MODEL_LABELS", "healthy,sick").split(",")
|
MODEL_LABELS = os.getenv("MODEL_LABELS", "healthy,sick").split(",")
|
||||||
|
MODEL_VERSION = os.getenv("MODEL_VERSION", "mock-2026.08.14" if MODEL_MODE == "mock" else "best.onnx")
|
||||||
# mock 模式返回的固定结果
|
# mock 模式返回的固定结果
|
||||||
MOCK_CLASS = os.getenv("MOCK_CLASS", "healthy")
|
MOCK_CLASS = os.getenv("MOCK_CLASS", "healthy")
|
||||||
MOCK_CONFIDENCE = float(os.getenv("MOCK_CONFIDENCE", "0.95"))
|
MOCK_CONFIDENCE = float(os.getenv("MOCK_CONFIDENCE", "0.95"))
|
||||||
|
INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "silk-internal-2026")
|
||||||
|
ALLOWED_STREAM_HOSTS = [
|
||||||
|
item.strip()
|
||||||
|
for item in os.getenv("ALLOWED_STREAM_HOSTS", "localhost,127.0.0.1,100.83.103.1").split(",")
|
||||||
|
if item.strip()
|
||||||
|
]
|
||||||
|
STREAM_TASK_MAX_WORKERS = int(os.getenv("STREAM_TASK_MAX_WORKERS", "2"))
|
||||||
|
STREAM_TASK_MAX_FRAMES = int(os.getenv("STREAM_TASK_MAX_FRAMES", "10"))
|
||||||
|
|||||||
@@ -5,6 +5,52 @@ from abc import ABC, abstractmethod
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
def _label_set(labels) -> set[str]:
|
||||||
|
return {label.strip().lower() for label in labels if label.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def abnormal_probability(detections: list[dict], labels: tuple[str, ...] | list[str]) -> float:
|
||||||
|
"""单帧异常概率:取异常类检测的最高置信度,避免多框求和造成虚高。"""
|
||||||
|
abnormal = _label_set(labels) - {"healthy"}
|
||||||
|
best = 0.0
|
||||||
|
for detection in detections:
|
||||||
|
class_name = str(detection.get("class_name", "")).strip().lower()
|
||||||
|
if class_name in ("", "healthy", "unknown"):
|
||||||
|
continue
|
||||||
|
if abnormal and class_name not in abnormal:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
confidence = float(detection.get("confidence", 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
confidence = 0.0
|
||||||
|
best = max(best, min(1.0, max(0.0, confidence)))
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def detection_status(detections: list[dict], labels: tuple[str, ...] | list[str]) -> str:
|
||||||
|
"""空检测或 unknown 类不再被当作 healthy。"""
|
||||||
|
if not detections:
|
||||||
|
return "unknown"
|
||||||
|
abnormal = _label_set(labels) - {"healthy"}
|
||||||
|
healthy_seen = False
|
||||||
|
unknown_seen = False
|
||||||
|
for detection in detections:
|
||||||
|
class_name = str(detection.get("class_name", "")).strip().lower()
|
||||||
|
if abnormal and class_name in abnormal:
|
||||||
|
return "abnormal"
|
||||||
|
if not abnormal and class_name not in ("", "healthy", "unknown"):
|
||||||
|
return "abnormal"
|
||||||
|
if class_name == "healthy":
|
||||||
|
healthy_seen = True
|
||||||
|
elif class_name in ("", "unknown"):
|
||||||
|
unknown_seen = True
|
||||||
|
else:
|
||||||
|
unknown_seen = True
|
||||||
|
if healthy_seen and not unknown_seen:
|
||||||
|
return "healthy"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
class Detector(ABC):
|
class Detector(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def detect(self, image_bytes: bytes) -> list[dict]:
|
def detect(self, image_bytes: bytes) -> list[dict]:
|
||||||
|
|||||||
+45
-34
@@ -2,25 +2,30 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
from fastapi import FastAPI, File, HTTPException, Request, UploadFile
|
from fastapi import FastAPI, File, HTTPException, Request, UploadFile
|
||||||
|
|
||||||
from . import config
|
from . import config
|
||||||
from .detector import MockDetector, ONNXDetector
|
from .detector import MockDetector, ONNXDetector, abnormal_probability, detection_status
|
||||||
|
from .stream_tasks import StreamTaskWorker, is_allowed_stream_ref
|
||||||
|
|
||||||
app = FastAPI(title="Silk AI Service", version="0.1.0")
|
app = FastAPI(title="Silk AI Service", version="0.1.0")
|
||||||
START_TIME = time.time()
|
START_TIME = time.time()
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
_requests = 0
|
_requests = 0
|
||||||
_latency_total = 0.0
|
_latency_total = 0.0
|
||||||
|
stream_worker = StreamTaskWorker(config.STREAM_TASK_MAX_WORKERS)
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def count_requests(request: Request, call_next):
|
async def count_requests(request: Request, call_next):
|
||||||
global _requests, _latency_total
|
global _requests, _latency_total
|
||||||
start = time.time()
|
start = time.time()
|
||||||
|
request_id = request.headers.get("x-request-id") or str(uuid.uuid4())
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
latency = (time.time() - start) * 1000
|
latency = (time.time() - start) * 1000
|
||||||
|
response.headers["X-Request-ID"] = request_id
|
||||||
with _lock:
|
with _lock:
|
||||||
_requests += 1
|
_requests += 1
|
||||||
_latency_total += latency
|
_latency_total += latency
|
||||||
@@ -34,7 +39,12 @@ else:
|
|||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
return {"status": "ok", "model": config.MODEL_MODE}
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"model": config.MODEL_MODE,
|
||||||
|
"modelVersion": config.MODEL_VERSION,
|
||||||
|
"isMock": isinstance(detector, MockDetector),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/detect")
|
@app.post("/detect")
|
||||||
@@ -48,6 +58,10 @@ async def detect(file: UploadFile = File(...)):
|
|||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
return {
|
return {
|
||||||
"model": config.MODEL_MODE,
|
"model": config.MODEL_MODE,
|
||||||
|
"modelVersion": config.MODEL_VERSION,
|
||||||
|
"isMock": isinstance(detector, MockDetector),
|
||||||
|
"status": detection_status(detections, config.MODEL_LABELS),
|
||||||
|
"abnormalProbability": abnormal_probability(detections, config.MODEL_LABELS),
|
||||||
"detections": [
|
"detections": [
|
||||||
{
|
{
|
||||||
"bbox": d["bbox"],
|
"bbox": d["bbox"],
|
||||||
@@ -59,40 +73,35 @@ async def detect(file: UploadFile = File(...)):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
try:
|
|
||||||
import cv2 # noqa: E402
|
|
||||||
except ImportError:
|
|
||||||
cv2 = None
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/stream-detect")
|
@app.post("/stream-detect")
|
||||||
async def stream_detect(body: dict):
|
def stream_detect():
|
||||||
"""摄像头流 AI 巡检骨架(#23):拉流抽帧 → 检测。
|
raise HTTPException(status_code=410, detail="请使用 POST /internal/stream-tasks")
|
||||||
|
|
||||||
二期功能:正式接入前需补摄像头视角数据与巡检任务编排。
|
|
||||||
"""
|
@app.post("/internal/stream-tasks", status_code=202)
|
||||||
url = (body or {}).get("url") or ""
|
def create_stream_task(request: Request, body: dict):
|
||||||
if not url:
|
if request.headers.get("x-internal-key") != config.INTERNAL_API_KEY:
|
||||||
raise HTTPException(status_code=400, detail="缺少 url(rtsp/http-flv 流地址)")
|
raise HTTPException(status_code=401, detail="invalid internal key")
|
||||||
if cv2 is None:
|
|
||||||
raise HTTPException(status_code=503, detail="OpenCV 未安装,无法拉流")
|
task_id = (body or {}).get("taskId", "")
|
||||||
capture = cv2.VideoCapture(url)
|
stream_ref = (body or {}).get("streamRef")
|
||||||
if not capture.isOpened():
|
if not task_id or not isinstance(stream_ref, dict):
|
||||||
raise HTTPException(status_code=502, detail="无法连接视频流")
|
raise HTTPException(status_code=400, detail="缺少 taskId 或 streamRef")
|
||||||
try:
|
if not is_allowed_stream_ref(stream_ref):
|
||||||
frames = 0
|
raise HTTPException(status_code=400, detail="streamRef 不在允许范围")
|
||||||
detections = []
|
|
||||||
while frames < 3:
|
stream_worker.submit(task_id, stream_ref)
|
||||||
ok, frame = capture.read()
|
return {"taskId": task_id, "status": "queued"}
|
||||||
if not ok:
|
|
||||||
break
|
|
||||||
ok_encode, buf = cv2.imencode(".jpg", frame)
|
@app.get("/internal/stream-tasks/{task_id}")
|
||||||
if ok_encode:
|
def get_stream_task(request: Request, task_id: str):
|
||||||
detections.extend(detector.detect(buf.tobytes()))
|
if request.headers.get("x-internal-key") != config.INTERNAL_API_KEY:
|
||||||
frames += 1
|
raise HTTPException(status_code=401, detail="invalid internal key")
|
||||||
finally:
|
task = stream_worker.get(task_id)
|
||||||
capture.release()
|
if not task:
|
||||||
return {"model": config.MODEL_MODE, "frames": frames, "detections": detections[:10]}
|
raise HTTPException(status_code=404, detail="task not found")
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
@app.get("/metrics")
|
@app.get("/metrics")
|
||||||
@@ -115,6 +124,8 @@ def metrics():
|
|||||||
gpu = None
|
gpu = None
|
||||||
return {
|
return {
|
||||||
"model": config.MODEL_MODE,
|
"model": config.MODEL_MODE,
|
||||||
|
"modelVersion": config.MODEL_VERSION,
|
||||||
|
"isMock": isinstance(detector, MockDetector),
|
||||||
"uptimeSeconds": int(time.time() - START_TIME),
|
"uptimeSeconds": int(time.time() - START_TIME),
|
||||||
"requests": reqs,
|
"requests": reqs,
|
||||||
"avgLatencyMs": round(avg, 2),
|
"avgLatencyMs": round(avg, 2),
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
from .detector import MockDetector, ONNXDetector
|
||||||
|
|
||||||
|
|
||||||
|
def _detector():
|
||||||
|
if config.MODEL_MODE == "onnx":
|
||||||
|
return ONNXDetector(config.MODEL_PATH, tuple(config.MODEL_LABELS))
|
||||||
|
return MockDetector(class_name=config.MOCK_CLASS, confidence=config.MOCK_CONFIDENCE)
|
||||||
|
|
||||||
|
|
||||||
|
def is_allowed_stream_ref(stream_ref):
|
||||||
|
url = (stream_ref or {}).get("url", "")
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("rtsp", "http", "https"):
|
||||||
|
return False
|
||||||
|
if parsed.username or parsed.password:
|
||||||
|
return False
|
||||||
|
host = (parsed.hostname or "").lower()
|
||||||
|
allowed = {item.strip().lower() for item in config.ALLOWED_STREAM_HOSTS}
|
||||||
|
return host in allowed
|
||||||
|
|
||||||
|
|
||||||
|
class StreamTaskWorker:
|
||||||
|
def __init__(self, max_workers=2):
|
||||||
|
self.executor = ThreadPoolExecutor(max_workers=max_workers)
|
||||||
|
self.tasks = {}
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
|
||||||
|
def submit(self, task_id, stream_ref):
|
||||||
|
with self.lock:
|
||||||
|
self.tasks[task_id] = {
|
||||||
|
"taskId": task_id,
|
||||||
|
"status": "queued",
|
||||||
|
"startedAt": time.time(),
|
||||||
|
"finishedAt": None,
|
||||||
|
"frames": 0,
|
||||||
|
"detections": [],
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
self.executor.submit(self._run, task_id, stream_ref)
|
||||||
|
|
||||||
|
def get(self, task_id):
|
||||||
|
with self.lock:
|
||||||
|
task = self.tasks.get(task_id)
|
||||||
|
return dict(task) if task else None
|
||||||
|
|
||||||
|
def _run(self, task_id, stream_ref):
|
||||||
|
try:
|
||||||
|
with self.lock:
|
||||||
|
task = self.tasks.get(task_id)
|
||||||
|
if task:
|
||||||
|
task["status"] = "running"
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
except ImportError:
|
||||||
|
self._fail(task_id, "OpenCV 未安装,无法拉流")
|
||||||
|
return
|
||||||
|
|
||||||
|
capture = cv2.VideoCapture(stream_ref.get("url", ""))
|
||||||
|
if not capture.isOpened():
|
||||||
|
self._fail(task_id, "无法连接视频流")
|
||||||
|
return
|
||||||
|
|
||||||
|
max_frames = min(int(stream_ref.get("maxFrames", config.STREAM_TASK_MAX_FRAMES)), config.STREAM_TASK_MAX_FRAMES)
|
||||||
|
detector = _detector()
|
||||||
|
frames = 0
|
||||||
|
detections = []
|
||||||
|
try:
|
||||||
|
while frames < max_frames:
|
||||||
|
ok, frame = capture.read()
|
||||||
|
if not ok:
|
||||||
|
break
|
||||||
|
ok_encode, buf = cv2.imencode(".jpg", frame)
|
||||||
|
if ok_encode:
|
||||||
|
detections.extend(detector.detect(buf.tobytes()))
|
||||||
|
frames += 1
|
||||||
|
finally:
|
||||||
|
capture.release()
|
||||||
|
|
||||||
|
with self.lock:
|
||||||
|
task = self.tasks.get(task_id)
|
||||||
|
if task:
|
||||||
|
task["status"] = "completed"
|
||||||
|
task["frames"] = frames
|
||||||
|
task["detections"] = detections[:10]
|
||||||
|
task["finishedAt"] = time.time()
|
||||||
|
except Exception as exc:
|
||||||
|
self._fail(task_id, str(exc))
|
||||||
|
|
||||||
|
def _fail(self, task_id, message):
|
||||||
|
with self.lock:
|
||||||
|
task = self.tasks.get(task_id)
|
||||||
|
if task:
|
||||||
|
task["status"] = "failed"
|
||||||
|
task["error"] = message
|
||||||
|
task["finishedAt"] = time.time()
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
import base64
|
import base64
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("INTERNAL_API_KEY", "test-internal-key")
|
||||||
|
os.environ.setdefault("ALLOWED_STREAM_HOSTS", "localhost,127.0.0.1,100.83.103.1")
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import main as main_module
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
from app.detector import MockDetector
|
||||||
|
|
||||||
# 1x1 透明 PNG
|
# 1x1 透明 PNG
|
||||||
TINY_PNG = base64.b64decode(
|
TINY_PNG = base64.b64decode(
|
||||||
@@ -18,13 +24,20 @@ def test_health():
|
|||||||
body = r.json()
|
body = r.json()
|
||||||
assert body["status"] == "ok"
|
assert body["status"] == "ok"
|
||||||
assert body["model"] in ("mock", "onnx")
|
assert body["model"] in ("mock", "onnx")
|
||||||
|
assert body["modelVersion"]
|
||||||
|
assert body["isMock"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_detect_ok():
|
def test_detect_ok():
|
||||||
r = client.post("/detect", files={"file": ("a.png", TINY_PNG, "image/png")})
|
r = client.post("/detect", files={"file": ("a.png", TINY_PNG, "image/png")})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
assert r.headers.get("x-request-id")
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert body["model"] == "mock"
|
assert body["model"] == "mock"
|
||||||
|
assert body["modelVersion"]
|
||||||
|
assert body["isMock"] is True
|
||||||
|
assert body["status"] == "healthy"
|
||||||
|
assert body["abnormalProbability"] == 0
|
||||||
assert len(body["detections"]) >= 1
|
assert len(body["detections"]) >= 1
|
||||||
d = body["detections"][0]
|
d = body["detections"][0]
|
||||||
assert d["class"] in ("healthy", "sick")
|
assert d["class"] in ("healthy", "sick")
|
||||||
@@ -32,6 +45,28 @@ def test_detect_ok():
|
|||||||
assert d["bbox"]["w"] > 0
|
assert d["bbox"]["w"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_uses_abnormal_class_confidence(monkeypatch):
|
||||||
|
monkeypatch.setattr(main_module, "detector", MockDetector(class_name="sick", confidence=0.92))
|
||||||
|
r = client.post("/detect", files={"file": ("a.png", TINY_PNG, "image/png")})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["status"] == "abnormal"
|
||||||
|
assert body["abnormalProbability"] == 0.92
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_empty_result_is_unknown(monkeypatch):
|
||||||
|
class EmptyDetector:
|
||||||
|
def detect(self, image_bytes):
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(main_module, "detector", EmptyDetector())
|
||||||
|
r = client.post("/detect", files={"file": ("a.png", TINY_PNG, "image/png")})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["status"] == "unknown"
|
||||||
|
assert body["abnormalProbability"] == 0
|
||||||
|
|
||||||
|
|
||||||
def test_detect_empty_file_rejected():
|
def test_detect_empty_file_rejected():
|
||||||
r = client.post("/detect", files={"file": ("a.png", b"", "image/png")})
|
r = client.post("/detect", files={"file": ("a.png", b"", "image/png")})
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
@@ -42,8 +77,44 @@ def test_detect_invalid_image_rejected():
|
|||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
def test_stream_detect_missing_url():
|
def test_stream_detect_is_disabled():
|
||||||
r = client.post("/stream-detect", json={})
|
r = client.post("/stream-detect", json={})
|
||||||
|
assert r.status_code == 410
|
||||||
|
|
||||||
|
|
||||||
|
def test_internal_stream_tasks_requires_internal_key():
|
||||||
|
r = client.post("/internal/stream-tasks", json={})
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_internal_stream_tasks_accepts_internal_stream_ref():
|
||||||
|
r = client.post(
|
||||||
|
"/internal/stream-tasks",
|
||||||
|
headers={"X-Internal-Key": "test-internal-key"},
|
||||||
|
json={
|
||||||
|
"taskId": "task-1",
|
||||||
|
"streamRef": {
|
||||||
|
"url": "rtsp://127.0.0.1:8554/live/1",
|
||||||
|
"maxFrames": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 202
|
||||||
|
assert r.json()["taskId"] == "task-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_internal_stream_tasks_rejects_metadata_url():
|
||||||
|
r = client.post(
|
||||||
|
"/internal/stream-tasks",
|
||||||
|
headers={"X-Internal-Key": "test-internal-key"},
|
||||||
|
json={
|
||||||
|
"taskId": "task-2",
|
||||||
|
"streamRef": {
|
||||||
|
"url": "http://169.254.169.254/latest/meta-data",
|
||||||
|
"maxFrames": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import base64
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.detector import MockDetector
|
from app.detector import MockDetector, abnormal_probability, detection_status
|
||||||
|
|
||||||
# 1x1 透明 PNG
|
# 1x1 透明 PNG
|
||||||
TINY_PNG = base64.b64decode(
|
TINY_PNG = base64.b64decode(
|
||||||
@@ -23,3 +23,22 @@ def test_mock_detector_rejects_invalid_image():
|
|||||||
det = MockDetector()
|
det = MockDetector()
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
det.detect(b"not an image")
|
det.detect(b"not an image")
|
||||||
|
|
||||||
|
|
||||||
|
def test_abnormal_probability_ignores_healthy_and_unknown():
|
||||||
|
detections = [
|
||||||
|
{"class_name": "healthy", "confidence": 0.95},
|
||||||
|
{"class_name": "unknown", "confidence": 0.8},
|
||||||
|
{"class_name": "sick", "confidence": 0.72},
|
||||||
|
]
|
||||||
|
assert abnormal_probability(detections, ("healthy", "sick")) == 0.72
|
||||||
|
|
||||||
|
|
||||||
|
def test_detection_status_returns_unknown_for_empty_or_unknown():
|
||||||
|
assert detection_status([], ("healthy", "sick")) == "unknown"
|
||||||
|
assert detection_status([{"class_name": "unknown", "confidence": 0.8}], ("healthy", "sick")) == "unknown"
|
||||||
|
assert detection_status([{"class_name": "healthy", "confidence": 0.95}], ("healthy", "sick")) == "healthy"
|
||||||
|
assert detection_status(
|
||||||
|
[{"class_name": "white_muscardine", "confidence": 0.7}],
|
||||||
|
("healthy", "white_muscardine", "nuclear_polyhedrosis"),
|
||||||
|
) == "abnormal"
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
extends: '@react-native',
|
||||||
|
rules: {
|
||||||
|
'prettier/prettier': 'warn',
|
||||||
|
},
|
||||||
|
};
|
||||||
Generated
+3292
-274
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -17,14 +17,13 @@
|
|||||||
"@react-navigation/native-stack": "^6.10.0",
|
"@react-navigation/native-stack": "^6.10.0",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
"react": "18.3.1",
|
"react": "18.2.0",
|
||||||
"react-native": "0.74.5",
|
"react-native": "0.74.5",
|
||||||
"react-native-paper": "^5.12.3",
|
"react-native-paper": "^5.12.3",
|
||||||
"react-native-safe-area-context": "^4.10.5",
|
"react-native-safe-area-context": "^4.10.5",
|
||||||
"react-native-screens": "^3.32.0",
|
"react-native-screens": "^3.32.0",
|
||||||
"react-native-vector-icons": "^10.1.0",
|
"react-native-vector-icons": "^10.1.0",
|
||||||
"react-native-video": "^6.2.0",
|
"react-native-video": "^6.2.0",
|
||||||
"victory-native": "^41.6.0",
|
|
||||||
"zustand": "^4.5.4"
|
"zustand": "^4.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -32,9 +31,12 @@
|
|||||||
"@babel/preset-env": "^7.25.3",
|
"@babel/preset-env": "^7.25.3",
|
||||||
"@babel/runtime": "^7.25.0",
|
"@babel/runtime": "^7.25.0",
|
||||||
"@react-native/babel-preset": "0.74.85",
|
"@react-native/babel-preset": "0.74.85",
|
||||||
|
"@react-native/eslint-config": "0.74.85",
|
||||||
"@react-native/metro-config": "^0.74.85",
|
"@react-native/metro-config": "^0.74.85",
|
||||||
"@react-native/typescript-config": "0.74.85",
|
"@react-native/typescript-config": "0.74.85",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
|
"eslint": "^8.57.1",
|
||||||
|
"prettier": "^2.8.8",
|
||||||
"react-native-dotenv": "^3.4.11",
|
"react-native-dotenv": "^3.4.11",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, StyleSheet, Text, Dimensions } from 'react-native';
|
import { View, StyleSheet, Text } from 'react-native';
|
||||||
|
|
||||||
interface MiniChartProps {
|
interface MiniChartProps {
|
||||||
data: { time: string; temp?: number; humidity?: number; co2?: number }[];
|
data: { time: string; temp?: number; humidity?: number; co2?: number }[];
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useEffect } from 'react';
|
|||||||
import { NavigationContainer, useNavigation } from '@react-navigation/native';
|
import { NavigationContainer, useNavigation } from '@react-navigation/native';
|
||||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||||
import { Provider as PaperProvider, useTheme, MD3LightTheme } from 'react-native-paper';
|
import { Provider as PaperProvider, MD3LightTheme } from 'react-native-paper';
|
||||||
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||||
import { setNavigationRef } from '../api/client';
|
import { setNavigationRef } from '../api/client';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
@@ -25,7 +25,7 @@ import SettingsScreen from '../screens/SettingsScreen';
|
|||||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||||
const Tab = createBottomTabNavigator<MainTabParamList>();
|
const Tab = createBottomTabNavigator<MainTabParamList>();
|
||||||
|
|
||||||
const TabIcon: React.FC<{ name: string; activeName: string; color: string; size: number; focused: boolean }> = ({ name, activeName, color, size, focused }) => (
|
const TabIcon: React.FC<{ name: string; activeName: string; color: string; size: number; focused: boolean }> = ({ name, activeName, color, focused }) => (
|
||||||
<Icon name={focused ? activeName : name} size={focused ? 26 : 22} color={color} />
|
<Icon name={focused ? activeName : name} size={focused ? 26 : 22} color={color} />
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import React, { useEffect, useState, useCallback } from 'react';
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import { StyleSheet, View, FlatList, RefreshControl, Alert } from 'react-native';
|
import { StyleSheet, View, FlatList, RefreshControl, Alert } from 'react-native';
|
||||||
import { Text, Card, useTheme, ActivityIndicator, Surface, SegmentedButtons, Button, Snackbar } from 'react-native-paper';
|
import { Text, Card, ActivityIndicator, Surface, SegmentedButtons, Button, Snackbar } from 'react-native-paper';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
|
||||||
import { getAlarms, ackAlarm, getAlarmClip } from '../api/alarms';
|
import { getAlarms, ackAlarm, getAlarmClip } from '../api/alarms';
|
||||||
import { extractErrorMessage } from '../api/client';
|
import { extractErrorMessage } from '../api/client';
|
||||||
import { formatRelativeTime, getSeverityLabel } from '../utils/format';
|
import { formatRelativeTime, getSeverityLabel } from '../utils/format';
|
||||||
import type { Alarm } from '../types';
|
import type { Alarm } from '../types';
|
||||||
|
|
||||||
export default function AlertsScreen() {
|
export default function AlertsScreen() {
|
||||||
const theme = useTheme();
|
|
||||||
const navigation = useNavigation<any>();
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [alarms, setAlarms] = useState<Alarm[]>([]);
|
const [alarms, setAlarms] = useState<Alarm[]>([]);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState, useCallback } from 'react';
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import { StyleSheet, View, ScrollView, Alert } from 'react-native';
|
import { StyleSheet, View, ScrollView } from 'react-native';
|
||||||
import { Text, Card, Button, Switch, useTheme, ActivityIndicator, TextInput, SegmentedButtons, Divider, Snackbar } from 'react-native-paper';
|
import { Text, Card, Button, Switch, ActivityIndicator, TextInput, SegmentedButtons, Divider, Snackbar } from 'react-native-paper';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRoute } from '@react-navigation/native';
|
import { useRoute } from '@react-navigation/native';
|
||||||
import { sendControl } from '../api/devices';
|
import { sendControl } from '../api/devices';
|
||||||
@@ -10,7 +10,6 @@ import { formatNumber } from '../utils/format';
|
|||||||
import type { RealtimeMetric } from '../types';
|
import type { RealtimeMetric } from '../types';
|
||||||
|
|
||||||
export default function DeviceControlScreen() {
|
export default function DeviceControlScreen() {
|
||||||
const theme = useTheme();
|
|
||||||
const route = useRoute<any>();
|
const route = useRoute<any>();
|
||||||
const { deviceKey, deviceName } = route.params;
|
const { deviceKey, deviceName } = route.params;
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState, useCallback } from 'react';
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import { StyleSheet, View, FlatList, RefreshControl } from 'react-native';
|
import { StyleSheet, View, FlatList, RefreshControl } from 'react-native';
|
||||||
import { Text, Card, useTheme, ActivityIndicator, Surface, Searchbar, IconButton } from 'react-native-paper';
|
import { Text, Card, ActivityIndicator, Surface, Searchbar, IconButton } from 'react-native-paper';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { getDevices } from '../api/devices';
|
import { getDevices } from '../api/devices';
|
||||||
@@ -8,7 +8,6 @@ import { formatRelativeTime } from '../utils/format';
|
|||||||
import type { Device } from '../types';
|
import type { Device } from '../types';
|
||||||
|
|
||||||
export default function DevicesScreen() {
|
export default function DevicesScreen() {
|
||||||
const theme = useTheme();
|
|
||||||
const navigation = useNavigation<any>();
|
const navigation = useNavigation<any>();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { StyleSheet, View, ScrollView, RefreshControl } from 'react-native';
|
import { StyleSheet, View, ScrollView, RefreshControl } from 'react-native';
|
||||||
import { Text, Card, useTheme, ActivityIndicator, Surface } from 'react-native-paper';
|
import { Text, Card, ActivityIndicator, Surface } from 'react-native-paper';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRoute } from '@react-navigation/native';
|
import { useRoute } from '@react-navigation/native';
|
||||||
import { getRoom } from '../api/rooms';
|
import { getRoom } from '../api/rooms';
|
||||||
@@ -11,7 +11,6 @@ import { formatRelativeTime } from '../utils/format';
|
|||||||
import type { Room, RealtimeMetric, Device, TrendPoint } from '../types';
|
import type { Room, RealtimeMetric, Device, TrendPoint } from '../types';
|
||||||
|
|
||||||
export default function RoomDetailScreen() {
|
export default function RoomDetailScreen() {
|
||||||
const theme = useTheme();
|
|
||||||
const route = useRoute<any>();
|
const route = useRoute<any>();
|
||||||
const { roomId, roomName } = route.params;
|
const { roomId, roomName } = route.params;
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import React, { useEffect, useState, useCallback } from 'react';
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import { StyleSheet, View, FlatList, RefreshControl } from 'react-native';
|
import { StyleSheet, View, FlatList, RefreshControl } from 'react-native';
|
||||||
import { Text, Card, useTheme, ActivityIndicator, IconButton } from 'react-native-paper';
|
import { Text, Card, ActivityIndicator, IconButton } from 'react-native-paper';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { getRooms } from '../api/rooms';
|
import { getRooms } from '../api/rooms';
|
||||||
import { formatRelativeTime } from '../utils/format';
|
|
||||||
import type { Room } from '../types';
|
import type { Room } from '../types';
|
||||||
|
|
||||||
export default function RoomsScreen() {
|
export default function RoomsScreen() {
|
||||||
const theme = useTheme();
|
|
||||||
const navigation = useNavigation<any>();
|
const navigation = useNavigation<any>();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ import React, { useState } from 'react';
|
|||||||
import { StyleSheet, View, Alert, ScrollView } from 'react-native';
|
import { StyleSheet, View, Alert, ScrollView } from 'react-native';
|
||||||
import { Text, Card, Button, useTheme, Divider, TextInput, List, Avatar } from 'react-native-paper';
|
import { Text, Card, Button, useTheme, Divider, TextInput, List, Avatar } from 'react-native-paper';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { API_BASE_URL } from '@env';
|
import { API_BASE_URL } from '@env';
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const navigation = useNavigation<any>();
|
|
||||||
const { user, logout } = useAuthStore();
|
const { user, logout } = useAuthStore();
|
||||||
const [apiUrl, setApiUrl] = useState(API_BASE_URL || 'http://localhost:3000/api/v1');
|
const [apiUrl, setApiUrl] = useState(API_BASE_URL || 'http://localhost:3000/api/v1');
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { StyleSheet, View, StatusBar, Alert, ActivityIndicator } from 'react-native';
|
import { StyleSheet, View, StatusBar, ActivityIndicator } from 'react-native';
|
||||||
import { Text, IconButton, Surface } from 'react-native-paper';
|
import { Text, IconButton, Surface } from 'react-native-paper';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRoute, useNavigation } from '@react-navigation/native';
|
import { useRoute, useNavigation } from '@react-navigation/native';
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Text, Card, Button, useTheme, ActivityIndicator, Surface, IconButton, S
|
|||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { getCameras, getClips, playCamera } from '../api/video';
|
import { getCameras, getClips, playCamera } from '../api/video';
|
||||||
import { extractErrorMessage, resolveUrl } from '../api/client';
|
import { resolveUrl } from '../api/client';
|
||||||
import { formatDateTime, formatDuration, formatFileSize } from '../utils/format';
|
import { formatDateTime, formatDuration, formatFileSize } from '../utils/format';
|
||||||
import type { Camera, VideoClip } from '../types';
|
import type { Camera, VideoClip } from '../types';
|
||||||
|
|
||||||
@@ -41,12 +41,16 @@ export default function VideoScreen() {
|
|||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
const handlePlay = async (camera: Camera) => {
|
const handlePlay = async (camera: Camera) => {
|
||||||
const streamUrl = resolveUrl(`/api/v1/video/cameras/${camera.id}/live/stream`);
|
try {
|
||||||
|
const info = await playCamera(camera.id, 'flv');
|
||||||
navigation.navigate('VideoPlayer', {
|
navigation.navigate('VideoPlayer', {
|
||||||
cameraId: camera.id,
|
cameraId: camera.id,
|
||||||
cameraName: camera.name,
|
cameraName: camera.name,
|
||||||
streamUrl,
|
streamUrl: resolveUrl(info.url),
|
||||||
});
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
Alert.alert('提示', err?.message || '获取播放地址失败');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePlayClip = async (clip: VideoClip) => {
|
const handlePlayClip = async (clip: VideoClip) => {
|
||||||
|
|||||||
+19
-5
@@ -1,17 +1,19 @@
|
|||||||
import { WS_BASE_URL } from '@env';
|
import { WS_BASE_URL } from '@env';
|
||||||
|
import { get } from '../api/client';
|
||||||
|
|
||||||
export type WSMessageHandler = (data: any) => void;
|
export type WSMessageHandler = (data: any) => void;
|
||||||
|
|
||||||
class WebSocketManager {
|
class WebSocketManager {
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private token: string | null = null;
|
private token: string | null = null;
|
||||||
|
private ticket: string | null = null;
|
||||||
private handlers: Set<WSMessageHandler> = new Set();
|
private handlers: Set<WSMessageHandler> = new Set();
|
||||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private reconnectAttempts = 0;
|
private reconnectAttempts = 0;
|
||||||
private maxReconnectAttempts = 10;
|
private maxReconnectAttempts = 10;
|
||||||
private isManualClose = false;
|
private isManualClose = false;
|
||||||
|
|
||||||
connect(token: string): void {
|
async connect(token: string): Promise<void> {
|
||||||
// Close any existing connection before creating a new one
|
// Close any existing connection before creating a new one
|
||||||
if (this.ws) {
|
if (this.ws) {
|
||||||
this.isManualClose = true;
|
this.isManualClose = true;
|
||||||
@@ -27,14 +29,14 @@ class WebSocketManager {
|
|||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
this.token = token;
|
this.token = token;
|
||||||
this.isManualClose = false;
|
this.isManualClose = false;
|
||||||
this.doConnect();
|
await this.refreshTicketAndConnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
private doConnect(): void {
|
private doConnect(): void {
|
||||||
if (!this.token) return;
|
if (!this.token || !this.ticket) return;
|
||||||
|
|
||||||
const wsUrl = (WS_BASE_URL || 'ws://localhost:3000/ws').replace(/\?.*$/, '');
|
const wsUrl = (WS_BASE_URL || 'ws://localhost:3000/ws').replace(/\?.*$/, '');
|
||||||
const url = `${wsUrl}?token=${encodeURIComponent(this.token)}`;
|
const url = `${wsUrl}?ticket=${encodeURIComponent(this.ticket)}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.ws = new WebSocket(url);
|
this.ws = new WebSocket(url);
|
||||||
@@ -84,11 +86,23 @@ class WebSocketManager {
|
|||||||
|
|
||||||
this.reconnectTimer = setTimeout(() => {
|
this.reconnectTimer = setTimeout(() => {
|
||||||
if (!this.isManualClose && this.token) {
|
if (!this.isManualClose && this.token) {
|
||||||
this.doConnect();
|
this.refreshTicketAndConnect();
|
||||||
}
|
}
|
||||||
}, delay);
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async refreshTicketAndConnect(): Promise<void> {
|
||||||
|
if (!this.token) return;
|
||||||
|
try {
|
||||||
|
const { ticket } = await get<{ ticket: string }>('/ws/ticket');
|
||||||
|
this.ticket = ticket;
|
||||||
|
this.doConnect();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[WS] Failed to fetch ticket:', e);
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
this.isManualClose = true;
|
this.isManualClose = true;
|
||||||
if (this.reconnectTimer) {
|
if (this.reconnectTimer) {
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# 核心端到端验收用例
|
||||||
|
|
||||||
|
执行环境:开发服务器或隔离试点环境。每条用例记录:日期、执行人、环境、结果、失败原因、证据截图/日志。
|
||||||
|
|
||||||
|
## E2E-01 healthy 图片不触发检测
|
||||||
|
|
||||||
|
1. 使用真实或 mock healthy 图片上传巡检。
|
||||||
|
2. 期望 `abnormalProbability=0`,风险不因 healthy 高置信度升高。
|
||||||
|
3. 期望不创建待确认检测任务。
|
||||||
|
|
||||||
|
## E2E-02 sick/橙色风险只创建一个待确认任务
|
||||||
|
|
||||||
|
1. 上传产生橙色风险的非 mock 巡检。
|
||||||
|
2. 等待 Outbox worker。
|
||||||
|
3. 查询 `/detection-tasks?sourceType=inspection`,同巡检只出现一个任务。
|
||||||
|
|
||||||
|
## E2E-03 AI 失败可重试且不产生虚假风险
|
||||||
|
|
||||||
|
1. 停止 AI 服务后上传巡检。
|
||||||
|
2. 期望 `aiStatus=failed`、无风险分。
|
||||||
|
3. 恢复 AI 后重试,期望成功且使用同一幂等键不重复记录。
|
||||||
|
|
||||||
|
## E2E-04 qPCR 无效不确诊
|
||||||
|
|
||||||
|
1. 按当前规则录入无有效对照或无效结果。
|
||||||
|
2. 期望 `result=invalid`,不创建 `DiseaseEvent`。
|
||||||
|
|
||||||
|
## E2E-05 有效阳性创建发病事件并自动溯源
|
||||||
|
|
||||||
|
1. LAMP 或统一检测任务录入 `positive`。
|
||||||
|
2. 期望创建 `DiseaseEvent`。
|
||||||
|
3. 期望自动创建关联 `TraceRecord`,状态为 `pending`。
|
||||||
|
|
||||||
|
## E2E-06 无权用户访问受保护资源返回 403
|
||||||
|
|
||||||
|
1. 使用无权限用户访问视频、设备控制、WebSocket 设备订阅。
|
||||||
|
2. 期望业务接口 403,WebSocket 返回拒绝事件。
|
||||||
|
|
||||||
|
## E2E-07 离线巡检重启后仅同步一次
|
||||||
|
|
||||||
|
1. 断网拍照入队。
|
||||||
|
2. 重启小程序。
|
||||||
|
3. 联网后触发同步。
|
||||||
|
4. 期望服务端仅一条记录,离线队列转为 `synced`。
|
||||||
|
|
||||||
|
## E2E-08 通知失败重试且状态可查
|
||||||
|
|
||||||
|
1. 制造微信/通道失败事件。
|
||||||
|
2. 等待重试或最终失败。
|
||||||
|
3. 期望 `notifications` 状态、尝试次数、错误可查询。
|
||||||
|
|
||||||
|
## E2E-09 服务重启后任务/通知/吊销状态保持
|
||||||
|
|
||||||
|
1. 创建 pending 事件、Outbox、token 吊销。
|
||||||
|
2. 重启 Go 服务。
|
||||||
|
3. 期望任务和通知继续处理,吊销仍生效。
|
||||||
|
|
||||||
|
## E2E-10 备份恢复后核心记录与对象引用一致
|
||||||
|
|
||||||
|
1. 按 `docs/operations/backup-restore-drill.md` 恢复。
|
||||||
|
2. 核对巡检、检测、会诊、溯源、图片/光谱/录像引用。
|
||||||
|
3. 记录 RPO/RTO 和失败项。
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 发布门禁清单
|
||||||
|
|
||||||
|
## 发布前
|
||||||
|
|
||||||
|
- [ ] 确认发布基线 commit 和回滚点。
|
||||||
|
- [ ] 执行 `powershell -ExecutionPolicy Bypass -File scripts/verify.ps1`,exit 0。
|
||||||
|
- [ ] 执行 `git diff --check`,exit 0。
|
||||||
|
- [ ] 涉及 schema 时完成 `pg_dump` 备份并记录恢复命令。
|
||||||
|
- [ ] 检查磁盘、端口、服务进程和依赖健康。
|
||||||
|
- [ ] 核对需求追踪矩阵:本次涉及的需求 ID 有实现、测试和限制说明。
|
||||||
|
|
||||||
|
## 发布中
|
||||||
|
|
||||||
|
- [ ] 保留旧二进制和旧 `dist`。
|
||||||
|
- [ ] 上传新构建产物。
|
||||||
|
- [ ] 执行数据库迁移。
|
||||||
|
- [ ] 重启服务。
|
||||||
|
- [ ] 健康检查和核心接口冒烟。
|
||||||
|
|
||||||
|
## 发布后
|
||||||
|
|
||||||
|
- [ ] 记录部署 commit、迁移版本、备份路径、冒烟结果。
|
||||||
|
- [ ] 更新 `变更记录.md` 和 `开发交接记录.md`。
|
||||||
|
- [ ] 执行核心端到端用例中受影响的 E2E。
|
||||||
|
- [ ] 如失败,按回滚预案恢复并记录原因。
|
||||||
|
|
||||||
|
## 回滚
|
||||||
|
|
||||||
|
- 代码:恢复旧二进制/旧 dist 或 `git checkout <pre-commit>`。
|
||||||
|
- 数据库:`pg_restore` 恢复备份。
|
||||||
|
- 回滚后重新健康检查并记录结论。
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# 需求追踪矩阵
|
||||||
|
|
||||||
|
追踪列:需求 ID、实现、自动测试、手工用例、环境、证据、状态、限制、负责人。
|
||||||
|
|
||||||
|
状态约定:
|
||||||
|
|
||||||
|
- `已实现`:代码和单元/构建证据存在,但未在目标环境完成最终验收。
|
||||||
|
- `部分可用`:主链路可用,仍有明确限制。
|
||||||
|
- `延后`:按决策暂不实施。
|
||||||
|
- `未完成`:未实现或实现不足。
|
||||||
|
|
||||||
|
## Wave 1:安全与正确性
|
||||||
|
|
||||||
|
| 需求 ID | 实现 | 自动测试 | 手工用例 | 环境 | 证据 | 状态 | 限制 | 负责人 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| IAM-001 | `handler/auth.go`、`middleware/token_blacklist.go` | `middleware/state_test.go` | 登录/刷新/登出/跨实例吊销 | 开发/生产 | `go test ./...` | 部分可用 | Redis 不可用时保守失败 | 后端 |
|
||||||
|
| IAM-002 | `middleware/permission.go`、`permission_seed.go` | 无专项测试 | 角色访问矩阵 | 开发 | 路由权限检查 | 部分可用 | 缺少对象级 ACL | 后端 |
|
||||||
|
| IAM-003 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 需组织/区域/房间 ACL | 后端 |
|
||||||
|
| IAM-004 | 延后 Task 3 | 无 | 无 | 无 | 决策记录 | 延后 | 默认凭据和强制改密待最后阶段 | 后端+运维 |
|
||||||
|
| AI-INS-002 | `service/risk.go`、`ai-service` | `risk_test.go`、AI pytest | healthy/sick 图片 | 本地 | verify.ps1 | 部分可用 | 真实模型未验收 | AI+后端 |
|
||||||
|
| AI-INS-003 | `inspection.go`、`is_mock` | `inspection_test.go` | Mock 标识与统计排除 | 本地 | verify.ps1 | 部分可用 | 历史 mock 需人工审阅 | 后端 |
|
||||||
|
| AI-INS-004 | `RiskAssessment` | `risk_test.go` | 风险解释展示 | 本地 | verify.ps1 | 部分可用 | 权重待试点校准 | 后端 |
|
||||||
|
| AI-INS-005 | `offlineQueue.ts` | `offlineQueue.test.ts` | 离线→重启→联网 | 微信开发者工具 | miniapp test | 部分可用 | 未做截图取证 | 小程序 |
|
||||||
|
| RISK-001 | `RiskLevel` | `risk_test.go` | 边界用例 | 本地 | go test | 已实现 | 无 | 后端 |
|
||||||
|
| RISK-002 | `RiskInput` 指针 | `risk_test.go` | 缺失组件展示 | 本地 | go test | 已实现 | 无 | 后端 |
|
||||||
|
| RISK-003 | `risk_assessment` | `risk_test.go` | 巡检详情 | 本地 | go test | 已实现 | 无 | 后端 |
|
||||||
|
| RISK-004 | `risk-v2-2026.08.14` | 无 | 试点校准 | 待试点 | 无 | 部分可用 | 权重非科学结论 | 后端+专家 |
|
||||||
|
| RISK-005 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 人工纠正等级 | 后端 |
|
||||||
|
|
||||||
|
## Wave 2:工程可靠性
|
||||||
|
|
||||||
|
| 需求 ID | 实现 | 自动测试 | 手工用例 | 环境 | 证据 | 状态 | 限制 | 负责人 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| DET-001 | `detection_task.go` | `detection_task_test.go` | 任务中心 | 本地 | verify.ps1 | 部分可用 | 未端到端联调 | 后端 |
|
||||||
|
| DET-002 | `sample.go` | `detection_task_test.go` | 样本流转 | 本地 | go test | 部分可用 | 单样本/任务 | 后端 |
|
||||||
|
| DET-003 | `molecular.go` | `molecular_test.go` | 结果枚举 | 本地 | go test | 部分可用 | qPCR 四态延后 | 后端 |
|
||||||
|
| DET-004 | 延后 Task 7 | 无 | 无 | 无 | 决策记录 | 延后 | 专家确认 | 后端+专家 |
|
||||||
|
| DET-005 | `lamp.go` | `lamp_test_test.go` | LAMP 5 步 | 本地 | go test | 部分可用 | 对照/照片质控待完善 | 后端 |
|
||||||
|
| DET-006 | `cross_validate.go` | `cross_validate_test.go` | 交叉验证 | 本地 | go test | 部分可用 | 真实数据待联调 | 后端 |
|
||||||
|
| DET-007 | `detection_recommend.go` | `detection_recommend_test.go` | 推荐接口 | 本地 | go test | 部分可用 | 未绑定组织可用设备 | 后端 |
|
||||||
|
| DET-008 | `consumable.go` | `consumable_test.go` | 耗材预警 | 本地 | go test | 部分可用 | 无批次/领用 | 后端 |
|
||||||
|
| DET-009 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 设备回传协议 | 后端 |
|
||||||
|
| DET-010 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | LAMP 图像判读 | AI |
|
||||||
|
| DET-011 | `spectrum_entries` | 无专项测试 | 光谱上传 | 本地 | 构建 | 部分可用 | 方法学验证未做 | AI |
|
||||||
|
| MSG-001 | `outbox.go`、`notification.go` | `outbox_test.go` | 通知重试 | 本地 | verify.ps1 | 部分可用 | 未真实联调 | 后端 |
|
||||||
|
| MSG-002 | `wechat.go`、outbox | `wechat_test.go` | 微信订阅 | 本地 | pytest/go test | 部分可用 | 缺真实凭证 | 后端 |
|
||||||
|
|
||||||
|
## Wave 3:业务闭环
|
||||||
|
|
||||||
|
| 需求 ID | 实现 | 自动测试 | 手工用例 | 环境 | 证据 | 状态 | 限制 | 负责人 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| FARM-001 | `tray_batch.go` | `knowledge_seed_test.go` 等 | 蚕房/批次 CRUD | 本地 | go test | 已实现 | 无 | 后端 |
|
||||||
|
| FARM-002 | `identity_links.go` | `biosecurity_test.go` | 二维码签发/解析 | 本地 | go test | 部分可用 | 未打印扫码 | 后端+小程序 |
|
||||||
|
| FARM-003 | `rearing_records` | 无专项测试 | 饲养记录 | 本地 | go test | 部分可用 | 缺死亡/淘汰字段 | 后端 |
|
||||||
|
| FARM-004 | `disinfection_records` | `biosecurity_test.go` | 消毒计划/执行 | 本地 | verify.ps1 | 部分可用 | 未现场联调 | 后端+小程序 |
|
||||||
|
| FARM-005 | `seed_sources` | `biosecurity_test.go` | 种源链 | 本地 | go test | 部分可用 | 供应商凭证待接入 | 后端 |
|
||||||
|
| FARM-006 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 产量/损失/成本 | 后端 |
|
||||||
|
| ENV-001 | `telemetry.go`、`mqtt.go` | 无专项测试 | 遥测查询 | 开发 | 构建 | 已实现 | 数据质量标识不足 | 后端 |
|
||||||
|
| ENV-002 | `threshold.go`、`alarm.go` | 无专项测试 | 阈值告警 | 开发 | 构建 | 已实现 | 无对象级授权 | 后端 |
|
||||||
|
| ENV-003 | `rule_engine.go` | `rule_engine_test.go` | 过期数据 | 本地 | go test | 部分可用 | 未接入生产遥测 | 后端 |
|
||||||
|
| ENV-004 | `rule_engine.go`、`weather_risk.go` | `rule_engine_test.go` | 连续时窗 | 本地 | go test | 部分可用 | 密度/通风等输入待接入 | 后端 |
|
||||||
|
| ENV-005 | `rule_engine.go` | `rule_engine_test.go` | 规则解释 | 本地 | go test | 部分可用 | 结果持久化未全链路 | 后端 |
|
||||||
|
| ENV-006 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 设备校准/维护 | 后端 |
|
||||||
|
| ENV-007 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 安全控制建议 | 后端 |
|
||||||
|
| EXP-001 | `consultation.go` | `consultation_test.go` | 病例快照 | 本地 | go test | 已实现 | 环境/天气摘要待增强 | 后端 |
|
||||||
|
| EXP-002 | `consultation.go` | `consultation_test.go` | 会诊状态机 | 本地 | go test | 已实现 | 无 | 后端 |
|
||||||
|
| EXP-003 | `ConsultationSLAState` | `consultation_test.go` | SLA 超时 | 本地 | go test | 部分可用 | 无升级通知 | 后端 |
|
||||||
|
| EXP-004 | `consultation_opinion_versions` | `consultation_test.go` | 意见版本 | 本地 | go test | 已实现 | 无 | 后端 |
|
||||||
|
| EXP-005 | `health_profile.go` effect | `health_profile_test.go` | 效果报告 | 本地 | go test | 部分可用 | 损失/成本缺失 | 后端 |
|
||||||
|
| KB-001 | `knowledge.go` | `knowledge_governance_test.go` | 审核发布 | 本地 | go test | 部分可用 | 审核权限基于角色 | 后端+Web |
|
||||||
|
| KB-002 | `knowledge.go` source 字段 | 无专项测试 | 来源展示 | 本地 | 构建 | 部分可用 | 专家签名待接入 | 后端 |
|
||||||
|
| KB-003 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 案例沉淀 | 后端 |
|
||||||
|
| TRACE-001 | `disease_events` | `disease_event_test.go` | 发病事件 | 本地 | go test | 部分可用 | 未端到端 | 后端 |
|
||||||
|
| TRACE-002 | `trace.go` auto | 无专项测试 | 自动溯源 | 本地 | go test | 部分可用 | 空间/样本链不足 | 后端 |
|
||||||
|
| TRACE-003 | `trace.go` checklist | 无专项测试 | 二级排查 | 本地 | go test | 部分可用 | 无版本化清单 | 后端 |
|
||||||
|
| TRACE-004 | `trace.go` lab note | 无专项测试 | 三级溯源 | 本地 | go test | 部分可用 | 结构化实验室数据未做 | 后端 |
|
||||||
|
| TRACE-005 | `trace.go` | `trace_test.go` | 来源结论 | 本地 | go test | 部分可用 | 专家确认流程未做 | 后端 |
|
||||||
|
| TRACE-006 | `region_stat.go` | `region_stat_test.go` | 区域统计 | 本地 | go test | 部分可用 | 小样本脱敏未做 | 后端 |
|
||||||
|
| TRACE-007 | `seed_sources` + 复发字段 | `biosecurity_test.go` | 复发关联 | 本地 | go test | 部分可用 | 人工确认未做 | 后端 |
|
||||||
|
| VIDEO-001 | `video_auth.go` | `video_auth_test.go` | 播放授权 | 本地 | go test | 部分可用 | 对象级授权待后续 | 后端 |
|
||||||
|
| VIDEO-002 | DTO 脱敏 | `video_dto.go` | 密钥不返回 | 本地 | go test | 部分可用 | 历史配置审计 | 后端 |
|
||||||
|
| VIDEO-003 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 录制状态持久化 | 后端 |
|
||||||
|
| VIDEO-004 | `stream_tasks.py` | AI pytest | 受限拉流 | 本地 | pytest | 部分可用 | 摄像头真实视角待接入 | AI |
|
||||||
|
| ANA-001 | `health_profile.go` | `health_profile_test.go` | 健康画像 | 本地 | go test | 部分可用 | 公式未回测 | 后端 |
|
||||||
|
| ANA-002 | `health_profile.go` effect | `health_profile_test.go` | 效果评估 | 本地 | go test | 部分可用 | 损失/成本缺失 | 后端 |
|
||||||
|
| ANA-003 | `region_stat.go` | `region_stat_test.go` | 区域图 | 本地 | go test | 部分可用 | 地图/脱敏未做 | 后端 |
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# 2026-08-13 整改决策记录
|
||||||
|
|
||||||
|
> 范围:对应《项目整改实施计划 V1.0》Task 0 与《蚕病智能防控平台规格说明书 V2.2》第 13 章开放决策。
|
||||||
|
> 执行原则:项目负责人已指示开始整改;未获得明确产品/合规/专业确认的事项,本记录采用“保持现状 + 明确复审触发条件”,不擅自进入生产部署。
|
||||||
|
|
||||||
|
| Decision ID | 结论 | 备选方案 | 选择理由 | 影响 | 负责人 | 复审条件 |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| DEC-001 | Go 数据库迁移采用 `golang-migrate/migrate/v4`;SQL 迁移文件是唯一 schema 事实来源,命名 `NNNNNN_name.up.sql` / `NNNNNN_name.down.sql` | goose;自研 runner;GORM AutoMigrate | 与计划中 `000001_baseline.up.sql` 命名一致;支持嵌入式 SQL、版本表、up/down 和 PostgreSQL;可复用 Go 标准 `embed` | Task 2 起引入迁移依赖;生产启动执行迁移并禁止 AutoMigrate | 项目负责人 + 后端 | golang-migrate 升级、迁移目录规则变更或生产多实例发现锁冲突时 |
|
||||||
|
| DEC-002 | CI 保持现状:不新增远程 CI;本地门禁使用 `scripts/verify.ps1`;候选平台为 GitHub Actions | GitLab CI;Jenkins;Gitea Actions | 仓库当前无 remote,无法配置并验证外部 CI;先保留可复现本地门禁 | Task 1 的 CI 接入步骤暂不完成;CI 配置待平台确定 | 项目负责人 + 运维 | 仓库获得 remote 或团队确认 CI 平台时 |
|
||||||
|
| DEC-003 | 制品保持现状:构建产物不入库,沿用 `web/dist`、`miniapp/dist`、二进制等 gitignore 规则 | GitHub Releases/Artifacts;NAS;Git LFS | 尚未确定 CI 与发布平台,先避免生成产物污染仓库 | 发布继续按 AGENTS.md 手动备份/回滚 | 项目负责人 + 运维 | 进入正式发布流程前 |
|
||||||
|
| DEC-004 | 数据保留期限保持现状:不自动删除历史数据;具体 bucket/表保留期在 Task 13/14 前评审 | 固定 30/90/365 天 | 当前试点数据量有限,且未获合规/业务确认,不应擅自删数 | 存储增长需纳入容量监控 | 项目负责人 + 运维 | 生产试点或容量告警触发时 |
|
||||||
|
| DEC-005 | qPCR 保持“只录入、不自动判读”;Task 7 延后到最后处理,领域专家确认 protocolVersion、对照规则和阈值后才启用自动判读 | 沿用现有“无 Ct 即阴性”;当前 Wave 1 实施四态结果 | 当前自动判读存在误判风险,且用户 2026-08-14 明确要求跳过 Task 7 并留到最后 | 本轮不实现 positive/negative/invalid/indeterminate;专家签字前不输出业务结论 | 领域专家 + 后端 | 用户恢复 Task 7,或专家签字并给出 protocolVersion 时 |
|
||||||
|
| DEC-006 | APP 保持现状:作为环境监控端,不扩展为全量蚕病功能 | 全量同步 Web/小程序蚕病功能 | 当前 APP 源码未实现蚕病功能,扩大同步会显著增加维护面 | Task 0 后不新增 APP 蚕病页面 | 产品负责人 | 试点用户明确提出 APP 蚕病需求时 |
|
||||||
|
| DEC-007 | 真实模型二分类转多病种指标门槛保持“未定义,不允许上生产”;待数据集修复和训练恢复后按冻结测试集、mAP、混淆矩阵评审 | 先预设 mAP 0.5 | 当前无真实模型,不能凭空设置验收门槛 | AI 生产继续 mock/不接受为真实验收 | AI 负责人 + 项目负责人 | 数据集修复完成或模型训练恢复时 |
|
||||||
|
| DEC-008 | 生产云厂商、对象存储、域名和证书保持现状:不迁移云,继续沿用物理机开发环境 | 云 OSS/COS;云 RDS | 当前无生产试点和上云评审;上云属于发布决策 | 部署仍按 `部署指南(物理机).md` | 项目负责人 + 运维 | 正式生产试点前 |
|
||||||
|
| DEC-009 | Task 3 移除默认密钥/默认管理员密码延后到最后处理;本轮跳过,不执行密钥轮换和客户端密码预填删除 | 当前 Wave 1 执行;最后阶段集中执行 | 用户 2026-08-13 明确要求跳过并留到最后 | 默认凭据风险保持开放;最后阶段需安排密钥轮换窗口 | 项目负责人 + 后端 + 运维 | 进入最后整改阶段时 |
|
||||||
|
|
||||||
|
## 生效与复审
|
||||||
|
|
||||||
|
- 生效日期:2026-08-13。
|
||||||
|
- 所有“保持现状”决策不是永久结论,仅在本轮整改期内有效。
|
||||||
|
- 任何决策变化必须更新本表,并在 `开发交接记录.md` 追加影响与回滚点。
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# 备份恢复演练
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
验证 PostgreSQL、IoTDB、Ceph/S3 和业务配置的可恢复性,记录 RPO/RTO。
|
||||||
|
|
||||||
|
## 演练前
|
||||||
|
|
||||||
|
1. 记录当前版本、commit、schema 版本和服务清单。
|
||||||
|
2. 使用 `pg_dump` 备份 PostgreSQL 到隔离命名空间。
|
||||||
|
3. 备份 IoTDB 数据目录或按现有运维流程导出。
|
||||||
|
4. 记录 Ceph/S3 bucket 清单和对象数量。
|
||||||
|
|
||||||
|
## 恢复步骤
|
||||||
|
|
||||||
|
1. 在隔离环境创建空 PostgreSQL 数据库。
|
||||||
|
2. 执行 `pg_restore` 恢复备份。
|
||||||
|
3. 恢复 IoTDB 数据并启动服务。
|
||||||
|
4. 校验图片、光谱、录像对象引用与数据库记录一致。
|
||||||
|
5. 启动 Go/AI/Web 服务并执行核心接口冒烟。
|
||||||
|
|
||||||
|
## 记录项
|
||||||
|
|
||||||
|
- 备份开始/结束时间
|
||||||
|
- 备份体积
|
||||||
|
- 恢复开始/结束时间
|
||||||
|
- RPO(最近可用备份到故障时间)
|
||||||
|
- RTO(故障到服务可用)
|
||||||
|
- 数据点核对:巡检、遥测、检测、通知、任务、会诊、溯源
|
||||||
|
- 失败项与原因
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
- 核心业务记录无缺失。
|
||||||
|
- 图片/光谱/录像对象引用可访问。
|
||||||
|
- 至少完成一次真实恢复演练后才允许执行正式生产迁移。
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 负载测试场景
|
||||||
|
|
||||||
|
目标环境:Go 后端 + PostgreSQL + Redis + IoTDB + AI 服务。结果需记录测试日期、版本、机器配置、并发、时长、p50/p95、失败率和观察到的限制。
|
||||||
|
|
||||||
|
## A. 500 在线用户 API 混合负载
|
||||||
|
|
||||||
|
比例:
|
||||||
|
|
||||||
|
- 80% 查询类:房间、设备、遥测最新值、告警列表
|
||||||
|
- 10% 遥测趋势:历史/聚合查询
|
||||||
|
- 5% 上传类:图片巡检上传
|
||||||
|
- 5% 管理操作:阈值、批次、检测任务、会诊
|
||||||
|
|
||||||
|
验收:API 错误率 <1%,业务接口 p95 <1s;上传成功且巡检闭环可追踪。
|
||||||
|
|
||||||
|
## B. 1000 WebSocket 连接
|
||||||
|
|
||||||
|
- 1000 个连接按授权设备订阅
|
||||||
|
- 持续 30 分钟
|
||||||
|
- 记录连接成功率、掉线率、消息延迟、CPU/内存
|
||||||
|
|
||||||
|
验收:连接成功率 >99%,消息 P95 延迟 <500ms,无内存持续增长。
|
||||||
|
|
||||||
|
## C. AI 峰值
|
||||||
|
|
||||||
|
固定条件:
|
||||||
|
|
||||||
|
- GPU:目标 T4 16G
|
||||||
|
- 模型版本和 ONNX 文件 hash
|
||||||
|
- 输入尺寸
|
||||||
|
- 并发数(建议 1/4/8/16)
|
||||||
|
|
||||||
|
记录 p50/p95、失败率、GPU 显存和单帧延迟。结果用于设置生产并发上限和推理告警阈值。
|
||||||
|
|
||||||
|
## 禁止事项
|
||||||
|
|
||||||
|
- 不在生产库执行写入类负载。
|
||||||
|
- 不使用真实用户隐私图片做公开压测。
|
||||||
|
- 压测前必须备份数据库并记录环境状态。
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 可观测性 SLO 与告警阈值
|
||||||
|
|
||||||
|
## 日志与请求 ID
|
||||||
|
|
||||||
|
- Go 后端每个 HTTP 请求生成或透传 `X-Request-ID`,日志统一记录 `requestId/method/path/query/status/latency/ip/authType`。
|
||||||
|
- 日志不记录 Authorization 原始值;query 中 `token/password/secret/authorization` 类参数统一替换为 `[redacted]`。
|
||||||
|
- AI 服务 `/detect` 同样透传 `X-Request-ID`,便于跨 Go/Python 全链路追踪。
|
||||||
|
|
||||||
|
## 依赖指标
|
||||||
|
|
||||||
|
`GET /api/v1/ops/metrics`(`log:read`)返回依赖指标,至少记录请求数、失败数、最近延迟、P50/P95、最近成功/失败时间和最近错误。目标覆盖:
|
||||||
|
|
||||||
|
- PostgreSQL
|
||||||
|
- Redis
|
||||||
|
- MQTT
|
||||||
|
- IoTDB
|
||||||
|
- S3
|
||||||
|
- AI 服务
|
||||||
|
- WVP/ZLM
|
||||||
|
- 微信
|
||||||
|
- 天气
|
||||||
|
|
||||||
|
## 初始告警阈值
|
||||||
|
|
||||||
|
| 指标 | 目标 | 告警阈值 | 负责人 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| API 可用性 | 99.5% | 5 分钟错误率 >5% | 后端 + 运维 |
|
||||||
|
| API p95 延迟 | <500ms(业务接口) | 5 分钟 p95 >1s | 后端 |
|
||||||
|
| 登录失败率 | 正常用户 <2% | 5 分钟失败率 >10% | 后端 + 安全 |
|
||||||
|
| Redis | 可用 | Ping 失败 3 次 | 运维 |
|
||||||
|
| PostgreSQL | 可用 | 连接/查询失败 3 次 | 后端 + 运维 |
|
||||||
|
| IoTDB | 可用或明确降级 | 持续不可用且遥测回退 | 后端 |
|
||||||
|
| AI 推理失败率 | <1% | 5 分钟失败率 >5% | AI + 后端 |
|
||||||
|
| Ceph 容量 | 使用率 <80% | 使用率 >80% | 运维 |
|
||||||
|
| Outbox 积压 | 常态 <50 | pending+retry >200 | 后端 |
|
||||||
|
|
||||||
|
## 模拟故障验证
|
||||||
|
|
||||||
|
每次发布前至少验证一次通知链:断开 Redis → 确认认证接口返回 503 → 恢复 Redis → 确认自动恢复;如部署告警工具,则同时验证告警通道可送达负责人。
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
"framework": "React"
|
"framework": "React"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"test": "ts-node --transpile-only src/services/offlineQueue.test.ts",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
"build:weapp": "taro build --type weapp",
|
"build:weapp": "taro build --type weapp",
|
||||||
"build:swan": "taro build --type swan",
|
"build:swan": "taro build --type swan",
|
||||||
"build:alipay": "taro build --type alipay",
|
"build:alipay": "taro build --type alipay",
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { get, post } from './request';
|
||||||
|
|
||||||
|
export interface QRResolveResult {
|
||||||
|
entityType: string;
|
||||||
|
publicId: string;
|
||||||
|
entity: {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
roomId?: string;
|
||||||
|
batchId?: string;
|
||||||
|
sampleNo?: string;
|
||||||
|
state?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisinfectionRecord {
|
||||||
|
roomId?: string;
|
||||||
|
batchId?: string;
|
||||||
|
kind: 'plan' | 'execution';
|
||||||
|
agent: string;
|
||||||
|
concentration: string;
|
||||||
|
amount?: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveQR = (payload: string) =>
|
||||||
|
post<QRResolveResult>('/biosecurity/qr/resolve', { payload });
|
||||||
|
|
||||||
|
export const createDisinfectionRecord = (data: DisinfectionRecord) =>
|
||||||
|
post('/biosecurity/disinfection-records', data as unknown as Record<string, unknown>);
|
||||||
|
|
||||||
|
export const listDisinfectionRecords = (params?: any) =>
|
||||||
|
get<DisinfectionRecord[]>('/biosecurity/disinfection-records', { params });
|
||||||
@@ -43,24 +43,24 @@ export function getServerUrl(): string {
|
|||||||
// 保留导出以兼容现有代码(模块加载时的默认值,实际请求请使用 getApiBaseUrl())
|
// 保留导出以兼容现有代码(模块加载时的默认值,实际请求请使用 getApiBaseUrl())
|
||||||
export const API_BASE_URL = DEFAULT_API_BASE_URL;
|
export const API_BASE_URL = DEFAULT_API_BASE_URL;
|
||||||
|
|
||||||
export function getWsUrl(token: string): string {
|
export function getWsUrl(ticket: string): string {
|
||||||
const baseUrl = getApiBaseUrl();
|
const baseUrl = getApiBaseUrl();
|
||||||
|
|
||||||
// 如果是完整 URL,从中推导 WS 地址
|
// 如果是完整 URL,从中推导 WS 地址
|
||||||
if (baseUrl.startsWith('http')) {
|
if (baseUrl.startsWith('http')) {
|
||||||
const serverUrl = baseUrl.replace(/\/api\/v1\/?$/, '');
|
const serverUrl = baseUrl.replace(/\/api\/v1\/?$/, '');
|
||||||
const wsUrl = serverUrl.replace(/^http/, 'ws');
|
const wsUrl = serverUrl.replace(/^http/, 'ws');
|
||||||
return `${wsUrl}/ws?token=${token}`;
|
return `${wsUrl}/ws?ticket=${ticket}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// H5 相对路径 - 使用 window.location 推导
|
// H5 相对路径 - 使用 window.location 推导
|
||||||
if (isH5) {
|
if (isH5) {
|
||||||
const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const host = typeof window !== 'undefined' ? window.location.host : 'localhost:3000';
|
const host = typeof window !== 'undefined' ? window.location.host : 'localhost:3000';
|
||||||
return `${protocol}//${host}/ws?token=${token}`;
|
return `${protocol}//${host}/ws?ticket=${ticket}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `ws://100.83.103.1:3000/ws?token=${token}`;
|
return `ws://100.83.103.1:3000/ws?ticket=${ticket}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const REQUEST_TIMEOUT = 15000;
|
export const REQUEST_TIMEOUT = 15000;
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import Taro from '@tarojs/taro';
|
import Taro from '@tarojs/taro';
|
||||||
import { getApiBaseUrl } from './config';
|
import { getApiBaseUrl } from './config';
|
||||||
import { get } from './request';
|
import { get, refreshSession } from './request';
|
||||||
import type { InspectionRecord } from '@/types';
|
import type { InspectionRecord } from '@/types';
|
||||||
|
|
||||||
export const uploadInspection = (
|
export const uploadInspection = (
|
||||||
filePath: string,
|
filePath: string,
|
||||||
roomId?: string,
|
roomId?: string,
|
||||||
|
idempotencyKey?: string,
|
||||||
|
retried?: boolean,
|
||||||
): Promise<InspectionRecord> => {
|
): Promise<InspectionRecord> => {
|
||||||
const token = Taro.getStorageSync('token');
|
const token = Taro.getStorageSync('token');
|
||||||
const idempotencyKey = `insp-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
const key = idempotencyKey || `insp-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
const header: Record<string, string> = { 'Idempotency-Key': idempotencyKey };
|
const header: Record<string, string> = { 'Idempotency-Key': key };
|
||||||
if (token) {
|
if (token) {
|
||||||
header['Authorization'] = `Bearer ${token}`;
|
header['Authorization'] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
@@ -24,8 +26,20 @@ export const uploadInspection = (
|
|||||||
success: (res) => {
|
success: (res) => {
|
||||||
try {
|
try {
|
||||||
const body = JSON.parse(res.data || '{}');
|
const body = JSON.parse(res.data || '{}');
|
||||||
|
if (res.statusCode === 401 && !retried) {
|
||||||
|
refreshSession().then((ok) => {
|
||||||
|
if (ok) {
|
||||||
|
uploadInspection(filePath, roomId, key, true).then(resolve, reject);
|
||||||
|
} else {
|
||||||
|
reject(new Error('登录已过期,请重新登录'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (res.statusCode >= 400) {
|
if (res.statusCode >= 400) {
|
||||||
reject(new Error(body?.error || `上传失败 (${res.statusCode})`));
|
const err = new Error(body?.error || `上传失败 (${res.statusCode})`) as Error & { statusCode?: number };
|
||||||
|
err.statusCode = res.statusCode;
|
||||||
|
reject(err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resolve(body as InspectionRecord);
|
resolve(body as InspectionRecord);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface RequestOptions {
|
|||||||
data?: Record<string, unknown>;
|
data?: Record<string, unknown>;
|
||||||
params?: Record<string, unknown>;
|
params?: Record<string, unknown>;
|
||||||
header?: Record<string, string>;
|
header?: Record<string, string>;
|
||||||
|
_retried?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildQueryString(params?: Record<string, unknown>): string {
|
function buildQueryString(params?: Record<string, unknown>): string {
|
||||||
@@ -21,6 +22,28 @@ function buildQueryString(params?: Record<string, unknown>): string {
|
|||||||
return `?${qs}`;
|
return `?${qs}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function refreshSession(): Promise<boolean> {
|
||||||
|
const refreshToken = Taro.getStorageSync('refreshToken');
|
||||||
|
if (!refreshToken) return false;
|
||||||
|
try {
|
||||||
|
const res = await Taro.request({
|
||||||
|
url: `${getApiBaseUrl()}/auth/refresh`,
|
||||||
|
method: 'POST',
|
||||||
|
data: { refreshToken },
|
||||||
|
header: { 'Content-Type': 'application/json' },
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
const body = res.data as { accessToken?: string; refreshToken?: string; user?: unknown };
|
||||||
|
if (res.statusCode !== 200 || !body.accessToken) return false;
|
||||||
|
Taro.setStorageSync('token', body.accessToken);
|
||||||
|
if (body.refreshToken) Taro.setStorageSync('refreshToken', body.refreshToken);
|
||||||
|
if (body.user) Taro.setStorageSync('user', body.user);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function request<T = unknown>(options: RequestOptions): Promise<T> {
|
export async function request<T = unknown>(options: RequestOptions): Promise<T> {
|
||||||
const token = Taro.getStorageSync('token');
|
const token = Taro.getStorageSync('token');
|
||||||
const baseUrl = getApiBaseUrl();
|
const baseUrl = getApiBaseUrl();
|
||||||
@@ -46,6 +69,12 @@ export async function request<T = unknown>(options: RequestOptions): Promise<T>
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res.statusCode === 401) {
|
if (res.statusCode === 401) {
|
||||||
|
if (!options._retried) {
|
||||||
|
options._retried = true;
|
||||||
|
if (await refreshSession()) {
|
||||||
|
return request<T>(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
console.warn('[Request] 401 未授权,清除登录状态');
|
console.warn('[Request] 401 未授权,清除登录状态');
|
||||||
Taro.removeStorageSync('token');
|
Taro.removeStorageSync('token');
|
||||||
Taro.removeStorageSync('refreshToken');
|
Taro.removeStorageSync('refreshToken');
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export default defineAppConfig({
|
|||||||
'pages/inspection/index',
|
'pages/inspection/index',
|
||||||
'pages/notification/index',
|
'pages/notification/index',
|
||||||
'pages/lamp/index',
|
'pages/lamp/index',
|
||||||
|
'pages/biosecurity/index',
|
||||||
],
|
],
|
||||||
window: {
|
window: {
|
||||||
backgroundTextStyle: 'dark',
|
backgroundTextStyle: 'dark',
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useDidShow, useDidHide } from '@tarojs/taro';
|
import { useDidShow, useDidHide } from '@tarojs/taro';
|
||||||
import { useStore } from '@/store/useStore';
|
import { useStore } from '@/store/useStore';
|
||||||
import './app.scss';
|
import './app.scss';
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ const AlertsPage: React.FC = () => {
|
|||||||
<View className={styles.alarmHeader}>
|
<View className={styles.alarmHeader}>
|
||||||
<View className={styles.alarmTitleRow}>
|
<View className={styles.alarmTitleRow}>
|
||||||
<Text className={styles.alarmIcon}>
|
<Text className={styles.alarmIcon}>
|
||||||
{alarm.severity === 'critical' || alarm.severity === 1 ? '🔴' : '🟡'}
|
{alarm.severity === 'critical' || alarm.severity === '1' ? '🔴' : '🟡'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className={styles.alarmTitle}>
|
<Text className={styles.alarmTitle}>
|
||||||
{alarm.title || alarm.code || '环境告警'}
|
{alarm.title || alarm.code || '环境告警'}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24px;
|
||||||
|
background: #f5f6f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: block;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #4e5969;
|
||||||
|
margin: 18px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
height: 76px;
|
||||||
|
background: #f2f3f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 20px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnPrimary {
|
||||||
|
margin-top: 24px;
|
||||||
|
background: #10b981;
|
||||||
|
color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityBox {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f0fdf4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityType {
|
||||||
|
display: block;
|
||||||
|
color: #4e5969;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityName {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Button, Input, Text, View } from '@tarojs/components';
|
||||||
|
import Taro from '@tarojs/taro';
|
||||||
|
import styles from './index.module.scss';
|
||||||
|
import { createDisinfectionRecord, resolveQR, type QRResolveResult } from '@/api/biosecurity';
|
||||||
|
|
||||||
|
const BiosecurityPage: React.FC = () => {
|
||||||
|
const [entity, setEntity] = useState<QRResolveResult | null>(null);
|
||||||
|
const [agent, setAgent] = useState('');
|
||||||
|
const [concentration, setConcentration] = useState('');
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [note, setNote] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleScan = async () => {
|
||||||
|
try {
|
||||||
|
const res = await Taro.scanCode({ scanType: ['qrCode'] });
|
||||||
|
if (!res.result) throw new Error('未识别到二维码');
|
||||||
|
const data = await resolveQR(res.result);
|
||||||
|
setEntity(data);
|
||||||
|
Taro.showToast({ title: '扫码成功', icon: 'success' });
|
||||||
|
} catch (err) {
|
||||||
|
Taro.showToast({
|
||||||
|
title: err instanceof Error ? err.message : '扫码失败',
|
||||||
|
icon: 'none',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!entity) {
|
||||||
|
Taro.showToast({ title: '请先扫码', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!agent || !concentration) {
|
||||||
|
Taro.showToast({ title: '请填写药剂和浓度', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await createDisinfectionRecord({
|
||||||
|
kind: 'execution',
|
||||||
|
roomId: entity.entity.roomId,
|
||||||
|
batchId: entity.entityType === 'batch' ? entity.entity.id : entity.entity.batchId,
|
||||||
|
agent,
|
||||||
|
concentration,
|
||||||
|
amount: amount || undefined,
|
||||||
|
note: note || undefined,
|
||||||
|
});
|
||||||
|
Taro.showToast({ title: '消毒记录已提交', icon: 'success' });
|
||||||
|
setAgent('');
|
||||||
|
setConcentration('');
|
||||||
|
setAmount('');
|
||||||
|
setNote('');
|
||||||
|
} catch (err) {
|
||||||
|
Taro.showToast({
|
||||||
|
title: err instanceof Error ? err.message : '提交失败',
|
||||||
|
icon: 'none',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className={styles.page}>
|
||||||
|
<View className={styles.card}>
|
||||||
|
<Text className={styles.title}>二维码身份</Text>
|
||||||
|
<Button className={styles.btnPrimary} onClick={handleScan}>
|
||||||
|
扫描蚕房/批次/样本二维码
|
||||||
|
</Button>
|
||||||
|
{entity ? (
|
||||||
|
<View className={styles.entityBox}>
|
||||||
|
<Text className={styles.entityType}>类型:{entity.entityType}</Text>
|
||||||
|
<Text className={styles.entityName}>
|
||||||
|
{entity.entity.name || entity.entity.sampleNo || entity.entity.id}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{entity ? (
|
||||||
|
<View className={styles.card}>
|
||||||
|
<Text className={styles.title}>现场消毒执行</Text>
|
||||||
|
<Text className={styles.label}>消毒药剂</Text>
|
||||||
|
<Input
|
||||||
|
className={styles.input}
|
||||||
|
value={agent}
|
||||||
|
placeholder="例如:漂白粉"
|
||||||
|
onInput={(e) => setAgent(e.detail.value)}
|
||||||
|
/>
|
||||||
|
<Text className={styles.label}>浓度</Text>
|
||||||
|
<Input
|
||||||
|
className={styles.input}
|
||||||
|
value={concentration}
|
||||||
|
placeholder="例如:1%"
|
||||||
|
onInput={(e) => setConcentration(e.detail.value)}
|
||||||
|
/>
|
||||||
|
<Text className={styles.label}>用量</Text>
|
||||||
|
<Input
|
||||||
|
className={styles.input}
|
||||||
|
value={amount}
|
||||||
|
placeholder="可选"
|
||||||
|
onInput={(e) => setAmount(e.detail.value)}
|
||||||
|
/>
|
||||||
|
<Text className={styles.label}>备注</Text>
|
||||||
|
<Input
|
||||||
|
className={styles.input}
|
||||||
|
value={note}
|
||||||
|
placeholder="可选"
|
||||||
|
onInput={(e) => setNote(e.detail.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className={styles.btnPrimary}
|
||||||
|
loading={submitting}
|
||||||
|
disabled={submitting}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
提交消毒记录
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BiosecurityPage;
|
||||||
@@ -285,7 +285,7 @@ const DashboardPage: React.FC = () => {
|
|||||||
className={styles.sectionAction}
|
className={styles.sectionAction}
|
||||||
onClick={() => handleRoomDetail(selectedRoom.id)}
|
onClick={() => handleRoomDetail(selectedRoom.id)}
|
||||||
>
|
>
|
||||||
详情 >
|
详情 {'>'}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -347,7 +347,7 @@ const DashboardPage: React.FC = () => {
|
|||||||
<View className={styles.section}>
|
<View className={styles.section}>
|
||||||
<View className={styles.sectionHeader}>
|
<View className={styles.sectionHeader}>
|
||||||
<Text className={styles.sectionTitle}>最近告警</Text>
|
<Text className={styles.sectionTitle}>最近告警</Text>
|
||||||
<Text className={styles.sectionAction} onClick={handleAlarmTap}>全部 ></Text>
|
<Text className={styles.sectionAction} onClick={handleAlarmTap}>全部 {'>'}</Text>
|
||||||
</View>
|
</View>
|
||||||
{recentAlarms.length > 0 ? (
|
{recentAlarms.length > 0 ? (
|
||||||
<View className={styles.alarmPreview}>
|
<View className={styles.alarmPreview}>
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ const ControlPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggle = (newVal: boolean) => {
|
const handleToggle = (event: { detail: { value: boolean } }) => {
|
||||||
handleSend('power', newVal ? 'on' : 'off');
|
handleSend('power', event.detail.value ? 'on' : 'off');
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMetricColor = (status: string) => {
|
const getMetricColor = (status: string) => {
|
||||||
|
|||||||
@@ -120,6 +120,33 @@
|
|||||||
margin: $spacing-lg 0 $spacing-md;
|
margin: $spacing-lg 0 $spacing-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.queueMeta {
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
color: $color-text-tertiary;
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offlineCard {
|
||||||
|
background: #fff8e6;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
padding: $spacing-md;
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
box-shadow: $shadow-card;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offlineStatus {
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
color: $color-warning;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offlineError {
|
||||||
|
display: block;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
color: $color-error;
|
||||||
|
margin-top: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
.historyList {
|
.historyList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import React, { useCallback, useEffect, useState } from 'react';
|
|||||||
import { Button, Image, ScrollView, Text, View } from '@tarojs/components';
|
import { Button, Image, ScrollView, Text, View } from '@tarojs/components';
|
||||||
import Taro, { usePullDownRefresh } from '@tarojs/taro';
|
import Taro, { usePullDownRefresh } from '@tarojs/taro';
|
||||||
import styles from './index.module.scss';
|
import styles from './index.module.scss';
|
||||||
import { listInspections, uploadInspection } from '@/api/inspections';
|
import { listInspections } from '@/api/inspections';
|
||||||
|
import { offlineQueue } from '@/services/offlineQueueApp';
|
||||||
|
import type { OfflineInspectionItem } from '@/services/offlineQueue';
|
||||||
|
import { useStore } from '@/store/useStore';
|
||||||
import { formatRelativeTime } from '@/utils/format';
|
import { formatRelativeTime } from '@/utils/format';
|
||||||
import type { InspectionRecord } from '@/types';
|
import type { InspectionRecord } from '@/types';
|
||||||
|
|
||||||
@@ -14,11 +17,19 @@ const CLASS_LABELS: Record<string, string> = {
|
|||||||
const classLabel = (cls: string) => CLASS_LABELS[cls] || cls;
|
const classLabel = (cls: string) => CLASS_LABELS[cls] || cls;
|
||||||
|
|
||||||
const InspectionPage: React.FC = () => {
|
const InspectionPage: React.FC = () => {
|
||||||
|
const syncOfflineQueue = useStore((s) => s.syncOfflineQueue);
|
||||||
const [imagePath, setImagePath] = useState('');
|
const [imagePath, setImagePath] = useState('');
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [result, setResult] = useState<InspectionRecord | null>(null);
|
const [result, setResult] = useState<InspectionRecord | null>(null);
|
||||||
const [history, setHistory] = useState<InspectionRecord[]>([]);
|
const [history, setHistory] = useState<InspectionRecord[]>([]);
|
||||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||||
|
const [offlineItems, setOfflineItems] = useState<OfflineInspectionItem[]>([]);
|
||||||
|
const [queueStats, setQueueStats] = useState({ total: 0, pending: 0, synced: 0, failed: 0, conflict: 0, remaining: 30 });
|
||||||
|
|
||||||
|
const refreshQueue = useCallback(() => {
|
||||||
|
setOfflineItems(offlineQueue.list());
|
||||||
|
setQueueStats(offlineQueue.stats());
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadHistory = useCallback(async () => {
|
const loadHistory = useCallback(async () => {
|
||||||
setLoadingHistory(true);
|
setLoadingHistory(true);
|
||||||
@@ -31,11 +42,36 @@ const InspectionPage: React.FC = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
refreshQueue();
|
||||||
loadHistory();
|
loadHistory();
|
||||||
}, [loadHistory]);
|
syncOfflineQueue().then(() => {
|
||||||
|
refreshQueue();
|
||||||
|
loadHistory();
|
||||||
|
});
|
||||||
|
}, [loadHistory, refreshQueue, syncOfflineQueue]);
|
||||||
|
|
||||||
usePullDownRefresh(() => {
|
usePullDownRefresh(() => {
|
||||||
loadHistory().then(() => Taro.stopPullDownRefresh());
|
syncOfflineQueue()
|
||||||
|
.then(() => {
|
||||||
|
refreshQueue();
|
||||||
|
loadHistory();
|
||||||
|
})
|
||||||
|
.finally(() => Taro.stopPullDownRefresh());
|
||||||
|
});
|
||||||
|
|
||||||
|
const persistImage = (tempPath: string) =>
|
||||||
|
new Promise<string>((resolve) => {
|
||||||
|
const taroAny = Taro as any;
|
||||||
|
const fs = taroAny.getFileSystemManager?.();
|
||||||
|
if (!fs || !fs.saveFile) {
|
||||||
|
resolve(tempPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fs.saveFile({
|
||||||
|
tempFilePath: tempPath,
|
||||||
|
success: (res: { savedFilePath: string }) => resolve(res.savedFilePath),
|
||||||
|
fail: () => resolve(tempPath),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleChooseImage = () => {
|
const handleChooseImage = () => {
|
||||||
@@ -60,9 +96,11 @@ const InspectionPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
const rec = await uploadInspection(imagePath);
|
const savedPath = await persistImage(imagePath);
|
||||||
setResult(rec);
|
offlineQueue.enqueue(savedPath);
|
||||||
Taro.showToast({ title: rec.aiStatus === 'done' ? '检测完成' : '检测失败', icon: 'none' });
|
Taro.showToast({ title: '已保存到离线队列', icon: 'none' });
|
||||||
|
await syncOfflineQueue();
|
||||||
|
refreshQueue();
|
||||||
loadHistory();
|
loadHistory();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Taro.showToast({
|
Taro.showToast({
|
||||||
@@ -113,6 +151,7 @@ const InspectionPage: React.FC = () => {
|
|||||||
? '检测到疑似异常'
|
? '检测到疑似异常'
|
||||||
: '未见明显异常'
|
: '未见明显异常'
|
||||||
: 'AI 检测失败'}
|
: 'AI 检测失败'}
|
||||||
|
{result.isMock ? '(联调 Mock)' : ''}
|
||||||
</Text>
|
</Text>
|
||||||
{result.aiStatus === 'done' && result.detections && result.detections.length > 0 ? (
|
{result.aiStatus === 'done' && result.detections && result.detections.length > 0 ? (
|
||||||
<View className={styles.detectionList}>
|
<View className={styles.detectionList}>
|
||||||
@@ -135,6 +174,28 @@ const InspectionPage: React.FC = () => {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Text className={styles.sectionTitle}>巡检记录</Text>
|
<Text className={styles.sectionTitle}>巡检记录</Text>
|
||||||
|
<Text className={styles.sectionTitle}>离线队列(剩余 {queueStats.remaining})</Text>
|
||||||
|
<View className={styles.queueMeta}>
|
||||||
|
待同步 {queueStats.pending} · 失败 {queueStats.failed} · 冲突 {queueStats.conflict}
|
||||||
|
</View>
|
||||||
|
{offlineItems.map((item) => (
|
||||||
|
<View key={item.id} className={styles.offlineCard}>
|
||||||
|
<View className={styles.historyHeader}>
|
||||||
|
<Text className={styles.offlineStatus}>{item.state}</Text>
|
||||||
|
<Text className={styles.historyTime}>{item.idempotencyKey.slice(0, 18)}</Text>
|
||||||
|
</View>
|
||||||
|
{item.error ? <Text className={styles.offlineError}>{item.error}</Text> : null}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
className={styles.btnPrimary}
|
||||||
|
onClick={() => {
|
||||||
|
offlineQueue.clearSynced();
|
||||||
|
refreshQueue();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
清理已同步
|
||||||
|
</Button>
|
||||||
<View className={styles.historyList}>
|
<View className={styles.historyList}>
|
||||||
{history.map((rec) => (
|
{history.map((rec) => (
|
||||||
<View key={rec.id} className={styles.historyCard}>
|
<View key={rec.id} className={styles.historyCard}>
|
||||||
@@ -145,6 +206,7 @@ const InspectionPage: React.FC = () => {
|
|||||||
? '疑似异常'
|
? '疑似异常'
|
||||||
: '正常'
|
: '正常'
|
||||||
: '检测失败'}
|
: '检测失败'}
|
||||||
|
{rec.isMock ? '(Mock)' : ''}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className={styles.historyTime}>{formatRelativeTime(rec.createdAt)}</Text>
|
<Text className={styles.historyTime}>{formatRelativeTime(rec.createdAt)}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { Image, ScrollView, Text, View } from '@tarojs/components';
|
import { Image, ScrollView, Text, View } from '@tarojs/components';
|
||||||
import Taro, { useRouter } from '@tarojs/taro';
|
import { useRouter } from '@tarojs/taro';
|
||||||
import styles from './index.module.scss';
|
import styles from './index.module.scss';
|
||||||
import {
|
import {
|
||||||
getArticle,
|
getArticle,
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ const LampPage: React.FC = () => {
|
|||||||
const [stepsMap, setStepsMap] = useState<Record<string, LampTestStep[]>>({});
|
const [stepsMap, setStepsMap] = useState<Record<string, LampTestStep[]>>({});
|
||||||
const [expandedId, setExpandedId] = useState('');
|
const [expandedId, setExpandedId] = useState('');
|
||||||
const [result, setResult] = useState('');
|
const [result, setResult] = useState('');
|
||||||
const [imagePath, setImagePath] = useState('');
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
@@ -71,7 +70,6 @@ const LampPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
setExpandedId(t.id);
|
setExpandedId(t.id);
|
||||||
setResult(t.result || '');
|
setResult(t.result || '');
|
||||||
setImagePath('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStepToggle = async (t: LampTest, s: LampTestStep, done: boolean) => {
|
const handleStepToggle = async (t: LampTest, s: LampTestStep, done: boolean) => {
|
||||||
@@ -88,7 +86,6 @@ const LampPage: React.FC = () => {
|
|||||||
sizeType: ['compressed'],
|
sizeType: ['compressed'],
|
||||||
success: async (res) => {
|
success: async (res) => {
|
||||||
const path = res.tempFilePaths[0];
|
const path = res.tempFilePaths[0];
|
||||||
setImagePath(path);
|
|
||||||
try {
|
try {
|
||||||
const up = await uploadLampResultImage(t.id, path);
|
const up = await uploadLampResultImage(t.id, path);
|
||||||
setTests((prev) =>
|
setTests((prev) =>
|
||||||
@@ -155,11 +152,11 @@ const LampPage: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text className={styles.sectionLabel}>检测结果(天蓝=阳性,紫罗兰=阴性)</Text>
|
<Text className={styles.sectionLabel}>检测结果(天蓝=阳性,紫罗兰=阴性)</Text>
|
||||||
<RadioGroup value={result} onChange={(e) => setResult(e.detail.value)}>
|
<RadioGroup onChange={(e) => setResult(e.detail.value[0])}>
|
||||||
<View className={styles.radioRow}>
|
<View className={styles.radioRow}>
|
||||||
<Radio value="positive">阳性</Radio>
|
<Radio value="positive" checked={result === 'positive'}>阳性</Radio>
|
||||||
<Radio value="negative">阴性</Radio>
|
<Radio value="negative" checked={result === 'negative'}>阴性</Radio>
|
||||||
<Radio value="invalid">无效</Radio>
|
<Radio value="invalid" checked={result === 'invalid'}>无效</Radio>
|
||||||
</View>
|
</View>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
{t.result ? <Text className={styles.resultHint}>当前结果:{RESULT_LABELS[t.result]}</Text> : null}
|
{t.result ? <Text className={styles.resultHint}>当前结果:{RESULT_LABELS[t.result]}</Text> : null}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const NotificationPage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const res = await Taro.requestSubscribeMessage({
|
const res = await Taro.requestSubscribeMessage({
|
||||||
tmplIds: configured.map((t) => t.id),
|
tmplIds: configured.map((t) => t.id),
|
||||||
|
entityIds: configured.map((t) => t.id),
|
||||||
});
|
});
|
||||||
for (const t of configured) {
|
for (const t of configured) {
|
||||||
if (res[t.id] === 'accept') {
|
if (res[t.id] === 'accept') {
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ const SettingsPage: React.FC = () => {
|
|||||||
case 'lamp':
|
case 'lamp':
|
||||||
Taro.navigateTo({ url: '/pages/lamp/index' });
|
Taro.navigateTo({ url: '/pages/lamp/index' });
|
||||||
break;
|
break;
|
||||||
|
case 'biosecurity':
|
||||||
|
Taro.navigateTo({ url: '/pages/biosecurity/index' });
|
||||||
|
break;
|
||||||
case 'about':
|
case 'about':
|
||||||
Taro.showModal({
|
Taro.showModal({
|
||||||
title: '关于',
|
title: '关于',
|
||||||
@@ -190,6 +193,11 @@ const SettingsPage: React.FC = () => {
|
|||||||
<Text className={styles.menuLabel}>LAMP 检测</Text>
|
<Text className={styles.menuLabel}>LAMP 检测</Text>
|
||||||
<Text className={styles.menuArrow}>›</Text>
|
<Text className={styles.menuArrow}>›</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<View className={styles.menuItem} onClick={() => handleMenuTap('biosecurity')}>
|
||||||
|
<Text className={styles.menuIcon}>🛡️</Text>
|
||||||
|
<Text className={styles.menuLabel}>生物安全</Text>
|
||||||
|
<Text className={styles.menuArrow}>›</Text>
|
||||||
|
</View>
|
||||||
<View className={styles.menuItem} onClick={() => handleMenuTap('wsStatus')}>
|
<View className={styles.menuItem} onClick={() => handleMenuTap('wsStatus')}>
|
||||||
<Text className={styles.menuIcon}>🔗</Text>
|
<Text className={styles.menuIcon}>🔗</Text>
|
||||||
<Text className={styles.menuLabel}>连接状态</Text>
|
<Text className={styles.menuLabel}>连接状态</Text>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { View, Text, Video } from '@tarojs/components';
|
import { View, Text, Video } from '@tarojs/components';
|
||||||
import Taro, { useRouter } from '@tarojs/taro';
|
import { useRouter } from '@tarojs/taro';
|
||||||
import styles from './index.module.scss';
|
import styles from './index.module.scss';
|
||||||
import { getCameras, playCamera } from '@/api/video';
|
import { getCameras, playCamera } from '@/api/video';
|
||||||
import { resolveUrl } from '@/api/config';
|
import { resolveUrl } from '@/api/config';
|
||||||
import type { Camera, VideoPlayResponse } from '@/types';
|
import type { Camera } from '@/types';
|
||||||
|
|
||||||
const VideoPlayerPage: React.FC = () => {
|
const VideoPlayerPage: React.FC = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -30,10 +30,6 @@ const VideoPlayerPage: React.FC = () => {
|
|||||||
const found = cameras.find((c) => c.id === cameraId);
|
const found = cameras.find((c) => c.id === cameraId);
|
||||||
if (found) {
|
if (found) {
|
||||||
setCamera(found);
|
setCamera(found);
|
||||||
const url = found.hlsUrl || found.streamUrl || found.flvUrl;
|
|
||||||
if (url) {
|
|
||||||
setVideoUrl(resolveUrl(url));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[VideoPlayer] 获取摄像头信息失败:', err);
|
console.error('[VideoPlayer] 获取摄像头信息失败:', err);
|
||||||
@@ -44,14 +40,21 @@ const VideoPlayerPage: React.FC = () => {
|
|||||||
}, [cameraId, directUrl]);
|
}, [cameraId, directUrl]);
|
||||||
|
|
||||||
const handlePlay = async () => {
|
const handlePlay = async () => {
|
||||||
if (!cameraId && !camera) return;
|
const id = cameraId || camera?.id;
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
const streamUrl = resolveUrl(`/api/v1/video/cameras/${cameraId || camera!.id}/live/stream`);
|
try {
|
||||||
|
const info = await playCamera(id, { format: 'flv' });
|
||||||
|
const streamUrl = resolveUrl(info.url);
|
||||||
console.log('[VideoPlayer] 获取播放地址成功:', streamUrl);
|
console.log('[VideoPlayer] 获取播放地址成功:', streamUrl);
|
||||||
setVideoUrl(streamUrl);
|
setVideoUrl(streamUrl);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '获取播放地址失败');
|
||||||
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formats: { key: 'hls' | 'flv' | 'webrtc'; label: string }[] = [
|
const formats: { key: 'hls' | 'flv' | 'webrtc'; label: string }[] = [
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import * as assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { createOfflineQueue, type QueueStorage } from './offlineQueue';
|
||||||
|
|
||||||
|
function memoryStorage(): QueueStorage {
|
||||||
|
const data = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
getItem: (key) => data.get(key) ?? null,
|
||||||
|
setItem: (key, value) => void data.set(key, value),
|
||||||
|
removeItem: (key) => void data.delete(key),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('offline queue persists and restores pending items', () => {
|
||||||
|
const storage = memoryStorage();
|
||||||
|
const queue = createOfflineQueue({
|
||||||
|
storage,
|
||||||
|
uploader: async () => ({ id: 'server-1' }),
|
||||||
|
now: () => 1000,
|
||||||
|
idGenerator: () => 'id-1',
|
||||||
|
});
|
||||||
|
queue.enqueue('/tmp/a.png', 'room-1');
|
||||||
|
const restored = createOfflineQueue({
|
||||||
|
storage,
|
||||||
|
uploader: async () => ({ id: 'server-1' }),
|
||||||
|
now: () => 2000,
|
||||||
|
idGenerator: () => 'id-2',
|
||||||
|
});
|
||||||
|
assert.equal(restored.list().length, 1);
|
||||||
|
assert.equal(restored.list()[0].idempotencyKey, 'insp-id-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offline queue deduplicates repeated clicks', () => {
|
||||||
|
const queue = createOfflineQueue({
|
||||||
|
storage: memoryStorage(),
|
||||||
|
uploader: async () => ({ id: 'server-1' }),
|
||||||
|
now: () => 1000,
|
||||||
|
idGenerator: () => 'id-1',
|
||||||
|
});
|
||||||
|
const first = queue.enqueue('/tmp/a.png', 'room-1');
|
||||||
|
const second = queue.enqueue('/tmp/a.png', 'room-1');
|
||||||
|
assert.equal(first.id, second.id);
|
||||||
|
assert.equal(queue.stats().total, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offline queue marks permanent 4xx as failed', async () => {
|
||||||
|
const queue = createOfflineQueue({
|
||||||
|
storage: memoryStorage(),
|
||||||
|
uploader: async () => {
|
||||||
|
const err: Error & { statusCode?: number } = new Error('bad request');
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
},
|
||||||
|
now: () => 1000,
|
||||||
|
idGenerator: () => 'id-1',
|
||||||
|
});
|
||||||
|
queue.enqueue('/tmp/a.png');
|
||||||
|
const result = await queue.syncOnce();
|
||||||
|
assert.equal(result.synced, 0);
|
||||||
|
assert.equal(queue.list()[0].state, 'failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offline queue retries with backoff and fails permanently', async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
let now = 1000;
|
||||||
|
const queue = createOfflineQueue({
|
||||||
|
storage: memoryStorage(),
|
||||||
|
uploader: async () => {
|
||||||
|
attempts += 1;
|
||||||
|
throw new Error('network');
|
||||||
|
},
|
||||||
|
now: () => now,
|
||||||
|
idGenerator: () => 'id-1',
|
||||||
|
maxAttempts: 2,
|
||||||
|
backoffBase: 1000,
|
||||||
|
});
|
||||||
|
queue.enqueue('/tmp/a.png');
|
||||||
|
await queue.syncOnce();
|
||||||
|
assert.equal(queue.list()[0].state, 'pending');
|
||||||
|
assert.equal(queue.list()[0].attempts, 1);
|
||||||
|
now = 5000;
|
||||||
|
await queue.syncOnce();
|
||||||
|
assert.equal(queue.list()[0].state, 'failed');
|
||||||
|
assert.equal(queue.list()[0].attempts, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offline queue removes synced references', async () => {
|
||||||
|
const queue = createOfflineQueue({
|
||||||
|
storage: memoryStorage(),
|
||||||
|
uploader: async () => ({ id: 'server-1' }),
|
||||||
|
now: () => 1000,
|
||||||
|
idGenerator: () => 'id-1',
|
||||||
|
});
|
||||||
|
queue.enqueue('/tmp/a.png');
|
||||||
|
await queue.syncOnce();
|
||||||
|
assert.equal(queue.stats().synced, 1);
|
||||||
|
assert.equal(queue.clearSynced(), 1);
|
||||||
|
assert.equal(queue.stats().total, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
export type OfflineState = 'pending' | 'uploading' | 'synced' | 'conflict' | 'failed';
|
||||||
|
|
||||||
|
export interface OfflineInspectionItem {
|
||||||
|
id: string;
|
||||||
|
idempotencyKey: string;
|
||||||
|
imagePath: string;
|
||||||
|
roomId?: string;
|
||||||
|
state: OfflineState;
|
||||||
|
attempts: number;
|
||||||
|
nextAttemptAt: number;
|
||||||
|
error?: string;
|
||||||
|
serverId?: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueStorage {
|
||||||
|
getItem(key: string): string | null;
|
||||||
|
setItem(key: string, value: string): void;
|
||||||
|
removeItem(key: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueUploadInput {
|
||||||
|
imagePath: string;
|
||||||
|
roomId?: string;
|
||||||
|
idempotencyKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueUploader {
|
||||||
|
(input: QueueUploadInput): Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OfflineQueueDeps {
|
||||||
|
storage: QueueStorage;
|
||||||
|
uploader: QueueUploader;
|
||||||
|
now?: () => number;
|
||||||
|
idGenerator?: () => string;
|
||||||
|
maxItems?: number;
|
||||||
|
maxAttempts?: number;
|
||||||
|
backoffBase?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'offline_inspection_queue';
|
||||||
|
export const OFFLINE_QUEUE_STORAGE_KEY = STORAGE_KEY;
|
||||||
|
|
||||||
|
function loadQueue(storage: QueueStorage): OfflineInspectionItem[] {
|
||||||
|
const raw = storage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveQueue(storage: QueueStorage, items: OfflineInspectionItem[]): void {
|
||||||
|
storage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOfflineQueue(deps: OfflineQueueDeps) {
|
||||||
|
const now = deps.now || (() => Date.now());
|
||||||
|
const idGenerator = deps.idGenerator || (() => `offline-${now()}-${Math.random().toString(36).slice(2, 10)}`);
|
||||||
|
const maxItems = deps.maxItems || 30;
|
||||||
|
const maxAttempts = deps.maxAttempts || 8;
|
||||||
|
const backoffBase = deps.backoffBase || 30000;
|
||||||
|
|
||||||
|
const enqueue = (imagePath: string, roomId?: string): OfflineInspectionItem => {
|
||||||
|
const items = loadQueue(deps.storage);
|
||||||
|
const duplicate = items.find(
|
||||||
|
(item) =>
|
||||||
|
item.imagePath === imagePath &&
|
||||||
|
item.roomId === roomId &&
|
||||||
|
(item.state === 'pending' || item.state === 'uploading'),
|
||||||
|
);
|
||||||
|
if (duplicate) return duplicate;
|
||||||
|
if (items.length >= maxItems) {
|
||||||
|
throw new Error('离线巡检队列已满,请先清理已同步记录');
|
||||||
|
}
|
||||||
|
const timestamp = now();
|
||||||
|
const item: OfflineInspectionItem = {
|
||||||
|
id: idGenerator(),
|
||||||
|
idempotencyKey: `insp-${idGenerator()}`,
|
||||||
|
imagePath,
|
||||||
|
roomId,
|
||||||
|
state: 'pending',
|
||||||
|
attempts: 0,
|
||||||
|
nextAttemptAt: timestamp,
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
};
|
||||||
|
items.unshift(item);
|
||||||
|
saveQueue(deps.storage, items);
|
||||||
|
return item;
|
||||||
|
};
|
||||||
|
|
||||||
|
const list = (): OfflineInspectionItem[] => loadQueue(deps.storage);
|
||||||
|
|
||||||
|
const remove = (id: string): void => {
|
||||||
|
saveQueue(deps.storage, loadQueue(deps.storage).filter((item) => item.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSynced = (): number => {
|
||||||
|
const items = loadQueue(deps.storage);
|
||||||
|
const before = items.length;
|
||||||
|
saveQueue(deps.storage, items.filter((item) => item.state !== 'synced'));
|
||||||
|
return before - loadQueue(deps.storage).length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const retry = (id: string): OfflineInspectionItem | null => {
|
||||||
|
const items = loadQueue(deps.storage);
|
||||||
|
const item = items.find((entry) => entry.id === id);
|
||||||
|
if (!item || item.state === 'synced') return null;
|
||||||
|
item.state = 'pending';
|
||||||
|
item.error = undefined;
|
||||||
|
item.nextAttemptAt = now();
|
||||||
|
item.updatedAt = now();
|
||||||
|
saveQueue(deps.storage, items);
|
||||||
|
return item;
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncOnce = async (): Promise<{
|
||||||
|
synced: number;
|
||||||
|
item?: OfflineInspectionItem;
|
||||||
|
error?: string;
|
||||||
|
}> => {
|
||||||
|
const items = loadQueue(deps.storage);
|
||||||
|
const item = items.find((entry) => entry.state === 'pending' && entry.nextAttemptAt <= now());
|
||||||
|
if (!item) return { synced: 0 };
|
||||||
|
item.state = 'uploading';
|
||||||
|
item.updatedAt = now();
|
||||||
|
saveQueue(deps.storage, items);
|
||||||
|
try {
|
||||||
|
const result = await deps.uploader({
|
||||||
|
imagePath: item.imagePath,
|
||||||
|
roomId: item.roomId,
|
||||||
|
idempotencyKey: item.idempotencyKey,
|
||||||
|
});
|
||||||
|
const updated = loadQueue(deps.storage);
|
||||||
|
const current = updated.find((entry) => entry.id === item.id);
|
||||||
|
if (current) {
|
||||||
|
current.state = 'synced';
|
||||||
|
current.serverId = result.id;
|
||||||
|
current.error = undefined;
|
||||||
|
current.updatedAt = now();
|
||||||
|
saveQueue(deps.storage, updated);
|
||||||
|
}
|
||||||
|
return { synced: 1, item: current || item };
|
||||||
|
} catch (err) {
|
||||||
|
const updated = loadQueue(deps.storage);
|
||||||
|
const current = updated.find((entry) => entry.id === item.id);
|
||||||
|
if (!current) return { synced: 0, error: errorMessage(err) };
|
||||||
|
current.attempts += 1;
|
||||||
|
current.error = errorMessage(err);
|
||||||
|
const status = (err as { statusCode?: number }).statusCode;
|
||||||
|
const permanent =
|
||||||
|
status !== undefined &&
|
||||||
|
status >= 400 &&
|
||||||
|
status < 500 &&
|
||||||
|
status !== 401 &&
|
||||||
|
status !== 408 &&
|
||||||
|
status !== 429;
|
||||||
|
if (permanent) {
|
||||||
|
current.state = status === 409 ? 'conflict' : 'failed';
|
||||||
|
} else if (current.attempts >= maxAttempts) {
|
||||||
|
current.state = 'failed';
|
||||||
|
} else {
|
||||||
|
current.state = 'pending';
|
||||||
|
current.nextAttemptAt = now() + backoffBase * Math.min(Math.pow(2, current.attempts - 1), 16);
|
||||||
|
}
|
||||||
|
current.updatedAt = now();
|
||||||
|
saveQueue(deps.storage, updated);
|
||||||
|
return { synced: 0, item: current, error: current.error };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = () => {
|
||||||
|
const items = loadQueue(deps.storage);
|
||||||
|
return {
|
||||||
|
total: items.length,
|
||||||
|
pending: items.filter((item) => item.state === 'pending' || item.state === 'uploading').length,
|
||||||
|
synced: items.filter((item) => item.state === 'synced').length,
|
||||||
|
failed: items.filter((item) => item.state === 'failed').length,
|
||||||
|
conflict: items.filter((item) => item.state === 'conflict').length,
|
||||||
|
remaining: Math.max(0, maxItems - items.length),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return { enqueue, list, remove, clearSynced, retry, syncOnce, stats };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OfflineQueue = ReturnType<typeof createOfflineQueue>;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import Taro from '@tarojs/taro';
|
||||||
|
import { uploadInspection } from '@/api/inspections';
|
||||||
|
import { createOfflineQueue, OFFLINE_QUEUE_STORAGE_KEY } from './offlineQueue';
|
||||||
|
|
||||||
|
const storage = {
|
||||||
|
getItem: (key: string) => {
|
||||||
|
const value = Taro.getStorageSync(key);
|
||||||
|
return typeof value === 'string' && value ? value : null;
|
||||||
|
},
|
||||||
|
setItem: (key: string, value: string) => Taro.setStorageSync(key, value),
|
||||||
|
removeItem: (key: string) => Taro.removeStorageSync(key),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const offlineQueue = createOfflineQueue({
|
||||||
|
storage,
|
||||||
|
uploader: (input) => uploadInspection(input.imagePath, input.roomId, input.idempotencyKey),
|
||||||
|
});
|
||||||
|
|
||||||
|
export { OFFLINE_QUEUE_STORAGE_KEY };
|
||||||
@@ -3,6 +3,7 @@ import Taro from '@tarojs/taro';
|
|||||||
import type { User } from '@/types';
|
import type { User } from '@/types';
|
||||||
import { getCurrentUser } from '@/api/auth';
|
import { getCurrentUser } from '@/api/auth';
|
||||||
import { wsManager } from '@/utils/ws';
|
import { wsManager } from '@/utils/ws';
|
||||||
|
import { offlineQueue } from '@/services/offlineQueueApp';
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
token: string | null;
|
token: string | null;
|
||||||
@@ -13,6 +14,7 @@ interface AppState {
|
|||||||
logout: () => void;
|
logout: () => void;
|
||||||
isLoggedIn: () => boolean;
|
isLoggedIn: () => boolean;
|
||||||
restoreSession: () => Promise<void>;
|
restoreSession: () => Promise<void>;
|
||||||
|
syncOfflineQueue: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useStore = create<AppState>((set, get) => ({
|
export const useStore = create<AppState>((set, get) => ({
|
||||||
@@ -49,4 +51,11 @@ export const useStore = create<AppState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
set({ isReady: true });
|
set({ isReady: true });
|
||||||
},
|
},
|
||||||
|
syncOfflineQueue: async () => {
|
||||||
|
let synced = 0;
|
||||||
|
do {
|
||||||
|
const result = await offlineQueue.syncOnce();
|
||||||
|
synced = result.synced;
|
||||||
|
} while (synced > 0);
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -223,6 +223,17 @@ export interface InspectionRecord {
|
|||||||
detections?: AIDetection[];
|
detections?: AIDetection[];
|
||||||
riskScore?: number;
|
riskScore?: number;
|
||||||
riskLevel?: string;
|
riskLevel?: string;
|
||||||
|
riskAssessment?: {
|
||||||
|
score: number;
|
||||||
|
level: string;
|
||||||
|
confidence: string;
|
||||||
|
modelVersion: string;
|
||||||
|
ruleVersion: string;
|
||||||
|
components: Record<string, number | null>;
|
||||||
|
missing: string[];
|
||||||
|
};
|
||||||
|
modelVersion?: string;
|
||||||
|
isMock?: boolean;
|
||||||
aiStatus: string;
|
aiStatus: string;
|
||||||
idempotencyKey?: string;
|
idempotencyKey?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
|||||||
+19
-5
@@ -1,5 +1,6 @@
|
|||||||
import Taro from '@tarojs/taro';
|
import Taro from '@tarojs/taro';
|
||||||
import { getWsUrl } from '@/api/config';
|
import { getWsUrl } from '@/api/config';
|
||||||
|
import { get } from '@/api/request';
|
||||||
|
|
||||||
type MessageHandler = (data: unknown) => void;
|
type MessageHandler = (data: unknown) => void;
|
||||||
|
|
||||||
@@ -7,19 +8,20 @@ class WebSocketManager {
|
|||||||
private connected: boolean = false;
|
private connected: boolean = false;
|
||||||
private handlers: Map<string, Set<MessageHandler>> = new Map();
|
private handlers: Map<string, Set<MessageHandler>> = new Map();
|
||||||
private token: string = '';
|
private token: string = '';
|
||||||
|
private ticket: string = '';
|
||||||
private reconnectAttempts: number = 0;
|
private reconnectAttempts: number = 0;
|
||||||
private maxReconnectAttempts: number = 5;
|
private maxReconnectAttempts: number = 5;
|
||||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private isManualClose: boolean = false;
|
private isManualClose: boolean = false;
|
||||||
|
|
||||||
connect(token: string): void {
|
async connect(token: string): Promise<void> {
|
||||||
this.token = token;
|
this.token = token;
|
||||||
this.isManualClose = false;
|
this.isManualClose = false;
|
||||||
this.doConnect();
|
await this.refreshTicketAndConnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
private doConnect(): void {
|
private doConnect(): void {
|
||||||
if (!this.token) {
|
if (!this.token || !this.ticket) {
|
||||||
console.warn('[WS] 无 token,无法连接 WebSocket');
|
console.warn('[WS] 无 token,无法连接 WebSocket');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -34,7 +36,7 @@ class WebSocketManager {
|
|||||||
this.connected = false;
|
this.connected = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = getWsUrl(this.token);
|
const url = getWsUrl(this.ticket);
|
||||||
console.log('[WS] 正在连接:', url);
|
console.log('[WS] 正在连接:', url);
|
||||||
|
|
||||||
// 注册全局回调(每次调用会替换上一次的回调)
|
// 注册全局回调(每次调用会替换上一次的回调)
|
||||||
@@ -98,10 +100,22 @@ class WebSocketManager {
|
|||||||
clearTimeout(this.reconnectTimer);
|
clearTimeout(this.reconnectTimer);
|
||||||
}
|
}
|
||||||
this.reconnectTimer = setTimeout(() => {
|
this.reconnectTimer = setTimeout(() => {
|
||||||
this.doConnect();
|
this.refreshTicketAndConnect();
|
||||||
}, delay);
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async refreshTicketAndConnect(): Promise<void> {
|
||||||
|
if (!this.token) return;
|
||||||
|
try {
|
||||||
|
const { ticket } = await get<{ ticket: string }>('/ws/ticket');
|
||||||
|
this.ticket = ticket;
|
||||||
|
this.doConnect();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[WS] 获取 ticket 失败:', e);
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
on(type: string, handler: MessageHandler): () => void {
|
on(type: string, handler: MessageHandler): () => void {
|
||||||
if (!this.handlers.has(type)) {
|
if (!this.handlers.has(type)) {
|
||||||
this.handlers.set(type, new Set());
|
this.handlers.set(type, new Set());
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"baseUrl": "",
|
"baseUrl": "",
|
||||||
"rootDir": "",
|
"rootDir": "",
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- Task 6 Step 5: 只读历史数据评估报告
|
||||||
|
-- 仅统计,不更新数据;未经人工确认不得批量重算或覆盖历史 risk_score/risk_level。
|
||||||
|
|
||||||
|
WITH real_inspections AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
risk_level,
|
||||||
|
risk_score,
|
||||||
|
detections
|
||||||
|
FROM inspection_records
|
||||||
|
WHERE ai_status = 'done'
|
||||||
|
AND (is_mock IS NULL OR is_mock = false)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
risk_level,
|
||||||
|
count(*) AS records,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM jsonb_array_elements(detections) AS d
|
||||||
|
WHERE d->>'class' = 'healthy'
|
||||||
|
AND COALESCE((d->>'confidence')::numeric, 0) >= 0.80
|
||||||
|
)
|
||||||
|
) AS healthy_high_conf_records
|
||||||
|
FROM real_inspections
|
||||||
|
GROUP BY risk_level
|
||||||
|
ORDER BY risk_level;
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
|
$script:failed = @()
|
||||||
|
|
||||||
|
function Resolve-Go {
|
||||||
|
$cmd = Get-Command go -ErrorAction SilentlyContinue
|
||||||
|
if ($cmd) {
|
||||||
|
return $cmd.Source
|
||||||
|
}
|
||||||
|
foreach ($candidate in @('C:\Program Files\Go\bin\go.exe', 'C:\Go\bin\go.exe')) {
|
||||||
|
if (Test-Path -LiteralPath $candidate) {
|
||||||
|
return $candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw 'Go executable not found. Install Go 1.23 or add it to PATH.'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Check {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Name,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[scriptblock]$Command
|
||||||
|
)
|
||||||
|
|
||||||
|
Write-Host "[$Name]"
|
||||||
|
try {
|
||||||
|
& $Command
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "FAILED: $Name (exit $LASTEXITCODE)"
|
||||||
|
$script:failed += $Name
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host "FAILED: $Name - $($_.Exception.Message)"
|
||||||
|
$script:failed += $Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$goExe = Resolve-Go
|
||||||
|
|
||||||
|
Push-Location (Join-Path $root 'server-go')
|
||||||
|
try {
|
||||||
|
Invoke-Check 'server-go test' { & $goExe test ./... }
|
||||||
|
Invoke-Check 'server-go vet' { & $goExe vet ./... }
|
||||||
|
Invoke-Check 'server-go build' { & $goExe build ./... }
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
Push-Location (Join-Path $root 'web')
|
||||||
|
try {
|
||||||
|
Invoke-Check 'web test' { npm test }
|
||||||
|
Invoke-Check 'web lint' { npm run lint }
|
||||||
|
Invoke-Check 'web build' { npm run build }
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
Push-Location (Join-Path $root 'miniapp')
|
||||||
|
try {
|
||||||
|
Invoke-Check 'miniapp test' { npm test }
|
||||||
|
Invoke-Check 'miniapp typecheck' { npm run typecheck }
|
||||||
|
Invoke-Check 'miniapp build:weapp' { npm run build:weapp }
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
Push-Location (Join-Path $root 'app')
|
||||||
|
try {
|
||||||
|
Invoke-Check 'app typecheck' { npm run tsc }
|
||||||
|
Invoke-Check 'app lint' { npm run lint }
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
$aiPython = Join-Path $root 'ai-service\.venv\Scripts\python.exe'
|
||||||
|
if (-not (Test-Path -LiteralPath $aiPython)) {
|
||||||
|
$aiPython = 'python'
|
||||||
|
}
|
||||||
|
|
||||||
|
Push-Location (Join-Path $root 'ai-service')
|
||||||
|
try {
|
||||||
|
Invoke-Check 'ai-service pytest' { & $aiPython -m pytest }
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($script:failed.Count -gt 0) {
|
||||||
|
Write-Error "Verification failed for: $($script:failed -join ', ')"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host 'All verification checks passed.'
|
||||||
|
exit 0
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -28,7 +29,7 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 连接数据库(GORM 自动迁移)
|
// 2. 连接数据库并执行版本化迁移
|
||||||
if err := database.Init(cfg); err != nil {
|
if err := database.Init(cfg); err != nil {
|
||||||
slog.Error("数据库连接失败", "err", err)
|
slog.Error("数据库连接失败", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -41,18 +42,28 @@ func main() {
|
|||||||
slog.Warn("IoTDB 初始化失败,将降级使用 PostgreSQL", "err", err)
|
slog.Warn("IoTDB 初始化失败,将降级使用 PostgreSQL", "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 连接 Redis(失败不阻断启动)
|
// 4. 连接 Redis(失败不阻断启动,但认证/限流保守失败)
|
||||||
|
var rdb *redis.Client
|
||||||
if opt, err := redis.ParseURL(cfg.Redis); err == nil {
|
if opt, err := redis.ParseURL(cfg.Redis); err == nil {
|
||||||
rdb := redis.NewClient(opt)
|
rdb = redis.NewClient(opt)
|
||||||
if err := rdb.Ping(context.Background()).Err(); err != nil {
|
if err := rdb.Ping(context.Background()).Err(); err != nil {
|
||||||
slog.Warn("Redis 连接失败", "err", err)
|
slog.Error("Redis 连接失败,认证/限流将保守失败", "err", err)
|
||||||
|
_ = rdb.Close()
|
||||||
|
rdb = nil
|
||||||
} else {
|
} else {
|
||||||
slog.Info("Redis 连接成功")
|
slog.Info("Redis 连接成功")
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
slog.Error("Redis URL 解析失败,认证/限流将保守失败", "err", err)
|
||||||
|
}
|
||||||
|
if rdb != nil {
|
||||||
|
middleware.InitState(middleware.NewRedisState(rdb), cfg.AppEnv)
|
||||||
|
} else {
|
||||||
|
middleware.InitState(nil, cfg.AppEnv)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 创建 WebSocket Hub
|
// 5. 创建 WebSocket Hub
|
||||||
hub := ws.NewHub(cfg.JWTSecret)
|
hub := ws.NewHub(cfg.JWTSecret, ws.NewDBDeviceAuthorizer(db), strings.Split(cfg.WSAllowedOrigins, ","))
|
||||||
|
|
||||||
// 6. 创建并启动 MQTT 服务
|
// 6. 创建并启动 MQTT 服务
|
||||||
mqttSvc := service.NewMQTTService(cfg.MQTT, db, iotdb, hub)
|
mqttSvc := service.NewMQTTService(cfg.MQTT, db, iotdb, hub)
|
||||||
@@ -75,10 +86,16 @@ func main() {
|
|||||||
aiSvc := service.NewAIClient(cfg.AIServiceBase)
|
aiSvc := service.NewAIClient(cfg.AIServiceBase)
|
||||||
wechatSvc := service.NewWechatService(cfg.WechatAppID, cfg.WechatSecret)
|
wechatSvc := service.NewWechatService(cfg.WechatAppID, cfg.WechatSecret)
|
||||||
weatherSvc := service.NewWeatherService(cfg.QWeatherAPIKey, cfg.QWeatherLocation)
|
weatherSvc := service.NewWeatherService(cfg.QWeatherAPIKey, cfg.QWeatherLocation)
|
||||||
|
outbox := service.NewOutbox(db)
|
||||||
|
outbox.SetHandler(service.NewOutboxHandler(db, wechatSvc))
|
||||||
|
outboxCtx, cancelOutbox := context.WithCancel(context.Background())
|
||||||
|
defer cancelOutbox()
|
||||||
|
outbox.Start(outboxCtx)
|
||||||
|
|
||||||
// 9. 创建 Gin 引擎
|
// 9. 创建 Gin 引擎
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
|
r.Use(middleware.RequestIDMiddleware())
|
||||||
r.Use(middleware.Logger())
|
r.Use(middleware.Logger())
|
||||||
r.Use(middleware.SecurityHeaders())
|
r.Use(middleware.SecurityHeaders())
|
||||||
r.Use(middleware.CORS())
|
r.Use(middleware.CORS())
|
||||||
@@ -89,6 +106,7 @@ func main() {
|
|||||||
// 11. API 路由组(经过 JWT auth 中间件,白名单路径自动跳过)
|
// 11. API 路由组(经过 JWT auth 中间件,白名单路径自动跳过)
|
||||||
api := r.Group("/api/v1")
|
api := r.Group("/api/v1")
|
||||||
api.Use(middleware.Auth(cfg))
|
api.Use(middleware.Auth(cfg))
|
||||||
|
api.GET("/ws/ticket", hub.HandleTicket)
|
||||||
|
|
||||||
// 公开路由(auth 白名单中跳过鉴权)
|
// 公开路由(auth 白名单中跳过鉴权)
|
||||||
handler.RegisterVideoStreamRoutes(api, transcodeSvc, db, mediaSvc, cfg)
|
handler.RegisterVideoStreamRoutes(api, transcodeSvc, db, mediaSvc, cfg)
|
||||||
@@ -109,12 +127,14 @@ func main() {
|
|||||||
handler.RegisterVideoRecordRoutes(api, db, mediaSvc, cfg)
|
handler.RegisterVideoRecordRoutes(api, db, mediaSvc, cfg)
|
||||||
handler.RegisterStorageRoutes(api, db)
|
handler.RegisterStorageRoutes(api, db)
|
||||||
handler.RegisterKnowledgeRoutes(api, db, s3Svc, cfg.S3BucketImages)
|
handler.RegisterKnowledgeRoutes(api, db, s3Svc, cfg.S3BucketImages)
|
||||||
handler.RegisterInspectionRoutes(api, db, s3Svc, aiSvc, cfg.S3BucketImages, wechatSvc, cfg.WechatTemplateInspection)
|
handler.RegisterInspectionRoutes(api, db, s3Svc, aiSvc, cfg.S3BucketImages, outbox, cfg.WechatTemplateInspection, cfg.AppEnv)
|
||||||
handler.RegisterTrayBatchRoutes(api, db)
|
handler.RegisterTrayBatchRoutes(api, db)
|
||||||
handler.RegisterWechatRoutes(api, db, wechatSvc)
|
handler.RegisterWechatRoutes(api, db, wechatSvc)
|
||||||
handler.RegisterWeatherRoutes(api, db, weatherSvc)
|
handler.RegisterWeatherRoutes(api, db, weatherSvc)
|
||||||
handler.RegisterLampRoutes(api, db, s3Svc, cfg.S3BucketImages)
|
handler.RegisterLampRoutes(api, db, s3Svc, cfg.S3BucketImages)
|
||||||
|
handler.RegisterDetectionTaskRoutes(api, db)
|
||||||
handler.RegisterConsumableRoutes(api, db)
|
handler.RegisterConsumableRoutes(api, db)
|
||||||
|
handler.RegisterBiosecurityRoutes(api, db)
|
||||||
handler.RegisterConsultationRoutes(api, db)
|
handler.RegisterConsultationRoutes(api, db)
|
||||||
handler.RegisterDetectionMethodRoutes(api, db)
|
handler.RegisterDetectionMethodRoutes(api, db)
|
||||||
handler.RegisterTraceRoutes(api, db)
|
handler.RegisterTraceRoutes(api, db)
|
||||||
@@ -131,6 +151,7 @@ func main() {
|
|||||||
handler.RegisterAuditRoutes(api, db)
|
handler.RegisterAuditRoutes(api, db)
|
||||||
handler.RegisterUserRoutes(api, db)
|
handler.RegisterUserRoutes(api, db)
|
||||||
handler.RegisterPermissionRoutes(api, db)
|
handler.RegisterPermissionRoutes(api, db)
|
||||||
|
handler.RegisterOpsRoutes(api, db)
|
||||||
|
|
||||||
// 12. 启动 HTTP 服务
|
// 12. 启动 HTTP 服务
|
||||||
addr := ":" + strconv.Itoa(cfg.Port)
|
addr := ":" + strconv.Itoa(cfg.Port)
|
||||||
|
|||||||
+12
-7
@@ -5,6 +5,7 @@ go 1.23
|
|||||||
exclude github.com/rogpeppe/go-internal v1.15.0
|
exclude github.com/rogpeppe/go-internal v1.15.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||||
github.com/aws/aws-sdk-go-v2 v1.30.3
|
github.com/aws/aws-sdk-go-v2 v1.30.3
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.27
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.27
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.2
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.2
|
||||||
@@ -12,9 +13,10 @@ require (
|
|||||||
github.com/eclipse/paho.mqtt.golang v1.5.0
|
github.com/eclipse/paho.mqtt.golang v1.5.0
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.18.2
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/redis/go-redis/v9 v9.6.1
|
github.com/redis/go-redis/v9 v9.6.1
|
||||||
golang.org/x/crypto v0.25.0
|
golang.org/x/crypto v0.31.0
|
||||||
gorm.io/driver/postgres v1.5.9
|
gorm.io/driver/postgres v1.5.9
|
||||||
gorm.io/gorm v1.25.10
|
gorm.io/gorm v1.25.10
|
||||||
)
|
)
|
||||||
@@ -41,6 +43,8 @@ require (
|
|||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
github.com/jackc/pgx/v5 v5.5.5 // indirect
|
github.com/jackc/pgx/v5 v5.5.5 // indirect
|
||||||
@@ -49,8 +53,8 @@ require (
|
|||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||||
github.com/kr/text v0.2.0 // indirect
|
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/lib/pq v1.10.9 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
@@ -58,11 +62,12 @@ require (
|
|||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
|
go.uber.org/atomic v1.7.0 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
golang.org/x/net v0.27.0 // indirect
|
golang.org/x/net v0.33.0 // indirect
|
||||||
golang.org/x/sync v0.7.0 // indirect
|
golang.org/x/sync v0.10.0 // indirect
|
||||||
golang.org/x/sys v0.26.0 // indirect
|
golang.org/x/sys v0.28.0 // indirect
|
||||||
golang.org/x/text v0.16.0 // indirect
|
golang.org/x/text v0.21.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.1 // indirect
|
google.golang.org/protobuf v1.34.2 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
+70
-15
@@ -1,3 +1,9 @@
|
|||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY=
|
github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc=
|
github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg=
|
||||||
@@ -38,20 +44,35 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/
|
|||||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8=
|
||||||
|
github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM=
|
||||||
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
|
github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4=
|
||||||
|
github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
|
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||||
|
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||||
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
github.com/eclipse/paho.mqtt.golang v1.5.0 h1:EH+bUVJNgttidWFkLLVKaQPGmkTUfQQqjOsyvMGvD6o=
|
github.com/eclipse/paho.mqtt.golang v1.5.0 h1:EH+bUVJNgttidWFkLLVKaQPGmkTUfQQqjOsyvMGvD6o=
|
||||||
github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTqANF7XU7Fk0aOTAgk=
|
github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTqANF7XU7Fk0aOTAgk=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
@@ -62,13 +83,22 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
|
|||||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
|
||||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
|
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||||
|
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
@@ -83,6 +113,7 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
|||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||||
@@ -93,15 +124,29 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
|
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||||
|
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4=
|
github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4=
|
||||||
@@ -124,25 +169,35 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
|||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||||
|
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||||
|
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
||||||
|
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
|
||||||
|
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
|
||||||
|
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
||||||
|
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
||||||
|
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
||||||
|
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
|
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import (
|
|||||||
|
|
||||||
// Config 全局配置,从环境变量加载
|
// Config 全局配置,从环境变量加载
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
AppEnv string `env:"APP_ENV" envDefault:"development"`
|
||||||
|
AllowDevAutoMigrate bool `env:"ALLOW_DEV_AUTOMIGRATE" envDefault:"false"`
|
||||||
|
WSAllowedOrigins string `env:"WS_ALLOWED_ORIGINS" envDefault:"http://localhost:5174,http://localhost:3000,http://127.0.0.1:5174,http://127.0.0.1:3000,http://100.83.103.1:5174,http://100.83.103.1:3000"`
|
||||||
PG string `env:"PG" envDefault:"postgresql://postgres:pan@localhost:5432/silk"`
|
PG string `env:"PG" envDefault:"postgresql://postgres:pan@localhost:5432/silk"`
|
||||||
Redis string `env:"REDIS" envDefault:"redis://:pan@localhost:6379"`
|
Redis string `env:"REDIS" envDefault:"redis://:pan@localhost:6379"`
|
||||||
JWTSecret string `env:"JWT_SECRET" envDefault:"silk-secret-please-change-me"`
|
JWTSecret string `env:"JWT_SECRET" envDefault:"silk-secret-please-change-me"`
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
"silk-server-go/internal/config"
|
"silk-server-go/internal/config"
|
||||||
@@ -21,7 +22,13 @@ func Init(cfg *config.Config) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
DB = db
|
DB = db
|
||||||
// 自动迁移(错误不阻止启动,仅记录警告)
|
if err := RunMigrations(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := CheckSchemaVersion(db, CurrentSchemaVersion); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cfg.AllowDevAutoMigrate && cfg.AppEnv != "production" {
|
||||||
if err := db.AutoMigrate(
|
if err := db.AutoMigrate(
|
||||||
&model.User{}, &model.Room{}, &model.Device{}, &model.Sensor{},
|
&model.User{}, &model.Room{}, &model.Device{}, &model.Sensor{},
|
||||||
&model.Threshold{}, &model.Alarm{}, &model.Camera{}, &model.VideoClip{},
|
&model.Threshold{}, &model.Alarm{}, &model.Camera{}, &model.VideoClip{},
|
||||||
@@ -29,6 +36,17 @@ func Init(cfg *config.Config) error {
|
|||||||
&model.Permission{}, &model.RolePermission{},
|
&model.Permission{}, &model.RolePermission{},
|
||||||
&model.Disease{}, &model.KnowledgeArticle{},
|
&model.Disease{}, &model.KnowledgeArticle{},
|
||||||
&model.InspectionRecord{},
|
&model.InspectionRecord{},
|
||||||
|
&model.OutboxEvent{},
|
||||||
|
&model.Notification{},
|
||||||
|
&model.DetectionTask{},
|
||||||
|
&model.Sample{},
|
||||||
|
&model.DiseaseEvent{},
|
||||||
|
&model.SeedSource{},
|
||||||
|
&model.DisinfectionRecord{},
|
||||||
|
&model.IdentityLink{},
|
||||||
|
&model.ConsultationOpinionVersion{},
|
||||||
|
&model.RuleEngineResult{},
|
||||||
|
&model.KnowledgeReview{},
|
||||||
&model.Tray{}, &model.Batch{}, &model.RearingRecord{},
|
&model.Tray{}, &model.Batch{}, &model.RearingRecord{},
|
||||||
&model.WechatBinding{},
|
&model.WechatBinding{},
|
||||||
&model.WeatherAlert{},
|
&model.WeatherAlert{},
|
||||||
@@ -38,13 +56,15 @@ func Init(cfg *config.Config) error {
|
|||||||
&model.SpectrumEntry{},
|
&model.SpectrumEntry{},
|
||||||
&model.TraceRecord{},
|
&model.TraceRecord{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
slog.Warn("自动迁移有警告(可忽略)", "err", err)
|
return fmt.Errorf("开发环境 AutoMigrate 失败: %w", err)
|
||||||
|
}
|
||||||
|
slog.Warn("开发环境 AutoMigrate 已启用,SQL 迁移仍是生产 schema 事实来源")
|
||||||
}
|
}
|
||||||
// 初始化权限种子数据
|
// 初始化权限种子数据
|
||||||
seedPermissions(db)
|
seedPermissions(db)
|
||||||
// 初始化知识库种子数据
|
// 初始化知识库种子数据
|
||||||
seedKnowledge(db)
|
seedKnowledge(db)
|
||||||
slog.Info("数据库连接成功,自动迁移完成")
|
slog.Info("数据库连接成功,版本化迁移完成", "schemaVersion", CurrentSchemaVersion)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +74,9 @@ func seedKnowledge(db *gorm.DB) {
|
|||||||
var cnt int64
|
var cnt int64
|
||||||
db.Model(&model.Disease{}).Where("name = ?", d.Name).Count(&cnt)
|
db.Model(&model.Disease{}).Where("name = ?", d.Name).Count(&cnt)
|
||||||
if cnt == 0 {
|
if cnt == 0 {
|
||||||
if err := db.Create(&d).Error; err != nil {
|
seed := d
|
||||||
|
seed.Status = "published"
|
||||||
|
if err := db.Create(&seed).Error; err != nil {
|
||||||
slog.Warn("写入病种种子失败", "name", d.Name, "err", err)
|
slog.Warn("写入病种种子失败", "name", d.Name, "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,7 +85,9 @@ func seedKnowledge(db *gorm.DB) {
|
|||||||
var cnt int64
|
var cnt int64
|
||||||
db.Model(&model.KnowledgeArticle{}).Where("kind = ? AND title = ?", a.Kind, a.Title).Count(&cnt)
|
db.Model(&model.KnowledgeArticle{}).Where("kind = ? AND title = ?", a.Kind, a.Title).Count(&cnt)
|
||||||
if cnt == 0 {
|
if cnt == 0 {
|
||||||
if err := db.Create(&a).Error; err != nil {
|
seed := a
|
||||||
|
seed.Status = "published"
|
||||||
|
if err := db.Create(&seed).Error; err != nil {
|
||||||
slog.Warn("写入知识文章种子失败", "title", a.Title, "err", err)
|
slog.Warn("写入知识文章种子失败", "title", a.Title, "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/golang-migrate/migrate/v4"
|
||||||
|
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||||
|
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"silk-server-go/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CurrentSchemaVersion 是当前后端代码期望的迁移版本。
|
||||||
|
const CurrentSchemaVersion = "8"
|
||||||
|
|
||||||
|
// RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。
|
||||||
|
func RunMigrations(db *gorm.DB) error {
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取数据库连接: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceDriver, err := iofs.New(migrations.FS, ".")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("加载嵌入式迁移文件: %w", err)
|
||||||
|
}
|
||||||
|
defer sourceDriver.Close()
|
||||||
|
|
||||||
|
databaseDriver, err := postgres.WithInstance(sqlDB, &postgres.Config{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("初始化 PostgreSQL 迁移驱动: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := migrate.NewWithInstance("iofs", sourceDriver, "postgres", databaseDriver)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("创建迁移实例: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||||
|
return fmt.Errorf("执行数据库迁移: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckSchemaVersion 校验 schema_migrations 当前版本与 expected 一致,且不是 dirty。
|
||||||
|
func CheckSchemaVersion(db *gorm.DB, expected string) error {
|
||||||
|
var version int64
|
||||||
|
var dirty bool
|
||||||
|
|
||||||
|
row := db.Raw("SELECT version, dirty FROM schema_migrations ORDER BY version DESC LIMIT 1").Row()
|
||||||
|
if err := row.Scan(&version, &dirty); err != nil {
|
||||||
|
return fmt.Errorf("读取 schema_migrations: %w", err)
|
||||||
|
}
|
||||||
|
if dirty {
|
||||||
|
return fmt.Errorf("schema 迁移处于 dirty 状态,版本 %d", version)
|
||||||
|
}
|
||||||
|
if strconv.FormatInt(version, 10) != expected {
|
||||||
|
return fmt.Errorf("schema 版本不匹配:当前 %d,期望 %s", version, expected)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"silk-server-go/migrations"
|
||||||
|
|
||||||
|
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newMockGormDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
sqlDB, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gdb, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open gorm with sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return gdb, mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckSchemaVersionRejectsMismatch(t *testing.T) {
|
||||||
|
db, mock := newMockGormDB(t)
|
||||||
|
defer func() {
|
||||||
|
sqlDB, _ := db.DB()
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
mock.ExpectQuery("SELECT version, dirty FROM schema_migrations").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}).AddRow(1, false))
|
||||||
|
|
||||||
|
if err := CheckSchemaVersion(db, "2"); err == nil {
|
||||||
|
t.Fatal("expected schema mismatch error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckSchemaVersionAcceptsMatch(t *testing.T) {
|
||||||
|
db, mock := newMockGormDB(t)
|
||||||
|
defer func() {
|
||||||
|
sqlDB, _ := db.DB()
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
mock.ExpectQuery("SELECT version, dirty FROM schema_migrations").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}).AddRow(1, false))
|
||||||
|
|
||||||
|
if err := CheckSchemaVersion(db, "1"); err != nil {
|
||||||
|
t.Fatalf("expected schema version match, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckSchemaVersionRejectsMissingVersion(t *testing.T) {
|
||||||
|
db, mock := newMockGormDB(t)
|
||||||
|
defer func() {
|
||||||
|
sqlDB, _ := db.DB()
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
mock.ExpectQuery("SELECT version, dirty FROM schema_migrations").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}))
|
||||||
|
|
||||||
|
if err := CheckSchemaVersion(db, "1"); err == nil {
|
||||||
|
t.Fatal("expected missing schema version error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckSchemaVersionRejectsDirty(t *testing.T) {
|
||||||
|
db, mock := newMockGormDB(t)
|
||||||
|
defer func() {
|
||||||
|
sqlDB, _ := db.DB()
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
mock.ExpectQuery("SELECT version, dirty FROM schema_migrations").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}).AddRow(1, true))
|
||||||
|
|
||||||
|
if err := CheckSchemaVersion(db, "1"); err == nil {
|
||||||
|
t.Fatal("expected dirty migration error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmbeddedMigrationsIncludeBaseline(t *testing.T) {
|
||||||
|
driver, err := iofs.New(migrations.FS, ".")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load embedded migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
version, err := driver.First()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read first migration: %v", err)
|
||||||
|
}
|
||||||
|
if version != 1 {
|
||||||
|
t.Fatalf("expected baseline migration version 1, got %d", version)
|
||||||
|
}
|
||||||
|
next, err := driver.Next(version)
|
||||||
|
if err != nil || next != 2 {
|
||||||
|
t.Fatalf("expected risk assessment migration version 2, got %d (err %v)", next, err)
|
||||||
|
}
|
||||||
|
next, err = driver.Next(next)
|
||||||
|
if err != nil || next != 4 {
|
||||||
|
t.Fatalf("expected notifications/outbox migration version 4, got %d (err %v)", next, err)
|
||||||
|
}
|
||||||
|
next, err = driver.Next(next)
|
||||||
|
if err != nil || next != 5 {
|
||||||
|
t.Fatalf("expected detection/disease migration version 5, got %d (err %v)", next, err)
|
||||||
|
}
|
||||||
|
next, err = driver.Next(next)
|
||||||
|
if err != nil || next != 6 {
|
||||||
|
t.Fatalf("expected biosecurity migration version 6, got %d (err %v)", next, err)
|
||||||
|
}
|
||||||
|
next, err = driver.Next(next)
|
||||||
|
if err != nil || next != 7 {
|
||||||
|
t.Fatalf("expected inspection idempotency migration version 7, got %d (err %v)", next, err)
|
||||||
|
}
|
||||||
|
next, err = driver.Next(next)
|
||||||
|
if err != nil || next != 8 {
|
||||||
|
t.Fatalf("expected governance migration version 8, got %d (err %v)", next, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"silk-server-go/internal/middleware"
|
"silk-server-go/internal/middleware"
|
||||||
"silk-server-go/internal/model"
|
"silk-server-go/internal/model"
|
||||||
@@ -19,6 +20,12 @@ func RegisterAlarmClipRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
|||||||
// getAlarmClip 获取告警关联视频片段(查 video_clips 表 where alarm_id=:id,按 start_at DESC)
|
// getAlarmClip 获取告警关联视频片段(查 video_clips 表 where alarm_id=:id,按 start_at DESC)
|
||||||
func getAlarmClip(db *gorm.DB) gin.HandlerFunc {
|
func getAlarmClip(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
userID := currentUserID(c)
|
||||||
|
if userID == nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供用户信息"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
// 先检查告警是否存在
|
// 先检查告警是否存在
|
||||||
@@ -33,7 +40,11 @@ func getAlarmClip(db *gorm.DB) gin.HandlerFunc {
|
|||||||
|
|
||||||
// 为每个片段设置 playbackUrl
|
// 为每个片段设置 playbackUrl
|
||||||
for i := range clips {
|
for i := range clips {
|
||||||
url := fmt.Sprintf("/api/v1/video/clips/%d/stream", clips[i].ID)
|
token, err := IssueVideoToken(*userID, "clip", strconv.FormatUint(uint64(clips[i].ID), 10), videoTokenTTL)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("/api/v1/video/clips/%d/stream?videoToken=%s", clips[i].ID, token)
|
||||||
clips[i].PlaybackURL = &url
|
clips[i].PlaybackURL = &url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,13 +123,19 @@ func loginHandler(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
|||||||
|
|
||||||
var user model.User
|
var user model.User
|
||||||
if db.Where("username = ? OR email = ?", body.Username, body.Username).First(&user).Error != nil {
|
if db.Where("username = ? OR email = ?", body.Username, body.Username).First(&user).Error != nil {
|
||||||
middleware.RecordLoginFail(c, body.Username)
|
if err := middleware.RecordLoginFail(c, body.Username); err != nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(body.Password)); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(body.Password)); err != nil {
|
||||||
middleware.RecordLoginFail(c, body.Username)
|
if err := middleware.RecordLoginFail(c, body.Username); err != nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -140,7 +146,10 @@ func loginHandler(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 登录成功,清空失败计数
|
// 登录成功,清空失败计数
|
||||||
middleware.RecordLoginSuccess(c, body.Username)
|
if err := middleware.RecordLoginSuccess(c, body.Username); err != nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 记录审计日志
|
// 记录审计日志
|
||||||
uid := user.ID
|
uid := user.ID
|
||||||
@@ -184,7 +193,12 @@ func refreshHandler(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "刷新令牌无效"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "刷新令牌无效"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if middleware.IsRevoked(claims) {
|
revoked, err := middleware.IsRevoked(claims)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "认证状态服务不可用"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if revoked {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "刷新令牌已注销"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "刷新令牌已注销"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -201,7 +215,10 @@ func refreshHandler(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 吊销旧刷新令牌(一次性使用),签发新令牌对
|
// 吊销旧刷新令牌(一次性使用),签发新令牌对
|
||||||
middleware.RevokeToken(claims, body.RefreshToken, claims.ExpiresAt.Time)
|
if err := middleware.RevokeToken(claims, body.RefreshToken, claims.ExpiresAt.Time); err != nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, buildLoginPayload(db, user, cfg))
|
c.JSON(http.StatusOK, buildLoginPayload(db, user, cfg))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,7 +231,10 @@ func logoutHandler(cfg *config.Config) gin.HandlerFunc {
|
|||||||
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
|
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
|
||||||
if claims, token, err := middleware.ExtractClaims(parts[1], cfg.JWTSecret); err == nil && token.Valid {
|
if claims, token, err := middleware.ExtractClaims(parts[1], cfg.JWTSecret); err == nil && token.Valid {
|
||||||
if claims.ExpiresAt != nil {
|
if claims.ExpiresAt != nil {
|
||||||
middleware.RevokeToken(claims, parts[1], claims.ExpiresAt.Time)
|
if err := middleware.RevokeToken(claims, parts[1], claims.ExpiresAt.Time); err != nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"silk-server-go/internal/middleware"
|
||||||
|
"silk-server-go/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterBiosecurityRoutes 注册种源、消毒和二维码身份路由。
|
||||||
|
func RegisterBiosecurityRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||||
|
read := middleware.RequirePermission(db, "biosecurity:read")
|
||||||
|
write := middleware.RequirePermission(db, "biosecurity:write")
|
||||||
|
rg.GET("/biosecurity/seed-sources", read, listSeedSources(db))
|
||||||
|
rg.POST("/biosecurity/seed-sources", write, createSeedSource(db))
|
||||||
|
rg.PATCH("/biosecurity/seed-sources/:id", write, updateSeedSource(db))
|
||||||
|
rg.GET("/biosecurity/disinfection-records", read, listDisinfectionRecords(db))
|
||||||
|
rg.POST("/biosecurity/disinfection-records", write, createDisinfectionRecord(db))
|
||||||
|
rg.PATCH("/biosecurity/disinfection-records/:id", write, updateDisinfectionRecord(db))
|
||||||
|
rg.POST("/biosecurity/qr", write, issueQR(db))
|
||||||
|
rg.POST("/biosecurity/qr/resolve", read, resolveQR(db))
|
||||||
|
}
|
||||||
|
|
||||||
|
func listSeedSources(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
q := db.Model(&model.SeedSource{})
|
||||||
|
if batch := c.Query("batchId"); batch != "" {
|
||||||
|
q = q.Where("batch_id = ?", batch)
|
||||||
|
}
|
||||||
|
var list []model.SeedSource
|
||||||
|
q.Order("created_at DESC").Limit(200).Find(&list)
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSeedSource(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
BatchID *string `json:"batchId"`
|
||||||
|
ParentID *string `json:"parentId"`
|
||||||
|
Supplier string `json:"supplier"`
|
||||||
|
SeedBatchNo string `json:"seedBatchNo"`
|
||||||
|
QuarantineNo *string `json:"quarantineNo"`
|
||||||
|
Variety *string `json:"variety"`
|
||||||
|
CertificateURL *string `json:"certificateUrl"`
|
||||||
|
EntryAt *time.Time `json:"entryAt"`
|
||||||
|
Note *string `json:"note"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, id := range []*string{body.BatchID, body.ParentID} {
|
||||||
|
if id != nil && !isUUID(*id) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "关联 ID 不是合法的 UUID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
source := model.SeedSource{
|
||||||
|
PublicID: randomPublicID(),
|
||||||
|
BatchID: body.BatchID,
|
||||||
|
ParentID: body.ParentID,
|
||||||
|
Supplier: body.Supplier,
|
||||||
|
SeedBatchNo: body.SeedBatchNo,
|
||||||
|
QuarantineNo: body.QuarantineNo,
|
||||||
|
Variety: body.Variety,
|
||||||
|
CertificateURL: body.CertificateURL,
|
||||||
|
EntryAt: body.EntryAt,
|
||||||
|
Note: body.Note,
|
||||||
|
CreatedBy: currentUserID(c),
|
||||||
|
}
|
||||||
|
if err := model.ValidateSeedSource(source); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if source.ParentID != nil {
|
||||||
|
if err := model.SeedSourceCycleError(seedParentChain(db), source.ID, *source.ParentID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := db.Create(&source).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "创建种源失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
linkSeedSourceToBatch(db, source)
|
||||||
|
c.JSON(http.StatusCreated, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSeedSource(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var source model.SeedSource
|
||||||
|
if db.Where("id = ?", c.Param("id")).First(&source).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "seed source not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
BatchID *string `json:"batchId"`
|
||||||
|
ParentID *string `json:"parentId"`
|
||||||
|
Supplier *string `json:"supplier"`
|
||||||
|
SeedBatchNo *string `json:"seedBatchNo"`
|
||||||
|
QuarantineNo *string `json:"quarantineNo"`
|
||||||
|
Variety *string `json:"variety"`
|
||||||
|
CertificateURL *string `json:"certificateUrl"`
|
||||||
|
EntryAt *time.Time `json:"entryAt"`
|
||||||
|
Note *string `json:"note"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates := map[string]interface{}{}
|
||||||
|
if body.BatchID != nil {
|
||||||
|
updates["batch_id"] = *body.BatchID
|
||||||
|
}
|
||||||
|
if body.ParentID != nil {
|
||||||
|
if *body.ParentID != "" {
|
||||||
|
if err := model.SeedSourceCycleError(seedParentChain(db), source.ID, *body.ParentID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["parent_id"] = *body.ParentID
|
||||||
|
} else {
|
||||||
|
updates["parent_id"] = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body.Supplier != nil {
|
||||||
|
updates["supplier"] = *body.Supplier
|
||||||
|
}
|
||||||
|
if body.SeedBatchNo != nil {
|
||||||
|
updates["seed_batch_no"] = *body.SeedBatchNo
|
||||||
|
}
|
||||||
|
if body.QuarantineNo != nil {
|
||||||
|
updates["quarantine_no"] = *body.QuarantineNo
|
||||||
|
}
|
||||||
|
if body.Variety != nil {
|
||||||
|
updates["variety"] = *body.Variety
|
||||||
|
}
|
||||||
|
if body.CertificateURL != nil {
|
||||||
|
updates["certificate_url"] = *body.CertificateURL
|
||||||
|
}
|
||||||
|
if body.EntryAt != nil {
|
||||||
|
updates["entry_at"] = *body.EntryAt
|
||||||
|
}
|
||||||
|
if body.Note != nil {
|
||||||
|
updates["note"] = *body.Note
|
||||||
|
}
|
||||||
|
if len(updates) > 0 {
|
||||||
|
if err := db.Model(&model.SeedSource{}).Where("id = ?", source.ID).Updates(updates).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "更新种源失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.Where("id = ?", source.ID).First(&source)
|
||||||
|
if body.BatchID != nil {
|
||||||
|
linkSeedSourceToBatch(db, source)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listDisinfectionRecords(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
q := db.Model(&model.DisinfectionRecord{})
|
||||||
|
if room := c.Query("roomId"); room != "" {
|
||||||
|
q = q.Where("room_id = ?", room)
|
||||||
|
}
|
||||||
|
if batch := c.Query("batchId"); batch != "" {
|
||||||
|
q = q.Where("batch_id = ?", batch)
|
||||||
|
}
|
||||||
|
if kind := c.Query("kind"); kind != "" {
|
||||||
|
q = q.Where("kind = ?", kind)
|
||||||
|
}
|
||||||
|
var list []model.DisinfectionRecord
|
||||||
|
q.Order("created_at DESC").Limit(200).Find(&list)
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createDisinfectionRecord(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var body model.DisinfectionRecord
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body.ID = ""
|
||||||
|
body.CreatedBy = currentUserID(c)
|
||||||
|
if err := model.ValidateDisinfectionRecord(body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if body.Kind == "plan" && body.PlannedAt == nil {
|
||||||
|
body.PlannedAt = &now
|
||||||
|
}
|
||||||
|
if body.Kind == "execution" {
|
||||||
|
body.ExecutorID = currentUserID(c)
|
||||||
|
if body.ExecutedAt == nil {
|
||||||
|
body.ExecutedAt = &now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body.RoomID != nil && !isUUID(*body.RoomID) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "roomId 不是合法的 UUID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.BatchID != nil && !isUUID(*body.BatchID) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "batchId 不是合法的 UUID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := db.Create(&body).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "创建消毒记录失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateDisinfectionRecord(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var record model.DisinfectionRecord
|
||||||
|
if db.Where("id = ?", c.Param("id")).First(&record).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "disinfection record not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
ExecutedAt *time.Time `json:"executedAt"`
|
||||||
|
ReviewedAt *time.Time `json:"reviewedAt"`
|
||||||
|
ReviewerID *string `json:"reviewerId"`
|
||||||
|
PhotoURL *string `json:"photoUrl"`
|
||||||
|
Note *string `json:"note"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates := map[string]interface{}{}
|
||||||
|
now := time.Now()
|
||||||
|
if body.ExecutedAt != nil {
|
||||||
|
updates["executed_at"] = now
|
||||||
|
updates["executor_id"] = currentUserID(c)
|
||||||
|
}
|
||||||
|
if body.ReviewedAt != nil {
|
||||||
|
updates["reviewed_at"] = *body.ReviewedAt
|
||||||
|
}
|
||||||
|
if body.ReviewerID != nil {
|
||||||
|
updates["reviewer_id"] = *body.ReviewerID
|
||||||
|
}
|
||||||
|
if body.PhotoURL != nil {
|
||||||
|
updates["photo_url"] = *body.PhotoURL
|
||||||
|
}
|
||||||
|
if body.Note != nil {
|
||||||
|
updates["note"] = *body.Note
|
||||||
|
}
|
||||||
|
if len(updates) > 0 {
|
||||||
|
if err := db.Model(&model.DisinfectionRecord{}).Where("id = ?", record.ID).Updates(updates).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "更新消毒记录失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.Where("id = ?", record.ID).First(&record)
|
||||||
|
c.JSON(http.StatusOK, record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func issueQR(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
EntityType string `json:"entityType"`
|
||||||
|
EntityID string `json:"entityId"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !model.ValidQRIdentityType(body.EntityType) || !isUUID(body.EntityID) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "entityType/entityId 无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !entityExists(db, body.EntityType, body.EntityID) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "二维码关联实体不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var link model.IdentityLink
|
||||||
|
if db.Where("entity_type = ? AND entity_id = ?", body.EntityType, body.EntityID).First(&link).Error != nil {
|
||||||
|
link = model.IdentityLink{PublicID: randomPublicID(), EntityType: body.EntityType, EntityID: body.EntityID, Version: model.QRVersion}
|
||||||
|
if err := db.Create(&link).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建二维码失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"publicId": link.PublicID,
|
||||||
|
"entityType": link.EntityType,
|
||||||
|
"entityId": link.EntityID,
|
||||||
|
"payload": model.EncodeQRPayload(link.EntityType, link.PublicID, link.Version),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveQR(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Payload string `json:"payload"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entityType, publicID, version, err := model.ParseQRPayload(body.Payload)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var link model.IdentityLink
|
||||||
|
if db.Where("public_id = ? AND entity_type = ?", publicID, entityType).First(&link).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "二维码身份不存在或已失效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if link.Version != version {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "二维码版本不匹配"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
summary, ok := entitySummary(db, link.EntityType, link.EntityID)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "二维码关联实体不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"entityType": link.EntityType,
|
||||||
|
"publicId": link.PublicID,
|
||||||
|
"entity": summary,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedParentChain(db *gorm.DB) map[string]string {
|
||||||
|
var rows []struct {
|
||||||
|
ID string `gorm:"column:id"`
|
||||||
|
ParentID *string `gorm:"column:parent_id"`
|
||||||
|
}
|
||||||
|
db.Table("seed_sources").Select("id, parent_id").Scan(&rows)
|
||||||
|
chain := make(map[string]string, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.ParentID != nil {
|
||||||
|
chain[row.ID] = *row.ParentID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chain
|
||||||
|
}
|
||||||
|
|
||||||
|
func linkSeedSourceToBatch(db *gorm.DB, source model.SeedSource) {
|
||||||
|
if source.BatchID == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = db.Model(&model.Batch{}).Where("id = ?", *source.BatchID).Update("seed_source_id", source.ID).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func entityExists(db *gorm.DB, entityType, entityID string) bool {
|
||||||
|
switch entityType {
|
||||||
|
case "batch":
|
||||||
|
var batch model.Batch
|
||||||
|
return db.Where("id = ?", entityID).First(&batch).Error == nil
|
||||||
|
case "tray":
|
||||||
|
var tray model.Tray
|
||||||
|
return db.Where("id = ?", entityID).First(&tray).Error == nil
|
||||||
|
case "sample":
|
||||||
|
var sample model.Sample
|
||||||
|
return db.Where("id = ?", entityID).First(&sample).Error == nil
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func entitySummary(db *gorm.DB, entityType, entityID string) (map[string]interface{}, bool) {
|
||||||
|
switch entityType {
|
||||||
|
case "batch":
|
||||||
|
var batch model.Batch
|
||||||
|
if db.Where("id = ?", entityID).First(&batch).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return gin.H{"id": batch.ID, "name": batch.Name, "roomId": batch.RoomID}, true
|
||||||
|
case "tray":
|
||||||
|
var tray model.Tray
|
||||||
|
if db.Where("id = ?", entityID).First(&tray).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return gin.H{"id": tray.ID, "name": tray.Name, "roomId": tray.RoomID}, true
|
||||||
|
case "sample":
|
||||||
|
var sample model.Sample
|
||||||
|
if db.Where("id = ?", entityID).First(&sample).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return gin.H{"id": sample.ID, "sampleNo": sample.SampleNo, "detectionTaskId": sample.DetectionTaskID, "state": sample.State}, true
|
||||||
|
default:
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomPublicID() string {
|
||||||
|
b := make([]byte, 24)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return hex.EncodeToString([]byte(time.Now().Format(time.RFC3339Nano)))
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"silk-server-go/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQRPayloadRoundTripAndTamper(t *testing.T) {
|
||||||
|
payload := model.EncodeQRPayload("batch", "public-abc", model.QRVersion)
|
||||||
|
entityType, publicID, version, err := model.ParseQRPayload(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse failed: %v", err)
|
||||||
|
}
|
||||||
|
if entityType != "batch" || publicID != "public-abc" || version != model.QRVersion {
|
||||||
|
t.Fatalf("payload = %s/%s/%d", entityType, publicID, version)
|
||||||
|
}
|
||||||
|
if _, _, _, err := model.ParseQRPayload("batch:public-abc:1"); err == nil {
|
||||||
|
t.Fatal("tampered format should fail")
|
||||||
|
}
|
||||||
|
if _, _, _, err := model.ParseQRPayload(model.EncodeQRPayload("batch", "public-abc", 2)); err != nil {
|
||||||
|
t.Fatal("different version should parse but must be rejected by resolver")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedSourceCycleRejected(t *testing.T) {
|
||||||
|
chain := map[string]string{
|
||||||
|
"a": "b",
|
||||||
|
"b": "c",
|
||||||
|
"c": "a",
|
||||||
|
}
|
||||||
|
if err := model.SeedSourceCycleError(chain, "d", "a"); err == nil {
|
||||||
|
t.Fatal("cycle should be rejected")
|
||||||
|
}
|
||||||
|
if err := model.SeedSourceCycleError(chain, "a", "a"); err == nil {
|
||||||
|
t.Fatal("self reference should be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedSourceCycleAllowsAcyclic(t *testing.T) {
|
||||||
|
chain := map[string]string{"a": "b", "b": "c"}
|
||||||
|
if err := model.SeedSourceCycleError(chain, "d", "a"); err != nil {
|
||||||
|
t.Fatalf("acyclic chain should pass: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisinfectionRequiredFields(t *testing.T) {
|
||||||
|
record := model.DisinfectionRecord{Kind: "execution", Agent: "漂白粉", Concentration: "1%"}
|
||||||
|
if err := model.ValidateDisinfectionRecord(record); err != nil {
|
||||||
|
t.Fatalf("valid record should pass: %v", err)
|
||||||
|
}
|
||||||
|
record.Agent = ""
|
||||||
|
if err := model.ValidateDisinfectionRecord(record); err == nil {
|
||||||
|
t.Fatal("missing agent should fail")
|
||||||
|
}
|
||||||
|
record.Agent = "漂白粉"
|
||||||
|
record.Concentration = ""
|
||||||
|
if err := model.ValidateDisinfectionRecord(record); err == nil {
|
||||||
|
t.Fatal("missing concentration should fail")
|
||||||
|
}
|
||||||
|
record.Concentration = "1%"
|
||||||
|
record.Kind = "unknown"
|
||||||
|
if err := model.ValidateDisinfectionRecord(record); err == nil {
|
||||||
|
t.Fatal("invalid kind should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,7 +93,7 @@ func createConsultation(db *gorm.DB) gin.HandlerFunc {
|
|||||||
BatchID: body.BatchID,
|
BatchID: body.BatchID,
|
||||||
LampTestID: body.LampTestID,
|
LampTestID: body.LampTestID,
|
||||||
Summary: body.Summary,
|
Summary: body.Summary,
|
||||||
Status: "pending",
|
Status: "unassigned",
|
||||||
}
|
}
|
||||||
if body.Title != "" {
|
if body.Title != "" {
|
||||||
rec.Title = body.Title
|
rec.Title = body.Title
|
||||||
@@ -183,6 +183,16 @@ func updateConsultation(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "非法的会诊状态流转"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "非法的会诊状态流转"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
now := time.Now()
|
||||||
|
switch to {
|
||||||
|
case "assigned":
|
||||||
|
updates["assigned_at"] = now
|
||||||
|
updates["sla_deadline"] = now.Add(24 * time.Hour)
|
||||||
|
case "accepted":
|
||||||
|
updates["accepted_at"] = now
|
||||||
|
case "needs_info":
|
||||||
|
updates["needs_info_at"] = now
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if len(updates) > 0 {
|
if len(updates) > 0 {
|
||||||
db.Model(&model.Consultation{}).Where("id = ?", id).Updates(updates)
|
db.Model(&model.Consultation{}).Where("id = ?", id).Updates(updates)
|
||||||
@@ -208,6 +218,7 @@ func resolveConsultation(db *gorm.DB) gin.HandlerFunc {
|
|||||||
var body struct {
|
var body struct {
|
||||||
Opinion string `json:"opinion"`
|
Opinion string `json:"opinion"`
|
||||||
Plan string `json:"plan"`
|
Plan string `json:"plan"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -218,19 +229,44 @@ func resolveConsultation(db *gorm.DB) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
authorID := currentUserID(c)
|
||||||
updates := map[string]interface{}{
|
updates := map[string]interface{}{
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"expert_id": currentUserID(c),
|
"expert_id": authorID,
|
||||||
"opinion": body.Opinion,
|
"opinion": body.Opinion,
|
||||||
"plan": body.Plan,
|
"plan": body.Plan,
|
||||||
"resolved_at": now,
|
"resolved_at": now,
|
||||||
}
|
}
|
||||||
db.Model(&model.Consultation{}).Where("id = ?", id).Updates(updates)
|
var versionCount int64
|
||||||
|
db.Model(&model.ConsultationOpinionVersion{}).Where("consultation_id = ?", id).Count(&versionCount)
|
||||||
|
version := int(versionCount) + 1
|
||||||
|
if err := db.Create(&model.ConsultationOpinionVersion{
|
||||||
|
ConsultationID: id,
|
||||||
|
Version: version,
|
||||||
|
AuthorID: authorID,
|
||||||
|
Opinion: body.Opinion,
|
||||||
|
Plan: body.Plan,
|
||||||
|
Reason: consultationStrPtr(body.Reason),
|
||||||
|
}).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存意见版本失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := db.Model(&model.Consultation{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存会诊方案失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
db.Where("id = ?", id).First(&t)
|
db.Where("id = ?", id).First(&t)
|
||||||
c.JSON(http.StatusOK, t)
|
c.JSON(http.StatusOK, t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func consultationStrPtr(value string) *string {
|
||||||
|
if value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
// archiveConsultation 归档
|
// archiveConsultation 归档
|
||||||
func archiveConsultation(db *gorm.DB) gin.HandlerFunc {
|
func archiveConsultation(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConsultationStrPtr(t *testing.T) {
|
||||||
|
if consultationStrPtr("") != nil {
|
||||||
|
t.Error("空字符串应为 nil")
|
||||||
|
}
|
||||||
|
if got := consultationStrPtr("原因"); got == nil || *got != "原因" {
|
||||||
|
t.Errorf("非空字符串应返回指针: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanReviewKnowledge(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
if canReviewKnowledge(c) {
|
||||||
|
t.Error("nil context 不应通过")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,579 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"silk-server-go/internal/middleware"
|
||||||
|
"silk-server-go/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterDetectionTaskRoutes 注册统一检测任务、样本和发病事件路由。
|
||||||
|
func RegisterDetectionTaskRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||||
|
lampRead := middleware.RequirePermission(db, "lamp:read")
|
||||||
|
lampWrite := middleware.RequirePermission(db, "lamp:write")
|
||||||
|
traceRead := middleware.RequirePermission(db, "trace:read")
|
||||||
|
traceWrite := middleware.RequirePermission(db, "trace:write")
|
||||||
|
|
||||||
|
rg.GET("/detection-tasks", lampRead, listDetectionTasks(db))
|
||||||
|
rg.POST("/detection-tasks", lampWrite, createDetectionTask(db))
|
||||||
|
rg.GET("/detection-tasks/:id", lampRead, getDetectionTask(db))
|
||||||
|
rg.PATCH("/detection-tasks/:id", lampWrite, updateDetectionTask(db))
|
||||||
|
rg.GET("/detection-tasks/:id/samples", lampRead, listSamples(db))
|
||||||
|
rg.POST("/detection-tasks/:id/samples", lampWrite, createSample(db))
|
||||||
|
rg.PATCH("/samples/:id", lampWrite, updateSample(db))
|
||||||
|
rg.GET("/disease-events", traceRead, listDiseaseEvents(db))
|
||||||
|
rg.POST("/disease-events", traceWrite, createDiseaseEvent(db))
|
||||||
|
rg.PATCH("/disease-events/:id", traceWrite, updateDiseaseEvent(db))
|
||||||
|
}
|
||||||
|
|
||||||
|
func listDetectionTasks(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
q := db.Model(&model.DetectionTask{})
|
||||||
|
if status := c.Query("status"); status != "" {
|
||||||
|
q = q.Where("status = ?", status)
|
||||||
|
}
|
||||||
|
if room := c.Query("roomId"); room != "" {
|
||||||
|
q = q.Where("room_id = ?", room)
|
||||||
|
}
|
||||||
|
if sourceType := c.Query("sourceType"); sourceType != "" {
|
||||||
|
q = q.Where("source_type = ?", sourceType)
|
||||||
|
}
|
||||||
|
var list []model.DetectionTask
|
||||||
|
q.Order("created_at DESC").Limit(200).Find(&list)
|
||||||
|
fillDetectionTaskRoomNames(db, list)
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDetectionTask(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var task model.DetectionTask
|
||||||
|
if db.Where("id = ?", c.Param("id")).First(&task).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "detection task not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fillDetectionTaskRoomNames(db, []model.DetectionTask{task})
|
||||||
|
c.JSON(http.StatusOK, task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createDetectionTask(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
SourceKey string `json:"sourceKey"`
|
||||||
|
SourceType string `json:"sourceType"`
|
||||||
|
SourceID string `json:"sourceId"`
|
||||||
|
RoomID *string `json:"roomId"`
|
||||||
|
BatchID *string `json:"batchId"`
|
||||||
|
InspectionID *string `json:"inspectionId"`
|
||||||
|
Disease string `json:"disease"`
|
||||||
|
RecommendedMethod string `json:"recommendedMethod"`
|
||||||
|
Method *string `json:"method"`
|
||||||
|
Priority string `json:"priority"`
|
||||||
|
Note *string `json:"note"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Disease == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "病种不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Method != nil && !model.ValidDetectionMethod(*body.Method) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "method 仅支持 lamp/qpcr/sers/hyperspectral"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, id := range []*string{body.RoomID, body.BatchID, body.InspectionID} {
|
||||||
|
if id != nil && *id != "" && !isUUID(*id) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "关联 ID 不是合法的 UUID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sourceKey := body.SourceKey
|
||||||
|
if sourceKey == "" {
|
||||||
|
sourceKey = fmt.Sprintf("manual-%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
sourceType := body.SourceType
|
||||||
|
if sourceType == "" {
|
||||||
|
sourceType = "manual"
|
||||||
|
}
|
||||||
|
priority := body.Priority
|
||||||
|
if priority == "" {
|
||||||
|
priority = "routine"
|
||||||
|
}
|
||||||
|
task := model.DetectionTask{
|
||||||
|
SourceKey: sourceKey,
|
||||||
|
SourceType: sourceType,
|
||||||
|
SourceID: body.SourceID,
|
||||||
|
RoomID: body.RoomID,
|
||||||
|
BatchID: body.BatchID,
|
||||||
|
InspectionID: body.InspectionID,
|
||||||
|
Disease: body.Disease,
|
||||||
|
RecommendedMethod: body.RecommendedMethod,
|
||||||
|
Method: body.Method,
|
||||||
|
Priority: priority,
|
||||||
|
Status: "pending",
|
||||||
|
CreatedBy: currentUserID(c),
|
||||||
|
Note: body.Note,
|
||||||
|
}
|
||||||
|
if err := db.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "source_key"}},
|
||||||
|
DoNothing: true,
|
||||||
|
}).Create(&task).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "创建检测任务失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if task.ID == "" {
|
||||||
|
_ = db.Where("source_key = ?", sourceKey).First(&task).Error
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateDetectionTask(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var task model.DetectionTask
|
||||||
|
if db.Where("id = ?", c.Param("id")).First(&task).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "detection task not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
AssigneeID *string `json:"assigneeId"`
|
||||||
|
Method *string `json:"method"`
|
||||||
|
Status *string `json:"status"`
|
||||||
|
Result *string `json:"result"`
|
||||||
|
CancelledReason *string `json:"cancelledReason"`
|
||||||
|
Note *string `json:"note"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Method != nil && !model.ValidDetectionMethod(*body.Method) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "method 仅支持 lamp/qpcr/sers/hyperspectral"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Status != nil && !model.ValidDetectionTaskTransition(task.Status, *body.Status) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("检测任务状态不能从 %s 转到 %s", task.Status, *body.Status)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Status != nil && *body.Status == "assigned" && body.AssigneeID == nil && task.AssigneeID == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "指派状态必须先分配负责人"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Status != nil && *body.Status == "completed" && body.Result == nil && task.Result == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "完成检测任务前必须录入结果"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Status != nil && *body.Status == "cancelled" && (body.CancelledReason == nil || strings.TrimSpace(*body.CancelledReason) == "") {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "取消任务必须填写原因"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates := map[string]interface{}{}
|
||||||
|
if body.AssigneeID != nil {
|
||||||
|
updates["assignee_id"] = *body.AssigneeID
|
||||||
|
updates["assigned_at"] = time.Now()
|
||||||
|
}
|
||||||
|
if body.Method != nil {
|
||||||
|
updates["method"] = *body.Method
|
||||||
|
}
|
||||||
|
if body.Note != nil {
|
||||||
|
updates["note"] = *body.Note
|
||||||
|
}
|
||||||
|
if body.Result != nil {
|
||||||
|
if task.Status != "review" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "结果需在复核阶段录入"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["result"] = *body.Result
|
||||||
|
updates["resulted_at"] = time.Now()
|
||||||
|
}
|
||||||
|
if body.Status != nil {
|
||||||
|
updates["status"] = *body.Status
|
||||||
|
if *body.Status == "cancelled" {
|
||||||
|
updates["cancelled_reason"] = *body.CancelledReason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(updates) > 0 {
|
||||||
|
if err := db.Model(&model.DetectionTask{}).Where("id = ?", task.ID).Updates(updates).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "更新检测任务失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.Where("id = ?", task.ID).First(&task)
|
||||||
|
if task.Result != nil && *task.Result == "positive" {
|
||||||
|
_ = ensureDiseaseEventFromDetectionTask(db, task)
|
||||||
|
}
|
||||||
|
fillDetectionTaskRoomNames(db, []model.DetectionTask{task})
|
||||||
|
c.JSON(http.StatusOK, task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listSamples(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var samples []model.Sample
|
||||||
|
db.Where("detection_task_id = ?", c.Param("id")).Order("created_at ASC").Find(&samples)
|
||||||
|
c.JSON(http.StatusOK, samples)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSample(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var task model.DetectionTask
|
||||||
|
if db.Where("id = ?", c.Param("id")).First(&task).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "detection task not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var existing model.Sample
|
||||||
|
if db.Where("detection_task_id = ?", task.ID).First(&existing).Error == nil {
|
||||||
|
c.JSON(http.StatusOK, existing)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
SampleNo string `json:"sampleNo"`
|
||||||
|
RoomID *string `json:"roomId"`
|
||||||
|
BatchID *string `json:"batchId"`
|
||||||
|
TrayID *string `json:"trayId"`
|
||||||
|
Note *string `json:"note"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sampleNo := body.SampleNo
|
||||||
|
if sampleNo == "" {
|
||||||
|
sampleNo = fmt.Sprintf("S-%s", strings.ToUpper(task.ID[:8]))
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
sample := model.Sample{
|
||||||
|
DetectionTaskID: task.ID,
|
||||||
|
SampleNo: sampleNo,
|
||||||
|
RoomID: body.RoomID,
|
||||||
|
BatchID: body.BatchID,
|
||||||
|
TrayID: body.TrayID,
|
||||||
|
SampledBy: currentUserID(c),
|
||||||
|
SampledAt: &now,
|
||||||
|
State: "created",
|
||||||
|
Note: body.Note,
|
||||||
|
}
|
||||||
|
if sample.RoomID == nil {
|
||||||
|
sample.RoomID = task.RoomID
|
||||||
|
}
|
||||||
|
if sample.BatchID == nil {
|
||||||
|
sample.BatchID = task.BatchID
|
||||||
|
}
|
||||||
|
if err := db.Create(&sample).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "创建样本失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, sample)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSample(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var sample model.Sample
|
||||||
|
if db.Where("id = ?", c.Param("id")).First(&sample).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "sample not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
State *string `json:"state"`
|
||||||
|
Note *string `json:"note"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates := map[string]interface{}{}
|
||||||
|
if body.Note != nil {
|
||||||
|
updates["note"] = *body.Note
|
||||||
|
}
|
||||||
|
if body.State != nil {
|
||||||
|
if !model.ValidSampleTransition(sample.State, *body.State) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("样本状态不能从 %s 转到 %s", sample.State, *body.State)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["state"] = *body.State
|
||||||
|
now := time.Now()
|
||||||
|
switch *body.State {
|
||||||
|
case "collected":
|
||||||
|
updates["collected_at"] = now
|
||||||
|
case "handed_over":
|
||||||
|
updates["handed_over_at"] = now
|
||||||
|
updates["handed_over_by"] = currentUserID(c)
|
||||||
|
case "received":
|
||||||
|
updates["received_at"] = now
|
||||||
|
updates["received_by"] = currentUserID(c)
|
||||||
|
case "testing":
|
||||||
|
updates["testing_started_at"] = now
|
||||||
|
case "consumed":
|
||||||
|
updates["consumed_at"] = now
|
||||||
|
case "disposed":
|
||||||
|
updates["disposed_at"] = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(updates) > 0 {
|
||||||
|
if err := db.Model(&model.Sample{}).Where("id = ?", sample.ID).Updates(updates).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "更新样本失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.Where("id = ?", sample.ID).First(&sample)
|
||||||
|
c.JSON(http.StatusOK, sample)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listDiseaseEvents(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
q := db.Model(&model.DiseaseEvent{})
|
||||||
|
if status := c.Query("status"); status != "" {
|
||||||
|
q = q.Where("status = ?", status)
|
||||||
|
}
|
||||||
|
if room := c.Query("roomId"); room != "" {
|
||||||
|
q = q.Where("room_id = ?", room)
|
||||||
|
}
|
||||||
|
var list []model.DiseaseEvent
|
||||||
|
q.Order("created_at DESC").Limit(200).Find(&list)
|
||||||
|
fillDiseaseEventRoomNames(db, list)
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createDiseaseEvent(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
SourceKey string `json:"sourceKey"`
|
||||||
|
RoomID *string `json:"roomId"`
|
||||||
|
BatchID *string `json:"batchId"`
|
||||||
|
DetectionTaskID *string `json:"detectionTaskId"`
|
||||||
|
LampTestID *string `json:"lampTestId"`
|
||||||
|
ConsultationID *string `json:"consultationId"`
|
||||||
|
InspectionID *string `json:"inspectionId"`
|
||||||
|
Disease string `json:"disease"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Evidence json.RawMessage `json:"evidence"`
|
||||||
|
LossSummary *string `json:"lossSummary"`
|
||||||
|
Measure *string `json:"measure"`
|
||||||
|
Note *string `json:"note"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Disease == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "病种不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, id := range []*string{body.RoomID, body.BatchID, body.DetectionTaskID, body.LampTestID, body.ConsultationID, body.InspectionID} {
|
||||||
|
if id != nil && *id != "" && !isUUID(*id) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "关联 ID 不是合法的 UUID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body.Status == "" {
|
||||||
|
body.Status = "suspected"
|
||||||
|
}
|
||||||
|
event := model.DiseaseEvent{
|
||||||
|
SourceKey: body.SourceKey,
|
||||||
|
RoomID: body.RoomID,
|
||||||
|
BatchID: body.BatchID,
|
||||||
|
DetectionTaskID: body.DetectionTaskID,
|
||||||
|
LampTestID: body.LampTestID,
|
||||||
|
ConsultationID: body.ConsultationID,
|
||||||
|
InspectionID: body.InspectionID,
|
||||||
|
Disease: body.Disease,
|
||||||
|
Status: body.Status,
|
||||||
|
Evidence: body.Evidence,
|
||||||
|
LossSummary: body.LossSummary,
|
||||||
|
Measure: body.Measure,
|
||||||
|
Note: body.Note,
|
||||||
|
}
|
||||||
|
if event.SourceKey == "" {
|
||||||
|
event.SourceKey = fmt.Sprintf("manual-%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
if err := model.ValidateDiseaseEventEvidence(event); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if event.Status == "confirmed" {
|
||||||
|
now := time.Now()
|
||||||
|
event.ConfirmedAt = &now
|
||||||
|
event.ConfirmedBy = currentUserID(c)
|
||||||
|
}
|
||||||
|
if err := db.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "source_key"}},
|
||||||
|
DoNothing: true,
|
||||||
|
}).Create(&event).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "创建发病事件失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if event.ID == "" {
|
||||||
|
_ = db.Where("source_key = ?", event.SourceKey).First(&event).Error
|
||||||
|
}
|
||||||
|
if event.Status == "confirmed" {
|
||||||
|
_ = ensureTraceForDiseaseEvent(db, event)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateDiseaseEvent(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var event model.DiseaseEvent
|
||||||
|
if db.Where("id = ?", c.Param("id")).First(&event).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "disease event not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Status *string `json:"status"`
|
||||||
|
Evidence json.RawMessage `json:"evidence"`
|
||||||
|
LossSummary *string `json:"lossSummary"`
|
||||||
|
Measure *string `json:"measure"`
|
||||||
|
Note *string `json:"note"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates := map[string]interface{}{}
|
||||||
|
if body.Status != nil {
|
||||||
|
if !model.ValidDiseaseEventTransition(event.Status, *body.Status) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("发病事件状态不能从 %s 转到 %s", event.Status, *body.Status)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["status"] = *body.Status
|
||||||
|
if *body.Status == "confirmed" {
|
||||||
|
if len(body.Evidence) == 0 && len(event.Evidence) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "确诊发病事件必须提供证据"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
updates["confirmed_at"] = now
|
||||||
|
updates["confirmed_by"] = currentUserID(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(body.Evidence) > 0 {
|
||||||
|
updates["evidence"] = body.Evidence
|
||||||
|
}
|
||||||
|
if body.LossSummary != nil {
|
||||||
|
updates["loss_summary"] = *body.LossSummary
|
||||||
|
}
|
||||||
|
if body.Measure != nil {
|
||||||
|
updates["measure"] = *body.Measure
|
||||||
|
}
|
||||||
|
if body.Note != nil {
|
||||||
|
updates["note"] = *body.Note
|
||||||
|
}
|
||||||
|
if len(updates) > 0 {
|
||||||
|
if err := db.Model(&model.DiseaseEvent{}).Where("id = ?", event.ID).Updates(updates).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "更新发病事件失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.Where("id = ?", event.ID).First(&event)
|
||||||
|
if event.Status == "confirmed" {
|
||||||
|
_ = ensureTraceForDiseaseEvent(db, event)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fillDetectionTaskRoomNames(db *gorm.DB, list []model.DetectionTask) {
|
||||||
|
var rooms []model.Room
|
||||||
|
db.Select("id", "name").Find(&rooms)
|
||||||
|
names := make(map[string]string, len(rooms))
|
||||||
|
for _, r := range rooms {
|
||||||
|
names[r.ID] = r.Name
|
||||||
|
}
|
||||||
|
for i := range list {
|
||||||
|
if list[i].RoomID != nil {
|
||||||
|
if name, ok := names[*list[i].RoomID]; ok {
|
||||||
|
list[i].RoomName = &name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fillDiseaseEventRoomNames(db *gorm.DB, list []model.DiseaseEvent) {
|
||||||
|
var rooms []model.Room
|
||||||
|
db.Select("id", "name").Find(&rooms)
|
||||||
|
names := make(map[string]string, len(rooms))
|
||||||
|
for _, r := range rooms {
|
||||||
|
names[r.ID] = r.Name
|
||||||
|
}
|
||||||
|
for i := range list {
|
||||||
|
if list[i].RoomID != nil {
|
||||||
|
if name, ok := names[*list[i].RoomID]; ok {
|
||||||
|
list[i].RoomName = &name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureTraceForDiseaseEvent 确诊后自动创建关联溯源记录,重复调用不重复创建。
|
||||||
|
func ensureTraceForDiseaseEvent(db *gorm.DB, event model.DiseaseEvent) error {
|
||||||
|
if event.Status != "confirmed" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err := db.Model(&model.TraceRecord{}).Where("disease_event_id = ?", event.ID).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
trace := model.TraceRecord{
|
||||||
|
DiseaseEventID: &event.ID,
|
||||||
|
RoomID: event.RoomID,
|
||||||
|
LampTestID: event.LampTestID,
|
||||||
|
Disease: event.Disease,
|
||||||
|
Status: "pending",
|
||||||
|
}
|
||||||
|
return db.Create(&trace).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureDiseaseEventFromDetectionTask(db *gorm.DB, task model.DetectionTask) error {
|
||||||
|
if task.Result == nil || *task.Result != "positive" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
evidence, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"result": *task.Result,
|
||||||
|
"method": task.Method,
|
||||||
|
"detectionTaskId": task.ID,
|
||||||
|
"resultedAt": task.ResultedAt,
|
||||||
|
})
|
||||||
|
sourceKey := "detection-task-" + task.ID
|
||||||
|
event := model.DiseaseEvent{
|
||||||
|
SourceKey: sourceKey,
|
||||||
|
RoomID: task.RoomID,
|
||||||
|
BatchID: task.BatchID,
|
||||||
|
DetectionTaskID: &task.ID,
|
||||||
|
InspectionID: task.InspectionID,
|
||||||
|
Disease: task.Disease,
|
||||||
|
Status: "confirmed",
|
||||||
|
Evidence: evidence,
|
||||||
|
ConfirmedAt: &now,
|
||||||
|
ConfirmedBy: task.AssigneeID,
|
||||||
|
}
|
||||||
|
if err := db.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "source_key"}},
|
||||||
|
DoNothing: true,
|
||||||
|
}).Create(&event).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if event.ID == "" {
|
||||||
|
_ = db.Where("source_key = ?", sourceKey).First(&event).Error
|
||||||
|
}
|
||||||
|
return ensureTraceForDiseaseEvent(db, event)
|
||||||
|
}
|
||||||
@@ -24,6 +24,9 @@ func RegisterHealthProfileRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
|||||||
rg.GET("/health-profiles/:roomId",
|
rg.GET("/health-profiles/:roomId",
|
||||||
middleware.RequirePermission(db, "room:read"),
|
middleware.RequirePermission(db, "room:read"),
|
||||||
roomHealthProfile(db))
|
roomHealthProfile(db))
|
||||||
|
rg.GET("/health-profiles/:roomId/effect",
|
||||||
|
middleware.RequirePermission(db, "room:read"),
|
||||||
|
roomEffectReport(db))
|
||||||
}
|
}
|
||||||
|
|
||||||
// roomHealthProfile 单蚕房健康画像:巡检风险/检测结果/事件聚合 → 综合健康分
|
// roomHealthProfile 单蚕房健康画像:巡检风险/检测结果/事件聚合 → 综合健康分
|
||||||
@@ -44,7 +47,7 @@ func roomHealthProfile(db *gorm.DB) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
db.Table("inspection_records").
|
db.Table("inspection_records").
|
||||||
Select("risk_level, count(*) AS cnt").
|
Select("risk_level, count(*) AS cnt").
|
||||||
Where("room_id = ? AND ai_status = 'done' AND risk_level IS NOT NULL AND created_at >= ?", id, since).
|
Where("room_id = ? AND ai_status = 'done' AND risk_level IS NOT NULL AND created_at >= ? AND COALESCE(is_mock, false) = false", id, since).
|
||||||
Group("risk_level").
|
Group("risk_level").
|
||||||
Scan(&riskRows)
|
Scan(&riskRows)
|
||||||
riskCounts := map[string]int64{}
|
riskCounts := map[string]int64{}
|
||||||
@@ -99,3 +102,54 @@ func roomHealthProfile(db *gorm.DB) gin.HandlerFunc {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// roomEffectReport 处置前后窗口效果报告。
|
||||||
|
func roomEffectReport(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
roomID := c.Param("roomId")
|
||||||
|
now := time.Now()
|
||||||
|
parse := func(key string, fallback time.Time) time.Time {
|
||||||
|
raw := c.Query(key)
|
||||||
|
if raw == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339, raw)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
window := service.EffectWindow{
|
||||||
|
BeforeStart: parse("beforeStart", now.Add(-60*24*time.Hour)),
|
||||||
|
BeforeEnd: parse("beforeEnd", now.Add(-30*24*time.Hour)),
|
||||||
|
AfterStart: parse("afterStart", now.Add(-30*24*time.Hour)),
|
||||||
|
AfterEnd: parse("afterEnd", now),
|
||||||
|
}
|
||||||
|
before, after := service.EffectMetrics{}, service.EffectMetrics{}
|
||||||
|
loadEffectMetrics := func(start, end time.Time) service.EffectMetrics {
|
||||||
|
metrics := service.EffectMetrics{}
|
||||||
|
db.Table("inspection_records").
|
||||||
|
Where("room_id = ? AND ai_status = 'done' AND COALESCE(is_mock, false) = false AND risk_score IS NOT NULL AND created_at >= ? AND created_at < ?", roomID, start, end).
|
||||||
|
Pluck("risk_score", &metrics.RiskSamples)
|
||||||
|
var lampTotal int64
|
||||||
|
db.Model(&model.LampTest{}).
|
||||||
|
Where("room_id = ? AND status = 'resulted' AND created_at >= ? AND created_at < ?", roomID, start, end).
|
||||||
|
Count(&lampTotal)
|
||||||
|
metrics.LampTotal = int(lampTotal)
|
||||||
|
var lampPositive int64
|
||||||
|
db.Model(&model.LampTest{}).
|
||||||
|
Where("room_id = ? AND status = 'resulted' AND result = 'positive' AND created_at >= ? AND created_at < ?", roomID, start, end).
|
||||||
|
Count(&lampPositive)
|
||||||
|
metrics.LampPositive = int(lampPositive)
|
||||||
|
return metrics
|
||||||
|
}
|
||||||
|
before = loadEffectMetrics(window.BeforeStart, window.BeforeEnd)
|
||||||
|
after = loadEffectMetrics(window.AfterStart, window.AfterEnd)
|
||||||
|
var recurrences int64
|
||||||
|
db.Model(&model.DiseaseEvent{}).
|
||||||
|
Where("room_id = ? AND status = 'reopened' AND created_at >= ? AND created_at < ?", roomID, window.AfterStart, window.AfterEnd).
|
||||||
|
Count(&recurrences)
|
||||||
|
after.Recurrences = int(recurrences)
|
||||||
|
c.JSON(http.StatusOK, service.EvaluateControlEffect(roomID, window, before, after))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -26,8 +27,8 @@ func isUUID(s string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RegisterInspectionRoutes 注册 AI 巡检路由
|
// RegisterInspectionRoutes 注册 AI 巡检路由
|
||||||
func RegisterInspectionRoutes(rg *gin.RouterGroup, db *gorm.DB, s3 *service.S3Service, ai *service.AIClient, imageBucket string, wechat *service.WechatService, inspectionTemplateID string) {
|
func RegisterInspectionRoutes(rg *gin.RouterGroup, db *gorm.DB, s3 *service.S3Service, ai *service.AIClient, imageBucket string, outbox *service.Outbox, inspectionTemplateID string, appEnv string) {
|
||||||
rg.POST("/inspections", middleware.RequirePermission(db, "inspection:create"), createInspection(db, s3, ai, imageBucket, wechat, inspectionTemplateID))
|
rg.POST("/inspections", middleware.RequirePermission(db, "inspection:create"), createInspection(db, s3, ai, imageBucket, outbox, inspectionTemplateID, appEnv))
|
||||||
rg.GET("/inspections", middleware.RequirePermission(db, "inspection:read"), listInspections(db))
|
rg.GET("/inspections", middleware.RequirePermission(db, "inspection:read"), listInspections(db))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,16 +44,37 @@ func currentUserID(c *gin.Context) *string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildRiskInput(detRes *service.AIDetectResponse) service.RiskInput {
|
||||||
|
status := detRes.Status
|
||||||
|
if status == "" {
|
||||||
|
status = service.AIDetectionStatus(detRes.Detections)
|
||||||
|
}
|
||||||
|
modelVersion := detRes.ModelVersion
|
||||||
|
if modelVersion == "" {
|
||||||
|
modelVersion = "unknown"
|
||||||
|
}
|
||||||
|
in := service.RiskInput{ModelVersion: modelVersion}
|
||||||
|
if status != "unknown" {
|
||||||
|
aiProb := detRes.AbnormalProbability
|
||||||
|
if len(detRes.Detections) > 0 && aiProb == 0 {
|
||||||
|
aiProb = service.AbnormalProbability(detRes.Detections)
|
||||||
|
}
|
||||||
|
in.AI = &aiProb
|
||||||
|
}
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
|
||||||
// createInspection 拍照巡检:图片存 S3 → 调 AI /detect → 写记录。
|
// createInspection 拍照巡检:图片存 S3 → 调 AI /detect → 写记录。
|
||||||
// 幂等:客户端传 Idempotency-Key 头时,重复请求返回已有记录。
|
// 幂等:客户端传 Idempotency-Key 头时,重复请求返回已有记录。
|
||||||
func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient, bucket string, wechat *service.WechatService, inspectionTemplateID string) gin.HandlerFunc {
|
func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient, bucket string, outbox *service.Outbox, inspectionTemplateID string, appEnv string) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
idemKey := strings.TrimSpace(c.GetHeader("Idempotency-Key"))
|
idemKey := strings.TrimSpace(c.GetHeader("Idempotency-Key"))
|
||||||
roomID := strings.TrimSpace(c.PostForm("roomId"))
|
roomID := strings.TrimSpace(c.PostForm("roomId"))
|
||||||
|
userID := currentUserID(c)
|
||||||
|
|
||||||
if idemKey != "" {
|
if idemKey != "" && userID != nil {
|
||||||
var exist model.InspectionRecord
|
var exist model.InspectionRecord
|
||||||
if db.Where("idempotency_key = ?", idemKey).First(&exist).Error == nil {
|
if db.Where("user_id = ? AND idempotency_key = ?", *userID, idemKey).First(&exist).Error == nil {
|
||||||
c.JSON(http.StatusOK, exist)
|
c.JSON(http.StatusOK, exist)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -92,7 +114,7 @@ func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient,
|
|||||||
imageURL := s3.Endpoint() + "/" + bucket + "/" + key
|
imageURL := s3.Endpoint() + "/" + bucket + "/" + key
|
||||||
|
|
||||||
rec := model.InspectionRecord{
|
rec := model.InspectionRecord{
|
||||||
UserID: currentUserID(c),
|
UserID: userID,
|
||||||
ImageURL: &imageURL,
|
ImageURL: &imageURL,
|
||||||
AIStatus: "done",
|
AIStatus: "done",
|
||||||
}
|
}
|
||||||
@@ -113,57 +135,96 @@ func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient,
|
|||||||
} else {
|
} else {
|
||||||
raw, _ := json.Marshal(detRes.Detections)
|
raw, _ := json.Marshal(detRes.Detections)
|
||||||
rec.Detections = raw
|
rec.Detections = raw
|
||||||
|
isMock := detRes.IsMock
|
||||||
|
rec.IsMock = &isMock
|
||||||
|
modelVersion := detRes.ModelVersion
|
||||||
|
if modelVersion == "" {
|
||||||
|
modelVersion = "unknown"
|
||||||
|
}
|
||||||
|
rec.ModelVersion = &modelVersion
|
||||||
|
|
||||||
|
if appEnv == "production" && isMock {
|
||||||
|
slog.Error("生产环境收到 Mock AI 检测结果,按失败记录", "modelVersion", modelVersion, "roomId", roomID)
|
||||||
|
rec.AIStatus = "failed"
|
||||||
|
} else {
|
||||||
|
// 风险评分(#9 V2):只消费 AI 异常概率;缺失环境/阶段不填 0
|
||||||
|
riskInput := buildRiskInput(detRes)
|
||||||
|
riskInput.Env, riskInput.Stage = loadRoomRisk(db, roomID)
|
||||||
|
assessment := service.ComputeRiskScore(riskInput)
|
||||||
|
rec.RiskScore = &assessment.Score
|
||||||
|
rec.RiskLevel = &assessment.Level
|
||||||
|
rawRisk, _ := json.Marshal(assessment)
|
||||||
|
rec.RiskAssessment = rawRisk
|
||||||
|
|
||||||
// 风险评分(#9):AI 置信度取检测结果最大值;环境/阶段系数在有 roomId 时按房间数据计算
|
|
||||||
aiConf := 0.0
|
|
||||||
for _, d := range detRes.Detections {
|
|
||||||
if d.Confidence > aiConf {
|
|
||||||
aiConf = d.Confidence
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stageCoef, envCoef := loadRoomRisk(db, roomID)
|
|
||||||
score := service.ComputeRiskScore(service.RiskInput{
|
// 微信订阅消息(#11 骨架):Mock 不进入告警;业务事务内写 outbox,重启后仍可重试
|
||||||
AI: aiConf,
|
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||||
Env: envCoef,
|
if err := tx.Create(&rec).Error; err != nil {
|
||||||
Stage: stageCoef,
|
return err
|
||||||
|
}
|
||||||
|
if rec.AIStatus == "done" && rec.UserID != nil && rec.IsMock != nil && !*rec.IsMock {
|
||||||
|
if key := service.WechatTemplateKey(*rec.RiskLevel); key != "" {
|
||||||
|
payload, _ := json.Marshal(service.WechatSubscribePayload{
|
||||||
|
UserID: *rec.UserID,
|
||||||
|
TemplateKey: key,
|
||||||
|
TemplateID: inspectionTemplateID,
|
||||||
|
Page: "pages/inspection/index",
|
||||||
|
Title: "巡检风险提醒",
|
||||||
|
Body: fmt.Sprintf("风险等级 %s,风险分 %.0f", *rec.RiskLevel, *rec.RiskScore),
|
||||||
|
Data: service.BuildSubscribeData(*rec.RiskLevel, *rec.RiskScore),
|
||||||
|
BusinessType: "inspection",
|
||||||
|
BusinessID: rec.ID,
|
||||||
})
|
})
|
||||||
rec.RiskScore = &score
|
event := service.Event{
|
||||||
level := service.RiskLevel(score)
|
ID: "inspection-" + rec.ID + "-" + *rec.RiskLevel,
|
||||||
rec.RiskLevel = &level
|
Type: service.OutboxEventWechatSubscribe,
|
||||||
|
AggregateType: "inspection",
|
||||||
// 微信订阅消息(#11 骨架):风险非绿且用户已授权时异步推送
|
AggregateID: rec.ID,
|
||||||
if key := service.WechatTemplateKey(level); key != "" {
|
Payload: payload,
|
||||||
go func(uid *string, lv string, sc float64) {
|
|
||||||
if uid == nil || !wechat.Configured() || inspectionTemplateID == "" {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
var binding model.WechatBinding
|
if err := outbox.PublishTx(tx, event); err != nil {
|
||||||
if db.Where("user_id = ?", *uid).First(&binding).Error != nil {
|
return err
|
||||||
return
|
|
||||||
}
|
|
||||||
var authorized []string
|
|
||||||
if len(binding.AuthorizedTemplates) > 0 {
|
|
||||||
_ = json.Unmarshal(binding.AuthorizedTemplates, &authorized)
|
|
||||||
}
|
|
||||||
if !service.IsAuthorized(authorized, key) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = wechat.SendSubscribe(
|
|
||||||
context.Background(),
|
|
||||||
binding.OpenID,
|
|
||||||
inspectionTemplateID,
|
|
||||||
service.BuildSubscribeData(lv, sc),
|
|
||||||
"pages/inspection/index",
|
|
||||||
)
|
|
||||||
}(rec.UserID, level, score)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if err := db.Create(&rec).Error; err != nil {
|
if rec.IsMock != nil && !*rec.IsMock && (*rec.RiskLevel == "orange" || *rec.RiskLevel == "red") {
|
||||||
|
priority := "routine"
|
||||||
|
if *rec.RiskLevel == "red" {
|
||||||
|
priority = "urgent"
|
||||||
|
}
|
||||||
|
roomID := ""
|
||||||
|
if rec.RoomID != nil {
|
||||||
|
roomID = *rec.RoomID
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(service.DetectionTaskCreatePayload{
|
||||||
|
SourceKey: "inspection-" + rec.ID,
|
||||||
|
SourceType: "inspection",
|
||||||
|
SourceID: rec.ID,
|
||||||
|
RoomID: roomID,
|
||||||
|
InspectionID: rec.ID,
|
||||||
|
Disease: "待确认",
|
||||||
|
Priority: priority,
|
||||||
|
})
|
||||||
|
event := service.Event{
|
||||||
|
ID: "detection-task-" + rec.ID,
|
||||||
|
Type: service.OutboxEventDetectionTaskCreate,
|
||||||
|
AggregateType: "inspection",
|
||||||
|
AggregateID: rec.ID,
|
||||||
|
Payload: payload,
|
||||||
|
}
|
||||||
|
if err := outbox.PublishTx(tx, event); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if txErr != nil {
|
||||||
// 并发幂等:唯一索引冲突时返回已有记录
|
// 并发幂等:唯一索引冲突时返回已有记录
|
||||||
if idemKey != "" {
|
if idemKey != "" && userID != nil {
|
||||||
var exist model.InspectionRecord
|
var exist model.InspectionRecord
|
||||||
if db.Where("idempotency_key = ?", idemKey).First(&exist).Error == nil {
|
if db.Where("user_id = ? AND idempotency_key = ?", *userID, idemKey).First(&exist).Error == nil {
|
||||||
c.JSON(http.StatusOK, exist)
|
c.JSON(http.StatusOK, exist)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -175,17 +236,19 @@ func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadRoomRisk 加载房间阶段系数与环境系数(无房间/无数据时返回 0)
|
// loadRoomRisk 加载房间阶段系数与环境系数(无房间/无数据时返回 nil)
|
||||||
func loadRoomRisk(db *gorm.DB, roomID string) (stageCoef, envCoef float64) {
|
func loadRoomRisk(db *gorm.DB, roomID string) (*float64, *float64) {
|
||||||
if roomID == "" {
|
if roomID == "" {
|
||||||
return 0, 0
|
return nil, nil
|
||||||
}
|
}
|
||||||
var room model.Room
|
var room model.Room
|
||||||
if db.Where("id = ?", roomID).First(&room).Error != nil {
|
if db.Where("id = ?", roomID).First(&room).Error != nil {
|
||||||
return 0, 0
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
var stageCoef *float64
|
||||||
if room.Stage != nil {
|
if room.Stage != nil {
|
||||||
stageCoef = service.StageCoefficient(*room.Stage)
|
value := service.StageCoefficient(*room.Stage)
|
||||||
|
stageCoef = &value
|
||||||
}
|
}
|
||||||
|
|
||||||
var humidity, temperature *float64
|
var humidity, temperature *float64
|
||||||
@@ -209,7 +272,11 @@ func loadRoomRisk(db *gorm.DB, roomID string) (stageCoef, envCoef float64) {
|
|||||||
First(&t).Error; err == nil {
|
First(&t).Error; err == nil {
|
||||||
temperature = &t.Value
|
temperature = &t.Value
|
||||||
}
|
}
|
||||||
envCoef = service.EnvCoefficient(temperature, humidity)
|
var envCoef *float64
|
||||||
|
if humidity != nil || temperature != nil {
|
||||||
|
value := service.EnvCoefficient(temperature, humidity)
|
||||||
|
envCoef = &value
|
||||||
|
}
|
||||||
return stageCoef, envCoef
|
return stageCoef, envCoef
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"silk-server-go/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
func TestIsUUID(t *testing.T) {
|
func TestIsUUID(t *testing.T) {
|
||||||
valid := []string{
|
valid := []string{
|
||||||
@@ -19,3 +23,33 @@ func TestIsUUID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildRiskInputUsesAbnormalProbability(t *testing.T) {
|
||||||
|
in := buildRiskInput(&service.AIDetectResponse{
|
||||||
|
ModelVersion: "silk-yolo-2026.08.1",
|
||||||
|
Status: "abnormal",
|
||||||
|
AbnormalProbability: 0.93,
|
||||||
|
})
|
||||||
|
if in.AI == nil || *in.AI != 0.93 {
|
||||||
|
t.Fatalf("AI 异常概率应进入风险输入,实际 %v", in.AI)
|
||||||
|
}
|
||||||
|
if in.ModelVersion != "silk-yolo-2026.08.1" {
|
||||||
|
t.Errorf("modelVersion = %s", in.ModelVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildRiskInputUnknownKeepsAIMissing(t *testing.T) {
|
||||||
|
in := buildRiskInput(&service.AIDetectResponse{Status: "unknown"})
|
||||||
|
if in.AI != nil {
|
||||||
|
t.Fatalf("unknown 状态不应把 0 当作 AI 组件,实际 %v", in.AI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildRiskInputFallsBackToDetections(t *testing.T) {
|
||||||
|
in := buildRiskInput(&service.AIDetectResponse{
|
||||||
|
Detections: []service.AIDetection{{ClassName: "sick", Confidence: 0.9}},
|
||||||
|
})
|
||||||
|
if in.AI == nil || *in.AI != 0.9 {
|
||||||
|
t.Fatalf("旧 AI 响应应从检测类别计算异常概率,实际 %v", in.AI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,6 +80,15 @@ func listDiseases(db *gorm.DB) gin.HandlerFunc {
|
|||||||
if enabled := c.Query("enabled"); enabled != "" {
|
if enabled := c.Query("enabled"); enabled != "" {
|
||||||
q = q.Where("enabled = ?", enabled == "true")
|
q = q.Where("enabled = ?", enabled == "true")
|
||||||
}
|
}
|
||||||
|
if status := c.Query("status"); status != "" {
|
||||||
|
if !canReviewKnowledge(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权限查看未发布内容"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q = q.Where("status = ?", status)
|
||||||
|
} else {
|
||||||
|
q = q.Where("status = 'published'")
|
||||||
|
}
|
||||||
var list []model.Disease
|
var list []model.Disease
|
||||||
q.Order("sort_order ASC, name ASC").Find(&list)
|
q.Order("sort_order ASC, name ASC").Find(&list)
|
||||||
c.JSON(http.StatusOK, list)
|
c.JSON(http.StatusOK, list)
|
||||||
@@ -94,6 +103,10 @@ func getDisease(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "disease not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "disease not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if d.Status != "published" && !canReviewKnowledge(c) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "disease not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, d)
|
c.JSON(http.StatusOK, d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,6 +120,7 @@ func createDisease(db *gorm.DB) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
d.ID = ""
|
d.ID = ""
|
||||||
|
d.Status = "draft"
|
||||||
if d.Name == "" {
|
if d.Name == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "病种名称不能为空"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "病种名称不能为空"})
|
||||||
return
|
return
|
||||||
@@ -134,6 +148,10 @@ func updateDisease(db *gorm.DB) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(updates) > 0 {
|
if len(updates) > 0 {
|
||||||
|
if status, ok := updates["status"].(string); ok && !model.ValidKnowledgeTransition(d.Status, status) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "非法的知识状态流转"})
|
||||||
|
return
|
||||||
|
}
|
||||||
db.Model(&model.Disease{}).Where("id = ?", id).Updates(updates)
|
db.Model(&model.Disease{}).Where("id = ?", id).Updates(updates)
|
||||||
}
|
}
|
||||||
db.Where("id = ?", id).First(&d)
|
db.Where("id = ?", id).First(&d)
|
||||||
@@ -165,6 +183,15 @@ func listArticles(db *gorm.DB) gin.HandlerFunc {
|
|||||||
if enabled := c.Query("enabled"); enabled != "" {
|
if enabled := c.Query("enabled"); enabled != "" {
|
||||||
q = q.Where("enabled = ?", enabled == "true")
|
q = q.Where("enabled = ?", enabled == "true")
|
||||||
}
|
}
|
||||||
|
if status := c.Query("status"); status != "" {
|
||||||
|
if !canReviewKnowledge(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权限查看未发布内容"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q = q.Where("status = ?", status)
|
||||||
|
} else {
|
||||||
|
q = q.Where("status = 'published'")
|
||||||
|
}
|
||||||
var list []model.KnowledgeArticle
|
var list []model.KnowledgeArticle
|
||||||
q.Order("sort_order ASC, created_at DESC").Find(&list)
|
q.Order("sort_order ASC, created_at DESC").Find(&list)
|
||||||
c.JSON(http.StatusOK, list)
|
c.JSON(http.StatusOK, list)
|
||||||
@@ -179,6 +206,10 @@ func getArticle(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "article not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "article not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if a.Status != "published" && !canReviewKnowledge(c) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "article not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, a)
|
c.JSON(http.StatusOK, a)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,6 +223,7 @@ func createArticle(db *gorm.DB) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.ID = ""
|
a.ID = ""
|
||||||
|
a.Status = "draft"
|
||||||
if a.Kind == "" || a.Title == "" {
|
if a.Kind == "" || a.Title == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "文章类型和标题不能为空"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "文章类型和标题不能为空"})
|
||||||
return
|
return
|
||||||
@@ -219,6 +251,10 @@ func updateArticle(db *gorm.DB) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(updates) > 0 {
|
if len(updates) > 0 {
|
||||||
|
if status, ok := updates["status"].(string); ok && !model.ValidKnowledgeTransition(a.Status, status) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "非法的知识状态流转"})
|
||||||
|
return
|
||||||
|
}
|
||||||
db.Model(&model.KnowledgeArticle{}).Where("id = ?", id).Updates(updates)
|
db.Model(&model.KnowledgeArticle{}).Where("id = ?", id).Updates(updates)
|
||||||
}
|
}
|
||||||
db.Where("id = ?", id).First(&a)
|
db.Where("id = ?", id).First(&a)
|
||||||
@@ -226,6 +262,19 @@ func updateArticle(db *gorm.DB) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func canReviewKnowledge(c *gin.Context) bool {
|
||||||
|
user, ok := c.Get("user")
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
m, ok := user.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
role, _ := m["role"].(string)
|
||||||
|
return role == "admin" || role == "operator"
|
||||||
|
}
|
||||||
|
|
||||||
// deleteArticle 删除知识文章
|
// deleteArticle 删除知识文章
|
||||||
func deleteArticle(db *gorm.DB) gin.HandlerFunc {
|
func deleteArticle(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RegisterLampRoutes 注册 LAMP 检测路由
|
// RegisterLampRoutes 注册 LAMP 检测路由
|
||||||
@@ -79,6 +80,10 @@ func createLampTest(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "batchId 不是合法的 UUID"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "batchId 不是合法的 UUID"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if t.DetectionTaskID != nil && !isUUID(*t.DetectionTaskID) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "detectionTaskId 不是合法的 UUID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if t.Status == "" {
|
if t.Status == "" {
|
||||||
t.Status = "pending"
|
t.Status = "pending"
|
||||||
}
|
}
|
||||||
@@ -127,6 +132,8 @@ func judgeQPCR(db *gorm.DB) gin.HandlerFunc {
|
|||||||
db.Model(&model.LampTest{}).Where("id = ?", id).Updates(map[string]interface{}{
|
db.Model(&model.LampTest{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||||
"extra_data": raw, "result": result, "status": "resulted", "resulted_at": now,
|
"extra_data": raw, "result": result, "status": "resulted", "resulted_at": now,
|
||||||
})
|
})
|
||||||
|
_ = syncDetectionTaskFromLamp(db, id)
|
||||||
|
_ = ensureDiseaseEventForLamp(db, id)
|
||||||
_ = runCrossValidation(db, id, result)
|
_ = runCrossValidation(db, id, result)
|
||||||
db.Where("id = ?", id).First(&t)
|
db.Where("id = ?", id).First(&t)
|
||||||
c.JSON(http.StatusOK, t)
|
c.JSON(http.StatusOK, t)
|
||||||
@@ -312,12 +319,81 @@ func updateLampTest(db *gorm.DB) gin.HandlerFunc {
|
|||||||
// 交叉验证:结果落库后与同房间最近巡检比对
|
// 交叉验证:结果落库后与同房间最近巡检比对
|
||||||
if resultSet && resultValue != "" {
|
if resultSet && resultValue != "" {
|
||||||
_ = runCrossValidation(db, id, resultValue)
|
_ = runCrossValidation(db, id, resultValue)
|
||||||
|
_ = syncDetectionTaskFromLamp(db, id)
|
||||||
|
_ = ensureDiseaseEventForLamp(db, id)
|
||||||
}
|
}
|
||||||
db.Where("id = ?", id).First(&t)
|
db.Where("id = ?", id).First(&t)
|
||||||
c.JSON(http.StatusOK, t)
|
c.JSON(http.StatusOK, t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// syncDetectionTaskFromLamp LAMP 结果同步到统一检测任务。
|
||||||
|
func syncDetectionTaskFromLamp(db *gorm.DB, lampID string) error {
|
||||||
|
var t model.LampTest
|
||||||
|
if err := db.Where("id = ?", lampID).First(&t).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if t.DetectionTaskID == nil || t.Result == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return db.Model(&model.DetectionTask{}).Where("id = ?", *t.DetectionTaskID).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"result": *t.Result,
|
||||||
|
"status": "completed",
|
||||||
|
"resulted_at": t.ResultedAt,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureDiseaseEventForLamp 有效阳性结果创建发病事件,重复结果不重复建单。
|
||||||
|
func ensureDiseaseEventForLamp(db *gorm.DB, lampID string) error {
|
||||||
|
var t model.LampTest
|
||||||
|
if err := db.Where("id = ?", lampID).First(&t).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if t.Result == nil || *t.Result != "positive" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
disease := "待确认"
|
||||||
|
var diseases []string
|
||||||
|
if len(t.Diseases) > 0 {
|
||||||
|
_ = json.Unmarshal(t.Diseases, &diseases)
|
||||||
|
if len(diseases) > 0 {
|
||||||
|
disease = diseases[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
evidence, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"result": *t.Result,
|
||||||
|
"method": t.Method,
|
||||||
|
"resultedAt": t.ResultedAt,
|
||||||
|
"lampTestId": t.ID,
|
||||||
|
"operatorId": t.OperatorID,
|
||||||
|
})
|
||||||
|
sourceKey := "lamp-" + t.ID
|
||||||
|
event := model.DiseaseEvent{
|
||||||
|
SourceKey: sourceKey,
|
||||||
|
RoomID: t.RoomID,
|
||||||
|
BatchID: t.BatchID,
|
||||||
|
DetectionTaskID: t.DetectionTaskID,
|
||||||
|
LampTestID: &t.ID,
|
||||||
|
Disease: disease,
|
||||||
|
Status: "confirmed",
|
||||||
|
Evidence: evidence,
|
||||||
|
ConfirmedAt: &now,
|
||||||
|
ConfirmedBy: t.OperatorID,
|
||||||
|
}
|
||||||
|
if err := db.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "source_key"}},
|
||||||
|
DoNothing: true,
|
||||||
|
}).Create(&event).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if event.ID == "" {
|
||||||
|
_ = db.Where("source_key = ?", sourceKey).First(&event).Error
|
||||||
|
}
|
||||||
|
return ensureTraceForDiseaseEvent(db, event)
|
||||||
|
}
|
||||||
|
|
||||||
// runCrossValidation 用同房间最近的巡检记录做交叉验证,并回写 cross_status/cross_reason
|
// runCrossValidation 用同房间最近的巡检记录做交叉验证,并回写 cross_status/cross_reason
|
||||||
func runCrossValidation(db *gorm.DB, lampTestID, lampResult string) error {
|
func runCrossValidation(db *gorm.DB, lampTestID, lampResult string) error {
|
||||||
var t model.LampTest
|
var t model.LampTest
|
||||||
|
|||||||
@@ -1,61 +1,34 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"math/rand"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"silk-server-go/internal/middleware"
|
"silk-server-go/internal/middleware"
|
||||||
|
"silk-server-go/internal/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// notificationItem 通知项
|
|
||||||
type notificationItem struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Channel string `json:"channel"`
|
|
||||||
Target string `json:"target"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Body string `json:"body"`
|
|
||||||
CreatedAt string `json:"createdAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 通知内存存储(后续可替换为 Redis)
|
|
||||||
var (
|
|
||||||
notificationStore []notificationItem
|
|
||||||
notificationMu sync.Mutex
|
|
||||||
)
|
|
||||||
|
|
||||||
// RegisterNotificationRoutes 注册通知路由
|
// RegisterNotificationRoutes 注册通知路由
|
||||||
func RegisterNotificationRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
func RegisterNotificationRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||||
readPerm := middleware.RequirePermission(db, "alarm:read")
|
readPerm := middleware.RequirePermission(db, "alarm:read")
|
||||||
rg.GET("/notifications", readPerm, listNotifications())
|
rg.GET("/notifications", readPerm, listNotifications(db))
|
||||||
rg.POST("/notifications", readPerm, createNotification())
|
rg.POST("/notifications", readPerm, createNotification(db))
|
||||||
}
|
}
|
||||||
|
|
||||||
// listNotifications 通知列表(上限200)
|
// listNotifications 通知列表(上限200)
|
||||||
func listNotifications() gin.HandlerFunc {
|
func listNotifications(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
notificationMu.Lock()
|
var list []model.Notification
|
||||||
defer notificationMu.Unlock()
|
db.Order("created_at DESC").Limit(200).Find(&list)
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
limit := 200
|
|
||||||
if len(notificationStore) < limit {
|
|
||||||
limit = len(notificationStore)
|
|
||||||
}
|
|
||||||
// 返回最新的 limit 条(存储已按新到旧排序)
|
|
||||||
result := make([]notificationItem, limit)
|
|
||||||
copy(result, notificationStore[:limit])
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, result)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// createNotification 手动发通知
|
// createNotification 手动发通知
|
||||||
func createNotification() gin.HandlerFunc {
|
func createNotification(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var body struct {
|
var body struct {
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
@@ -68,24 +41,25 @@ func createNotification() gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ntf := notificationItem{
|
ntf := model.Notification{
|
||||||
ID: fmt.Sprintf("%d%d", time.Now().UnixNano(), rand.Intn(1000000)),
|
UserID: currentUserID(c),
|
||||||
Channel: body.Channel,
|
Channel: body.Channel,
|
||||||
Target: body.Target,
|
Target: body.Target,
|
||||||
Title: body.Title,
|
Title: body.Title,
|
||||||
Body: body.Body,
|
Body: body.Body,
|
||||||
CreatedAt: time.Now().Format(time.RFC3339),
|
Status: "sent",
|
||||||
|
NextAttemptAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
if ntf.Channel == "" {
|
||||||
notificationMu.Lock()
|
ntf.Channel = "manual"
|
||||||
// 插入到头部(最新在前)
|
}
|
||||||
notificationStore = append([]notificationItem{ntf}, notificationStore...)
|
if ntf.Target == "" {
|
||||||
// 保留最近 500 条
|
ntf.Target = "all"
|
||||||
if len(notificationStore) > 500 {
|
}
|
||||||
notificationStore = notificationStore[:500]
|
if err := db.Create(&ntf).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建通知失败"})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
notificationMu.Unlock()
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, ntf)
|
c.JSON(http.StatusCreated, ntf)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"silk-server-go/internal/middleware"
|
||||||
|
"silk-server-go/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var opsStartedAt = time.Now()
|
||||||
|
|
||||||
|
// RegisterOpsRoutes 注册运维指标路由。
|
||||||
|
func RegisterOpsRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||||
|
rg.GET("/ops/metrics", middleware.RequirePermission(db, "log:read"), opsMetrics())
|
||||||
|
}
|
||||||
|
|
||||||
|
func opsMetrics() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"uptimeSeconds": int(time.Since(opsStartedAt).Seconds()),
|
||||||
|
"dependencies": service.DependencySnapshot(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -130,6 +130,7 @@ func createTraceRecord(db *gorm.DB) gin.HandlerFunc {
|
|||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var body struct {
|
var body struct {
|
||||||
RoomID *string `json:"roomId"`
|
RoomID *string `json:"roomId"`
|
||||||
|
DiseaseEventID *string `json:"diseaseEventId"`
|
||||||
LampTestID *string `json:"lampTestId"`
|
LampTestID *string `json:"lampTestId"`
|
||||||
ConsultationID *string `json:"consultationId"`
|
ConsultationID *string `json:"consultationId"`
|
||||||
Disease string `json:"disease"`
|
Disease string `json:"disease"`
|
||||||
@@ -138,7 +139,7 @@ func createTraceRecord(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, id := range []*string{body.RoomID, body.LampTestID, body.ConsultationID} {
|
for _, id := range []*string{body.RoomID, body.DiseaseEventID, body.LampTestID, body.ConsultationID} {
|
||||||
if id != nil && *id != "" && !isUUID(*id) {
|
if id != nil && *id != "" && !isUUID(*id) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "关联 ID 不是合法的 UUID"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "关联 ID 不是合法的 UUID"})
|
||||||
return
|
return
|
||||||
@@ -149,7 +150,8 @@ func createTraceRecord(db *gorm.DB) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
rec := model.TraceRecord{
|
rec := model.TraceRecord{
|
||||||
RoomID: body.RoomID, LampTestID: body.LampTestID,
|
RoomID: body.RoomID, DiseaseEventID: body.DiseaseEventID,
|
||||||
|
LampTestID: body.LampTestID,
|
||||||
ConsultationID: body.ConsultationID, Disease: body.Disease, Status: "pending",
|
ConsultationID: body.ConsultationID, Disease: body.Disease, Status: "pending",
|
||||||
}
|
}
|
||||||
if err := db.Create(&rec).Error; err != nil {
|
if err := db.Create(&rec).Error; err != nil {
|
||||||
@@ -260,6 +262,35 @@ func autoTrace(db *gorm.DB) gin.HandlerFunc {
|
|||||||
"pastCount": pastCount,
|
"pastCount": pastCount,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 生物安全证据:种源与消毒记录
|
||||||
|
bio := map[string]interface{}{
|
||||||
|
"seedSourceCount": int64(0),
|
||||||
|
"disinfectionCount": int64(0),
|
||||||
|
"evidenceSufficient": false,
|
||||||
|
"missing": []string{"种源", "消毒"},
|
||||||
|
}
|
||||||
|
if t.RoomID != nil {
|
||||||
|
var seedCount, disinfectionCount int64
|
||||||
|
db.Table("seed_sources").
|
||||||
|
Joins("JOIN batches ON batches.id = seed_sources.batch_id AND batches.room_id = ?", *t.RoomID).
|
||||||
|
Count(&seedCount)
|
||||||
|
db.Table("disinfection_records").
|
||||||
|
Joins("JOIN batches ON batches.id = disinfection_records.batch_id AND batches.room_id = ?", *t.RoomID).
|
||||||
|
Count(&disinfectionCount)
|
||||||
|
missing := []string{}
|
||||||
|
if seedCount == 0 {
|
||||||
|
missing = append(missing, "种源")
|
||||||
|
}
|
||||||
|
if disinfectionCount == 0 {
|
||||||
|
missing = append(missing, "消毒")
|
||||||
|
}
|
||||||
|
bio["seedSourceCount"] = seedCount
|
||||||
|
bio["disinfectionCount"] = disinfectionCount
|
||||||
|
bio["evidenceSufficient"] = seedCount > 0 && disinfectionCount > 0
|
||||||
|
bio["missing"] = missing
|
||||||
|
}
|
||||||
|
report["biosecurity"] = bio
|
||||||
|
|
||||||
// 传播途径推断
|
// 传播途径推断
|
||||||
mode, source := service.TransmissionInference(t.Disease)
|
mode, source := service.TransmissionInference(t.Disease)
|
||||||
report["transmission"] = map[string]interface{}{"mode": mode, "source": source}
|
report["transmission"] = map[string]interface{}{"mode": mode, "source": source}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const videoTokenTTL = 5 * time.Minute
|
||||||
|
|
||||||
|
var videoTokenSecret []byte
|
||||||
|
|
||||||
|
// SetVideoTokenSecret 设置视频短时令牌签名密钥。
|
||||||
|
func SetVideoTokenSecret(secret string) {
|
||||||
|
videoTokenSecret = []byte(secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueVideoToken 签发绑定用户、资源类型和资源 ID 的短时视频令牌。
|
||||||
|
func IssueVideoToken(userID, resourceType, resourceID string, ttl time.Duration) (string, error) {
|
||||||
|
if len(videoTokenSecret) == 0 {
|
||||||
|
return "", errors.New("video token secret not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"sub": userID,
|
||||||
|
"purpose": "video",
|
||||||
|
"rtype": resourceType,
|
||||||
|
"rid": resourceID,
|
||||||
|
"iat": now.Unix(),
|
||||||
|
"exp": now.Add(ttl).Unix(),
|
||||||
|
}
|
||||||
|
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(videoTokenSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateVideoToken 校验视频令牌的资源类型、资源 ID、用途和有效期。
|
||||||
|
func ValidateVideoToken(token, resourceType, resourceID string) error {
|
||||||
|
claims, err := parseVideoToken(token)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return validateVideoClaims(claims, resourceType, resourceID, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateVideoTokenForUser 额外校验令牌所属用户,用于可以拿到当前 JWT 的调用方。
|
||||||
|
func ValidateVideoTokenForUser(token, resourceType, resourceID, userID string) error {
|
||||||
|
claims, err := parseVideoToken(token)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return validateVideoClaims(claims, resourceType, resourceID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireVideoToken 校验流代理 URL 上的 videoToken,缺失返回 401,错误/过期/资源错配返回 403。
|
||||||
|
func requireVideoToken(c *gin.Context, resourceType, resourceID string) bool {
|
||||||
|
token := c.Query("videoToken")
|
||||||
|
if token == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing video token"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := ValidateVideoToken(token, resourceType, resourceID); err != nil {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "invalid video token"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseVideoToken(token string) (jwt.MapClaims, error) {
|
||||||
|
if len(videoTokenSecret) == 0 {
|
||||||
|
return nil, errors.New("video token secret not configured")
|
||||||
|
}
|
||||||
|
claims := jwt.MapClaims{}
|
||||||
|
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
return videoTokenSecret, nil
|
||||||
|
}, jwt.WithValidMethods([]string{"HS256"}))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !parsed.Valid {
|
||||||
|
return nil, errors.New("invalid video token")
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateVideoClaims(claims jwt.MapClaims, resourceType, resourceID, userID string) error {
|
||||||
|
if claims["purpose"] != "video" {
|
||||||
|
return errors.New("invalid video token purpose")
|
||||||
|
}
|
||||||
|
if claims["rtype"] != resourceType {
|
||||||
|
return errors.New("video token resource type mismatch")
|
||||||
|
}
|
||||||
|
if claims["rid"] != resourceID {
|
||||||
|
return errors.New("video token resource mismatch")
|
||||||
|
}
|
||||||
|
if userID != "" && claims["sub"] != userID {
|
||||||
|
return errors.New("video token user mismatch")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"silk-server-go/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCameraResponseNeverContainsSecrets(t *testing.T) {
|
||||||
|
password := "secret-password"
|
||||||
|
camera := model.Camera{
|
||||||
|
PasswordEnc: &password,
|
||||||
|
GbAuthPassword: &password,
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := json.Marshal(camera)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal camera: %v", err)
|
||||||
|
}
|
||||||
|
body := string(raw)
|
||||||
|
if strings.Contains(body, "passwordEnc") || strings.Contains(body, password) {
|
||||||
|
t.Fatalf("camera JSON must not contain password fields: %s", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "gbAuthPassword") {
|
||||||
|
t.Fatalf("camera JSON must not contain gbAuthPassword: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueAndValidateVideoToken(t *testing.T) {
|
||||||
|
SetVideoTokenSecret("test-secret")
|
||||||
|
token, err := IssueVideoToken("user-1", "camera", "12", time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue video token: %v", err)
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
t.Fatal("expected non-empty video token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ValidateVideoToken(token, "camera", "12"); err != nil {
|
||||||
|
t.Fatalf("valid video token rejected: %v", err)
|
||||||
|
}
|
||||||
|
if err := ValidateVideoToken(token, "camera", "13"); err == nil {
|
||||||
|
t.Fatal("expected resource mismatch error")
|
||||||
|
}
|
||||||
|
if err := ValidateVideoToken(token, "clip", "12"); err == nil {
|
||||||
|
t.Fatal("expected resource type mismatch error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateVideoTokenForUserRejectsMismatch(t *testing.T) {
|
||||||
|
SetVideoTokenSecret("test-secret")
|
||||||
|
token, err := IssueVideoToken("user-1", "camera", "12", time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue video token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ValidateVideoTokenForUser(token, "camera", "12", "user-1"); err != nil {
|
||||||
|
t.Fatalf("same-user token rejected: %v", err)
|
||||||
|
}
|
||||||
|
if err := ValidateVideoTokenForUser(token, "camera", "12", "user-2"); err == nil {
|
||||||
|
t.Fatal("expected cross-user token rejection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVideoTokenExpires(t *testing.T) {
|
||||||
|
SetVideoTokenSecret("test-secret")
|
||||||
|
token, err := IssueVideoToken("user-1", "camera", "12", -time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue expired token: %v", err)
|
||||||
|
}
|
||||||
|
if err := ValidateVideoToken(token, "camera", "12"); err == nil {
|
||||||
|
t.Fatal("expected expired token rejection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVideoStreamRequiresValidToken(t *testing.T) {
|
||||||
|
SetVideoTokenSecret("test-secret")
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = &http.Request{URL: &url.URL{RawQuery: ""}}
|
||||||
|
if requireVideoToken(c, "camera", "12") {
|
||||||
|
t.Fatal("missing token should be rejected")
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("missing token status = %d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := IssueVideoToken("user-1", "camera", "13", time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue token: %v", err)
|
||||||
|
}
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
c2, _ := gin.CreateTestContext(rec2)
|
||||||
|
c2.Request = &http.Request{URL: &url.URL{RawQuery: "videoToken=" + token}}
|
||||||
|
if requireVideoToken(c2, "camera", "12") {
|
||||||
|
t.Fatal("wrong resource token should be rejected")
|
||||||
|
}
|
||||||
|
if rec2.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("wrong resource token status = %d, want 403", rec2.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,7 +86,11 @@ func listCameras(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
|||||||
slog.Warn("同步 WVP 设备信息失败,保留 DB 状态", "error", err)
|
slog.Warn("同步 WVP 设备信息失败,保留 DB 状态", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, cameras)
|
publicCameras := make([]CameraPublic, 0, len(cameras))
|
||||||
|
for i := range cameras {
|
||||||
|
publicCameras = append(publicCameras, toCameraPublic(cameras[i]))
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, publicCameras)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,18 +103,19 @@ func getCamera(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, camera)
|
c.JSON(http.StatusOK, toCameraPublic(camera))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// createCamera 新建摄像头
|
// createCamera 新建摄像头
|
||||||
func createCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
func createCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var camera model.Camera
|
var input CameraInput
|
||||||
if err := c.ShouldBindJSON(&camera); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
camera := input.toModel()
|
||||||
camera.ID = 0 // 让数据库自动生成
|
camera.ID = 0 // 让数据库自动生成
|
||||||
if camera.RoomID == nil {
|
if camera.RoomID == nil {
|
||||||
defaultRoom := "1"
|
defaultRoom := "1"
|
||||||
@@ -137,7 +142,7 @@ func createCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusCreated, camera)
|
c.JSON(http.StatusCreated, toCameraPublic(camera))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +190,7 @@ func updateCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, camera)
|
c.JSON(http.StatusOK, toCameraPublic(camera))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +217,12 @@ func deleteCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
|||||||
// playCamera 播放摄像头实时流(body: {format},调用 media.StartPlay)
|
// playCamera 播放摄像头实时流(body: {format},调用 media.StartPlay)
|
||||||
func playCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
func playCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
userID := currentUserID(c)
|
||||||
|
if userID == nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供用户信息"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
var camera model.Camera
|
var camera model.Camera
|
||||||
if db.Where("id = ?", id).First(&camera).Error != nil {
|
if db.Where("id = ?", id).First(&camera).Error != nil {
|
||||||
@@ -223,71 +234,45 @@ func playCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var body struct {
|
token, err := IssueVideoToken(*userID, "camera", strconv.FormatUint(uint64(camera.ID), 10), videoTokenTTL)
|
||||||
Format string `json:"format"`
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成播放令牌失败"})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
c.ShouldBindJSON(&body)
|
expiresAt := time.Now().Add(videoTokenTTL).UTC().Format(time.RFC3339)
|
||||||
format := body.Format
|
streamURL := fmt.Sprintf("/api/v1/video/cameras/%d/live/proxy?videoToken=%s", camera.ID, token)
|
||||||
if format == "" {
|
|
||||||
format = "hls"
|
|
||||||
}
|
|
||||||
|
|
||||||
expiresAt := time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339)
|
|
||||||
|
|
||||||
// GB28181 摄像头:通过 WVP 媒体服务器播放
|
// GB28181 摄像头:通过 WVP 媒体服务器播放
|
||||||
if camera.GbDeviceID != nil && camera.GbChannelID != nil &&
|
if camera.GbDeviceID != nil && camera.GbChannelID != nil &&
|
||||||
*camera.GbDeviceID != "" && *camera.GbChannelID != "" {
|
*camera.GbDeviceID != "" && *camera.GbChannelID != "" {
|
||||||
result, err := media.StartPlay(*camera.GbDeviceID, *camera.GbChannelID)
|
_, playErr := media.StartPlay(*camera.GbDeviceID, *camera.GbChannelID)
|
||||||
if err != nil {
|
if playErr != nil {
|
||||||
// StartPlay 失败不等于摄像头离线,可能是 WVP/ZLM 瞬时问题,不修改 is_online
|
// StartPlay 失败不等于摄像头离线,可能是 WVP/ZLM 瞬时问题,不修改 is_online
|
||||||
slog.Warn("StartPlay 失败", "deviceId", *camera.GbDeviceID, "channelId", *camera.GbChannelID, "error", err)
|
slog.Warn("StartPlay 失败", "deviceId", *camera.GbDeviceID, "channelId", *camera.GbChannelID, "error", playErr)
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "播放失败: " + err.Error()})
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "播放失败: " + playErr.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
url := result.HLS
|
|
||||||
if format == "flv" && result.FLV != "" {
|
|
||||||
url = result.FLV
|
|
||||||
} else if format == "webrtc" && result.WebRtc != "" {
|
|
||||||
url = result.WebRtc
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"cameraId": camera.ID,
|
"cameraId": camera.ID,
|
||||||
"gbDeviceId": camera.GbDeviceID,
|
"gbDeviceId": camera.GbDeviceID,
|
||||||
"gbChannelId": camera.GbChannelID,
|
"gbChannelId": camera.GbChannelID,
|
||||||
"format": format,
|
"format": "flv",
|
||||||
"url": url,
|
"url": streamURL,
|
||||||
"expiresAt": expiresAt,
|
"expiresAt": expiresAt,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback:使用摄像头自身的流地址
|
// Fallback 不再返回直连流地址,避免绕过令牌代理。
|
||||||
var url string
|
|
||||||
switch format {
|
|
||||||
case "flv":
|
|
||||||
if camera.FlvURL != nil {
|
|
||||||
url = *camera.FlvURL
|
|
||||||
}
|
|
||||||
case "webrtc":
|
|
||||||
if camera.WebrtcURL != nil {
|
|
||||||
url = *camera.WebrtcURL
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
if camera.HlsURL != nil {
|
|
||||||
url = *camera.HlsURL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"cameraId": camera.ID,
|
"cameraId": camera.ID,
|
||||||
"gbDeviceId": nil,
|
"gbDeviceId": nil,
|
||||||
"gbChannelId": nil,
|
"gbChannelId": nil,
|
||||||
"format": format,
|
"format": "flv",
|
||||||
"url": url,
|
"url": streamURL,
|
||||||
"expiresAt": expiresAt,
|
"expiresAt": expiresAt,
|
||||||
"mock": url == "",
|
"mock": true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,6 +280,12 @@ func playCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
|||||||
// playbackCamera 查询摄像头的历史录像片段(query: from/to/limit)
|
// playbackCamera 查询摄像头的历史录像片段(query: from/to/limit)
|
||||||
func playbackCamera(db *gorm.DB) gin.HandlerFunc {
|
func playbackCamera(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
userID := currentUserID(c)
|
||||||
|
if userID == nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供用户信息"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
limit := 50
|
limit := 50
|
||||||
@@ -318,7 +309,11 @@ func playbackCamera(db *gorm.DB) gin.HandlerFunc {
|
|||||||
|
|
||||||
// 为每个片段设置 playbackUrl
|
// 为每个片段设置 playbackUrl
|
||||||
for i := range clips {
|
for i := range clips {
|
||||||
url := fmt.Sprintf("/api/v1/video/clips/%d/stream", clips[i].ID)
|
token, err := IssueVideoToken(*userID, "clip", strconv.FormatUint(uint64(clips[i].ID), 10), videoTokenTTL)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("/api/v1/video/clips/%d/stream?videoToken=%s", clips[i].ID, token)
|
||||||
clips[i].PlaybackURL = &url
|
clips[i].PlaybackURL = &url
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,7 +337,6 @@ func getWvpConfig(media *service.MediaService) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"sipId": sip["id"],
|
"sipId": sip["id"],
|
||||||
"sipDomain": sip["domain"],
|
"sipDomain": sip["domain"],
|
||||||
"sipPassword": sip["password"],
|
|
||||||
"sipPort": sip["port"],
|
"sipPort": sip["port"],
|
||||||
"sipShowIp": sip["showIp"],
|
"sipShowIp": sip["showIp"],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ func RegisterVideoClipRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Confi
|
|||||||
// listClips 录像片段列表(query: cameraId/from/to/limit,按 startAt DESC)
|
// listClips 录像片段列表(query: cameraId/from/to/limit,按 startAt DESC)
|
||||||
func listClips(db *gorm.DB) gin.HandlerFunc {
|
func listClips(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
userID := currentUserID(c)
|
||||||
|
if userID == nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供用户信息"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
limit := 50
|
limit := 50
|
||||||
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 {
|
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 {
|
||||||
limit = l
|
limit = l
|
||||||
@@ -49,7 +55,11 @@ func listClips(db *gorm.DB) gin.HandlerFunc {
|
|||||||
|
|
||||||
// 为每个片段设置 playbackUrl
|
// 为每个片段设置 playbackUrl
|
||||||
for i := range clips {
|
for i := range clips {
|
||||||
url := fmt.Sprintf("/api/v1/video/clips/%d/stream", clips[i].ID)
|
token, err := IssueVideoToken(*userID, "clip", strconv.FormatUint(uint64(clips[i].ID), 10), videoTokenTTL)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("/api/v1/video/clips/%d/stream?videoToken=%s", clips[i].ID, token)
|
||||||
clips[i].PlaybackURL = &url
|
clips[i].PlaybackURL = &url
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +70,12 @@ func listClips(db *gorm.DB) gin.HandlerFunc {
|
|||||||
// playClip 获取片段播放地址
|
// playClip 获取片段播放地址
|
||||||
func playClip(db *gorm.DB) gin.HandlerFunc {
|
func playClip(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
userID := currentUserID(c)
|
||||||
|
if userID == nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供用户信息"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
clipId := c.Param("clipId")
|
clipId := c.Param("clipId")
|
||||||
var clip model.VideoClip
|
var clip model.VideoClip
|
||||||
if db.Where("id = ?", clipId).First(&clip).Error != nil {
|
if db.Where("id = ?", clipId).First(&clip).Error != nil {
|
||||||
@@ -72,11 +88,17 @@ func playClip(db *gorm.DB) gin.HandlerFunc {
|
|||||||
format = "mp4"
|
format = "mp4"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
token, err := IssueVideoToken(*userID, "clip", strconv.FormatUint(uint64(clip.ID), 10), videoTokenTTL)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成播放令牌失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"clipId": clip.ID,
|
"clipId": clip.ID,
|
||||||
"url": fmt.Sprintf("/api/v1/video/clips/%d/stream", clip.ID),
|
"url": fmt.Sprintf("/api/v1/video/clips/%d/stream?videoToken=%s", clip.ID, token),
|
||||||
"format": format,
|
"format": format,
|
||||||
"expiresAt": time.Now().Add(60 * time.Minute).UTC().Format(time.RFC3339),
|
"expiresAt": time.Now().Add(videoTokenTTL).UTC().Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"silk-server-go/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CameraPublic 摄像头对外返回的非敏感字段。
|
||||||
|
type CameraPublic struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
RoomID *string `json:"roomId,omitempty"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
RtspURL *string `json:"rtspUrl,omitempty"`
|
||||||
|
HTTPURL *string `json:"httpUrl,omitempty"`
|
||||||
|
Username *string `json:"username,omitempty"`
|
||||||
|
Position *string `json:"position,omitempty"`
|
||||||
|
Resolution *string `json:"resolution,omitempty"`
|
||||||
|
FPS *int `json:"fps,omitempty"`
|
||||||
|
IsOnline bool `json:"isOnline"`
|
||||||
|
GbDeviceID *string `json:"gbDeviceId,omitempty"`
|
||||||
|
GbChannelID *string `json:"gbChannelId,omitempty"`
|
||||||
|
GbAuthID *string `json:"gbAuthId,omitempty"`
|
||||||
|
GbStreamType *string `json:"gbStreamType,omitempty"`
|
||||||
|
GbTransport *string `json:"gbTransport,omitempty"`
|
||||||
|
GbAlarmChannelID *string `json:"gbAlarmChannelId,omitempty"`
|
||||||
|
GbVoiceChannelID *string `json:"gbVoiceChannelId,omitempty"`
|
||||||
|
GbManufacturer *string `json:"gbManufacturer,omitempty"`
|
||||||
|
ManufacturerID *string `json:"manufacturerId,omitempty"`
|
||||||
|
StreamURL *string `json:"streamUrl,omitempty"`
|
||||||
|
HlsURL *string `json:"hlsUrl,omitempty"`
|
||||||
|
FlvURL *string `json:"flvUrl,omitempty"`
|
||||||
|
WebrtcURL *string `json:"webrtcUrl,omitempty"`
|
||||||
|
SnapshotURL *string `json:"snapshotUrl,omitempty"`
|
||||||
|
Online bool `json:"online"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CameraInput 摄像头创建输入,允许接收密码字段,但不会作为公开响应。
|
||||||
|
type CameraInput struct {
|
||||||
|
ID uint `json:"id,omitempty"`
|
||||||
|
RoomID *string `json:"roomId,omitempty"`
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
RtspURL *string `json:"rtspUrl,omitempty"`
|
||||||
|
HTTPURL *string `json:"httpUrl,omitempty"`
|
||||||
|
Username *string `json:"username,omitempty"`
|
||||||
|
PasswordEnc *string `json:"passwordEnc,omitempty"`
|
||||||
|
Position *string `json:"position,omitempty"`
|
||||||
|
Resolution *string `json:"resolution,omitempty"`
|
||||||
|
FPS *int `json:"fps,omitempty"`
|
||||||
|
IsOnline bool `json:"isOnline"`
|
||||||
|
GbDeviceID *string `json:"gbDeviceId,omitempty"`
|
||||||
|
GbChannelID *string `json:"gbChannelId,omitempty"`
|
||||||
|
GbAuthID *string `json:"gbAuthId,omitempty"`
|
||||||
|
GbAuthPassword *string `json:"gbAuthPassword,omitempty"`
|
||||||
|
GbStreamType *string `json:"gbStreamType,omitempty"`
|
||||||
|
GbTransport *string `json:"gbTransport,omitempty"`
|
||||||
|
GbAlarmChannelID *string `json:"gbAlarmChannelId,omitempty"`
|
||||||
|
GbVoiceChannelID *string `json:"gbVoiceChannelId,omitempty"`
|
||||||
|
GbManufacturer *string `json:"gbManufacturer,omitempty"`
|
||||||
|
ManufacturerID *string `json:"manufacturerId,omitempty"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (in CameraInput) toModel() model.Camera {
|
||||||
|
return model.Camera{
|
||||||
|
RoomID: in.RoomID,
|
||||||
|
Code: in.Code,
|
||||||
|
Name: in.Name,
|
||||||
|
RtspURL: in.RtspURL,
|
||||||
|
HTTPURL: in.HTTPURL,
|
||||||
|
Username: in.Username,
|
||||||
|
PasswordEnc: in.PasswordEnc,
|
||||||
|
Position: in.Position,
|
||||||
|
Resolution: in.Resolution,
|
||||||
|
FPS: in.FPS,
|
||||||
|
IsOnline: in.IsOnline,
|
||||||
|
GbDeviceID: in.GbDeviceID,
|
||||||
|
GbChannelID: in.GbChannelID,
|
||||||
|
GbAuthID: in.GbAuthID,
|
||||||
|
GbAuthPassword: in.GbAuthPassword,
|
||||||
|
GbStreamType: in.GbStreamType,
|
||||||
|
GbTransport: in.GbTransport,
|
||||||
|
GbAlarmChannelID: in.GbAlarmChannelID,
|
||||||
|
GbVoiceChannelID: in.GbVoiceChannelID,
|
||||||
|
GbManufacturer: in.GbManufacturer,
|
||||||
|
ManufacturerID: in.ManufacturerID,
|
||||||
|
Enabled: in.Enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toCameraPublic(camera model.Camera) CameraPublic {
|
||||||
|
return CameraPublic{
|
||||||
|
ID: camera.ID,
|
||||||
|
RoomID: camera.RoomID,
|
||||||
|
Code: camera.Code,
|
||||||
|
Name: camera.Name,
|
||||||
|
RtspURL: camera.RtspURL,
|
||||||
|
HTTPURL: camera.HTTPURL,
|
||||||
|
Username: camera.Username,
|
||||||
|
Position: camera.Position,
|
||||||
|
Resolution: camera.Resolution,
|
||||||
|
FPS: camera.FPS,
|
||||||
|
IsOnline: camera.IsOnline,
|
||||||
|
GbDeviceID: camera.GbDeviceID,
|
||||||
|
GbChannelID: camera.GbChannelID,
|
||||||
|
GbAuthID: camera.GbAuthID,
|
||||||
|
GbStreamType: camera.GbStreamType,
|
||||||
|
GbTransport: camera.GbTransport,
|
||||||
|
GbAlarmChannelID: camera.GbAlarmChannelID,
|
||||||
|
GbVoiceChannelID: camera.GbVoiceChannelID,
|
||||||
|
GbManufacturer: camera.GbManufacturer,
|
||||||
|
ManufacturerID: camera.ManufacturerID,
|
||||||
|
StreamURL: camera.StreamURL,
|
||||||
|
HlsURL: camera.HlsURL,
|
||||||
|
FlvURL: camera.FlvURL,
|
||||||
|
WebrtcURL: camera.WebrtcURL,
|
||||||
|
SnapshotURL: camera.SnapshotURL,
|
||||||
|
Online: camera.Online,
|
||||||
|
Enabled: camera.Enabled,
|
||||||
|
CreatedAt: camera.CreatedAt,
|
||||||
|
UpdatedAt: camera.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,8 +14,9 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RegisterVideoStreamRoutes 注册视频流代理路由(公开接口,不需要 JWT)
|
// RegisterVideoStreamRoutes 注册视频流代理路由;路由可免 Authorization,但必须校验 videoToken。
|
||||||
func RegisterVideoStreamRoutes(rg *gin.RouterGroup, transcode *service.TranscodeService, db *gorm.DB, media *service.MediaService, cfg *config.Config) {
|
func RegisterVideoStreamRoutes(rg *gin.RouterGroup, transcode *service.TranscodeService, db *gorm.DB, media *service.MediaService, cfg *config.Config) {
|
||||||
|
SetVideoTokenSecret(cfg.JWTSecret)
|
||||||
rg.GET("/video/clips/:clipId/stream", streamClip(transcode))
|
rg.GET("/video/clips/:clipId/stream", streamClip(transcode))
|
||||||
rg.GET("/video/cameras/:id/live/stream", streamLive(db, media, cfg))
|
rg.GET("/video/cameras/:id/live/stream", streamLive(db, media, cfg))
|
||||||
rg.GET("/video/cameras/:id/live/proxy", proxyLive(db, media, cfg))
|
rg.GET("/video/cameras/:id/live/proxy", proxyLive(db, media, cfg))
|
||||||
@@ -25,6 +26,9 @@ func RegisterVideoStreamRoutes(rg *gin.RouterGroup, transcode *service.Transcode
|
|||||||
func streamClip(transcode *service.TranscodeService) gin.HandlerFunc {
|
func streamClip(transcode *service.TranscodeService) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
clipId := c.Param("clipId")
|
clipId := c.Param("clipId")
|
||||||
|
if !requireVideoToken(c, "clip", clipId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
c.Header("Content-Type", "video/mp4")
|
c.Header("Content-Type", "video/mp4")
|
||||||
|
|
||||||
if err := transcode.StreamClip(clipId, c.Writer); err != nil {
|
if err := transcode.StreamClip(clipId, c.Writer); err != nil {
|
||||||
@@ -40,6 +44,9 @@ func streamClip(transcode *service.TranscodeService) gin.HandlerFunc {
|
|||||||
func streamLive(db *gorm.DB, media *service.MediaService, cfg *config.Config) gin.HandlerFunc {
|
func streamLive(db *gorm.DB, media *service.MediaService, cfg *config.Config) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
cameraId := c.Param("id")
|
cameraId := c.Param("id")
|
||||||
|
if !requireVideoToken(c, "camera", cameraId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var camera model.Camera
|
var camera model.Camera
|
||||||
if db.Where("id = ?", cameraId).First(&camera).Error != nil {
|
if db.Where("id = ?", cameraId).First(&camera).Error != nil {
|
||||||
@@ -101,6 +108,9 @@ func streamLive(db *gorm.DB, media *service.MediaService, cfg *config.Config) gi
|
|||||||
func proxyLive(db *gorm.DB, media *service.MediaService, cfg *config.Config) gin.HandlerFunc {
|
func proxyLive(db *gorm.DB, media *service.MediaService, cfg *config.Config) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
cameraId := c.Param("id")
|
cameraId := c.Param("id")
|
||||||
|
if !requireVideoToken(c, "camera", cameraId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 查摄像头
|
// 1. 查摄像头
|
||||||
var camera model.Camera
|
var camera model.Camera
|
||||||
|
|||||||
@@ -44,13 +44,13 @@ func Auth(cfg *config.Config) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 视频片段流接口支持动态 ID,使用前缀+后缀匹配放行
|
// 视频片段流接口仅放行播放器请求;handler 必须校验 videoToken
|
||||||
if strings.HasPrefix(path, "/api/v1/video/clips/") && strings.HasSuffix(path, "/stream") {
|
if strings.HasPrefix(path, "/api/v1/video/clips/") && strings.HasSuffix(path, "/stream") {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 直播流转码代理接口
|
// 直播流转码代理接口仅放行播放器请求;handler 必须校验 videoToken
|
||||||
if strings.HasPrefix(path, "/api/v1/video/cameras/") && (strings.HasSuffix(path, "/live/stream") || strings.HasSuffix(path, "/live/proxy")) {
|
if strings.HasPrefix(path, "/api/v1/video/cameras/") && (strings.HasSuffix(path, "/live/stream") || strings.HasSuffix(path, "/live/proxy")) {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
@@ -83,7 +83,12 @@ func Auth(cfg *config.Config) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 校验令牌是否已被登出吊销
|
// 校验令牌是否已被登出吊销
|
||||||
if IsRevoked(claims) {
|
revoked, err := IsRevoked(claims)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "认证状态服务不可用"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if revoked {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "令牌已注销,请重新登录"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "令牌已注销,请重新登录"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -17,13 +18,22 @@ func Logger() gin.HandlerFunc {
|
|||||||
|
|
||||||
latency := time.Since(start)
|
latency := time.Since(start)
|
||||||
status := c.Writer.Status()
|
status := c.Writer.Status()
|
||||||
|
query := RedactSensitiveQuery(c.Request.URL.RawQuery)
|
||||||
|
auth := c.GetHeader("Authorization")
|
||||||
|
authPrefix := ""
|
||||||
|
if auth != "" {
|
||||||
|
authPrefix = strings.SplitN(auth, " ", 2)[0]
|
||||||
|
}
|
||||||
|
|
||||||
slog.Info("请求",
|
slog.Info("请求",
|
||||||
|
"requestId", RequestID(c),
|
||||||
"method", c.Request.Method,
|
"method", c.Request.Method,
|
||||||
"path", path,
|
"path", path,
|
||||||
|
"query", query,
|
||||||
"status", status,
|
"status", status,
|
||||||
"latency", latency.String(),
|
"latency", latency.String(),
|
||||||
"ip", c.ClientIP(),
|
"ip", c.ClientIP(),
|
||||||
|
"authType", authPrefix,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,107 +1,38 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// loginAttempt 登录失败计数(按 IP + 用户名维度)
|
|
||||||
type loginAttempt struct {
|
|
||||||
failures int
|
|
||||||
lockUntil time.Time
|
|
||||||
lastFail time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type loginLimiter struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
seen map[string]*loginAttempt
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maxFailures = 5 // 连续失败 5 次后锁定
|
maxFailures = 5 // 连续失败 5 次后锁定
|
||||||
lockDuration = 15 * time.Minute
|
lockDuration = 15 * time.Minute
|
||||||
failureWindow = 10 * time.Minute // 失败计数窗口
|
failureWindow = 10 * time.Minute // 失败计数窗口
|
||||||
cleanupInterval = 5 * time.Minute
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var defaultLoginLimiter = newLoginLimiter()
|
|
||||||
|
|
||||||
func newLoginLimiter() *loginLimiter {
|
|
||||||
l := &loginLimiter{seen: make(map[string]*loginAttempt)}
|
|
||||||
go l.cleanupLoop()
|
|
||||||
return l
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *loginLimiter) cleanupLoop() {
|
|
||||||
t := time.NewTicker(cleanupInterval)
|
|
||||||
defer t.Stop()
|
|
||||||
for range t.C {
|
|
||||||
l.mu.Lock()
|
|
||||||
now := time.Now()
|
|
||||||
for k, v := range l.seen {
|
|
||||||
if now.After(v.lockUntil) && now.Sub(v.lastFail) > failureWindow {
|
|
||||||
delete(l.seen, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
l.mu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// key = ip + "|" + username(小写)
|
// key = ip + "|" + username(小写)
|
||||||
func limiterKey(c *gin.Context, username string) string {
|
func limiterKey(c *gin.Context, username string) string {
|
||||||
return c.ClientIP() + "|" + strings.ToLower(strings.TrimSpace(username))
|
return c.ClientIP() + "|" + strings.ToLower(strings.TrimSpace(username))
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkLock 返回是否被锁定及剩余锁定时间
|
|
||||||
func (l *loginLimiter) checkLock(key string) (bool, time.Duration) {
|
|
||||||
l.mu.Lock()
|
|
||||||
defer l.mu.Unlock()
|
|
||||||
a, ok := l.seen[key]
|
|
||||||
if !ok {
|
|
||||||
return false, 0
|
|
||||||
}
|
|
||||||
if time.Now().Before(a.lockUntil) {
|
|
||||||
return true, time.Until(a.lockUntil)
|
|
||||||
}
|
|
||||||
return false, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// recordFailure 记录一次失败,达到阈值则锁定
|
|
||||||
func (l *loginLimiter) recordFailure(key string) {
|
|
||||||
l.mu.Lock()
|
|
||||||
defer l.mu.Unlock()
|
|
||||||
a, ok := l.seen[key]
|
|
||||||
if !ok {
|
|
||||||
a = &loginAttempt{}
|
|
||||||
l.seen[key] = a
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
// 窗口外重置
|
|
||||||
if now.Sub(a.lastFail) > failureWindow {
|
|
||||||
a.failures = 0
|
|
||||||
}
|
|
||||||
a.failures++
|
|
||||||
a.lastFail = now
|
|
||||||
if a.failures >= maxFailures {
|
|
||||||
a.lockUntil = now.Add(lockDuration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// recordSuccess 登录成功后清空计数
|
|
||||||
func (l *loginLimiter) recordSuccess(key string) {
|
|
||||||
l.mu.Lock()
|
|
||||||
delete(l.seen, key)
|
|
||||||
l.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckLoginLock 检查是否被锁定,被锁定则写 429 并返回 true(在 handler 解析 body 后调用)
|
// CheckLoginLock 检查是否被锁定,被锁定则写 429 并返回 true(在 handler 解析 body 后调用)
|
||||||
func CheckLoginLock(c *gin.Context, username string) bool {
|
func CheckLoginLock(c *gin.Context, username string) bool {
|
||||||
|
if authState == nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": stateUnavailable("执行登录限流").Error()})
|
||||||
|
return true
|
||||||
|
}
|
||||||
key := limiterKey(c, username)
|
key := limiterKey(c, username)
|
||||||
if locked, remain := defaultLoginLimiter.checkLock(key); locked {
|
locked, remain, err := authState.CheckLoginLock(context.Background(), key)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "登录状态服务不可用"})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if locked {
|
||||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
|
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
|
||||||
"error": "登录尝试过多,已锁定,请稍后再试",
|
"error": "登录尝试过多,已锁定,请稍后再试",
|
||||||
"retry": int(remain.Minutes()) + 1,
|
"retry": int(remain.Minutes()) + 1,
|
||||||
@@ -112,11 +43,17 @@ func CheckLoginLock(c *gin.Context, username string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RecordLoginFail 记录登录失败
|
// RecordLoginFail 记录登录失败
|
||||||
func RecordLoginFail(c *gin.Context, username string) {
|
func RecordLoginFail(c *gin.Context, username string) error {
|
||||||
defaultLoginLimiter.recordFailure(limiterKey(c, username))
|
if authState == nil {
|
||||||
|
return stateUnavailable("记录登录失败")
|
||||||
|
}
|
||||||
|
return authState.RecordLoginFailure(context.Background(), limiterKey(c, username))
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordLoginSuccess 登录成功后清空计数
|
// RecordLoginSuccess 登录成功后清空计数
|
||||||
func RecordLoginSuccess(c *gin.Context, username string) {
|
func RecordLoginSuccess(c *gin.Context, username string) error {
|
||||||
defaultLoginLimiter.recordSuccess(limiterKey(c, username))
|
if authState == nil {
|
||||||
|
return stateUnavailable("清空登录失败计数")
|
||||||
|
}
|
||||||
|
return authState.RecordLoginSuccess(context.Background(), limiterKey(c, username))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const RequestIDHeader = "X-Request-ID"
|
||||||
|
|
||||||
|
// RequestID 返回当前请求 ID。
|
||||||
|
func RequestID(c *gin.Context) string {
|
||||||
|
value, _ := c.Get("requestId")
|
||||||
|
if id, ok := value.(string); ok {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestIDMiddleware 生成或透传 X-Request-ID。
|
||||||
|
func RequestIDMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.GetHeader(RequestIDHeader)
|
||||||
|
if id == "" {
|
||||||
|
id = randomRequestID()
|
||||||
|
}
|
||||||
|
c.Set("requestId", id)
|
||||||
|
c.Header(RequestIDHeader, id)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomRequestID() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "req-" + hex.EncodeToString([]byte(time.Now().Format(time.RFC3339Nano)))
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedactSensitiveQuery 脱敏 URL query 中的 token/password/secret。
|
||||||
|
func RedactSensitiveQuery(rawQuery string) string {
|
||||||
|
if rawQuery == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
values, err := url.ParseQuery(rawQuery)
|
||||||
|
if err != nil {
|
||||||
|
return "[invalid-query]"
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(values))
|
||||||
|
for key := range values {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
for _, key := range keys {
|
||||||
|
lower := strings.ToLower(key)
|
||||||
|
if strings.Contains(lower, "token") || strings.Contains(lower, "password") || strings.Contains(lower, "secret") || strings.Contains(lower, "authorization") {
|
||||||
|
values.Set(key, "[redacted]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values.Encode()
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRequestIDMiddlewarePassesThrough(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
c.Request.Header.Set(RequestIDHeader, "request-1")
|
||||||
|
RequestIDMiddleware()(c)
|
||||||
|
if rec.Header().Get(RequestIDHeader) != "request-1" {
|
||||||
|
t.Fatalf("header = %s", rec.Header().Get(RequestIDHeader))
|
||||||
|
}
|
||||||
|
if RequestID(c) != "request-1" {
|
||||||
|
t.Fatalf("request id = %s", RequestID(c))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestIDMiddlewareGenerates(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
RequestIDMiddleware()(c)
|
||||||
|
if RequestID(c) == "" {
|
||||||
|
t.Fatal("should generate request id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedactSensitiveQuery(t *testing.T) {
|
||||||
|
got := RedactSensitiveQuery("access_token=abc&password=secret&roomId=1")
|
||||||
|
if got != "access_token=%5Bredacted%5D&password=%5Bredacted%5D&roomId=1" {
|
||||||
|
t.Fatalf("redacted query = %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
const statePrefix = "silk:auth:"
|
||||||
|
|
||||||
|
// StateStore 跨实例认证状态存储。
|
||||||
|
type StateStore interface {
|
||||||
|
RevokeToken(ctx context.Context, id string, exp time.Time) error
|
||||||
|
IsTokenRevoked(ctx context.Context, id string) (bool, error)
|
||||||
|
CheckLoginLock(ctx context.Context, key string) (bool, time.Duration, error)
|
||||||
|
RecordLoginFailure(ctx context.Context, key string) error
|
||||||
|
RecordLoginSuccess(ctx context.Context, key string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
authState StateStore
|
||||||
|
appEnv string
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitState 设置认证状态存储;store 为 nil 时认证相关接口保守失败。
|
||||||
|
func InitState(store StateStore, env string) {
|
||||||
|
authState = store
|
||||||
|
appEnv = env
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisState Redis 实现。
|
||||||
|
type RedisState struct {
|
||||||
|
rdb *redis.Client
|
||||||
|
prefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRedisState 创建 Redis 状态存储。
|
||||||
|
func NewRedisState(rdb *redis.Client) *RedisState {
|
||||||
|
return &RedisState{rdb: rdb, prefix: statePrefix}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RedisState) RevokeToken(ctx context.Context, id string, exp time.Time) error {
|
||||||
|
ttl := time.Until(exp)
|
||||||
|
if ttl <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.rdb.Set(ctx, s.prefix+"revoked:"+id, "1", ttl).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RedisState) IsTokenRevoked(ctx context.Context, id string) (bool, error) {
|
||||||
|
count, err := s.rdb.Exists(ctx, s.prefix+"revoked:"+id).Result()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RedisState) CheckLoginLock(ctx context.Context, key string) (bool, time.Duration, error) {
|
||||||
|
lockKey := s.prefix + "login-lock:" + key
|
||||||
|
if _, err := s.rdb.Get(ctx, lockKey).Result(); err == redis.Nil {
|
||||||
|
return false, 0, nil
|
||||||
|
} else if err != nil {
|
||||||
|
return false, 0, err
|
||||||
|
}
|
||||||
|
ttl, err := s.rdb.TTL(ctx, lockKey).Result()
|
||||||
|
if err != nil {
|
||||||
|
return false, 0, err
|
||||||
|
}
|
||||||
|
return true, ttl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var loginFailureScript = redis.NewScript(`
|
||||||
|
local count = redis.call('INCR', KEYS[1])
|
||||||
|
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
||||||
|
if tonumber(count) >= tonumber(ARGV[2]) then
|
||||||
|
redis.call('SET', KEYS[2], '1', 'PX', ARGV[3])
|
||||||
|
end
|
||||||
|
return count
|
||||||
|
`)
|
||||||
|
|
||||||
|
func (s *RedisState) RecordLoginFailure(ctx context.Context, key string) error {
|
||||||
|
return loginFailureScript.Run(ctx, s.rdb,
|
||||||
|
[]string{s.prefix + "login-failures:" + key, s.prefix + "login-lock:" + key},
|
||||||
|
int(failureWindow.Seconds()), maxFailures, int(lockDuration.Milliseconds()),
|
||||||
|
).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RedisState) RecordLoginSuccess(ctx context.Context, key string) error {
|
||||||
|
pipe := s.rdb.Pipeline()
|
||||||
|
pipe.Del(ctx, s.prefix+"login-failures:"+key, s.prefix+"login-lock:"+key)
|
||||||
|
_, err := pipe.Exec(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func stateUnavailable(operation string) error {
|
||||||
|
msg := "Redis 状态服务不可用,无法" + operation
|
||||||
|
if appEnv == "production" {
|
||||||
|
slog.Error(msg)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s", msg)
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type memoryState struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
revoked map[string]time.Time
|
||||||
|
failures map[string]int
|
||||||
|
locked map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMemoryState() *memoryState {
|
||||||
|
return &memoryState{
|
||||||
|
revoked: map[string]time.Time{},
|
||||||
|
failures: map[string]int{},
|
||||||
|
locked: map[string]time.Time{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryState) RevokeToken(_ context.Context, id string, exp time.Time) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.revoked[id] = exp
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryState) IsTokenRevoked(_ context.Context, id string) (bool, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
exp, ok := m.revoked[id]
|
||||||
|
if !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if time.Now().After(exp) {
|
||||||
|
delete(m.revoked, id)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryState) CheckLoginLock(_ context.Context, key string) (bool, time.Duration, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
until, ok := m.locked[key]
|
||||||
|
if !ok {
|
||||||
|
return false, 0, nil
|
||||||
|
}
|
||||||
|
if time.Now().After(until) {
|
||||||
|
delete(m.locked, key)
|
||||||
|
return false, 0, nil
|
||||||
|
}
|
||||||
|
return true, time.Until(until), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryState) RecordLoginFailure(_ context.Context, key string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.failures[key]++
|
||||||
|
if m.failures[key] >= maxFailures {
|
||||||
|
m.locked[key] = time.Now().Add(lockDuration)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryState) RecordLoginSuccess(_ context.Context, key string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
delete(m.failures, key)
|
||||||
|
delete(m.locked, key)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func withMemoryState(t *testing.T) *memoryState {
|
||||||
|
t.Helper()
|
||||||
|
store := newMemoryState()
|
||||||
|
InitState(store, "test")
|
||||||
|
t.Cleanup(func() { InitState(nil, "test") })
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevokeTokenIsCrossInstanceState(t *testing.T) {
|
||||||
|
withMemoryState(t)
|
||||||
|
claims := &JWTClaims{}
|
||||||
|
claims.ID = "token-1"
|
||||||
|
if err := RevokeToken(claims, "token", time.Now().Add(time.Hour)); err != nil {
|
||||||
|
t.Fatalf("RevokeToken failed: %v", err)
|
||||||
|
}
|
||||||
|
revoked, err := IsRevoked(claims)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IsRevoked failed: %v", err)
|
||||||
|
}
|
||||||
|
if !revoked {
|
||||||
|
t.Fatal("token should be revoked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevokeTokenUnavailableFailsClosed(t *testing.T) {
|
||||||
|
InitState(nil, "production")
|
||||||
|
t.Cleanup(func() { InitState(nil, "test") })
|
||||||
|
claims := &JWTClaims{}
|
||||||
|
claims.ID = "x"
|
||||||
|
if err := RevokeToken(claims, "token", time.Now().Add(time.Hour)); err == nil {
|
||||||
|
t.Fatal("state unavailable should fail closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginLockStateSemantics(t *testing.T) {
|
||||||
|
store := withMemoryState(t)
|
||||||
|
for i := 0; i < maxFailures; i++ {
|
||||||
|
if err := store.RecordLoginFailure(context.Background(), "1.1.1.1|user"); err != nil {
|
||||||
|
t.Fatalf("record failure failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
locked, _, err := store.CheckLoginLock(context.Background(), "1.1.1.1|user")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("check lock failed: %v", err)
|
||||||
|
}
|
||||||
|
if !locked {
|
||||||
|
t.Fatal("expected login lock after max failures")
|
||||||
|
}
|
||||||
|
if err := store.RecordLoginSuccess(context.Background(), "1.1.1.1|user"); err != nil {
|
||||||
|
t.Fatalf("record success failed: %v", err)
|
||||||
|
}
|
||||||
|
locked, _, _ = store.CheckLoginLock(context.Background(), "1.1.1.1|user")
|
||||||
|
if locked {
|
||||||
|
t.Fatal("login lock should be cleared after success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckLoginLockUnavailableReturns503(t *testing.T) {
|
||||||
|
InitState(nil, "test")
|
||||||
|
t.Cleanup(func() { InitState(nil, "test") })
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/auth/login", nil)
|
||||||
|
if !CheckLoginLock(c, "user") {
|
||||||
|
t.Fatal("state unavailable should abort login")
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("status = %d, want 503", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,45 +1,26 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
// tokenBlacklist 登出令牌黑名单(内存版,进程重启后失效,令牌自然过期兜底)
|
|
||||||
type tokenBlacklist struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
revoked map[string]time.Time // tokenID(jti) -> 过期时间
|
|
||||||
}
|
|
||||||
|
|
||||||
var defaultBlacklist = &tokenBlacklist{revoked: make(map[string]time.Time)}
|
|
||||||
|
|
||||||
// RevokeToken 将令牌加入黑名单(按 jti,若无 jti 则按 subject+签发时间)
|
// RevokeToken 将令牌加入黑名单(按 jti,若无 jti 则按 subject+签发时间)
|
||||||
func RevokeToken(claims *JWTClaims, tokenStr string, exp time.Time) {
|
func RevokeToken(claims *JWTClaims, tokenStr string, exp time.Time) error {
|
||||||
id := tokenIdentifier(claims)
|
if authState == nil {
|
||||||
defaultBlacklist.mu.Lock()
|
return stateUnavailable("吊销令牌")
|
||||||
defaultBlacklist.revoked[id] = exp
|
}
|
||||||
defaultBlacklist.mu.Unlock()
|
return authState.RevokeToken(context.Background(), tokenIdentifier(claims), exp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsRevoked 判断令牌是否已被吊销
|
// IsRevoked 判断令牌是否已被吊销
|
||||||
func IsRevoked(claims *JWTClaims) bool {
|
func IsRevoked(claims *JWTClaims) (bool, error) {
|
||||||
id := tokenIdentifier(claims)
|
if authState == nil {
|
||||||
defaultBlacklist.mu.RLock()
|
return false, stateUnavailable("校验令牌吊销状态")
|
||||||
exp, ok := defaultBlacklist.revoked[id]
|
|
||||||
defaultBlacklist.mu.RUnlock()
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
// 已过期的黑名单项自动清理
|
return authState.IsTokenRevoked(context.Background(), tokenIdentifier(claims))
|
||||||
if time.Now().After(exp) {
|
|
||||||
defaultBlacklist.mu.Lock()
|
|
||||||
delete(defaultBlacklist.revoked, id)
|
|
||||||
defaultBlacklist.mu.Unlock()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractClaims 从 token 字符串解析 claims(供 logout handler 使用)
|
// ExtractClaims 从 token 字符串解析 claims(供 logout handler 使用)
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const QRVersion = 1
|
||||||
|
|
||||||
|
// IdentityLink 不透明二维码映射,避免把内部 ID 或个人信息放进二维码。
|
||||||
|
type IdentityLink struct {
|
||||||
|
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||||
|
PublicID string `gorm:"column:public_id;size:64;uniqueIndex" json:"publicId"`
|
||||||
|
EntityType string `gorm:"column:entity_type;size:16;index" json:"entityType"`
|
||||||
|
EntityID string `gorm:"column:entity_id;size:128;uniqueIndex" json:"entityId"`
|
||||||
|
Version int `gorm:"default:1" json:"version"`
|
||||||
|
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (IdentityLink) TableName() string { return "identity_links" }
|
||||||
|
|
||||||
|
// SeedSource 蚕种来源与检疫链。
|
||||||
|
type SeedSource struct {
|
||||||
|
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||||
|
PublicID string `gorm:"column:public_id;size:64;uniqueIndex" json:"publicId"`
|
||||||
|
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
|
||||||
|
ParentID *string `gorm:"column:parent_id;type:uuid;index" json:"parentId,omitempty"`
|
||||||
|
Supplier string `gorm:"size:128" json:"supplier"`
|
||||||
|
SeedBatchNo string `gorm:"column:seed_batch_no;size:64" json:"seedBatchNo"`
|
||||||
|
QuarantineNo *string `gorm:"column:quarantine_no;size:64" json:"quarantineNo,omitempty"`
|
||||||
|
Variety *string `gorm:"size:64" json:"variety,omitempty"`
|
||||||
|
CertificateURL *string `gorm:"column:certificate_url;size:512" json:"certificateUrl,omitempty"`
|
||||||
|
EntryAt *time.Time `gorm:"column:entry_at;type:timestamptz" json:"entryAt,omitempty"`
|
||||||
|
Note *string `gorm:"type:text" json:"note,omitempty"`
|
||||||
|
CreatedBy *string `gorm:"column:created_by;type:uuid" json:"createdBy,omitempty"`
|
||||||
|
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SeedSource) TableName() string { return "seed_sources" }
|
||||||
|
|
||||||
|
// DisinfectionRecord 消毒计划与执行记录。
|
||||||
|
type DisinfectionRecord struct {
|
||||||
|
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||||
|
RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"`
|
||||||
|
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
|
||||||
|
Kind string `gorm:"size:16;default:plan" json:"kind"` // plan/execution
|
||||||
|
PlanID *string `gorm:"column:plan_id;type:uuid;index" json:"planId,omitempty"`
|
||||||
|
Agent string `gorm:"size:128" json:"agent"`
|
||||||
|
Concentration string `gorm:"size:64" json:"concentration"`
|
||||||
|
Amount *string `gorm:"size:64" json:"amount,omitempty"`
|
||||||
|
PlannedAt *time.Time `gorm:"column:planned_at;type:timestamptz" json:"plannedAt,omitempty"`
|
||||||
|
ExecutedAt *time.Time `gorm:"column:executed_at;type:timestamptz" json:"executedAt,omitempty"`
|
||||||
|
ExecutorID *string `gorm:"column:executor_id;type:uuid" json:"executorId,omitempty"`
|
||||||
|
ReviewedAt *time.Time `gorm:"column:reviewed_at;type:timestamptz" json:"reviewedAt,omitempty"`
|
||||||
|
ReviewerID *string `gorm:"column:reviewer_id;type:uuid" json:"reviewerId,omitempty"`
|
||||||
|
PhotoURL *string `gorm:"column:photo_url;size:512" json:"photoUrl,omitempty"`
|
||||||
|
Note *string `gorm:"type:text" json:"note,omitempty"`
|
||||||
|
CreatedBy *string `gorm:"column:created_by;type:uuid" json:"createdBy,omitempty"`
|
||||||
|
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (DisinfectionRecord) TableName() string { return "disinfection_records" }
|
||||||
|
|
||||||
|
// EncodeQRPayload 生成不透明二维码载荷。
|
||||||
|
func EncodeQRPayload(entityType, publicID string, version int) string {
|
||||||
|
return fmt.Sprintf("silk:v1:%s:%s:%d", entityType, publicID, version)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseQRPayload 解析并校验二维码载荷。
|
||||||
|
func ParseQRPayload(payload string) (string, string, int, error) {
|
||||||
|
parts := strings.Split(strings.TrimSpace(payload), ":")
|
||||||
|
if len(parts) != 5 || parts[0] != "silk" || parts[1] != "v1" {
|
||||||
|
return "", "", 0, errors.New("二维码格式不正确")
|
||||||
|
}
|
||||||
|
entityType := parts[2]
|
||||||
|
publicID := parts[3]
|
||||||
|
version, err := strconv.Atoi(parts[4])
|
||||||
|
if err != nil || version <= 0 {
|
||||||
|
return "", "", 0, errors.New("二维码版本不正确")
|
||||||
|
}
|
||||||
|
if !ValidQRIdentityType(entityType) || publicID == "" {
|
||||||
|
return "", "", 0, errors.New("二维码身份无效")
|
||||||
|
}
|
||||||
|
return entityType, publicID, version, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidQRIdentityType 当前二维码支持的实体类型。
|
||||||
|
func ValidQRIdentityType(entityType string) bool {
|
||||||
|
switch entityType {
|
||||||
|
case "batch", "tray", "sample":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateSeedSource 种源必填项。
|
||||||
|
func ValidateSeedSource(source SeedSource) error {
|
||||||
|
if strings.TrimSpace(source.Supplier) == "" {
|
||||||
|
return errors.New("供应商不能为空")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(source.SeedBatchNo) == "" {
|
||||||
|
return errors.New("蚕种批号不能为空")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateDisinfectionRecord 消毒必填项。
|
||||||
|
func ValidateDisinfectionRecord(record DisinfectionRecord) error {
|
||||||
|
if record.Kind != "plan" && record.Kind != "execution" {
|
||||||
|
return errors.New("kind 仅支持 plan/execution")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(record.Agent) == "" {
|
||||||
|
return errors.New("消毒药剂不能为空")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(record.Concentration) == "" {
|
||||||
|
return errors.New("消毒浓度不能为空")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedSourceCycleError 检测种源链是否形成循环。
|
||||||
|
func SeedSourceCycleError(chain map[string]string, id, parentID string) error {
|
||||||
|
seen := map[string]bool{id: true}
|
||||||
|
current := parentID
|
||||||
|
for current != "" {
|
||||||
|
if seen[current] {
|
||||||
|
return errors.New("种源链不能形成循环")
|
||||||
|
}
|
||||||
|
seen[current] = true
|
||||||
|
next := chain[current]
|
||||||
|
if next == current {
|
||||||
|
return errors.New("种源链不能自引用")
|
||||||
|
}
|
||||||
|
current = next
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -16,6 +16,13 @@ type Consultation struct {
|
|||||||
Snapshot json.RawMessage `gorm:"type:jsonb" json:"snapshot,omitempty"`
|
Snapshot json.RawMessage `gorm:"type:jsonb" json:"snapshot,omitempty"`
|
||||||
Status string `gorm:"size:16;default:pending" json:"status"` // pending/consulting/resolved/archived
|
Status string `gorm:"size:16;default:pending" json:"status"` // pending/consulting/resolved/archived
|
||||||
ExpertID *string `gorm:"column:expert_id;type:uuid" json:"expertId,omitempty"`
|
ExpertID *string `gorm:"column:expert_id;type:uuid" json:"expertId,omitempty"`
|
||||||
|
AssigneeID *string `gorm:"column:assignee_id;type:uuid" json:"assigneeId,omitempty"`
|
||||||
|
AssignedAt *time.Time `gorm:"column:assigned_at;type:timestamptz" json:"assignedAt,omitempty"`
|
||||||
|
AcceptedAt *time.Time `gorm:"column:accepted_at;type:timestamptz" json:"acceptedAt,omitempty"`
|
||||||
|
NeedsInfoAt *time.Time `gorm:"column:needs_info_at;type:timestamptz" json:"needsInfoAt,omitempty"`
|
||||||
|
SLADeadline *time.Time `gorm:"column:sla_deadline;type:timestamptz" json:"slaDeadline,omitempty"`
|
||||||
|
OverdueAt *time.Time `gorm:"column:overdue_at;type:timestamptz" json:"overdueAt,omitempty"`
|
||||||
|
LastSLAEventAt *time.Time `gorm:"column:last_sla_event_at;type:timestamptz" json:"lastSlaEventAt,omitempty"`
|
||||||
Opinion *string `gorm:"type:text" json:"opinion,omitempty"`
|
Opinion *string `gorm:"type:text" json:"opinion,omitempty"`
|
||||||
Plan *string `gorm:"type:text" json:"plan,omitempty"`
|
Plan *string `gorm:"type:text" json:"plan,omitempty"`
|
||||||
ResolvedAt *time.Time `gorm:"column:resolved_at;type:timestamptz" json:"resolvedAt,omitempty"`
|
ResolvedAt *time.Time `gorm:"column:resolved_at;type:timestamptz" json:"resolvedAt,omitempty"`
|
||||||
@@ -27,20 +34,35 @@ type Consultation struct {
|
|||||||
|
|
||||||
func (Consultation) TableName() string { return "consultations" }
|
func (Consultation) TableName() string { return "consultations" }
|
||||||
|
|
||||||
// ValidConsultationTransition 状态流转规则:pending→consulting/resolved;consulting→resolved;resolved→archived
|
// ValidConsultationTransition 状态流转规则:unassigned/assigned/accepted/needs_info/resolved/archived。
|
||||||
func ValidConsultationTransition(from, to string) bool {
|
func ValidConsultationTransition(from, to string) bool {
|
||||||
switch from {
|
switch from {
|
||||||
case "pending":
|
case "unassigned":
|
||||||
return to == "consulting" || to == "resolved"
|
return to == "assigned"
|
||||||
case "consulting":
|
case "assigned":
|
||||||
return to == "resolved"
|
return to == "accepted" || to == "needs_info"
|
||||||
|
case "accepted":
|
||||||
|
return to == "needs_info" || to == "resolved"
|
||||||
|
case "needs_info":
|
||||||
|
return to == "accepted" || to == "resolved"
|
||||||
case "resolved":
|
case "resolved":
|
||||||
return to == "archived"
|
return to == "archived" || to == "needs_info"
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConsultationSLAState 返回会诊 SLA 状态;超时只标记,不自动伪造专家结论。
|
||||||
|
func ConsultationSLAState(t Consultation, now time.Time) string {
|
||||||
|
if t.SLADeadline == nil {
|
||||||
|
return "unscheduled"
|
||||||
|
}
|
||||||
|
if now.After(*t.SLADeadline) {
|
||||||
|
return "overdue"
|
||||||
|
}
|
||||||
|
return "on_time"
|
||||||
|
}
|
||||||
|
|
||||||
// ConsultationSnapshot 病例快照(会诊时打包,避免引用数据变化)
|
// ConsultationSnapshot 病例快照(会诊时打包,避免引用数据变化)
|
||||||
type ConsultationSnapshot struct {
|
type ConsultationSnapshot struct {
|
||||||
RoomName string `json:"roomName,omitempty"`
|
RoomName string `json:"roomName,omitempty"`
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import (
|
|||||||
|
|
||||||
func TestValidConsultationTransition(t *testing.T) {
|
func TestValidConsultationTransition(t *testing.T) {
|
||||||
ok := [][2]string{
|
ok := [][2]string{
|
||||||
{"pending", "consulting"},
|
{"unassigned", "assigned"},
|
||||||
{"pending", "resolved"},
|
{"assigned", "accepted"},
|
||||||
{"consulting", "resolved"},
|
{"assigned", "needs_info"},
|
||||||
|
{"accepted", "resolved"},
|
||||||
|
{"needs_info", "resolved"},
|
||||||
{"resolved", "archived"},
|
{"resolved", "archived"},
|
||||||
}
|
}
|
||||||
for _, c := range ok {
|
for _, c := range ok {
|
||||||
@@ -19,7 +21,8 @@ func TestValidConsultationTransition(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
bad := [][2]string{
|
bad := [][2]string{
|
||||||
{"pending", "archived"},
|
{"unassigned", "archived"},
|
||||||
|
{"unassigned", "resolved"},
|
||||||
{"resolved", "resolved"},
|
{"resolved", "resolved"},
|
||||||
{"archived", "pending"},
|
{"archived", "pending"},
|
||||||
{"", "resolved"},
|
{"", "resolved"},
|
||||||
@@ -31,6 +34,32 @@ func TestValidConsultationTransition(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConsultationRejectsArchiveBeforeResolution(t *testing.T) {
|
||||||
|
if ValidConsultationTransition("accepted", "archived") {
|
||||||
|
t.Fatal("accepted 不能直接归档")
|
||||||
|
}
|
||||||
|
if ValidConsultationTransition("unassigned", "archived") {
|
||||||
|
t.Fatal("unassigned 不能直接归档")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsultationSLAMarksOverdue(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
deadline := now.Add(-time.Hour)
|
||||||
|
task := Consultation{Status: "assigned", SLADeadline: &deadline}
|
||||||
|
if state := ConsultationSLAState(task, now); state != "overdue" {
|
||||||
|
t.Fatalf("SLA 状态 = %s, want overdue", state)
|
||||||
|
}
|
||||||
|
future := now.Add(time.Hour)
|
||||||
|
task.SLADeadline = &future
|
||||||
|
if state := ConsultationSLAState(task, now); state != "on_time" {
|
||||||
|
t.Fatalf("SLA 状态 = %s, want on_time", state)
|
||||||
|
}
|
||||||
|
if state := ConsultationSLAState(Consultation{}, now); state != "unscheduled" {
|
||||||
|
t.Fatalf("SLA 状态 = %s, want unscheduled", state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConsultationSnapshotJSON(t *testing.T) {
|
func TestConsultationSnapshotJSON(t *testing.T) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
s := ConsultationSnapshot{
|
s := ConsultationSnapshot{
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// DetectionTask 统一检测任务,覆盖 LAMP/qPCR/SERS/高光谱。
|
||||||
|
type DetectionTask struct {
|
||||||
|
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||||
|
SourceKey string `gorm:"column:source_key;size:128;uniqueIndex" json:"sourceKey,omitempty"`
|
||||||
|
SourceType string `gorm:"column:source_type;size:32" json:"sourceType"`
|
||||||
|
SourceID string `gorm:"column:source_id;size:128" json:"sourceId,omitempty"`
|
||||||
|
RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"`
|
||||||
|
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
|
||||||
|
InspectionID *string `gorm:"column:inspection_id;type:uuid;index" json:"inspectionId,omitempty"`
|
||||||
|
Disease string `gorm:"size:64" json:"disease"`
|
||||||
|
RecommendedMethod string `gorm:"column:recommended_method;size:32" json:"recommendedMethod,omitempty"`
|
||||||
|
Method *string `gorm:"size:32" json:"method,omitempty"`
|
||||||
|
Priority string `gorm:"size:16;default:routine" json:"priority"`
|
||||||
|
Status string `gorm:"size:16;default:pending;index" json:"status"`
|
||||||
|
AssigneeID *string `gorm:"column:assignee_id;type:uuid" json:"assigneeId,omitempty"`
|
||||||
|
AssignedAt *time.Time `gorm:"column:assigned_at;type:timestamptz" json:"assignedAt,omitempty"`
|
||||||
|
Result *string `gorm:"size:16" json:"result,omitempty"`
|
||||||
|
ResultedAt *time.Time `gorm:"column:resulted_at;type:timestamptz" json:"resultedAt,omitempty"`
|
||||||
|
CancelledReason *string `gorm:"column:cancelled_reason;type:text" json:"cancelledReason,omitempty"`
|
||||||
|
CreatedBy *string `gorm:"column:created_by;type:uuid" json:"createdBy,omitempty"`
|
||||||
|
Note *string `gorm:"type:text" json:"note,omitempty"`
|
||||||
|
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||||
|
RoomName *string `gorm:"-" json:"roomName,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (DetectionTask) TableName() string { return "detection_tasks" }
|
||||||
|
|
||||||
|
// ValidDetectionTaskTransition 状态机:draft/pending/assigned/sampling/testing/review/completed/cancelled。
|
||||||
|
func ValidDetectionTaskTransition(from, to string) bool {
|
||||||
|
switch from {
|
||||||
|
case "draft":
|
||||||
|
return to == "pending" || to == "cancelled"
|
||||||
|
case "pending":
|
||||||
|
return to == "assigned" || to == "cancelled"
|
||||||
|
case "assigned":
|
||||||
|
return to == "sampling" || to == "cancelled"
|
||||||
|
case "sampling":
|
||||||
|
return to == "testing" || to == "cancelled"
|
||||||
|
case "testing":
|
||||||
|
return to == "review" || to == "cancelled"
|
||||||
|
case "review":
|
||||||
|
return to == "completed" || to == "cancelled"
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample 检测样本链路。
|
||||||
|
type Sample struct {
|
||||||
|
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||||
|
DetectionTaskID string `gorm:"column:detection_task_id;type:uuid;uniqueIndex" json:"detectionTaskId"`
|
||||||
|
SampleNo string `gorm:"column:sample_no;size:64;uniqueIndex" json:"sampleNo"`
|
||||||
|
RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"`
|
||||||
|
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
|
||||||
|
TrayID *string `gorm:"column:tray_id;type:uuid" json:"trayId,omitempty"`
|
||||||
|
SampledBy *string `gorm:"column:sampled_by;type:uuid" json:"sampledBy,omitempty"`
|
||||||
|
SampledAt *time.Time `gorm:"column:sampled_at;type:timestamptz" json:"sampledAt,omitempty"`
|
||||||
|
CollectedAt *time.Time `gorm:"column:collected_at;type:timestamptz" json:"collectedAt,omitempty"`
|
||||||
|
HandedOverAt *time.Time `gorm:"column:handed_over_at;type:timestamptz" json:"handedOverAt,omitempty"`
|
||||||
|
HandedOverBy *string `gorm:"column:handed_over_by;type:uuid" json:"handedOverBy,omitempty"`
|
||||||
|
ReceivedAt *time.Time `gorm:"column:received_at;type:timestamptz" json:"receivedAt,omitempty"`
|
||||||
|
ReceivedBy *string `gorm:"column:received_by;type:uuid" json:"receivedBy,omitempty"`
|
||||||
|
TestingStartedAt *time.Time `gorm:"column:testing_started_at;type:timestamptz" json:"testingStartedAt,omitempty"`
|
||||||
|
ConsumedAt *time.Time `gorm:"column:consumed_at;type:timestamptz" json:"consumedAt,omitempty"`
|
||||||
|
DisposedAt *time.Time `gorm:"column:disposed_at;type:timestamptz" json:"disposedAt,omitempty"`
|
||||||
|
State string `gorm:"size:16;default:created" json:"state"`
|
||||||
|
Note *string `gorm:"type:text" json:"note,omitempty"`
|
||||||
|
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Sample) TableName() string { return "samples" }
|
||||||
|
|
||||||
|
// ValidSampleTransition 样本状态机:created/collected/handed_over/received/testing/consumed/disposed。
|
||||||
|
func ValidSampleTransition(from, to string) bool {
|
||||||
|
switch from {
|
||||||
|
case "created":
|
||||||
|
return to == "collected" || to == "disposed"
|
||||||
|
case "collected":
|
||||||
|
return to == "handed_over" || to == "disposed"
|
||||||
|
case "handed_over":
|
||||||
|
return to == "received" || to == "disposed"
|
||||||
|
case "received":
|
||||||
|
return to == "testing" || to == "disposed"
|
||||||
|
case "testing":
|
||||||
|
return to == "consumed" || to == "disposed"
|
||||||
|
case "consumed":
|
||||||
|
return to == "disposed"
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDetectionTaskRejectsInvalidTransition(t *testing.T) {
|
||||||
|
if ValidDetectionTaskTransition("pending", "testing") {
|
||||||
|
t.Error("pending 不能直接跳 testing")
|
||||||
|
}
|
||||||
|
if ValidDetectionTaskTransition("draft", "completed") {
|
||||||
|
t.Error("draft 不能直接 completed")
|
||||||
|
}
|
||||||
|
if ValidDetectionTaskTransition("completed", "review") {
|
||||||
|
t.Error("completed 不能回退")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectionTaskAllowsRequiredTransitions(t *testing.T) {
|
||||||
|
transitions := [][2]string{
|
||||||
|
{"draft", "pending"},
|
||||||
|
{"pending", "assigned"},
|
||||||
|
{"assigned", "sampling"},
|
||||||
|
{"sampling", "testing"},
|
||||||
|
{"testing", "review"},
|
||||||
|
{"review", "completed"},
|
||||||
|
{"pending", "cancelled"},
|
||||||
|
}
|
||||||
|
for _, tr := range transitions {
|
||||||
|
if !ValidDetectionTaskTransition(tr[0], tr[1]) {
|
||||||
|
t.Errorf("expected %s -> %s", tr[0], tr[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSampleTransition(t *testing.T) {
|
||||||
|
transitions := [][2]string{
|
||||||
|
{"created", "collected"},
|
||||||
|
{"collected", "handed_over"},
|
||||||
|
{"handed_over", "received"},
|
||||||
|
{"received", "testing"},
|
||||||
|
{"testing", "consumed"},
|
||||||
|
{"consumed", "disposed"},
|
||||||
|
}
|
||||||
|
for _, tr := range transitions {
|
||||||
|
if !ValidSampleTransition(tr[0], tr[1]) {
|
||||||
|
t.Errorf("expected %s -> %s", tr[0], tr[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ValidSampleTransition("created", "testing") {
|
||||||
|
t.Error("created 不能直接 testing")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DiseaseEvent 独立发病事件,作为处置、会诊、溯源和效果评估主线。
|
||||||
|
type DiseaseEvent struct {
|
||||||
|
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||||
|
SourceKey string `gorm:"column:source_key;size:128;uniqueIndex" json:"sourceKey,omitempty"`
|
||||||
|
RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"`
|
||||||
|
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
|
||||||
|
DetectionTaskID *string `gorm:"column:detection_task_id;type:uuid;index" json:"detectionTaskId,omitempty"`
|
||||||
|
LampTestID *string `gorm:"column:lamp_test_id;type:uuid;index" json:"lampTestId,omitempty"`
|
||||||
|
ConsultationID *string `gorm:"column:consultation_id;type:uuid;index" json:"consultationId,omitempty"`
|
||||||
|
InspectionID *string `gorm:"column:inspection_id;type:uuid;index" json:"inspectionId,omitempty"`
|
||||||
|
Disease string `gorm:"size:64" json:"disease"`
|
||||||
|
Status string `gorm:"size:16;default:suspected;index" json:"status"`
|
||||||
|
Evidence json.RawMessage `gorm:"type:jsonb" json:"evidence,omitempty"`
|
||||||
|
ConfirmedAt *time.Time `gorm:"column:confirmed_at;type:timestamptz" json:"confirmedAt,omitempty"`
|
||||||
|
ConfirmedBy *string `gorm:"column:confirmed_by;type:uuid" json:"confirmedBy,omitempty"`
|
||||||
|
LossSummary *string `gorm:"column:loss_summary;type:text" json:"lossSummary,omitempty"`
|
||||||
|
Measure *string `gorm:"type:text" json:"measure,omitempty"`
|
||||||
|
Note *string `gorm:"type:text" json:"note,omitempty"`
|
||||||
|
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||||
|
RoomName *string `gorm:"-" json:"roomName,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (DiseaseEvent) TableName() string { return "disease_events" }
|
||||||
|
|
||||||
|
// ValidDiseaseEventTransition 状态机:suspected/confirmed/controlled/closed/reopened。
|
||||||
|
func ValidDiseaseEventTransition(from, to string) bool {
|
||||||
|
switch from {
|
||||||
|
case "suspected":
|
||||||
|
return to == "confirmed" || to == "closed"
|
||||||
|
case "confirmed":
|
||||||
|
return to == "controlled" || to == "closed"
|
||||||
|
case "controlled":
|
||||||
|
return to == "closed" || to == "reopened"
|
||||||
|
case "closed":
|
||||||
|
return to == "reopened"
|
||||||
|
case "reopened":
|
||||||
|
return to == "confirmed" || to == "controlled" || to == "closed"
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateDiseaseEventEvidence 确诊必须有证据,不能仅凭状态字段确认。
|
||||||
|
func ValidateDiseaseEventEvidence(event DiseaseEvent) error {
|
||||||
|
if event.Status == "confirmed" && len(event.Evidence) == 0 {
|
||||||
|
return errors.New("确诊发病事件必须提供证据")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user