merge: 整改 Task 0-14 统一基线

This commit is contained in:
weijuesen
2026-08-14 17:52:35 +08:00
168 changed files with 12279 additions and 1020 deletions
+3
View File
@@ -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-servicePython 推理服务)文件例外 # ai-servicePython 推理服务)文件例外
!ai-service/ !ai-service/
!ai-service/app/ !ai-service/app/
+21 -11
View File
@@ -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 handlerauth/device/alarm/video 等) │ │ ├─ handler/ # HTTP handlerauth/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
View File
@@ -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 后端计算(#90.5×AI 置信度 + 0.2×环境 + 0.15×阶段 + 0.15×整齐度)。 - 只做检测并返回 `abnormalProbability`风险评分在 Go 后端计算(#90.5×AI 异常概率 + 0.2×环境 + 0.15×阶段 + 0.15×整齐度,缺失项归一化)。
- `healthy` 高置信度不贡献异常概率;空检测或 `unknown` 返回 `unknown`,不自动视为健康。
- ONNX 后处理按 YOLOv8 常见输出格式实现(含 letterbox 与 NMS),训练产物出来后需用真实模型校准验证。 - ONNX 后处理按 YOLOv8 常见输出格式实现(含 letterbox 与 NMS),训练产物出来后需用真实模型校准验证。
+9
View File
@@ -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"))
+46
View File
@@ -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
View File
@@ -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="缺少 urlrtsp/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),
+101
View File
@@ -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()
+72 -1
View File
@@ -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
+20 -1
View File
@@ -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"
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
root: true,
extends: '@react-native',
rules: {
'prettier/prettier': 'warn',
},
};
+3292 -274
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -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 -1
View File
@@ -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 -2
View File
@@ -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 -4
View File
@@ -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[]>([]);
+2 -3
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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 -3
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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';
+7 -3
View File
@@ -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
View File
@@ -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) {
+62
View File
@@ -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 和失败项。
+31
View File
@@ -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;自研 runnerGORM 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 CIJenkinsGitea Actions | 仓库当前无 remote,无法配置并验证外部 CI;先保留可复现本地门禁 | Task 1 的 CI 接入步骤暂不完成;CI 配置待平台确定 | 项目负责人 + 运维 | 仓库获得 remote 或团队确认 CI 平台时 |
| DEC-003 | 制品保持现状:构建产物不入库,沿用 `web/dist``miniapp/dist`、二进制等 gitignore 规则 | GitHub Releases/ArtifactsNASGit 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` 追加影响与回滚点。
+36
View File
@@ -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(故障到服务可用)
- 数据点核对:巡检、遥测、检测、通知、任务、会诊、溯源
- 失败项与原因
## 验收
- 核心业务记录无缺失。
- 图片/光谱/录像对象引用可访问。
- 至少完成一次真实恢复演练后才允许执行正式生产迁移。
+39
View File
@@ -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 显存和单帧延迟。结果用于设置生产并发上限和推理告警阈值。
## 禁止事项
- 不在生产库执行写入类负载。
- 不使用真实用户隐私图片做公开压测。
- 压测前必须备份数据库并记录环境状态。
+39
View File
@@ -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 → 确认自动恢复;如部署告警工具,则同时验证告警通道可送达负责人。
+2
View File
@@ -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",
+33
View File
@@ -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 });
+4 -4
View File
@@ -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;
+18 -4
View File
@@ -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);
+29
View File
@@ -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');
+1
View File
@@ -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
View File
@@ -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';
+1 -1
View File
@@ -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;
}
+127
View File
@@ -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;
+2 -2
View File
@@ -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}>
+2 -2
View File
@@ -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;
+68 -6
View File
@@ -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 -1
View File
@@ -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,
+4 -7
View File
@@ -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}
+1
View File
@@ -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') {
+8
View File
@@ -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>
+11 -8
View File
@@ -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 }[] = [
+99
View File
@@ -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);
});
+196
View File
@@ -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>;
+19
View File
@@ -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 };
+9
View File
@@ -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);
},
})); }));
+11
View File
@@ -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
View File
@@ -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());
+1
View File
@@ -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": "",
+27
View File
@@ -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;
+95
View File
@@ -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
+27 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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=
+3
View File
@@ -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"`
+29 -5
View File
@@ -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)
} }
} }
+64
View File
@@ -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
}
+127
View File
@@ -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)
}
}
+12 -1
View File
@@ -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
} }
+26 -6
View File
@@ -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
}
} }
} }
} }
+413
View File
@@ -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")
}
}
+39 -3
View File
@@ -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)
}
+55 -1
View File
@@ -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))
}
}
+122 -55
View File
@@ -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
} }
+35 -1
View File
@@ -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)
}
}
+49
View File
@@ -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) {
+76
View File
@@ -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
+21 -47
View File
@@ -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)
} }
} }
+28
View File
@@ -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(),
})
}
}
+33 -2
View File
@@ -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}
+102
View File
@@ -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)
}
}
+44 -50
View File
@@ -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 -3
View File
@@ -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),
}) })
} }
} }
+128
View File
@@ -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,
}
}
+11 -1
View File
@@ -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
+8 -3
View File
@@ -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
} }
+10
View File
@@ -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,
) )
} }
} }
+21 -84
View File
@@ -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)
}
}
+104
View File
@@ -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)
}
+152
View File
@@ -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 使用)
+144
View File
@@ -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
}
+28 -6
View File
@@ -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/resolvedconsulting→resolvedresolvedarchived // 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"`
+33 -4
View File
@@ -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")
}
}
+58
View File
@@ -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