merge: 整改 Task 0-14 统一基线
This commit is contained in:
@@ -26,8 +26,11 @@ __pycache__/
|
||||
/wvp/*.sh
|
||||
*.sh
|
||||
*.ps1
|
||||
!scripts/verify.ps1
|
||||
*.py
|
||||
*.sql
|
||||
!server-go/migrations/
|
||||
!server-go/migrations/*.sql
|
||||
# ai-service(Python 推理服务)文件例外
|
||||
!ai-service/
|
||||
!ai-service/app/
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
|
||||
### 1.6 通知与实时推送
|
||||
|
||||
- WebSocket 网关位于 `/ws`,使用 JWT 鉴权
|
||||
- WebSocket 网关位于 `/ws`,使用一次性 ticket 鉴权
|
||||
- 广播事件:`telemetry.all`、`telemetry`、`alarm`、`alarm.recovery`
|
||||
- Web/APP/小程序均接入 WebSocket 实时订阅
|
||||
- 登录后自动连接 WebSocket,登出自动断开
|
||||
@@ -64,12 +64,14 @@
|
||||
|
||||
- 蚕匾(`trays`)、蚕种批次(`batches`,含品种/蚕种来源/批次号/检疫证明/龄期/入房/上蔟)、饲养记录(`rearing_records`)三表 + CRUD
|
||||
- Web「批次管理」页(批次 + 蚕匾 + 饲养记录);小程序蚕房详情展示蚕匾与当前批次
|
||||
- 生物安全:种源与检疫链、消毒计划/执行、批次/蚕匾/样本二维码身份;小程序可扫码并现场提交消毒执行
|
||||
|
||||
### 1.9 AI 拍照巡检闭环(计划 #5/#6/#8/#9)
|
||||
|
||||
- `ai-service/`:FastAPI + ONNX Runtime(默认 mock 模式;`POST /detect` 返回框/类别/置信度,`POST /stream-detect` 拉流抽帧骨架,`GET /metrics` 监控指标)
|
||||
- 拍照上传 → AI 检测 → 风险评分(0-100 分,绿/黄/橙/红四级)→ 巡检记录(`inspection_records`,`Idempotency-Key` 幂等)
|
||||
- `ai-service/`:FastAPI + ONNX Runtime(默认 mock 模式;`POST /detect` 返回框/类别/置信度、`modelVersion`、`isMock`、`abnormalProbability`,`POST /internal/stream-tasks` 受限拉流任务,`GET /metrics` 监控指标)
|
||||
- 拍照上传 → AI 检测 → 风险评分(0-100 分,绿/黄/橙/红四级,只消费 AI 异常概率,缺失项不按 0 参与)→ 巡检记录(`inspection_records`,`Idempotency-Key` 幂等)
|
||||
- 小程序「拍照巡检」页;Web「巡检记录」页(技术员复查)
|
||||
- 小程序离线巡检:拍照先入本地持久队列,联网后串行上传并复用同一幂等键;401 自动刷新后重试
|
||||
|
||||
### 1.10 知识库与阶段风险提示(计划 #10/#13)
|
||||
|
||||
@@ -82,6 +84,7 @@
|
||||
- LAMP 检测任务单 + 标准 5 步流程 + 结果照片 + 结果录入
|
||||
- qPCR Ct 值自动判读(阈值可配);SERS 光谱数据上传与光谱库;高光谱方式预留
|
||||
- 交叉验证:AI 结果 vs 检测结果(一致→确认诊断;不一致→建议专家会诊)
|
||||
- 统一检测任务/样本/发病事件:`/detection-tasks`、`/samples`、`/disease-events`;橙色/红色巡检通过 Outbox 幂等创建待确认任务,阳性结果自动创建发病事件并关联溯源
|
||||
- 多检测方式推荐引擎(设备条件/紧急程度/操作者水平/成本偏好)
|
||||
- Web「分子检测」页 + 小程序 LAMP 录入
|
||||
|
||||
@@ -89,6 +92,7 @@
|
||||
|
||||
- 会诊单 + 病例快照(照片/AI/检测/环境/批次/天气)+ 状态机(受理/出方案/归档)+ 专家意见与防控方案
|
||||
- Web「专家会诊」页
|
||||
- 会诊治理:统一状态机、SLA 超时标记、专家意见版本;知识库草稿/审核/发布/撤回;防控效果报告
|
||||
|
||||
### 1.13 疫病溯源与区域统计(计划 #21/#22)
|
||||
|
||||
@@ -173,7 +177,7 @@ silk/
|
||||
│ ├─ cmd/server/main.go # 入口
|
||||
│ ├─ internal/
|
||||
│ │ ├─ config/ # 环境变量配置
|
||||
│ │ ├─ database/ # GORM 数据库连接与自动迁移
|
||||
│ │ ├─ database/ # GORM 数据库连接与版本化迁移
|
||||
│ │ ├─ handler/ # HTTP handler(auth/device/alarm/video 等)
|
||||
│ │ ├─ middleware/ # JWT 鉴权、RBAC 权限、CORS、限流、安全头
|
||||
│ │ ├─ model/ # GORM 数据模型 + 权限种子数据
|
||||
@@ -244,7 +248,7 @@ silk/
|
||||
| Alarms | `handler/alarm.go` | 告警生成、确认、解除、查询 |
|
||||
| AlarmClip | `handler/alarm_clip.go` | 告警关联视频片段 |
|
||||
| Control | `handler/control.go` | MQTT 控制命令下发 |
|
||||
| Notification | `handler/notification.go` | Redis 站内通知 |
|
||||
| Notification | `handler/notification.go` | PostgreSQL 持久化通知与 Outbox 状态 |
|
||||
| Video Camera | `handler/video_camera.go` | 摄像头管理、WVP 状态同步 |
|
||||
| Video Stream | `handler/video_stream.go` | 实时点播、FLV 代理 |
|
||||
| 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 /lamp-tests(/:id)` | 分子检测任务单(含步骤/结果照片/judge-qpcr/spectrum/cross-validation) |
|
||||
| `GET/POST/DELETE /spectrum-entries` | SERS 光谱库 |
|
||||
| `GET/POST/PATCH /detection-tasks(/:id)`、`/samples`、`/disease-events` | 统一检测任务、样本链路与发病事件 |
|
||||
| `GET /weather/now`、`GET /weather/alerts` | 天气与高发病预警 |
|
||||
| `GET/POST /wechat/binding`、`/wechat/bind`、`/wechat/subscribe` | 微信订阅绑定/授权 |
|
||||
| `GET/POST/PATCH /consultations(/:id)`、`/:id/resolve`、`/:id/archive` | 专家会诊 |
|
||||
@@ -426,7 +431,7 @@ Payload 示例:
|
||||
|
||||
### 10.1 PostgreSQL(业务数据)
|
||||
|
||||
保存用户、蚕房、设备、传感器、阈值、告警、摄像头、录像片段、审计日志、权限等业务数据。GORM 自动迁移建表,系统启动时自动初始化权限种子数据。
|
||||
保存用户、蚕房、设备、传感器、阈值、告警、摄像头、录像片段、审计日志、权限等业务数据。系统启动时先执行 `server-go/migrations/` 下的版本化 SQL 迁移,再初始化权限种子数据;生产环境禁用 AutoMigrate。
|
||||
|
||||
```env
|
||||
PG=postgresql://postgres:pan@localhost:5432/silk
|
||||
@@ -442,9 +447,9 @@ IOTDB_URL=http://localhost:18081
|
||||
|
||||
IoTDB 路径示例:`root.silk.telemetry.`sensor-001`.temperature`
|
||||
|
||||
### 10.3 Valkey/Redis(缓存与通知)
|
||||
### 10.3 Valkey/Redis(跨实例认证状态)
|
||||
|
||||
用于权限缓存(5 分钟有效期)、站内通知列表。
|
||||
用于 JWT 吊销、登录限流等跨实例状态;通知与事件箱持久化在 PostgreSQL,Redis 不可用时登录/刷新/吊销和已认证接口保守失败,不回退单机内存。
|
||||
|
||||
### 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 连接串 |
|
||||
| `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_EXPIRES_IN` | `2h` | JWT 有效期 |
|
||||
| `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
|
||||
```
|
||||
|
||||
- 接口:`GET /health`、`POST /detect`(multipart 图片)、`POST /stream-detect`(摄像头流拉帧骨架)、`GET /metrics`(含 GPU 信息)
|
||||
- 默认 mock 模式;训练恢复后放 `models/best.onnx` 并设 `MODEL_MODE=onnx`(`MODEL_LABELS` 可配类别)
|
||||
- 接口:`GET /health`、`POST /detect`(multipart 图片,返回 `abnormalProbability`)、`POST /internal/stream-tasks`(受限摄像头流拉帧任务)、`GET /metrics`(含 GPU 信息)
|
||||
- 默认 mock 模式且响应带 `isMock=true`;训练恢复后放 `models/best.onnx` 并设 `MODEL_MODE=onnx`(`MODEL_LABELS` 可配类别、`MODEL_VERSION` 必须显式配置)
|
||||
- 开发服务器部署:`/home/pan/ai-service`(venv + `start.sh`,:8000),服务器 pip 源已配置清华镜像
|
||||
|
||||
### 12.7 开发服务器部署摘要
|
||||
|
||||
- 部署规则(备份/回滚/冒烟)见根目录 `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
|
||||
- 可观测性:HTTP 请求统一 `X-Request-ID`;日志脱敏;`GET /api/v1/ops/metrics` 提供依赖指标;SLO/负载/备份恢复演练文档见 `docs/operations/`
|
||||
- 验收门禁:需求追踪、核心端到端用例和发布清单见 `docs/acceptance/`
|
||||
|
||||
## 13. Git 注意事项
|
||||
|
||||
|
||||
+17
-2
@@ -5,7 +5,8 @@ FastAPI + ONNX Runtime 的蚕病检测推理服务。YOLO 训练挂起期间以
|
||||
## 接口
|
||||
|
||||
- `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
|
||||
```
|
||||
|
||||
## 质量检查
|
||||
|
||||
仓库根目录的 `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_PATH` | `models/best.onnx` | ONNX 模型路径 |
|
||||
| `MODEL_LABELS` | `healthy,sick` | 类别列表(逗号分隔) |
|
||||
| `MODEL_VERSION` | `mock-2026.08.14` / `best.onnx` | 模型版本;真实模型上线时必须显式配置 |
|
||||
| `MOCK_CLASS` | `healthy` | mock 返回类别 |
|
||||
| `MOCK_CONFIDENCE` | `0.95` | mock 返回置信度 |
|
||||
| `INTERNAL_API_KEY` | `silk-internal-2026` | 内部接口认证密钥 |
|
||||
| `ALLOWED_STREAM_HOSTS` | `localhost,127.0.0.1,100.83.103.1` | 允许拉流的主机白名单 |
|
||||
| `STREAM_TASK_MAX_WORKERS` | `2` | 流任务并发 worker 数 |
|
||||
| `STREAM_TASK_MAX_FRAMES` | `10` | 单任务最大抽帧数 |
|
||||
|
||||
## 说明
|
||||
|
||||
- 只做检测,风险评分在 Go 后端计算(#9:0.5×AI 置信度 + 0.2×环境 + 0.15×阶段 + 0.15×整齐度)。
|
||||
- 只做检测并返回 `abnormalProbability`;风险评分在 Go 后端计算(#9:0.5×AI 异常概率 + 0.2×环境 + 0.15×阶段 + 0.15×整齐度,缺失项归一化)。
|
||||
- `healthy` 高置信度不贡献异常概率;空检测或 `unknown` 返回 `unknown`,不自动视为健康。
|
||||
- ONNX 后处理按 YOLOv8 常见输出格式实现(含 letterbox 与 NMS),训练产物出来后需用真实模型校准验证。
|
||||
|
||||
@@ -5,6 +5,15 @@ MODEL_MODE = os.getenv("MODEL_MODE", "mock")
|
||||
MODEL_PATH = os.getenv("MODEL_PATH", "models/best.onnx")
|
||||
# YOLO 类别(二分类训练基线:healthy/sick;7 类病种扩展后再调整)
|
||||
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_CLASS = os.getenv("MOCK_CLASS", "healthy")
|
||||
MOCK_CONFIDENCE = float(os.getenv("MOCK_CONFIDENCE", "0.95"))
|
||||
INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "silk-internal-2026")
|
||||
ALLOWED_STREAM_HOSTS = [
|
||||
item.strip()
|
||||
for item in os.getenv("ALLOWED_STREAM_HOSTS", "localhost,127.0.0.1,100.83.103.1").split(",")
|
||||
if item.strip()
|
||||
]
|
||||
STREAM_TASK_MAX_WORKERS = int(os.getenv("STREAM_TASK_MAX_WORKERS", "2"))
|
||||
STREAM_TASK_MAX_FRAMES = int(os.getenv("STREAM_TASK_MAX_FRAMES", "10"))
|
||||
|
||||
@@ -5,6 +5,52 @@ from abc import ABC, abstractmethod
|
||||
from PIL import Image
|
||||
|
||||
|
||||
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):
|
||||
@abstractmethod
|
||||
def detect(self, image_bytes: bytes) -> list[dict]:
|
||||
|
||||
+45
-34
@@ -2,25 +2,30 @@ import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from fastapi import FastAPI, File, HTTPException, Request, UploadFile
|
||||
|
||||
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")
|
||||
START_TIME = time.time()
|
||||
_lock = threading.Lock()
|
||||
_requests = 0
|
||||
_latency_total = 0.0
|
||||
stream_worker = StreamTaskWorker(config.STREAM_TASK_MAX_WORKERS)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def count_requests(request: Request, call_next):
|
||||
global _requests, _latency_total
|
||||
start = time.time()
|
||||
request_id = request.headers.get("x-request-id") or str(uuid.uuid4())
|
||||
response = await call_next(request)
|
||||
latency = (time.time() - start) * 1000
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
with _lock:
|
||||
_requests += 1
|
||||
_latency_total += latency
|
||||
@@ -34,7 +39,12 @@ else:
|
||||
|
||||
@app.get("/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")
|
||||
@@ -48,6 +58,10 @@ async def detect(file: UploadFile = File(...)):
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {
|
||||
"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": [
|
||||
{
|
||||
"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")
|
||||
async def stream_detect(body: dict):
|
||||
"""摄像头流 AI 巡检骨架(#23):拉流抽帧 → 检测。
|
||||
def stream_detect():
|
||||
raise HTTPException(status_code=410, detail="请使用 POST /internal/stream-tasks")
|
||||
|
||||
二期功能:正式接入前需补摄像头视角数据与巡检任务编排。
|
||||
"""
|
||||
url = (body or {}).get("url") or ""
|
||||
if not url:
|
||||
raise HTTPException(status_code=400, detail="缺少 url(rtsp/http-flv 流地址)")
|
||||
if cv2 is None:
|
||||
raise HTTPException(status_code=503, detail="OpenCV 未安装,无法拉流")
|
||||
capture = cv2.VideoCapture(url)
|
||||
if not capture.isOpened():
|
||||
raise HTTPException(status_code=502, detail="无法连接视频流")
|
||||
try:
|
||||
frames = 0
|
||||
detections = []
|
||||
while frames < 3:
|
||||
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()
|
||||
return {"model": config.MODEL_MODE, "frames": frames, "detections": detections[:10]}
|
||||
|
||||
@app.post("/internal/stream-tasks", status_code=202)
|
||||
def create_stream_task(request: Request, body: dict):
|
||||
if request.headers.get("x-internal-key") != config.INTERNAL_API_KEY:
|
||||
raise HTTPException(status_code=401, detail="invalid internal key")
|
||||
|
||||
task_id = (body or {}).get("taskId", "")
|
||||
stream_ref = (body or {}).get("streamRef")
|
||||
if not task_id or not isinstance(stream_ref, dict):
|
||||
raise HTTPException(status_code=400, detail="缺少 taskId 或 streamRef")
|
||||
if not is_allowed_stream_ref(stream_ref):
|
||||
raise HTTPException(status_code=400, detail="streamRef 不在允许范围")
|
||||
|
||||
stream_worker.submit(task_id, stream_ref)
|
||||
return {"taskId": task_id, "status": "queued"}
|
||||
|
||||
|
||||
@app.get("/internal/stream-tasks/{task_id}")
|
||||
def get_stream_task(request: Request, task_id: str):
|
||||
if request.headers.get("x-internal-key") != config.INTERNAL_API_KEY:
|
||||
raise HTTPException(status_code=401, detail="invalid internal key")
|
||||
task = stream_worker.get(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="task not found")
|
||||
return task
|
||||
|
||||
|
||||
@app.get("/metrics")
|
||||
@@ -115,6 +124,8 @@ def metrics():
|
||||
gpu = None
|
||||
return {
|
||||
"model": config.MODEL_MODE,
|
||||
"modelVersion": config.MODEL_VERSION,
|
||||
"isMock": isinstance(detector, MockDetector),
|
||||
"uptimeSeconds": int(time.time() - START_TIME),
|
||||
"requests": reqs,
|
||||
"avgLatencyMs": round(avg, 2),
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from . import config
|
||||
from .detector import MockDetector, ONNXDetector
|
||||
|
||||
|
||||
def _detector():
|
||||
if config.MODEL_MODE == "onnx":
|
||||
return ONNXDetector(config.MODEL_PATH, tuple(config.MODEL_LABELS))
|
||||
return MockDetector(class_name=config.MOCK_CLASS, confidence=config.MOCK_CONFIDENCE)
|
||||
|
||||
|
||||
def is_allowed_stream_ref(stream_ref):
|
||||
url = (stream_ref or {}).get("url", "")
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("rtsp", "http", "https"):
|
||||
return False
|
||||
if parsed.username or parsed.password:
|
||||
return False
|
||||
host = (parsed.hostname or "").lower()
|
||||
allowed = {item.strip().lower() for item in config.ALLOWED_STREAM_HOSTS}
|
||||
return host in allowed
|
||||
|
||||
|
||||
class StreamTaskWorker:
|
||||
def __init__(self, max_workers=2):
|
||||
self.executor = ThreadPoolExecutor(max_workers=max_workers)
|
||||
self.tasks = {}
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def submit(self, task_id, stream_ref):
|
||||
with self.lock:
|
||||
self.tasks[task_id] = {
|
||||
"taskId": task_id,
|
||||
"status": "queued",
|
||||
"startedAt": time.time(),
|
||||
"finishedAt": None,
|
||||
"frames": 0,
|
||||
"detections": [],
|
||||
"error": None,
|
||||
}
|
||||
self.executor.submit(self._run, task_id, stream_ref)
|
||||
|
||||
def get(self, task_id):
|
||||
with self.lock:
|
||||
task = self.tasks.get(task_id)
|
||||
return dict(task) if task else None
|
||||
|
||||
def _run(self, task_id, stream_ref):
|
||||
try:
|
||||
with self.lock:
|
||||
task = self.tasks.get(task_id)
|
||||
if task:
|
||||
task["status"] = "running"
|
||||
try:
|
||||
import cv2
|
||||
except ImportError:
|
||||
self._fail(task_id, "OpenCV 未安装,无法拉流")
|
||||
return
|
||||
|
||||
capture = cv2.VideoCapture(stream_ref.get("url", ""))
|
||||
if not capture.isOpened():
|
||||
self._fail(task_id, "无法连接视频流")
|
||||
return
|
||||
|
||||
max_frames = min(int(stream_ref.get("maxFrames", config.STREAM_TASK_MAX_FRAMES)), config.STREAM_TASK_MAX_FRAMES)
|
||||
detector = _detector()
|
||||
frames = 0
|
||||
detections = []
|
||||
try:
|
||||
while frames < max_frames:
|
||||
ok, frame = capture.read()
|
||||
if not ok:
|
||||
break
|
||||
ok_encode, buf = cv2.imencode(".jpg", frame)
|
||||
if ok_encode:
|
||||
detections.extend(detector.detect(buf.tobytes()))
|
||||
frames += 1
|
||||
finally:
|
||||
capture.release()
|
||||
|
||||
with self.lock:
|
||||
task = self.tasks.get(task_id)
|
||||
if task:
|
||||
task["status"] = "completed"
|
||||
task["frames"] = frames
|
||||
task["detections"] = detections[:10]
|
||||
task["finishedAt"] = time.time()
|
||||
except Exception as exc:
|
||||
self._fail(task_id, str(exc))
|
||||
|
||||
def _fail(self, task_id, message):
|
||||
with self.lock:
|
||||
task = self.tasks.get(task_id)
|
||||
if task:
|
||||
task["status"] = "failed"
|
||||
task["error"] = message
|
||||
task["finishedAt"] = time.time()
|
||||
@@ -1,8 +1,14 @@
|
||||
import base64
|
||||
import 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 app import main as main_module
|
||||
from app.main import app
|
||||
from app.detector import MockDetector
|
||||
|
||||
# 1x1 透明 PNG
|
||||
TINY_PNG = base64.b64decode(
|
||||
@@ -18,13 +24,20 @@ def test_health():
|
||||
body = r.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["model"] in ("mock", "onnx")
|
||||
assert body["modelVersion"]
|
||||
assert body["isMock"] is True
|
||||
|
||||
|
||||
def test_detect_ok():
|
||||
r = client.post("/detect", files={"file": ("a.png", TINY_PNG, "image/png")})
|
||||
assert r.status_code == 200
|
||||
assert r.headers.get("x-request-id")
|
||||
body = r.json()
|
||||
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
|
||||
d = body["detections"][0]
|
||||
assert d["class"] in ("healthy", "sick")
|
||||
@@ -32,6 +45,28 @@ def test_detect_ok():
|
||||
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():
|
||||
r = client.post("/detect", files={"file": ("a.png", b"", "image/png")})
|
||||
assert r.status_code == 400
|
||||
@@ -42,8 +77,44 @@ def test_detect_invalid_image_rejected():
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_stream_detect_missing_url():
|
||||
def test_stream_detect_is_disabled():
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import base64
|
||||
|
||||
import pytest
|
||||
|
||||
from app.detector import MockDetector
|
||||
from app.detector import MockDetector, abnormal_probability, detection_status
|
||||
|
||||
# 1x1 透明 PNG
|
||||
TINY_PNG = base64.b64decode(
|
||||
@@ -23,3 +23,22 @@ def test_mock_detector_rejects_invalid_image():
|
||||
det = MockDetector()
|
||||
with pytest.raises(ValueError):
|
||||
det.detect(b"not an image")
|
||||
|
||||
|
||||
def test_abnormal_probability_ignores_healthy_and_unknown():
|
||||
detections = [
|
||||
{"class_name": "healthy", "confidence": 0.95},
|
||||
{"class_name": "unknown", "confidence": 0.8},
|
||||
{"class_name": "sick", "confidence": 0.72},
|
||||
]
|
||||
assert abnormal_probability(detections, ("healthy", "sick")) == 0.72
|
||||
|
||||
|
||||
def test_detection_status_returns_unknown_for_empty_or_unknown():
|
||||
assert detection_status([], ("healthy", "sick")) == "unknown"
|
||||
assert detection_status([{"class_name": "unknown", "confidence": 0.8}], ("healthy", "sick")) == "unknown"
|
||||
assert detection_status([{"class_name": "healthy", "confidence": 0.95}], ("healthy", "sick")) == "healthy"
|
||||
assert detection_status(
|
||||
[{"class_name": "white_muscardine", "confidence": 0.7}],
|
||||
("healthy", "white_muscardine", "nuclear_polyhedrosis"),
|
||||
) == "abnormal"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: '@react-native',
|
||||
rules: {
|
||||
'prettier/prettier': 'warn',
|
||||
},
|
||||
};
|
||||
Generated
+3292
-274
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -17,14 +17,13 @@
|
||||
"@react-navigation/native-stack": "^6.10.0",
|
||||
"axios": "^1.7.2",
|
||||
"dayjs": "^1.11.11",
|
||||
"react": "18.3.1",
|
||||
"react": "18.2.0",
|
||||
"react-native": "0.74.5",
|
||||
"react-native-paper": "^5.12.3",
|
||||
"react-native-safe-area-context": "^4.10.5",
|
||||
"react-native-screens": "^3.32.0",
|
||||
"react-native-vector-icons": "^10.1.0",
|
||||
"react-native-video": "^6.2.0",
|
||||
"victory-native": "^41.6.0",
|
||||
"zustand": "^4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -32,9 +31,12 @@
|
||||
"@babel/preset-env": "^7.25.3",
|
||||
"@babel/runtime": "^7.25.0",
|
||||
"@react-native/babel-preset": "0.74.85",
|
||||
"@react-native/eslint-config": "0.74.85",
|
||||
"@react-native/metro-config": "^0.74.85",
|
||||
"@react-native/typescript-config": "0.74.85",
|
||||
"@types/react": "^18.3.3",
|
||||
"eslint": "^8.57.1",
|
||||
"prettier": "^2.8.8",
|
||||
"react-native-dotenv": "^3.4.11",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, Text, Dimensions } from 'react-native';
|
||||
import { View, StyleSheet, Text } from 'react-native';
|
||||
|
||||
interface MiniChartProps {
|
||||
data: { time: string; temp?: number; humidity?: number; co2?: number }[];
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect } from 'react';
|
||||
import { NavigationContainer, useNavigation } from '@react-navigation/native';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
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 { setNavigationRef } from '../api/client';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -25,7 +25,7 @@ import SettingsScreen from '../screens/SettingsScreen';
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
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} />
|
||||
);
|
||||
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
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 { useNavigation } from '@react-navigation/native';
|
||||
import { getAlarms, ackAlarm, getAlarmClip } from '../api/alarms';
|
||||
import { extractErrorMessage } from '../api/client';
|
||||
import { formatRelativeTime, getSeverityLabel } from '../utils/format';
|
||||
import type { Alarm } from '../types';
|
||||
|
||||
export default function AlertsScreen() {
|
||||
const theme = useTheme();
|
||||
const navigation = useNavigation<any>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [alarms, setAlarms] = useState<Alarm[]>([]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { StyleSheet, View, ScrollView, Alert } from 'react-native';
|
||||
import { Text, Card, Button, Switch, useTheme, ActivityIndicator, TextInput, SegmentedButtons, Divider, Snackbar } from 'react-native-paper';
|
||||
import { StyleSheet, View, ScrollView } from 'react-native';
|
||||
import { Text, Card, Button, Switch, ActivityIndicator, TextInput, SegmentedButtons, Divider, Snackbar } from 'react-native-paper';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useRoute } from '@react-navigation/native';
|
||||
import { sendControl } from '../api/devices';
|
||||
@@ -10,7 +10,6 @@ import { formatNumber } from '../utils/format';
|
||||
import type { RealtimeMetric } from '../types';
|
||||
|
||||
export default function DeviceControlScreen() {
|
||||
const theme = useTheme();
|
||||
const route = useRoute<any>();
|
||||
const { deviceKey, deviceName } = route.params;
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
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 { useNavigation } from '@react-navigation/native';
|
||||
import { getDevices } from '../api/devices';
|
||||
@@ -8,7 +8,6 @@ import { formatRelativeTime } from '../utils/format';
|
||||
import type { Device } from '../types';
|
||||
|
||||
export default function DevicesScreen() {
|
||||
const theme = useTheme();
|
||||
const navigation = useNavigation<any>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
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 { useRoute } from '@react-navigation/native';
|
||||
import { getRoom } from '../api/rooms';
|
||||
@@ -11,7 +11,6 @@ import { formatRelativeTime } from '../utils/format';
|
||||
import type { Room, RealtimeMetric, Device, TrendPoint } from '../types';
|
||||
|
||||
export default function RoomDetailScreen() {
|
||||
const theme = useTheme();
|
||||
const route = useRoute<any>();
|
||||
const { roomId, roomName } = route.params;
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
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 { useNavigation } from '@react-navigation/native';
|
||||
import { getRooms } from '../api/rooms';
|
||||
import { formatRelativeTime } from '../utils/format';
|
||||
import type { Room } from '../types';
|
||||
|
||||
export default function RoomsScreen() {
|
||||
const theme = useTheme();
|
||||
const navigation = useNavigation<any>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
@@ -2,13 +2,11 @@ import React, { useState } from 'react';
|
||||
import { StyleSheet, View, Alert, ScrollView } from 'react-native';
|
||||
import { Text, Card, Button, useTheme, Divider, TextInput, List, Avatar } from 'react-native-paper';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { API_BASE_URL } from '@env';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const theme = useTheme();
|
||||
const navigation = useNavigation<any>();
|
||||
const { user, logout } = useAuthStore();
|
||||
const [apiUrl, setApiUrl] = useState(API_BASE_URL || 'http://localhost:3000/api/v1');
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useRoute, useNavigation } from '@react-navigation/native';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Text, Card, Button, useTheme, ActivityIndicator, Surface, IconButton, S
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
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 type { Camera, VideoClip } from '../types';
|
||||
|
||||
@@ -41,12 +41,16 @@ export default function VideoScreen() {
|
||||
}, [loadData]);
|
||||
|
||||
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', {
|
||||
cameraId: camera.id,
|
||||
cameraName: camera.name,
|
||||
streamUrl,
|
||||
streamUrl: resolveUrl(info.url),
|
||||
});
|
||||
} catch (err: any) {
|
||||
Alert.alert('提示', err?.message || '获取播放地址失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayClip = async (clip: VideoClip) => {
|
||||
|
||||
+19
-5
@@ -1,17 +1,19 @@
|
||||
import { WS_BASE_URL } from '@env';
|
||||
import { get } from '../api/client';
|
||||
|
||||
export type WSMessageHandler = (data: any) => void;
|
||||
|
||||
class WebSocketManager {
|
||||
private ws: WebSocket | null = null;
|
||||
private token: string | null = null;
|
||||
private ticket: string | null = null;
|
||||
private handlers: Set<WSMessageHandler> = new Set();
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 10;
|
||||
private isManualClose = false;
|
||||
|
||||
connect(token: string): void {
|
||||
async connect(token: string): Promise<void> {
|
||||
// Close any existing connection before creating a new one
|
||||
if (this.ws) {
|
||||
this.isManualClose = true;
|
||||
@@ -27,14 +29,14 @@ class WebSocketManager {
|
||||
this.reconnectAttempts = 0;
|
||||
this.token = token;
|
||||
this.isManualClose = false;
|
||||
this.doConnect();
|
||||
await this.refreshTicketAndConnect();
|
||||
}
|
||||
|
||||
private doConnect(): void {
|
||||
if (!this.token) return;
|
||||
if (!this.token || !this.ticket) return;
|
||||
|
||||
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 {
|
||||
this.ws = new WebSocket(url);
|
||||
@@ -84,11 +86,23 @@ class WebSocketManager {
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
if (!this.isManualClose && this.token) {
|
||||
this.doConnect();
|
||||
this.refreshTicketAndConnect();
|
||||
}
|
||||
}, 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 {
|
||||
this.isManualClose = true;
|
||||
if (this.reconnectTimer) {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# 核心端到端验收用例
|
||||
|
||||
执行环境:开发服务器或隔离试点环境。每条用例记录:日期、执行人、环境、结果、失败原因、证据截图/日志。
|
||||
|
||||
## E2E-01 healthy 图片不触发检测
|
||||
|
||||
1. 使用真实或 mock healthy 图片上传巡检。
|
||||
2. 期望 `abnormalProbability=0`,风险不因 healthy 高置信度升高。
|
||||
3. 期望不创建待确认检测任务。
|
||||
|
||||
## E2E-02 sick/橙色风险只创建一个待确认任务
|
||||
|
||||
1. 上传产生橙色风险的非 mock 巡检。
|
||||
2. 等待 Outbox worker。
|
||||
3. 查询 `/detection-tasks?sourceType=inspection`,同巡检只出现一个任务。
|
||||
|
||||
## E2E-03 AI 失败可重试且不产生虚假风险
|
||||
|
||||
1. 停止 AI 服务后上传巡检。
|
||||
2. 期望 `aiStatus=failed`、无风险分。
|
||||
3. 恢复 AI 后重试,期望成功且使用同一幂等键不重复记录。
|
||||
|
||||
## E2E-04 qPCR 无效不确诊
|
||||
|
||||
1. 按当前规则录入无有效对照或无效结果。
|
||||
2. 期望 `result=invalid`,不创建 `DiseaseEvent`。
|
||||
|
||||
## E2E-05 有效阳性创建发病事件并自动溯源
|
||||
|
||||
1. LAMP 或统一检测任务录入 `positive`。
|
||||
2. 期望创建 `DiseaseEvent`。
|
||||
3. 期望自动创建关联 `TraceRecord`,状态为 `pending`。
|
||||
|
||||
## E2E-06 无权用户访问受保护资源返回 403
|
||||
|
||||
1. 使用无权限用户访问视频、设备控制、WebSocket 设备订阅。
|
||||
2. 期望业务接口 403,WebSocket 返回拒绝事件。
|
||||
|
||||
## E2E-07 离线巡检重启后仅同步一次
|
||||
|
||||
1. 断网拍照入队。
|
||||
2. 重启小程序。
|
||||
3. 联网后触发同步。
|
||||
4. 期望服务端仅一条记录,离线队列转为 `synced`。
|
||||
|
||||
## E2E-08 通知失败重试且状态可查
|
||||
|
||||
1. 制造微信/通道失败事件。
|
||||
2. 等待重试或最终失败。
|
||||
3. 期望 `notifications` 状态、尝试次数、错误可查询。
|
||||
|
||||
## E2E-09 服务重启后任务/通知/吊销状态保持
|
||||
|
||||
1. 创建 pending 事件、Outbox、token 吊销。
|
||||
2. 重启 Go 服务。
|
||||
3. 期望任务和通知继续处理,吊销仍生效。
|
||||
|
||||
## E2E-10 备份恢复后核心记录与对象引用一致
|
||||
|
||||
1. 按 `docs/operations/backup-restore-drill.md` 恢复。
|
||||
2. 核对巡检、检测、会诊、溯源、图片/光谱/录像引用。
|
||||
3. 记录 RPO/RTO 和失败项。
|
||||
@@ -0,0 +1,31 @@
|
||||
# 发布门禁清单
|
||||
|
||||
## 发布前
|
||||
|
||||
- [ ] 确认发布基线 commit 和回滚点。
|
||||
- [ ] 执行 `powershell -ExecutionPolicy Bypass -File scripts/verify.ps1`,exit 0。
|
||||
- [ ] 执行 `git diff --check`,exit 0。
|
||||
- [ ] 涉及 schema 时完成 `pg_dump` 备份并记录恢复命令。
|
||||
- [ ] 检查磁盘、端口、服务进程和依赖健康。
|
||||
- [ ] 核对需求追踪矩阵:本次涉及的需求 ID 有实现、测试和限制说明。
|
||||
|
||||
## 发布中
|
||||
|
||||
- [ ] 保留旧二进制和旧 `dist`。
|
||||
- [ ] 上传新构建产物。
|
||||
- [ ] 执行数据库迁移。
|
||||
- [ ] 重启服务。
|
||||
- [ ] 健康检查和核心接口冒烟。
|
||||
|
||||
## 发布后
|
||||
|
||||
- [ ] 记录部署 commit、迁移版本、备份路径、冒烟结果。
|
||||
- [ ] 更新 `变更记录.md` 和 `开发交接记录.md`。
|
||||
- [ ] 执行核心端到端用例中受影响的 E2E。
|
||||
- [ ] 如失败,按回滚预案恢复并记录原因。
|
||||
|
||||
## 回滚
|
||||
|
||||
- 代码:恢复旧二进制/旧 dist 或 `git checkout <pre-commit>`。
|
||||
- 数据库:`pg_restore` 恢复备份。
|
||||
- 回滚后重新健康检查并记录结论。
|
||||
@@ -0,0 +1,86 @@
|
||||
# 需求追踪矩阵
|
||||
|
||||
追踪列:需求 ID、实现、自动测试、手工用例、环境、证据、状态、限制、负责人。
|
||||
|
||||
状态约定:
|
||||
|
||||
- `已实现`:代码和单元/构建证据存在,但未在目标环境完成最终验收。
|
||||
- `部分可用`:主链路可用,仍有明确限制。
|
||||
- `延后`:按决策暂不实施。
|
||||
- `未完成`:未实现或实现不足。
|
||||
|
||||
## Wave 1:安全与正确性
|
||||
|
||||
| 需求 ID | 实现 | 自动测试 | 手工用例 | 环境 | 证据 | 状态 | 限制 | 负责人 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| IAM-001 | `handler/auth.go`、`middleware/token_blacklist.go` | `middleware/state_test.go` | 登录/刷新/登出/跨实例吊销 | 开发/生产 | `go test ./...` | 部分可用 | Redis 不可用时保守失败 | 后端 |
|
||||
| IAM-002 | `middleware/permission.go`、`permission_seed.go` | 无专项测试 | 角色访问矩阵 | 开发 | 路由权限检查 | 部分可用 | 缺少对象级 ACL | 后端 |
|
||||
| IAM-003 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 需组织/区域/房间 ACL | 后端 |
|
||||
| IAM-004 | 延后 Task 3 | 无 | 无 | 无 | 决策记录 | 延后 | 默认凭据和强制改密待最后阶段 | 后端+运维 |
|
||||
| AI-INS-002 | `service/risk.go`、`ai-service` | `risk_test.go`、AI pytest | healthy/sick 图片 | 本地 | verify.ps1 | 部分可用 | 真实模型未验收 | AI+后端 |
|
||||
| AI-INS-003 | `inspection.go`、`is_mock` | `inspection_test.go` | Mock 标识与统计排除 | 本地 | verify.ps1 | 部分可用 | 历史 mock 需人工审阅 | 后端 |
|
||||
| AI-INS-004 | `RiskAssessment` | `risk_test.go` | 风险解释展示 | 本地 | verify.ps1 | 部分可用 | 权重待试点校准 | 后端 |
|
||||
| AI-INS-005 | `offlineQueue.ts` | `offlineQueue.test.ts` | 离线→重启→联网 | 微信开发者工具 | miniapp test | 部分可用 | 未做截图取证 | 小程序 |
|
||||
| RISK-001 | `RiskLevel` | `risk_test.go` | 边界用例 | 本地 | go test | 已实现 | 无 | 后端 |
|
||||
| RISK-002 | `RiskInput` 指针 | `risk_test.go` | 缺失组件展示 | 本地 | go test | 已实现 | 无 | 后端 |
|
||||
| RISK-003 | `risk_assessment` | `risk_test.go` | 巡检详情 | 本地 | go test | 已实现 | 无 | 后端 |
|
||||
| RISK-004 | `risk-v2-2026.08.14` | 无 | 试点校准 | 待试点 | 无 | 部分可用 | 权重非科学结论 | 后端+专家 |
|
||||
| RISK-005 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 人工纠正等级 | 后端 |
|
||||
|
||||
## Wave 2:工程可靠性
|
||||
|
||||
| 需求 ID | 实现 | 自动测试 | 手工用例 | 环境 | 证据 | 状态 | 限制 | 负责人 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| DET-001 | `detection_task.go` | `detection_task_test.go` | 任务中心 | 本地 | verify.ps1 | 部分可用 | 未端到端联调 | 后端 |
|
||||
| DET-002 | `sample.go` | `detection_task_test.go` | 样本流转 | 本地 | go test | 部分可用 | 单样本/任务 | 后端 |
|
||||
| DET-003 | `molecular.go` | `molecular_test.go` | 结果枚举 | 本地 | go test | 部分可用 | qPCR 四态延后 | 后端 |
|
||||
| DET-004 | 延后 Task 7 | 无 | 无 | 无 | 决策记录 | 延后 | 专家确认 | 后端+专家 |
|
||||
| DET-005 | `lamp.go` | `lamp_test_test.go` | LAMP 5 步 | 本地 | go test | 部分可用 | 对照/照片质控待完善 | 后端 |
|
||||
| DET-006 | `cross_validate.go` | `cross_validate_test.go` | 交叉验证 | 本地 | go test | 部分可用 | 真实数据待联调 | 后端 |
|
||||
| DET-007 | `detection_recommend.go` | `detection_recommend_test.go` | 推荐接口 | 本地 | go test | 部分可用 | 未绑定组织可用设备 | 后端 |
|
||||
| DET-008 | `consumable.go` | `consumable_test.go` | 耗材预警 | 本地 | go test | 部分可用 | 无批次/领用 | 后端 |
|
||||
| DET-009 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 设备回传协议 | 后端 |
|
||||
| DET-010 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | LAMP 图像判读 | AI |
|
||||
| DET-011 | `spectrum_entries` | 无专项测试 | 光谱上传 | 本地 | 构建 | 部分可用 | 方法学验证未做 | AI |
|
||||
| MSG-001 | `outbox.go`、`notification.go` | `outbox_test.go` | 通知重试 | 本地 | verify.ps1 | 部分可用 | 未真实联调 | 后端 |
|
||||
| MSG-002 | `wechat.go`、outbox | `wechat_test.go` | 微信订阅 | 本地 | pytest/go test | 部分可用 | 缺真实凭证 | 后端 |
|
||||
|
||||
## Wave 3:业务闭环
|
||||
|
||||
| 需求 ID | 实现 | 自动测试 | 手工用例 | 环境 | 证据 | 状态 | 限制 | 负责人 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| FARM-001 | `tray_batch.go` | `knowledge_seed_test.go` 等 | 蚕房/批次 CRUD | 本地 | go test | 已实现 | 无 | 后端 |
|
||||
| FARM-002 | `identity_links.go` | `biosecurity_test.go` | 二维码签发/解析 | 本地 | go test | 部分可用 | 未打印扫码 | 后端+小程序 |
|
||||
| FARM-003 | `rearing_records` | 无专项测试 | 饲养记录 | 本地 | go test | 部分可用 | 缺死亡/淘汰字段 | 后端 |
|
||||
| FARM-004 | `disinfection_records` | `biosecurity_test.go` | 消毒计划/执行 | 本地 | verify.ps1 | 部分可用 | 未现场联调 | 后端+小程序 |
|
||||
| FARM-005 | `seed_sources` | `biosecurity_test.go` | 种源链 | 本地 | go test | 部分可用 | 供应商凭证待接入 | 后端 |
|
||||
| FARM-006 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 产量/损失/成本 | 后端 |
|
||||
| ENV-001 | `telemetry.go`、`mqtt.go` | 无专项测试 | 遥测查询 | 开发 | 构建 | 已实现 | 数据质量标识不足 | 后端 |
|
||||
| ENV-002 | `threshold.go`、`alarm.go` | 无专项测试 | 阈值告警 | 开发 | 构建 | 已实现 | 无对象级授权 | 后端 |
|
||||
| ENV-003 | `rule_engine.go` | `rule_engine_test.go` | 过期数据 | 本地 | go test | 部分可用 | 未接入生产遥测 | 后端 |
|
||||
| ENV-004 | `rule_engine.go`、`weather_risk.go` | `rule_engine_test.go` | 连续时窗 | 本地 | go test | 部分可用 | 密度/通风等输入待接入 | 后端 |
|
||||
| ENV-005 | `rule_engine.go` | `rule_engine_test.go` | 规则解释 | 本地 | go test | 部分可用 | 结果持久化未全链路 | 后端 |
|
||||
| ENV-006 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 设备校准/维护 | 后端 |
|
||||
| ENV-007 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 安全控制建议 | 后端 |
|
||||
| EXP-001 | `consultation.go` | `consultation_test.go` | 病例快照 | 本地 | go test | 已实现 | 环境/天气摘要待增强 | 后端 |
|
||||
| EXP-002 | `consultation.go` | `consultation_test.go` | 会诊状态机 | 本地 | go test | 已实现 | 无 | 后端 |
|
||||
| EXP-003 | `ConsultationSLAState` | `consultation_test.go` | SLA 超时 | 本地 | go test | 部分可用 | 无升级通知 | 后端 |
|
||||
| EXP-004 | `consultation_opinion_versions` | `consultation_test.go` | 意见版本 | 本地 | go test | 已实现 | 无 | 后端 |
|
||||
| EXP-005 | `health_profile.go` effect | `health_profile_test.go` | 效果报告 | 本地 | go test | 部分可用 | 损失/成本缺失 | 后端 |
|
||||
| KB-001 | `knowledge.go` | `knowledge_governance_test.go` | 审核发布 | 本地 | go test | 部分可用 | 审核权限基于角色 | 后端+Web |
|
||||
| KB-002 | `knowledge.go` source 字段 | 无专项测试 | 来源展示 | 本地 | 构建 | 部分可用 | 专家签名待接入 | 后端 |
|
||||
| KB-003 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 案例沉淀 | 后端 |
|
||||
| TRACE-001 | `disease_events` | `disease_event_test.go` | 发病事件 | 本地 | go test | 部分可用 | 未端到端 | 后端 |
|
||||
| TRACE-002 | `trace.go` auto | 无专项测试 | 自动溯源 | 本地 | go test | 部分可用 | 空间/样本链不足 | 后端 |
|
||||
| TRACE-003 | `trace.go` checklist | 无专项测试 | 二级排查 | 本地 | go test | 部分可用 | 无版本化清单 | 后端 |
|
||||
| TRACE-004 | `trace.go` lab note | 无专项测试 | 三级溯源 | 本地 | go test | 部分可用 | 结构化实验室数据未做 | 后端 |
|
||||
| TRACE-005 | `trace.go` | `trace_test.go` | 来源结论 | 本地 | go test | 部分可用 | 专家确认流程未做 | 后端 |
|
||||
| TRACE-006 | `region_stat.go` | `region_stat_test.go` | 区域统计 | 本地 | go test | 部分可用 | 小样本脱敏未做 | 后端 |
|
||||
| TRACE-007 | `seed_sources` + 复发字段 | `biosecurity_test.go` | 复发关联 | 本地 | go test | 部分可用 | 人工确认未做 | 后端 |
|
||||
| VIDEO-001 | `video_auth.go` | `video_auth_test.go` | 播放授权 | 本地 | go test | 部分可用 | 对象级授权待后续 | 后端 |
|
||||
| VIDEO-002 | DTO 脱敏 | `video_dto.go` | 密钥不返回 | 本地 | go test | 部分可用 | 历史配置审计 | 后端 |
|
||||
| VIDEO-003 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 录制状态持久化 | 后端 |
|
||||
| VIDEO-004 | `stream_tasks.py` | AI pytest | 受限拉流 | 本地 | pytest | 部分可用 | 摄像头真实视角待接入 | AI |
|
||||
| ANA-001 | `health_profile.go` | `health_profile_test.go` | 健康画像 | 本地 | go test | 部分可用 | 公式未回测 | 后端 |
|
||||
| ANA-002 | `health_profile.go` effect | `health_profile_test.go` | 效果评估 | 本地 | go test | 部分可用 | 损失/成本缺失 | 后端 |
|
||||
| ANA-003 | `region_stat.go` | `region_stat_test.go` | 区域图 | 本地 | go test | 部分可用 | 地图/脱敏未做 | 后端 |
|
||||
@@ -0,0 +1,22 @@
|
||||
# 2026-08-13 整改决策记录
|
||||
|
||||
> 范围:对应《项目整改实施计划 V1.0》Task 0 与《蚕病智能防控平台规格说明书 V2.2》第 13 章开放决策。
|
||||
> 执行原则:项目负责人已指示开始整改;未获得明确产品/合规/专业确认的事项,本记录采用“保持现状 + 明确复审触发条件”,不擅自进入生产部署。
|
||||
|
||||
| Decision ID | 结论 | 备选方案 | 选择理由 | 影响 | 负责人 | 复审条件 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| DEC-001 | Go 数据库迁移采用 `golang-migrate/migrate/v4`;SQL 迁移文件是唯一 schema 事实来源,命名 `NNNNNN_name.up.sql` / `NNNNNN_name.down.sql` | goose;自研 runner;GORM AutoMigrate | 与计划中 `000001_baseline.up.sql` 命名一致;支持嵌入式 SQL、版本表、up/down 和 PostgreSQL;可复用 Go 标准 `embed` | Task 2 起引入迁移依赖;生产启动执行迁移并禁止 AutoMigrate | 项目负责人 + 后端 | golang-migrate 升级、迁移目录规则变更或生产多实例发现锁冲突时 |
|
||||
| DEC-002 | CI 保持现状:不新增远程 CI;本地门禁使用 `scripts/verify.ps1`;候选平台为 GitHub Actions | GitLab CI;Jenkins;Gitea Actions | 仓库当前无 remote,无法配置并验证外部 CI;先保留可复现本地门禁 | Task 1 的 CI 接入步骤暂不完成;CI 配置待平台确定 | 项目负责人 + 运维 | 仓库获得 remote 或团队确认 CI 平台时 |
|
||||
| DEC-003 | 制品保持现状:构建产物不入库,沿用 `web/dist`、`miniapp/dist`、二进制等 gitignore 规则 | GitHub Releases/Artifacts;NAS;Git LFS | 尚未确定 CI 与发布平台,先避免生成产物污染仓库 | 发布继续按 AGENTS.md 手动备份/回滚 | 项目负责人 + 运维 | 进入正式发布流程前 |
|
||||
| DEC-004 | 数据保留期限保持现状:不自动删除历史数据;具体 bucket/表保留期在 Task 13/14 前评审 | 固定 30/90/365 天 | 当前试点数据量有限,且未获合规/业务确认,不应擅自删数 | 存储增长需纳入容量监控 | 项目负责人 + 运维 | 生产试点或容量告警触发时 |
|
||||
| DEC-005 | qPCR 保持“只录入、不自动判读”;Task 7 延后到最后处理,领域专家确认 protocolVersion、对照规则和阈值后才启用自动判读 | 沿用现有“无 Ct 即阴性”;当前 Wave 1 实施四态结果 | 当前自动判读存在误判风险,且用户 2026-08-14 明确要求跳过 Task 7 并留到最后 | 本轮不实现 positive/negative/invalid/indeterminate;专家签字前不输出业务结论 | 领域专家 + 后端 | 用户恢复 Task 7,或专家签字并给出 protocolVersion 时 |
|
||||
| DEC-006 | APP 保持现状:作为环境监控端,不扩展为全量蚕病功能 | 全量同步 Web/小程序蚕病功能 | 当前 APP 源码未实现蚕病功能,扩大同步会显著增加维护面 | Task 0 后不新增 APP 蚕病页面 | 产品负责人 | 试点用户明确提出 APP 蚕病需求时 |
|
||||
| DEC-007 | 真实模型二分类转多病种指标门槛保持“未定义,不允许上生产”;待数据集修复和训练恢复后按冻结测试集、mAP、混淆矩阵评审 | 先预设 mAP 0.5 | 当前无真实模型,不能凭空设置验收门槛 | AI 生产继续 mock/不接受为真实验收 | AI 负责人 + 项目负责人 | 数据集修复完成或模型训练恢复时 |
|
||||
| DEC-008 | 生产云厂商、对象存储、域名和证书保持现状:不迁移云,继续沿用物理机开发环境 | 云 OSS/COS;云 RDS | 当前无生产试点和上云评审;上云属于发布决策 | 部署仍按 `部署指南(物理机).md` | 项目负责人 + 运维 | 正式生产试点前 |
|
||||
| DEC-009 | Task 3 移除默认密钥/默认管理员密码延后到最后处理;本轮跳过,不执行密钥轮换和客户端密码预填删除 | 当前 Wave 1 执行;最后阶段集中执行 | 用户 2026-08-13 明确要求跳过并留到最后 | 默认凭据风险保持开放;最后阶段需安排密钥轮换窗口 | 项目负责人 + 后端 + 运维 | 进入最后整改阶段时 |
|
||||
|
||||
## 生效与复审
|
||||
|
||||
- 生效日期:2026-08-13。
|
||||
- 所有“保持现状”决策不是永久结论,仅在本轮整改期内有效。
|
||||
- 任何决策变化必须更新本表,并在 `开发交接记录.md` 追加影响与回滚点。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 备份恢复演练
|
||||
|
||||
## 目标
|
||||
|
||||
验证 PostgreSQL、IoTDB、Ceph/S3 和业务配置的可恢复性,记录 RPO/RTO。
|
||||
|
||||
## 演练前
|
||||
|
||||
1. 记录当前版本、commit、schema 版本和服务清单。
|
||||
2. 使用 `pg_dump` 备份 PostgreSQL 到隔离命名空间。
|
||||
3. 备份 IoTDB 数据目录或按现有运维流程导出。
|
||||
4. 记录 Ceph/S3 bucket 清单和对象数量。
|
||||
|
||||
## 恢复步骤
|
||||
|
||||
1. 在隔离环境创建空 PostgreSQL 数据库。
|
||||
2. 执行 `pg_restore` 恢复备份。
|
||||
3. 恢复 IoTDB 数据并启动服务。
|
||||
4. 校验图片、光谱、录像对象引用与数据库记录一致。
|
||||
5. 启动 Go/AI/Web 服务并执行核心接口冒烟。
|
||||
|
||||
## 记录项
|
||||
|
||||
- 备份开始/结束时间
|
||||
- 备份体积
|
||||
- 恢复开始/结束时间
|
||||
- RPO(最近可用备份到故障时间)
|
||||
- RTO(故障到服务可用)
|
||||
- 数据点核对:巡检、遥测、检测、通知、任务、会诊、溯源
|
||||
- 失败项与原因
|
||||
|
||||
## 验收
|
||||
|
||||
- 核心业务记录无缺失。
|
||||
- 图片/光谱/录像对象引用可访问。
|
||||
- 至少完成一次真实恢复演练后才允许执行正式生产迁移。
|
||||
@@ -0,0 +1,39 @@
|
||||
# 负载测试场景
|
||||
|
||||
目标环境:Go 后端 + PostgreSQL + Redis + IoTDB + AI 服务。结果需记录测试日期、版本、机器配置、并发、时长、p50/p95、失败率和观察到的限制。
|
||||
|
||||
## A. 500 在线用户 API 混合负载
|
||||
|
||||
比例:
|
||||
|
||||
- 80% 查询类:房间、设备、遥测最新值、告警列表
|
||||
- 10% 遥测趋势:历史/聚合查询
|
||||
- 5% 上传类:图片巡检上传
|
||||
- 5% 管理操作:阈值、批次、检测任务、会诊
|
||||
|
||||
验收:API 错误率 <1%,业务接口 p95 <1s;上传成功且巡检闭环可追踪。
|
||||
|
||||
## B. 1000 WebSocket 连接
|
||||
|
||||
- 1000 个连接按授权设备订阅
|
||||
- 持续 30 分钟
|
||||
- 记录连接成功率、掉线率、消息延迟、CPU/内存
|
||||
|
||||
验收:连接成功率 >99%,消息 P95 延迟 <500ms,无内存持续增长。
|
||||
|
||||
## C. AI 峰值
|
||||
|
||||
固定条件:
|
||||
|
||||
- GPU:目标 T4 16G
|
||||
- 模型版本和 ONNX 文件 hash
|
||||
- 输入尺寸
|
||||
- 并发数(建议 1/4/8/16)
|
||||
|
||||
记录 p50/p95、失败率、GPU 显存和单帧延迟。结果用于设置生产并发上限和推理告警阈值。
|
||||
|
||||
## 禁止事项
|
||||
|
||||
- 不在生产库执行写入类负载。
|
||||
- 不使用真实用户隐私图片做公开压测。
|
||||
- 压测前必须备份数据库并记录环境状态。
|
||||
@@ -0,0 +1,39 @@
|
||||
# 可观测性 SLO 与告警阈值
|
||||
|
||||
## 日志与请求 ID
|
||||
|
||||
- Go 后端每个 HTTP 请求生成或透传 `X-Request-ID`,日志统一记录 `requestId/method/path/query/status/latency/ip/authType`。
|
||||
- 日志不记录 Authorization 原始值;query 中 `token/password/secret/authorization` 类参数统一替换为 `[redacted]`。
|
||||
- AI 服务 `/detect` 同样透传 `X-Request-ID`,便于跨 Go/Python 全链路追踪。
|
||||
|
||||
## 依赖指标
|
||||
|
||||
`GET /api/v1/ops/metrics`(`log:read`)返回依赖指标,至少记录请求数、失败数、最近延迟、P50/P95、最近成功/失败时间和最近错误。目标覆盖:
|
||||
|
||||
- PostgreSQL
|
||||
- Redis
|
||||
- MQTT
|
||||
- IoTDB
|
||||
- S3
|
||||
- AI 服务
|
||||
- WVP/ZLM
|
||||
- 微信
|
||||
- 天气
|
||||
|
||||
## 初始告警阈值
|
||||
|
||||
| 指标 | 目标 | 告警阈值 | 负责人 |
|
||||
|---|---|---|---|
|
||||
| API 可用性 | 99.5% | 5 分钟错误率 >5% | 后端 + 运维 |
|
||||
| API p95 延迟 | <500ms(业务接口) | 5 分钟 p95 >1s | 后端 |
|
||||
| 登录失败率 | 正常用户 <2% | 5 分钟失败率 >10% | 后端 + 安全 |
|
||||
| Redis | 可用 | Ping 失败 3 次 | 运维 |
|
||||
| PostgreSQL | 可用 | 连接/查询失败 3 次 | 后端 + 运维 |
|
||||
| IoTDB | 可用或明确降级 | 持续不可用且遥测回退 | 后端 |
|
||||
| AI 推理失败率 | <1% | 5 分钟失败率 >5% | AI + 后端 |
|
||||
| Ceph 容量 | 使用率 <80% | 使用率 >80% | 运维 |
|
||||
| Outbox 积压 | 常态 <50 | pending+retry >200 | 后端 |
|
||||
|
||||
## 模拟故障验证
|
||||
|
||||
每次发布前至少验证一次通知链:断开 Redis → 确认认证接口返回 503 → 恢复 Redis → 确认自动恢复;如部署告警工具,则同时验证告警通道可送达负责人。
|
||||
@@ -10,6 +10,8 @@
|
||||
"framework": "React"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "ts-node --transpile-only src/services/offlineQueue.test.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build:weapp": "taro build --type weapp",
|
||||
"build:swan": "taro build --type swan",
|
||||
"build:alipay": "taro build --type alipay",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { get, post } from './request';
|
||||
|
||||
export interface QRResolveResult {
|
||||
entityType: string;
|
||||
publicId: string;
|
||||
entity: {
|
||||
id: string;
|
||||
name?: string;
|
||||
roomId?: string;
|
||||
batchId?: string;
|
||||
sampleNo?: string;
|
||||
state?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DisinfectionRecord {
|
||||
roomId?: string;
|
||||
batchId?: string;
|
||||
kind: 'plan' | 'execution';
|
||||
agent: string;
|
||||
concentration: string;
|
||||
amount?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export const resolveQR = (payload: string) =>
|
||||
post<QRResolveResult>('/biosecurity/qr/resolve', { payload });
|
||||
|
||||
export const createDisinfectionRecord = (data: DisinfectionRecord) =>
|
||||
post('/biosecurity/disinfection-records', data as unknown as Record<string, unknown>);
|
||||
|
||||
export const listDisinfectionRecords = (params?: any) =>
|
||||
get<DisinfectionRecord[]>('/biosecurity/disinfection-records', { params });
|
||||
@@ -43,24 +43,24 @@ export function getServerUrl(): string {
|
||||
// 保留导出以兼容现有代码(模块加载时的默认值,实际请求请使用 getApiBaseUrl())
|
||||
export const API_BASE_URL = DEFAULT_API_BASE_URL;
|
||||
|
||||
export function getWsUrl(token: string): string {
|
||||
export function getWsUrl(ticket: string): string {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
|
||||
// 如果是完整 URL,从中推导 WS 地址
|
||||
if (baseUrl.startsWith('http')) {
|
||||
const serverUrl = baseUrl.replace(/\/api\/v1\/?$/, '');
|
||||
const wsUrl = serverUrl.replace(/^http/, 'ws');
|
||||
return `${wsUrl}/ws?token=${token}`;
|
||||
return `${wsUrl}/ws?ticket=${ticket}`;
|
||||
}
|
||||
|
||||
// H5 相对路径 - 使用 window.location 推导
|
||||
if (isH5) {
|
||||
const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
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;
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { getApiBaseUrl } from './config';
|
||||
import { get } from './request';
|
||||
import { get, refreshSession } from './request';
|
||||
import type { InspectionRecord } from '@/types';
|
||||
|
||||
export const uploadInspection = (
|
||||
filePath: string,
|
||||
roomId?: string,
|
||||
idempotencyKey?: string,
|
||||
retried?: boolean,
|
||||
): Promise<InspectionRecord> => {
|
||||
const token = Taro.getStorageSync('token');
|
||||
const idempotencyKey = `insp-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const header: Record<string, string> = { 'Idempotency-Key': idempotencyKey };
|
||||
const key = idempotencyKey || `insp-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const header: Record<string, string> = { 'Idempotency-Key': key };
|
||||
if (token) {
|
||||
header['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
@@ -24,8 +26,20 @@ export const uploadInspection = (
|
||||
success: (res) => {
|
||||
try {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
resolve(body as InspectionRecord);
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface RequestOptions {
|
||||
data?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
header?: Record<string, string>;
|
||||
_retried?: boolean;
|
||||
}
|
||||
|
||||
function buildQueryString(params?: Record<string, unknown>): string {
|
||||
@@ -21,6 +22,28 @@ function buildQueryString(params?: Record<string, unknown>): string {
|
||||
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> {
|
||||
const token = Taro.getStorageSync('token');
|
||||
const baseUrl = getApiBaseUrl();
|
||||
@@ -46,6 +69,12 @@ export async function request<T = unknown>(options: RequestOptions): Promise<T>
|
||||
});
|
||||
|
||||
if (res.statusCode === 401) {
|
||||
if (!options._retried) {
|
||||
options._retried = true;
|
||||
if (await refreshSession()) {
|
||||
return request<T>(options);
|
||||
}
|
||||
}
|
||||
console.warn('[Request] 401 未授权,清除登录状态');
|
||||
Taro.removeStorageSync('token');
|
||||
Taro.removeStorageSync('refreshToken');
|
||||
|
||||
@@ -16,6 +16,7 @@ export default defineAppConfig({
|
||||
'pages/inspection/index',
|
||||
'pages/notification/index',
|
||||
'pages/lamp/index',
|
||||
'pages/biosecurity/index',
|
||||
],
|
||||
window: {
|
||||
backgroundTextStyle: 'dark',
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useDidShow, useDidHide } from '@tarojs/taro';
|
||||
import { useStore } from '@/store/useStore';
|
||||
import './app.scss';
|
||||
|
||||
@@ -188,7 +188,7 @@ const AlertsPage: React.FC = () => {
|
||||
<View className={styles.alarmHeader}>
|
||||
<View className={styles.alarmTitleRow}>
|
||||
<Text className={styles.alarmIcon}>
|
||||
{alarm.severity === 'critical' || alarm.severity === 1 ? '🔴' : '🟡'}
|
||||
{alarm.severity === 'critical' || alarm.severity === '1' ? '🔴' : '🟡'}
|
||||
</Text>
|
||||
<Text className={styles.alarmTitle}>
|
||||
{alarm.title || alarm.code || '环境告警'}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
background: #f5f6f7;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: block;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 26px;
|
||||
color: #4e5969;
|
||||
margin: 18px 0 8px;
|
||||
}
|
||||
|
||||
.input {
|
||||
height: 76px;
|
||||
background: #f2f3f5;
|
||||
border-radius: 8px;
|
||||
padding: 0 20px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.btnPrimary {
|
||||
margin-top: 24px;
|
||||
background: #10b981;
|
||||
color: #ffffff;
|
||||
border-radius: 8px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.entityBox {
|
||||
margin-top: 20px;
|
||||
padding: 20px;
|
||||
background: #f0fdf4;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.entityType {
|
||||
display: block;
|
||||
color: #4e5969;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.entityName {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Input, Text, View } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import styles from './index.module.scss';
|
||||
import { createDisinfectionRecord, resolveQR, type QRResolveResult } from '@/api/biosecurity';
|
||||
|
||||
const BiosecurityPage: React.FC = () => {
|
||||
const [entity, setEntity] = useState<QRResolveResult | null>(null);
|
||||
const [agent, setAgent] = useState('');
|
||||
const [concentration, setConcentration] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [note, setNote] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleScan = async () => {
|
||||
try {
|
||||
const res = await Taro.scanCode({ scanType: ['qrCode'] });
|
||||
if (!res.result) throw new Error('未识别到二维码');
|
||||
const data = await resolveQR(res.result);
|
||||
setEntity(data);
|
||||
Taro.showToast({ title: '扫码成功', icon: 'success' });
|
||||
} catch (err) {
|
||||
Taro.showToast({
|
||||
title: err instanceof Error ? err.message : '扫码失败',
|
||||
icon: 'none',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!entity) {
|
||||
Taro.showToast({ title: '请先扫码', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (!agent || !concentration) {
|
||||
Taro.showToast({ title: '请填写药剂和浓度', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createDisinfectionRecord({
|
||||
kind: 'execution',
|
||||
roomId: entity.entity.roomId,
|
||||
batchId: entity.entityType === 'batch' ? entity.entity.id : entity.entity.batchId,
|
||||
agent,
|
||||
concentration,
|
||||
amount: amount || undefined,
|
||||
note: note || undefined,
|
||||
});
|
||||
Taro.showToast({ title: '消毒记录已提交', icon: 'success' });
|
||||
setAgent('');
|
||||
setConcentration('');
|
||||
setAmount('');
|
||||
setNote('');
|
||||
} catch (err) {
|
||||
Taro.showToast({
|
||||
title: err instanceof Error ? err.message : '提交失败',
|
||||
icon: 'none',
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View className={styles.page}>
|
||||
<View className={styles.card}>
|
||||
<Text className={styles.title}>二维码身份</Text>
|
||||
<Button className={styles.btnPrimary} onClick={handleScan}>
|
||||
扫描蚕房/批次/样本二维码
|
||||
</Button>
|
||||
{entity ? (
|
||||
<View className={styles.entityBox}>
|
||||
<Text className={styles.entityType}>类型:{entity.entityType}</Text>
|
||||
<Text className={styles.entityName}>
|
||||
{entity.entity.name || entity.entity.sampleNo || entity.entity.id}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{entity ? (
|
||||
<View className={styles.card}>
|
||||
<Text className={styles.title}>现场消毒执行</Text>
|
||||
<Text className={styles.label}>消毒药剂</Text>
|
||||
<Input
|
||||
className={styles.input}
|
||||
value={agent}
|
||||
placeholder="例如:漂白粉"
|
||||
onInput={(e) => setAgent(e.detail.value)}
|
||||
/>
|
||||
<Text className={styles.label}>浓度</Text>
|
||||
<Input
|
||||
className={styles.input}
|
||||
value={concentration}
|
||||
placeholder="例如:1%"
|
||||
onInput={(e) => setConcentration(e.detail.value)}
|
||||
/>
|
||||
<Text className={styles.label}>用量</Text>
|
||||
<Input
|
||||
className={styles.input}
|
||||
value={amount}
|
||||
placeholder="可选"
|
||||
onInput={(e) => setAmount(e.detail.value)}
|
||||
/>
|
||||
<Text className={styles.label}>备注</Text>
|
||||
<Input
|
||||
className={styles.input}
|
||||
value={note}
|
||||
placeholder="可选"
|
||||
onInput={(e) => setNote(e.detail.value)}
|
||||
/>
|
||||
<Button
|
||||
className={styles.btnPrimary}
|
||||
loading={submitting}
|
||||
disabled={submitting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
提交消毒记录
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default BiosecurityPage;
|
||||
@@ -285,7 +285,7 @@ const DashboardPage: React.FC = () => {
|
||||
className={styles.sectionAction}
|
||||
onClick={() => handleRoomDetail(selectedRoom.id)}
|
||||
>
|
||||
详情 >
|
||||
详情 {'>'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
@@ -347,7 +347,7 @@ const DashboardPage: React.FC = () => {
|
||||
<View className={styles.section}>
|
||||
<View className={styles.sectionHeader}>
|
||||
<Text className={styles.sectionTitle}>最近告警</Text>
|
||||
<Text className={styles.sectionAction} onClick={handleAlarmTap}>全部 ></Text>
|
||||
<Text className={styles.sectionAction} onClick={handleAlarmTap}>全部 {'>'}</Text>
|
||||
</View>
|
||||
{recentAlarms.length > 0 ? (
|
||||
<View className={styles.alarmPreview}>
|
||||
|
||||
@@ -79,8 +79,8 @@ const ControlPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = (newVal: boolean) => {
|
||||
handleSend('power', newVal ? 'on' : 'off');
|
||||
const handleToggle = (event: { detail: { value: boolean } }) => {
|
||||
handleSend('power', event.detail.value ? 'on' : 'off');
|
||||
};
|
||||
|
||||
const getMetricColor = (status: string) => {
|
||||
|
||||
@@ -120,6 +120,33 @@
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -2,7 +2,10 @@ import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, Image, ScrollView, Text, View } from '@tarojs/components';
|
||||
import Taro, { usePullDownRefresh } from '@tarojs/taro';
|
||||
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 type { InspectionRecord } from '@/types';
|
||||
|
||||
@@ -14,11 +17,19 @@ const CLASS_LABELS: Record<string, string> = {
|
||||
const classLabel = (cls: string) => CLASS_LABELS[cls] || cls;
|
||||
|
||||
const InspectionPage: React.FC = () => {
|
||||
const syncOfflineQueue = useStore((s) => s.syncOfflineQueue);
|
||||
const [imagePath, setImagePath] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [result, setResult] = useState<InspectionRecord | null>(null);
|
||||
const [history, setHistory] = useState<InspectionRecord[]>([]);
|
||||
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 () => {
|
||||
setLoadingHistory(true);
|
||||
@@ -31,11 +42,36 @@ const InspectionPage: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshQueue();
|
||||
loadHistory();
|
||||
}, [loadHistory]);
|
||||
syncOfflineQueue().then(() => {
|
||||
refreshQueue();
|
||||
loadHistory();
|
||||
});
|
||||
}, [loadHistory, refreshQueue, syncOfflineQueue]);
|
||||
|
||||
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 = () => {
|
||||
@@ -60,9 +96,11 @@ const InspectionPage: React.FC = () => {
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
const rec = await uploadInspection(imagePath);
|
||||
setResult(rec);
|
||||
Taro.showToast({ title: rec.aiStatus === 'done' ? '检测完成' : '检测失败', icon: 'none' });
|
||||
const savedPath = await persistImage(imagePath);
|
||||
offlineQueue.enqueue(savedPath);
|
||||
Taro.showToast({ title: '已保存到离线队列', icon: 'none' });
|
||||
await syncOfflineQueue();
|
||||
refreshQueue();
|
||||
loadHistory();
|
||||
} catch (err) {
|
||||
Taro.showToast({
|
||||
@@ -113,6 +151,7 @@ const InspectionPage: React.FC = () => {
|
||||
? '检测到疑似异常'
|
||||
: '未见明显异常'
|
||||
: 'AI 检测失败'}
|
||||
{result.isMock ? '(联调 Mock)' : ''}
|
||||
</Text>
|
||||
{result.aiStatus === 'done' && result.detections && result.detections.length > 0 ? (
|
||||
<View className={styles.detectionList}>
|
||||
@@ -135,6 +174,28 @@ const InspectionPage: React.FC = () => {
|
||||
) : null}
|
||||
|
||||
<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}>
|
||||
{history.map((rec) => (
|
||||
<View key={rec.id} className={styles.historyCard}>
|
||||
@@ -145,6 +206,7 @@ const InspectionPage: React.FC = () => {
|
||||
? '疑似异常'
|
||||
: '正常'
|
||||
: '检测失败'}
|
||||
{rec.isMock ? '(Mock)' : ''}
|
||||
</Text>
|
||||
<Text className={styles.historyTime}>{formatRelativeTime(rec.createdAt)}</Text>
|
||||
</View>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
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 {
|
||||
getArticle,
|
||||
|
||||
@@ -37,7 +37,6 @@ const LampPage: React.FC = () => {
|
||||
const [stepsMap, setStepsMap] = useState<Record<string, LampTestStep[]>>({});
|
||||
const [expandedId, setExpandedId] = useState('');
|
||||
const [result, setResult] = useState('');
|
||||
const [imagePath, setImagePath] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
@@ -71,7 +70,6 @@ const LampPage: React.FC = () => {
|
||||
}
|
||||
setExpandedId(t.id);
|
||||
setResult(t.result || '');
|
||||
setImagePath('');
|
||||
};
|
||||
|
||||
const handleStepToggle = async (t: LampTest, s: LampTestStep, done: boolean) => {
|
||||
@@ -88,7 +86,6 @@ const LampPage: React.FC = () => {
|
||||
sizeType: ['compressed'],
|
||||
success: async (res) => {
|
||||
const path = res.tempFilePaths[0];
|
||||
setImagePath(path);
|
||||
try {
|
||||
const up = await uploadLampResultImage(t.id, path);
|
||||
setTests((prev) =>
|
||||
@@ -155,11 +152,11 @@ const LampPage: React.FC = () => {
|
||||
</View>
|
||||
|
||||
<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}>
|
||||
<Radio value="positive">阳性</Radio>
|
||||
<Radio value="negative">阴性</Radio>
|
||||
<Radio value="invalid">无效</Radio>
|
||||
<Radio value="positive" checked={result === 'positive'}>阳性</Radio>
|
||||
<Radio value="negative" checked={result === 'negative'}>阴性</Radio>
|
||||
<Radio value="invalid" checked={result === 'invalid'}>无效</Radio>
|
||||
</View>
|
||||
</RadioGroup>
|
||||
{t.result ? <Text className={styles.resultHint}>当前结果:{RESULT_LABELS[t.result]}</Text> : null}
|
||||
|
||||
@@ -56,6 +56,7 @@ const NotificationPage: React.FC = () => {
|
||||
try {
|
||||
const res = await Taro.requestSubscribeMessage({
|
||||
tmplIds: configured.map((t) => t.id),
|
||||
entityIds: configured.map((t) => t.id),
|
||||
});
|
||||
for (const t of configured) {
|
||||
if (res[t.id] === 'accept') {
|
||||
|
||||
@@ -59,6 +59,9 @@ const SettingsPage: React.FC = () => {
|
||||
case 'lamp':
|
||||
Taro.navigateTo({ url: '/pages/lamp/index' });
|
||||
break;
|
||||
case 'biosecurity':
|
||||
Taro.navigateTo({ url: '/pages/biosecurity/index' });
|
||||
break;
|
||||
case 'about':
|
||||
Taro.showModal({
|
||||
title: '关于',
|
||||
@@ -190,6 +193,11 @@ const SettingsPage: React.FC = () => {
|
||||
<Text className={styles.menuLabel}>LAMP 检测</Text>
|
||||
<Text className={styles.menuArrow}>›</Text>
|
||||
</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')}>
|
||||
<Text className={styles.menuIcon}>🔗</Text>
|
||||
<Text className={styles.menuLabel}>连接状态</Text>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
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 { getCameras, playCamera } from '@/api/video';
|
||||
import { resolveUrl } from '@/api/config';
|
||||
import type { Camera, VideoPlayResponse } from '@/types';
|
||||
import type { Camera } from '@/types';
|
||||
|
||||
const VideoPlayerPage: React.FC = () => {
|
||||
const router = useRouter();
|
||||
@@ -30,10 +30,6 @@ const VideoPlayerPage: React.FC = () => {
|
||||
const found = cameras.find((c) => c.id === cameraId);
|
||||
if (found) {
|
||||
setCamera(found);
|
||||
const url = found.hlsUrl || found.streamUrl || found.flvUrl;
|
||||
if (url) {
|
||||
setVideoUrl(resolveUrl(url));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[VideoPlayer] 获取摄像头信息失败:', err);
|
||||
@@ -44,14 +40,21 @@ const VideoPlayerPage: React.FC = () => {
|
||||
}, [cameraId, directUrl]);
|
||||
|
||||
const handlePlay = async () => {
|
||||
if (!cameraId && !camera) return;
|
||||
const id = cameraId || camera?.id;
|
||||
if (!id) return;
|
||||
|
||||
setLoading(true);
|
||||
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);
|
||||
setVideoUrl(streamUrl);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '获取播放地址失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formats: { key: 'hls' | 'flv' | 'webrtc'; label: string }[] = [
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import * as assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { createOfflineQueue, type QueueStorage } from './offlineQueue';
|
||||
|
||||
function memoryStorage(): QueueStorage {
|
||||
const data = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key) => data.get(key) ?? null,
|
||||
setItem: (key, value) => void data.set(key, value),
|
||||
removeItem: (key) => void data.delete(key),
|
||||
};
|
||||
}
|
||||
|
||||
test('offline queue persists and restores pending items', () => {
|
||||
const storage = memoryStorage();
|
||||
const queue = createOfflineQueue({
|
||||
storage,
|
||||
uploader: async () => ({ id: 'server-1' }),
|
||||
now: () => 1000,
|
||||
idGenerator: () => 'id-1',
|
||||
});
|
||||
queue.enqueue('/tmp/a.png', 'room-1');
|
||||
const restored = createOfflineQueue({
|
||||
storage,
|
||||
uploader: async () => ({ id: 'server-1' }),
|
||||
now: () => 2000,
|
||||
idGenerator: () => 'id-2',
|
||||
});
|
||||
assert.equal(restored.list().length, 1);
|
||||
assert.equal(restored.list()[0].idempotencyKey, 'insp-id-1');
|
||||
});
|
||||
|
||||
test('offline queue deduplicates repeated clicks', () => {
|
||||
const queue = createOfflineQueue({
|
||||
storage: memoryStorage(),
|
||||
uploader: async () => ({ id: 'server-1' }),
|
||||
now: () => 1000,
|
||||
idGenerator: () => 'id-1',
|
||||
});
|
||||
const first = queue.enqueue('/tmp/a.png', 'room-1');
|
||||
const second = queue.enqueue('/tmp/a.png', 'room-1');
|
||||
assert.equal(first.id, second.id);
|
||||
assert.equal(queue.stats().total, 1);
|
||||
});
|
||||
|
||||
test('offline queue marks permanent 4xx as failed', async () => {
|
||||
const queue = createOfflineQueue({
|
||||
storage: memoryStorage(),
|
||||
uploader: async () => {
|
||||
const err: Error & { statusCode?: number } = new Error('bad request');
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
},
|
||||
now: () => 1000,
|
||||
idGenerator: () => 'id-1',
|
||||
});
|
||||
queue.enqueue('/tmp/a.png');
|
||||
const result = await queue.syncOnce();
|
||||
assert.equal(result.synced, 0);
|
||||
assert.equal(queue.list()[0].state, 'failed');
|
||||
});
|
||||
|
||||
test('offline queue retries with backoff and fails permanently', async () => {
|
||||
let attempts = 0;
|
||||
let now = 1000;
|
||||
const queue = createOfflineQueue({
|
||||
storage: memoryStorage(),
|
||||
uploader: async () => {
|
||||
attempts += 1;
|
||||
throw new Error('network');
|
||||
},
|
||||
now: () => now,
|
||||
idGenerator: () => 'id-1',
|
||||
maxAttempts: 2,
|
||||
backoffBase: 1000,
|
||||
});
|
||||
queue.enqueue('/tmp/a.png');
|
||||
await queue.syncOnce();
|
||||
assert.equal(queue.list()[0].state, 'pending');
|
||||
assert.equal(queue.list()[0].attempts, 1);
|
||||
now = 5000;
|
||||
await queue.syncOnce();
|
||||
assert.equal(queue.list()[0].state, 'failed');
|
||||
assert.equal(queue.list()[0].attempts, 2);
|
||||
});
|
||||
|
||||
test('offline queue removes synced references', async () => {
|
||||
const queue = createOfflineQueue({
|
||||
storage: memoryStorage(),
|
||||
uploader: async () => ({ id: 'server-1' }),
|
||||
now: () => 1000,
|
||||
idGenerator: () => 'id-1',
|
||||
});
|
||||
queue.enqueue('/tmp/a.png');
|
||||
await queue.syncOnce();
|
||||
assert.equal(queue.stats().synced, 1);
|
||||
assert.equal(queue.clearSynced(), 1);
|
||||
assert.equal(queue.stats().total, 0);
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
export type OfflineState = 'pending' | 'uploading' | 'synced' | 'conflict' | 'failed';
|
||||
|
||||
export interface OfflineInspectionItem {
|
||||
id: string;
|
||||
idempotencyKey: string;
|
||||
imagePath: string;
|
||||
roomId?: string;
|
||||
state: OfflineState;
|
||||
attempts: number;
|
||||
nextAttemptAt: number;
|
||||
error?: string;
|
||||
serverId?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface QueueStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
export interface QueueUploadInput {
|
||||
imagePath: string;
|
||||
roomId?: string;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
export interface QueueUploader {
|
||||
(input: QueueUploadInput): Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export interface OfflineQueueDeps {
|
||||
storage: QueueStorage;
|
||||
uploader: QueueUploader;
|
||||
now?: () => number;
|
||||
idGenerator?: () => string;
|
||||
maxItems?: number;
|
||||
maxAttempts?: number;
|
||||
backoffBase?: number;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'offline_inspection_queue';
|
||||
export const OFFLINE_QUEUE_STORAGE_KEY = STORAGE_KEY;
|
||||
|
||||
function loadQueue(storage: QueueStorage): OfflineInspectionItem[] {
|
||||
const raw = storage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveQueue(storage: QueueStorage, items: OfflineInspectionItem[]): void {
|
||||
storage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
export function createOfflineQueue(deps: OfflineQueueDeps) {
|
||||
const now = deps.now || (() => Date.now());
|
||||
const idGenerator = deps.idGenerator || (() => `offline-${now()}-${Math.random().toString(36).slice(2, 10)}`);
|
||||
const maxItems = deps.maxItems || 30;
|
||||
const maxAttempts = deps.maxAttempts || 8;
|
||||
const backoffBase = deps.backoffBase || 30000;
|
||||
|
||||
const enqueue = (imagePath: string, roomId?: string): OfflineInspectionItem => {
|
||||
const items = loadQueue(deps.storage);
|
||||
const duplicate = items.find(
|
||||
(item) =>
|
||||
item.imagePath === imagePath &&
|
||||
item.roomId === roomId &&
|
||||
(item.state === 'pending' || item.state === 'uploading'),
|
||||
);
|
||||
if (duplicate) return duplicate;
|
||||
if (items.length >= maxItems) {
|
||||
throw new Error('离线巡检队列已满,请先清理已同步记录');
|
||||
}
|
||||
const timestamp = now();
|
||||
const item: OfflineInspectionItem = {
|
||||
id: idGenerator(),
|
||||
idempotencyKey: `insp-${idGenerator()}`,
|
||||
imagePath,
|
||||
roomId,
|
||||
state: 'pending',
|
||||
attempts: 0,
|
||||
nextAttemptAt: timestamp,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
items.unshift(item);
|
||||
saveQueue(deps.storage, items);
|
||||
return item;
|
||||
};
|
||||
|
||||
const list = (): OfflineInspectionItem[] => loadQueue(deps.storage);
|
||||
|
||||
const remove = (id: string): void => {
|
||||
saveQueue(deps.storage, loadQueue(deps.storage).filter((item) => item.id !== id));
|
||||
};
|
||||
|
||||
const clearSynced = (): number => {
|
||||
const items = loadQueue(deps.storage);
|
||||
const before = items.length;
|
||||
saveQueue(deps.storage, items.filter((item) => item.state !== 'synced'));
|
||||
return before - loadQueue(deps.storage).length;
|
||||
};
|
||||
|
||||
const retry = (id: string): OfflineInspectionItem | null => {
|
||||
const items = loadQueue(deps.storage);
|
||||
const item = items.find((entry) => entry.id === id);
|
||||
if (!item || item.state === 'synced') return null;
|
||||
item.state = 'pending';
|
||||
item.error = undefined;
|
||||
item.nextAttemptAt = now();
|
||||
item.updatedAt = now();
|
||||
saveQueue(deps.storage, items);
|
||||
return item;
|
||||
};
|
||||
|
||||
const syncOnce = async (): Promise<{
|
||||
synced: number;
|
||||
item?: OfflineInspectionItem;
|
||||
error?: string;
|
||||
}> => {
|
||||
const items = loadQueue(deps.storage);
|
||||
const item = items.find((entry) => entry.state === 'pending' && entry.nextAttemptAt <= now());
|
||||
if (!item) return { synced: 0 };
|
||||
item.state = 'uploading';
|
||||
item.updatedAt = now();
|
||||
saveQueue(deps.storage, items);
|
||||
try {
|
||||
const result = await deps.uploader({
|
||||
imagePath: item.imagePath,
|
||||
roomId: item.roomId,
|
||||
idempotencyKey: item.idempotencyKey,
|
||||
});
|
||||
const updated = loadQueue(deps.storage);
|
||||
const current = updated.find((entry) => entry.id === item.id);
|
||||
if (current) {
|
||||
current.state = 'synced';
|
||||
current.serverId = result.id;
|
||||
current.error = undefined;
|
||||
current.updatedAt = now();
|
||||
saveQueue(deps.storage, updated);
|
||||
}
|
||||
return { synced: 1, item: current || item };
|
||||
} catch (err) {
|
||||
const updated = loadQueue(deps.storage);
|
||||
const current = updated.find((entry) => entry.id === item.id);
|
||||
if (!current) return { synced: 0, error: errorMessage(err) };
|
||||
current.attempts += 1;
|
||||
current.error = errorMessage(err);
|
||||
const status = (err as { statusCode?: number }).statusCode;
|
||||
const permanent =
|
||||
status !== undefined &&
|
||||
status >= 400 &&
|
||||
status < 500 &&
|
||||
status !== 401 &&
|
||||
status !== 408 &&
|
||||
status !== 429;
|
||||
if (permanent) {
|
||||
current.state = status === 409 ? 'conflict' : 'failed';
|
||||
} else if (current.attempts >= maxAttempts) {
|
||||
current.state = 'failed';
|
||||
} else {
|
||||
current.state = 'pending';
|
||||
current.nextAttemptAt = now() + backoffBase * Math.min(Math.pow(2, current.attempts - 1), 16);
|
||||
}
|
||||
current.updatedAt = now();
|
||||
saveQueue(deps.storage, updated);
|
||||
return { synced: 0, item: current, error: current.error };
|
||||
}
|
||||
};
|
||||
|
||||
const stats = () => {
|
||||
const items = loadQueue(deps.storage);
|
||||
return {
|
||||
total: items.length,
|
||||
pending: items.filter((item) => item.state === 'pending' || item.state === 'uploading').length,
|
||||
synced: items.filter((item) => item.state === 'synced').length,
|
||||
failed: items.filter((item) => item.state === 'failed').length,
|
||||
conflict: items.filter((item) => item.state === 'conflict').length,
|
||||
remaining: Math.max(0, maxItems - items.length),
|
||||
};
|
||||
};
|
||||
|
||||
return { enqueue, list, remove, clearSynced, retry, syncOnce, stats };
|
||||
}
|
||||
|
||||
export type OfflineQueue = ReturnType<typeof createOfflineQueue>;
|
||||
@@ -0,0 +1,19 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { uploadInspection } from '@/api/inspections';
|
||||
import { createOfflineQueue, OFFLINE_QUEUE_STORAGE_KEY } from './offlineQueue';
|
||||
|
||||
const storage = {
|
||||
getItem: (key: string) => {
|
||||
const value = Taro.getStorageSync(key);
|
||||
return typeof value === 'string' && value ? value : null;
|
||||
},
|
||||
setItem: (key: string, value: string) => Taro.setStorageSync(key, value),
|
||||
removeItem: (key: string) => Taro.removeStorageSync(key),
|
||||
};
|
||||
|
||||
export const offlineQueue = createOfflineQueue({
|
||||
storage,
|
||||
uploader: (input) => uploadInspection(input.imagePath, input.roomId, input.idempotencyKey),
|
||||
});
|
||||
|
||||
export { OFFLINE_QUEUE_STORAGE_KEY };
|
||||
@@ -3,6 +3,7 @@ import Taro from '@tarojs/taro';
|
||||
import type { User } from '@/types';
|
||||
import { getCurrentUser } from '@/api/auth';
|
||||
import { wsManager } from '@/utils/ws';
|
||||
import { offlineQueue } from '@/services/offlineQueueApp';
|
||||
|
||||
interface AppState {
|
||||
token: string | null;
|
||||
@@ -13,6 +14,7 @@ interface AppState {
|
||||
logout: () => void;
|
||||
isLoggedIn: () => boolean;
|
||||
restoreSession: () => Promise<void>;
|
||||
syncOfflineQueue: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useStore = create<AppState>((set, get) => ({
|
||||
@@ -49,4 +51,11 @@ export const useStore = create<AppState>((set, get) => ({
|
||||
}
|
||||
set({ isReady: true });
|
||||
},
|
||||
syncOfflineQueue: async () => {
|
||||
let synced = 0;
|
||||
do {
|
||||
const result = await offlineQueue.syncOnce();
|
||||
synced = result.synced;
|
||||
} while (synced > 0);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -223,6 +223,17 @@ export interface InspectionRecord {
|
||||
detections?: AIDetection[];
|
||||
riskScore?: number;
|
||||
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;
|
||||
idempotencyKey?: string;
|
||||
createdAt?: string;
|
||||
|
||||
+19
-5
@@ -1,5 +1,6 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { getWsUrl } from '@/api/config';
|
||||
import { get } from '@/api/request';
|
||||
|
||||
type MessageHandler = (data: unknown) => void;
|
||||
|
||||
@@ -7,19 +8,20 @@ class WebSocketManager {
|
||||
private connected: boolean = false;
|
||||
private handlers: Map<string, Set<MessageHandler>> = new Map();
|
||||
private token: string = '';
|
||||
private ticket: string = '';
|
||||
private reconnectAttempts: number = 0;
|
||||
private maxReconnectAttempts: number = 5;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private isManualClose: boolean = false;
|
||||
|
||||
connect(token: string): void {
|
||||
async connect(token: string): Promise<void> {
|
||||
this.token = token;
|
||||
this.isManualClose = false;
|
||||
this.doConnect();
|
||||
await this.refreshTicketAndConnect();
|
||||
}
|
||||
|
||||
private doConnect(): void {
|
||||
if (!this.token) {
|
||||
if (!this.token || !this.ticket) {
|
||||
console.warn('[WS] 无 token,无法连接 WebSocket');
|
||||
return;
|
||||
}
|
||||
@@ -34,7 +36,7 @@ class WebSocketManager {
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
const url = getWsUrl(this.token);
|
||||
const url = getWsUrl(this.ticket);
|
||||
console.log('[WS] 正在连接:', url);
|
||||
|
||||
// 注册全局回调(每次调用会替换上一次的回调)
|
||||
@@ -98,10 +100,22 @@ class WebSocketManager {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
}
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.doConnect();
|
||||
this.refreshTicketAndConnect();
|
||||
}, 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 {
|
||||
if (!this.handlers.has(type)) {
|
||||
this.handlers.set(type, new Set());
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"strictNullChecks": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": "",
|
||||
"rootDir": "",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Task 6 Step 5: 只读历史数据评估报告
|
||||
-- 仅统计,不更新数据;未经人工确认不得批量重算或覆盖历史 risk_score/risk_level。
|
||||
|
||||
WITH real_inspections AS (
|
||||
SELECT
|
||||
id,
|
||||
risk_level,
|
||||
risk_score,
|
||||
detections
|
||||
FROM inspection_records
|
||||
WHERE ai_status = 'done'
|
||||
AND (is_mock IS NULL OR is_mock = false)
|
||||
)
|
||||
SELECT
|
||||
risk_level,
|
||||
count(*) AS records,
|
||||
count(*) FILTER (
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(detections) AS d
|
||||
WHERE d->>'class' = 'healthy'
|
||||
AND COALESCE((d->>'confidence')::numeric, 0) >= 0.80
|
||||
)
|
||||
) AS healthy_high_conf_records
|
||||
FROM real_inspections
|
||||
GROUP BY risk_level
|
||||
ORDER BY risk_level;
|
||||
@@ -0,0 +1,95 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$script:failed = @()
|
||||
|
||||
function Resolve-Go {
|
||||
$cmd = Get-Command go -ErrorAction SilentlyContinue
|
||||
if ($cmd) {
|
||||
return $cmd.Source
|
||||
}
|
||||
foreach ($candidate in @('C:\Program Files\Go\bin\go.exe', 'C:\Go\bin\go.exe')) {
|
||||
if (Test-Path -LiteralPath $candidate) {
|
||||
return $candidate
|
||||
}
|
||||
}
|
||||
throw 'Go executable not found. Install Go 1.23 or add it to PATH.'
|
||||
}
|
||||
|
||||
function Invoke-Check {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[scriptblock]$Command
|
||||
)
|
||||
|
||||
Write-Host "[$Name]"
|
||||
try {
|
||||
& $Command
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "FAILED: $Name (exit $LASTEXITCODE)"
|
||||
$script:failed += $Name
|
||||
}
|
||||
} catch {
|
||||
Write-Host "FAILED: $Name - $($_.Exception.Message)"
|
||||
$script:failed += $Name
|
||||
}
|
||||
}
|
||||
|
||||
$goExe = Resolve-Go
|
||||
|
||||
Push-Location (Join-Path $root 'server-go')
|
||||
try {
|
||||
Invoke-Check 'server-go test' { & $goExe test ./... }
|
||||
Invoke-Check 'server-go vet' { & $goExe vet ./... }
|
||||
Invoke-Check 'server-go build' { & $goExe build ./... }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Push-Location (Join-Path $root 'web')
|
||||
try {
|
||||
Invoke-Check 'web test' { npm test }
|
||||
Invoke-Check 'web lint' { npm run lint }
|
||||
Invoke-Check 'web build' { npm run build }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Push-Location (Join-Path $root 'miniapp')
|
||||
try {
|
||||
Invoke-Check 'miniapp test' { npm test }
|
||||
Invoke-Check 'miniapp typecheck' { npm run typecheck }
|
||||
Invoke-Check 'miniapp build:weapp' { npm run build:weapp }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Push-Location (Join-Path $root 'app')
|
||||
try {
|
||||
Invoke-Check 'app typecheck' { npm run tsc }
|
||||
Invoke-Check 'app lint' { npm run lint }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
$aiPython = Join-Path $root 'ai-service\.venv\Scripts\python.exe'
|
||||
if (-not (Test-Path -LiteralPath $aiPython)) {
|
||||
$aiPython = 'python'
|
||||
}
|
||||
|
||||
Push-Location (Join-Path $root 'ai-service')
|
||||
try {
|
||||
Invoke-Check 'ai-service pytest' { & $aiPython -m pytest }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
if ($script:failed.Count -gt 0) {
|
||||
Write-Error "Verification failed for: $($script:failed -join ', ')"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host 'All verification checks passed.'
|
||||
exit 0
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -28,7 +29,7 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 2. 连接数据库(GORM 自动迁移)
|
||||
// 2. 连接数据库并执行版本化迁移
|
||||
if err := database.Init(cfg); err != nil {
|
||||
slog.Error("数据库连接失败", "err", err)
|
||||
os.Exit(1)
|
||||
@@ -41,18 +42,28 @@ func main() {
|
||||
slog.Warn("IoTDB 初始化失败,将降级使用 PostgreSQL", "err", err)
|
||||
}
|
||||
|
||||
// 4. 连接 Redis(失败不阻断启动)
|
||||
// 4. 连接 Redis(失败不阻断启动,但认证/限流保守失败)
|
||||
var rdb *redis.Client
|
||||
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 {
|
||||
slog.Warn("Redis 连接失败", "err", err)
|
||||
slog.Error("Redis 连接失败,认证/限流将保守失败", "err", err)
|
||||
_ = rdb.Close()
|
||||
rdb = nil
|
||||
} else {
|
||||
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
|
||||
hub := ws.NewHub(cfg.JWTSecret)
|
||||
hub := ws.NewHub(cfg.JWTSecret, ws.NewDBDeviceAuthorizer(db), strings.Split(cfg.WSAllowedOrigins, ","))
|
||||
|
||||
// 6. 创建并启动 MQTT 服务
|
||||
mqttSvc := service.NewMQTTService(cfg.MQTT, db, iotdb, hub)
|
||||
@@ -75,10 +86,16 @@ func main() {
|
||||
aiSvc := service.NewAIClient(cfg.AIServiceBase)
|
||||
wechatSvc := service.NewWechatService(cfg.WechatAppID, cfg.WechatSecret)
|
||||
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 引擎
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(middleware.RequestIDMiddleware())
|
||||
r.Use(middleware.Logger())
|
||||
r.Use(middleware.SecurityHeaders())
|
||||
r.Use(middleware.CORS())
|
||||
@@ -89,6 +106,7 @@ func main() {
|
||||
// 11. API 路由组(经过 JWT auth 中间件,白名单路径自动跳过)
|
||||
api := r.Group("/api/v1")
|
||||
api.Use(middleware.Auth(cfg))
|
||||
api.GET("/ws/ticket", hub.HandleTicket)
|
||||
|
||||
// 公开路由(auth 白名单中跳过鉴权)
|
||||
handler.RegisterVideoStreamRoutes(api, transcodeSvc, db, mediaSvc, cfg)
|
||||
@@ -109,12 +127,14 @@ func main() {
|
||||
handler.RegisterVideoRecordRoutes(api, db, mediaSvc, cfg)
|
||||
handler.RegisterStorageRoutes(api, db)
|
||||
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.RegisterWechatRoutes(api, db, wechatSvc)
|
||||
handler.RegisterWeatherRoutes(api, db, weatherSvc)
|
||||
handler.RegisterLampRoutes(api, db, s3Svc, cfg.S3BucketImages)
|
||||
handler.RegisterDetectionTaskRoutes(api, db)
|
||||
handler.RegisterConsumableRoutes(api, db)
|
||||
handler.RegisterBiosecurityRoutes(api, db)
|
||||
handler.RegisterConsultationRoutes(api, db)
|
||||
handler.RegisterDetectionMethodRoutes(api, db)
|
||||
handler.RegisterTraceRoutes(api, db)
|
||||
@@ -131,6 +151,7 @@ func main() {
|
||||
handler.RegisterAuditRoutes(api, db)
|
||||
handler.RegisterUserRoutes(api, db)
|
||||
handler.RegisterPermissionRoutes(api, db)
|
||||
handler.RegisterOpsRoutes(api, db)
|
||||
|
||||
// 12. 启动 HTTP 服务
|
||||
addr := ":" + strconv.Itoa(cfg.Port)
|
||||
|
||||
+12
-7
@@ -5,6 +5,7 @@ go 1.23
|
||||
exclude github.com/rogpeppe/go-internal v1.15.0
|
||||
|
||||
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/credentials v1.17.27
|
||||
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/gin-gonic/gin v1.10.0
|
||||
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/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/gorm v1.25.10
|
||||
)
|
||||
@@ -41,6 +43,8 @@ require (
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // 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/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // 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/json-iterator/go v1.1.12 // 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/lib/pq v1.10.9 // 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/reflect2 v1.0.2 // indirect
|
||||
@@ -58,11 +62,12 @@ require (
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // 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/net v0.27.0 // indirect
|
||||
golang.org/x/sync v0.7.0 // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
+70
-15
@@ -1,3 +1,9 @@
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc=
|
||||
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/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
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/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/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/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/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/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/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
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/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
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/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/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
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/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/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
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.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
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/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/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/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-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
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/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/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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
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/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
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.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
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.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
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 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -6,6 +6,9 @@ import (
|
||||
|
||||
// Config 全局配置,从环境变量加载
|
||||
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"`
|
||||
Redis string `env:"REDIS" envDefault:"redis://:pan@localhost:6379"`
|
||||
JWTSecret string `env:"JWT_SECRET" envDefault:"silk-secret-please-change-me"`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"silk-server-go/internal/config"
|
||||
@@ -21,7 +22,13 @@ func Init(cfg *config.Config) error {
|
||||
return err
|
||||
}
|
||||
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(
|
||||
&model.User{}, &model.Room{}, &model.Device{}, &model.Sensor{},
|
||||
&model.Threshold{}, &model.Alarm{}, &model.Camera{}, &model.VideoClip{},
|
||||
@@ -29,6 +36,17 @@ func Init(cfg *config.Config) error {
|
||||
&model.Permission{}, &model.RolePermission{},
|
||||
&model.Disease{}, &model.KnowledgeArticle{},
|
||||
&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.WechatBinding{},
|
||||
&model.WeatherAlert{},
|
||||
@@ -38,13 +56,15 @@ func Init(cfg *config.Config) error {
|
||||
&model.SpectrumEntry{},
|
||||
&model.TraceRecord{},
|
||||
); err != nil {
|
||||
slog.Warn("自动迁移有警告(可忽略)", "err", err)
|
||||
return fmt.Errorf("开发环境 AutoMigrate 失败: %w", err)
|
||||
}
|
||||
slog.Warn("开发环境 AutoMigrate 已启用,SQL 迁移仍是生产 schema 事实来源")
|
||||
}
|
||||
// 初始化权限种子数据
|
||||
seedPermissions(db)
|
||||
// 初始化知识库种子数据
|
||||
seedKnowledge(db)
|
||||
slog.Info("数据库连接成功,自动迁移完成")
|
||||
slog.Info("数据库连接成功,版本化迁移完成", "schemaVersion", CurrentSchemaVersion)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -54,7 +74,9 @@ func seedKnowledge(db *gorm.DB) {
|
||||
var cnt int64
|
||||
db.Model(&model.Disease{}).Where("name = ?", d.Name).Count(&cnt)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -63,7 +85,9 @@ func seedKnowledge(db *gorm.DB) {
|
||||
var cnt int64
|
||||
db.Model(&model.KnowledgeArticle{}).Where("kind = ? AND title = ?", a.Kind, a.Title).Count(&cnt)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
"gorm.io/gorm"
|
||||
"silk-server-go/migrations"
|
||||
)
|
||||
|
||||
// CurrentSchemaVersion 是当前后端代码期望的迁移版本。
|
||||
const CurrentSchemaVersion = "8"
|
||||
|
||||
// RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。
|
||||
func RunMigrations(db *gorm.DB) error {
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取数据库连接: %w", err)
|
||||
}
|
||||
|
||||
sourceDriver, err := iofs.New(migrations.FS, ".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("加载嵌入式迁移文件: %w", err)
|
||||
}
|
||||
defer sourceDriver.Close()
|
||||
|
||||
databaseDriver, err := postgres.WithInstance(sqlDB, &postgres.Config{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("初始化 PostgreSQL 迁移驱动: %w", err)
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithInstance("iofs", sourceDriver, "postgres", databaseDriver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建迁移实例: %w", err)
|
||||
}
|
||||
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("执行数据库迁移: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckSchemaVersion 校验 schema_migrations 当前版本与 expected 一致,且不是 dirty。
|
||||
func CheckSchemaVersion(db *gorm.DB, expected string) error {
|
||||
var version int64
|
||||
var dirty bool
|
||||
|
||||
row := db.Raw("SELECT version, dirty FROM schema_migrations ORDER BY version DESC LIMIT 1").Row()
|
||||
if err := row.Scan(&version, &dirty); err != nil {
|
||||
return fmt.Errorf("读取 schema_migrations: %w", err)
|
||||
}
|
||||
if dirty {
|
||||
return fmt.Errorf("schema 迁移处于 dirty 状态,版本 %d", version)
|
||||
}
|
||||
if strconv.FormatInt(version, 10) != expected {
|
||||
return fmt.Errorf("schema 版本不匹配:当前 %d,期望 %s", version, expected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"silk-server-go/migrations"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newMockGormDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock) {
|
||||
t.Helper()
|
||||
|
||||
sqlDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
|
||||
gdb, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open gorm with sqlmock: %v", err)
|
||||
}
|
||||
|
||||
return gdb, mock
|
||||
}
|
||||
|
||||
func TestCheckSchemaVersionRejectsMismatch(t *testing.T) {
|
||||
db, mock := newMockGormDB(t)
|
||||
defer func() {
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
}()
|
||||
|
||||
mock.ExpectQuery("SELECT version, dirty FROM schema_migrations").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}).AddRow(1, false))
|
||||
|
||||
if err := CheckSchemaVersion(db, "2"); err == nil {
|
||||
t.Fatal("expected schema mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSchemaVersionAcceptsMatch(t *testing.T) {
|
||||
db, mock := newMockGormDB(t)
|
||||
defer func() {
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
}()
|
||||
|
||||
mock.ExpectQuery("SELECT version, dirty FROM schema_migrations").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}).AddRow(1, false))
|
||||
|
||||
if err := CheckSchemaVersion(db, "1"); err != nil {
|
||||
t.Fatalf("expected schema version match, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSchemaVersionRejectsMissingVersion(t *testing.T) {
|
||||
db, mock := newMockGormDB(t)
|
||||
defer func() {
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
}()
|
||||
|
||||
mock.ExpectQuery("SELECT version, dirty FROM schema_migrations").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}))
|
||||
|
||||
if err := CheckSchemaVersion(db, "1"); err == nil {
|
||||
t.Fatal("expected missing schema version error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSchemaVersionRejectsDirty(t *testing.T) {
|
||||
db, mock := newMockGormDB(t)
|
||||
defer func() {
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
}()
|
||||
|
||||
mock.ExpectQuery("SELECT version, dirty FROM schema_migrations").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}).AddRow(1, true))
|
||||
|
||||
if err := CheckSchemaVersion(db, "1"); err == nil {
|
||||
t.Fatal("expected dirty migration error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedMigrationsIncludeBaseline(t *testing.T) {
|
||||
driver, err := iofs.New(migrations.FS, ".")
|
||||
if err != nil {
|
||||
t.Fatalf("load embedded migrations: %v", err)
|
||||
}
|
||||
|
||||
version, err := driver.First()
|
||||
if err != nil {
|
||||
t.Fatalf("read first migration: %v", err)
|
||||
}
|
||||
if version != 1 {
|
||||
t.Fatalf("expected baseline migration version 1, got %d", version)
|
||||
}
|
||||
next, err := driver.Next(version)
|
||||
if err != nil || next != 2 {
|
||||
t.Fatalf("expected risk assessment migration version 2, got %d (err %v)", next, err)
|
||||
}
|
||||
next, err = driver.Next(next)
|
||||
if err != nil || next != 4 {
|
||||
t.Fatalf("expected notifications/outbox migration version 4, got %d (err %v)", next, err)
|
||||
}
|
||||
next, err = driver.Next(next)
|
||||
if err != nil || next != 5 {
|
||||
t.Fatalf("expected detection/disease migration version 5, got %d (err %v)", next, err)
|
||||
}
|
||||
next, err = driver.Next(next)
|
||||
if err != nil || next != 6 {
|
||||
t.Fatalf("expected biosecurity migration version 6, got %d (err %v)", next, err)
|
||||
}
|
||||
next, err = driver.Next(next)
|
||||
if err != nil || next != 7 {
|
||||
t.Fatalf("expected inspection idempotency migration version 7, got %d (err %v)", next, err)
|
||||
}
|
||||
next, err = driver.Next(next)
|
||||
if err != nil || next != 8 {
|
||||
t.Fatalf("expected governance migration version 8, got %d (err %v)", next, err)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"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)
|
||||
func getAlarmClip(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := currentUserID(c)
|
||||
if userID == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供用户信息"})
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
|
||||
// 先检查告警是否存在
|
||||
@@ -33,7 +40,11 @@ func getAlarmClip(db *gorm.DB) gin.HandlerFunc {
|
||||
|
||||
// 为每个片段设置 playbackUrl
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -123,13 +123,19 @@ func loginHandler(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
||||
|
||||
var user model.User
|
||||
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": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
|
||||
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": "用户名或密码错误"})
|
||||
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
|
||||
@@ -184,7 +193,12 @@ func refreshHandler(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "刷新令牌无效"})
|
||||
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": "刷新令牌已注销"})
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -214,7 +231,10 @@ func logoutHandler(cfg *config.Config) gin.HandlerFunc {
|
||||
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.ExpiresAt != nil {
|
||||
middleware.RevokeToken(claims, parts[1], claims.ExpiresAt.Time)
|
||||
if err := middleware.RevokeToken(claims, parts[1], claims.ExpiresAt.Time); err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterBiosecurityRoutes 注册种源、消毒和二维码身份路由。
|
||||
func RegisterBiosecurityRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
read := middleware.RequirePermission(db, "biosecurity:read")
|
||||
write := middleware.RequirePermission(db, "biosecurity:write")
|
||||
rg.GET("/biosecurity/seed-sources", read, listSeedSources(db))
|
||||
rg.POST("/biosecurity/seed-sources", write, createSeedSource(db))
|
||||
rg.PATCH("/biosecurity/seed-sources/:id", write, updateSeedSource(db))
|
||||
rg.GET("/biosecurity/disinfection-records", read, listDisinfectionRecords(db))
|
||||
rg.POST("/biosecurity/disinfection-records", write, createDisinfectionRecord(db))
|
||||
rg.PATCH("/biosecurity/disinfection-records/:id", write, updateDisinfectionRecord(db))
|
||||
rg.POST("/biosecurity/qr", write, issueQR(db))
|
||||
rg.POST("/biosecurity/qr/resolve", read, resolveQR(db))
|
||||
}
|
||||
|
||||
func listSeedSources(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
q := db.Model(&model.SeedSource{})
|
||||
if batch := c.Query("batchId"); batch != "" {
|
||||
q = q.Where("batch_id = ?", batch)
|
||||
}
|
||||
var list []model.SeedSource
|
||||
q.Order("created_at DESC").Limit(200).Find(&list)
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
}
|
||||
|
||||
func createSeedSource(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
BatchID *string `json:"batchId"`
|
||||
ParentID *string `json:"parentId"`
|
||||
Supplier string `json:"supplier"`
|
||||
SeedBatchNo string `json:"seedBatchNo"`
|
||||
QuarantineNo *string `json:"quarantineNo"`
|
||||
Variety *string `json:"variety"`
|
||||
CertificateURL *string `json:"certificateUrl"`
|
||||
EntryAt *time.Time `json:"entryAt"`
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
for _, id := range []*string{body.BatchID, body.ParentID} {
|
||||
if id != nil && !isUUID(*id) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "关联 ID 不是合法的 UUID"})
|
||||
return
|
||||
}
|
||||
}
|
||||
source := model.SeedSource{
|
||||
PublicID: randomPublicID(),
|
||||
BatchID: body.BatchID,
|
||||
ParentID: body.ParentID,
|
||||
Supplier: body.Supplier,
|
||||
SeedBatchNo: body.SeedBatchNo,
|
||||
QuarantineNo: body.QuarantineNo,
|
||||
Variety: body.Variety,
|
||||
CertificateURL: body.CertificateURL,
|
||||
EntryAt: body.EntryAt,
|
||||
Note: body.Note,
|
||||
CreatedBy: currentUserID(c),
|
||||
}
|
||||
if err := model.ValidateSeedSource(source); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if source.ParentID != nil {
|
||||
if err := model.SeedSourceCycleError(seedParentChain(db), source.ID, *source.ParentID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := db.Create(&source).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "创建种源失败"})
|
||||
return
|
||||
}
|
||||
linkSeedSourceToBatch(db, source)
|
||||
c.JSON(http.StatusCreated, source)
|
||||
}
|
||||
}
|
||||
|
||||
func updateSeedSource(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var source model.SeedSource
|
||||
if db.Where("id = ?", c.Param("id")).First(&source).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "seed source not found"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
BatchID *string `json:"batchId"`
|
||||
ParentID *string `json:"parentId"`
|
||||
Supplier *string `json:"supplier"`
|
||||
SeedBatchNo *string `json:"seedBatchNo"`
|
||||
QuarantineNo *string `json:"quarantineNo"`
|
||||
Variety *string `json:"variety"`
|
||||
CertificateURL *string `json:"certificateUrl"`
|
||||
EntryAt *time.Time `json:"entryAt"`
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updates := map[string]interface{}{}
|
||||
if body.BatchID != nil {
|
||||
updates["batch_id"] = *body.BatchID
|
||||
}
|
||||
if body.ParentID != nil {
|
||||
if *body.ParentID != "" {
|
||||
if err := model.SeedSourceCycleError(seedParentChain(db), source.ID, *body.ParentID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updates["parent_id"] = *body.ParentID
|
||||
} else {
|
||||
updates["parent_id"] = nil
|
||||
}
|
||||
}
|
||||
if body.Supplier != nil {
|
||||
updates["supplier"] = *body.Supplier
|
||||
}
|
||||
if body.SeedBatchNo != nil {
|
||||
updates["seed_batch_no"] = *body.SeedBatchNo
|
||||
}
|
||||
if body.QuarantineNo != nil {
|
||||
updates["quarantine_no"] = *body.QuarantineNo
|
||||
}
|
||||
if body.Variety != nil {
|
||||
updates["variety"] = *body.Variety
|
||||
}
|
||||
if body.CertificateURL != nil {
|
||||
updates["certificate_url"] = *body.CertificateURL
|
||||
}
|
||||
if body.EntryAt != nil {
|
||||
updates["entry_at"] = *body.EntryAt
|
||||
}
|
||||
if body.Note != nil {
|
||||
updates["note"] = *body.Note
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
if err := db.Model(&model.SeedSource{}).Where("id = ?", source.ID).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "更新种源失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
db.Where("id = ?", source.ID).First(&source)
|
||||
if body.BatchID != nil {
|
||||
linkSeedSourceToBatch(db, source)
|
||||
}
|
||||
c.JSON(http.StatusOK, source)
|
||||
}
|
||||
}
|
||||
|
||||
func listDisinfectionRecords(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
q := db.Model(&model.DisinfectionRecord{})
|
||||
if room := c.Query("roomId"); room != "" {
|
||||
q = q.Where("room_id = ?", room)
|
||||
}
|
||||
if batch := c.Query("batchId"); batch != "" {
|
||||
q = q.Where("batch_id = ?", batch)
|
||||
}
|
||||
if kind := c.Query("kind"); kind != "" {
|
||||
q = q.Where("kind = ?", kind)
|
||||
}
|
||||
var list []model.DisinfectionRecord
|
||||
q.Order("created_at DESC").Limit(200).Find(&list)
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
}
|
||||
|
||||
func createDisinfectionRecord(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body model.DisinfectionRecord
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
body.ID = ""
|
||||
body.CreatedBy = currentUserID(c)
|
||||
if err := model.ValidateDisinfectionRecord(body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if body.Kind == "plan" && body.PlannedAt == nil {
|
||||
body.PlannedAt = &now
|
||||
}
|
||||
if body.Kind == "execution" {
|
||||
body.ExecutorID = currentUserID(c)
|
||||
if body.ExecutedAt == nil {
|
||||
body.ExecutedAt = &now
|
||||
}
|
||||
}
|
||||
if body.RoomID != nil && !isUUID(*body.RoomID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "roomId 不是合法的 UUID"})
|
||||
return
|
||||
}
|
||||
if body.BatchID != nil && !isUUID(*body.BatchID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "batchId 不是合法的 UUID"})
|
||||
return
|
||||
}
|
||||
if err := db.Create(&body).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "创建消毒记录失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, body)
|
||||
}
|
||||
}
|
||||
|
||||
func updateDisinfectionRecord(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var record model.DisinfectionRecord
|
||||
if db.Where("id = ?", c.Param("id")).First(&record).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "disinfection record not found"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
ExecutedAt *time.Time `json:"executedAt"`
|
||||
ReviewedAt *time.Time `json:"reviewedAt"`
|
||||
ReviewerID *string `json:"reviewerId"`
|
||||
PhotoURL *string `json:"photoUrl"`
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updates := map[string]interface{}{}
|
||||
now := time.Now()
|
||||
if body.ExecutedAt != nil {
|
||||
updates["executed_at"] = now
|
||||
updates["executor_id"] = currentUserID(c)
|
||||
}
|
||||
if body.ReviewedAt != nil {
|
||||
updates["reviewed_at"] = *body.ReviewedAt
|
||||
}
|
||||
if body.ReviewerID != nil {
|
||||
updates["reviewer_id"] = *body.ReviewerID
|
||||
}
|
||||
if body.PhotoURL != nil {
|
||||
updates["photo_url"] = *body.PhotoURL
|
||||
}
|
||||
if body.Note != nil {
|
||||
updates["note"] = *body.Note
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
if err := db.Model(&model.DisinfectionRecord{}).Where("id = ?", record.ID).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "更新消毒记录失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
db.Where("id = ?", record.ID).First(&record)
|
||||
c.JSON(http.StatusOK, record)
|
||||
}
|
||||
}
|
||||
|
||||
func issueQR(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
EntityType string `json:"entityType"`
|
||||
EntityID string `json:"entityId"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !model.ValidQRIdentityType(body.EntityType) || !isUUID(body.EntityID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "entityType/entityId 无效"})
|
||||
return
|
||||
}
|
||||
if !entityExists(db, body.EntityType, body.EntityID) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "二维码关联实体不存在"})
|
||||
return
|
||||
}
|
||||
var link model.IdentityLink
|
||||
if db.Where("entity_type = ? AND entity_id = ?", body.EntityType, body.EntityID).First(&link).Error != nil {
|
||||
link = model.IdentityLink{PublicID: randomPublicID(), EntityType: body.EntityType, EntityID: body.EntityID, Version: model.QRVersion}
|
||||
if err := db.Create(&link).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建二维码失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"publicId": link.PublicID,
|
||||
"entityType": link.EntityType,
|
||||
"entityId": link.EntityID,
|
||||
"payload": model.EncodeQRPayload(link.EntityType, link.PublicID, link.Version),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func resolveQR(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
entityType, publicID, version, err := model.ParseQRPayload(body.Payload)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var link model.IdentityLink
|
||||
if db.Where("public_id = ? AND entity_type = ?", publicID, entityType).First(&link).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "二维码身份不存在或已失效"})
|
||||
return
|
||||
}
|
||||
if link.Version != version {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "二维码版本不匹配"})
|
||||
return
|
||||
}
|
||||
summary, ok := entitySummary(db, link.EntityType, link.EntityID)
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "二维码关联实体不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"entityType": link.EntityType,
|
||||
"publicId": link.PublicID,
|
||||
"entity": summary,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func seedParentChain(db *gorm.DB) map[string]string {
|
||||
var rows []struct {
|
||||
ID string `gorm:"column:id"`
|
||||
ParentID *string `gorm:"column:parent_id"`
|
||||
}
|
||||
db.Table("seed_sources").Select("id, parent_id").Scan(&rows)
|
||||
chain := make(map[string]string, len(rows))
|
||||
for _, row := range rows {
|
||||
if row.ParentID != nil {
|
||||
chain[row.ID] = *row.ParentID
|
||||
}
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
func linkSeedSourceToBatch(db *gorm.DB, source model.SeedSource) {
|
||||
if source.BatchID == nil {
|
||||
return
|
||||
}
|
||||
_ = db.Model(&model.Batch{}).Where("id = ?", *source.BatchID).Update("seed_source_id", source.ID).Error
|
||||
}
|
||||
|
||||
func entityExists(db *gorm.DB, entityType, entityID string) bool {
|
||||
switch entityType {
|
||||
case "batch":
|
||||
var batch model.Batch
|
||||
return db.Where("id = ?", entityID).First(&batch).Error == nil
|
||||
case "tray":
|
||||
var tray model.Tray
|
||||
return db.Where("id = ?", entityID).First(&tray).Error == nil
|
||||
case "sample":
|
||||
var sample model.Sample
|
||||
return db.Where("id = ?", entityID).First(&sample).Error == nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func entitySummary(db *gorm.DB, entityType, entityID string) (map[string]interface{}, bool) {
|
||||
switch entityType {
|
||||
case "batch":
|
||||
var batch model.Batch
|
||||
if db.Where("id = ?", entityID).First(&batch).Error != nil {
|
||||
return nil, false
|
||||
}
|
||||
return gin.H{"id": batch.ID, "name": batch.Name, "roomId": batch.RoomID}, true
|
||||
case "tray":
|
||||
var tray model.Tray
|
||||
if db.Where("id = ?", entityID).First(&tray).Error != nil {
|
||||
return nil, false
|
||||
}
|
||||
return gin.H{"id": tray.ID, "name": tray.Name, "roomId": tray.RoomID}, true
|
||||
case "sample":
|
||||
var sample model.Sample
|
||||
if db.Where("id = ?", entityID).First(&sample).Error != nil {
|
||||
return nil, false
|
||||
}
|
||||
return gin.H{"id": sample.ID, "sampleNo": sample.SampleNo, "detectionTaskId": sample.DetectionTaskID, "state": sample.State}, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func randomPublicID() string {
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return hex.EncodeToString([]byte(time.Now().Format(time.RFC3339Nano)))
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"silk-server-go/internal/model"
|
||||
)
|
||||
|
||||
func TestQRPayloadRoundTripAndTamper(t *testing.T) {
|
||||
payload := model.EncodeQRPayload("batch", "public-abc", model.QRVersion)
|
||||
entityType, publicID, version, err := model.ParseQRPayload(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
if entityType != "batch" || publicID != "public-abc" || version != model.QRVersion {
|
||||
t.Fatalf("payload = %s/%s/%d", entityType, publicID, version)
|
||||
}
|
||||
if _, _, _, err := model.ParseQRPayload("batch:public-abc:1"); err == nil {
|
||||
t.Fatal("tampered format should fail")
|
||||
}
|
||||
if _, _, _, err := model.ParseQRPayload(model.EncodeQRPayload("batch", "public-abc", 2)); err != nil {
|
||||
t.Fatal("different version should parse but must be rejected by resolver")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedSourceCycleRejected(t *testing.T) {
|
||||
chain := map[string]string{
|
||||
"a": "b",
|
||||
"b": "c",
|
||||
"c": "a",
|
||||
}
|
||||
if err := model.SeedSourceCycleError(chain, "d", "a"); err == nil {
|
||||
t.Fatal("cycle should be rejected")
|
||||
}
|
||||
if err := model.SeedSourceCycleError(chain, "a", "a"); err == nil {
|
||||
t.Fatal("self reference should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedSourceCycleAllowsAcyclic(t *testing.T) {
|
||||
chain := map[string]string{"a": "b", "b": "c"}
|
||||
if err := model.SeedSourceCycleError(chain, "d", "a"); err != nil {
|
||||
t.Fatalf("acyclic chain should pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisinfectionRequiredFields(t *testing.T) {
|
||||
record := model.DisinfectionRecord{Kind: "execution", Agent: "漂白粉", Concentration: "1%"}
|
||||
if err := model.ValidateDisinfectionRecord(record); err != nil {
|
||||
t.Fatalf("valid record should pass: %v", err)
|
||||
}
|
||||
record.Agent = ""
|
||||
if err := model.ValidateDisinfectionRecord(record); err == nil {
|
||||
t.Fatal("missing agent should fail")
|
||||
}
|
||||
record.Agent = "漂白粉"
|
||||
record.Concentration = ""
|
||||
if err := model.ValidateDisinfectionRecord(record); err == nil {
|
||||
t.Fatal("missing concentration should fail")
|
||||
}
|
||||
record.Concentration = "1%"
|
||||
record.Kind = "unknown"
|
||||
if err := model.ValidateDisinfectionRecord(record); err == nil {
|
||||
t.Fatal("invalid kind should fail")
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func createConsultation(db *gorm.DB) gin.HandlerFunc {
|
||||
BatchID: body.BatchID,
|
||||
LampTestID: body.LampTestID,
|
||||
Summary: body.Summary,
|
||||
Status: "pending",
|
||||
Status: "unassigned",
|
||||
}
|
||||
if body.Title != "" {
|
||||
rec.Title = body.Title
|
||||
@@ -183,6 +183,16 @@ func updateConsultation(db *gorm.DB) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "非法的会诊状态流转"})
|
||||
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 {
|
||||
db.Model(&model.Consultation{}).Where("id = ?", id).Updates(updates)
|
||||
@@ -208,6 +218,7 @@ func resolveConsultation(db *gorm.DB) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Opinion string `json:"opinion"`
|
||||
Plan string `json:"plan"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -218,19 +229,44 @@ func resolveConsultation(db *gorm.DB) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
authorID := currentUserID(c)
|
||||
updates := map[string]interface{}{
|
||||
"status": "resolved",
|
||||
"expert_id": currentUserID(c),
|
||||
"expert_id": authorID,
|
||||
"opinion": body.Opinion,
|
||||
"plan": body.Plan,
|
||||
"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)
|
||||
c.JSON(http.StatusOK, t)
|
||||
}
|
||||
}
|
||||
|
||||
func consultationStrPtr(value string) *string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
// archiveConsultation 归档
|
||||
func archiveConsultation(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestConsultationStrPtr(t *testing.T) {
|
||||
if consultationStrPtr("") != nil {
|
||||
t.Error("空字符串应为 nil")
|
||||
}
|
||||
if got := consultationStrPtr("原因"); got == nil || *got != "原因" {
|
||||
t.Errorf("非空字符串应返回指针: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanReviewKnowledge(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if canReviewKnowledge(c) {
|
||||
t.Error("nil context 不应通过")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// RegisterDetectionTaskRoutes 注册统一检测任务、样本和发病事件路由。
|
||||
func RegisterDetectionTaskRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
lampRead := middleware.RequirePermission(db, "lamp:read")
|
||||
lampWrite := middleware.RequirePermission(db, "lamp:write")
|
||||
traceRead := middleware.RequirePermission(db, "trace:read")
|
||||
traceWrite := middleware.RequirePermission(db, "trace:write")
|
||||
|
||||
rg.GET("/detection-tasks", lampRead, listDetectionTasks(db))
|
||||
rg.POST("/detection-tasks", lampWrite, createDetectionTask(db))
|
||||
rg.GET("/detection-tasks/:id", lampRead, getDetectionTask(db))
|
||||
rg.PATCH("/detection-tasks/:id", lampWrite, updateDetectionTask(db))
|
||||
rg.GET("/detection-tasks/:id/samples", lampRead, listSamples(db))
|
||||
rg.POST("/detection-tasks/:id/samples", lampWrite, createSample(db))
|
||||
rg.PATCH("/samples/:id", lampWrite, updateSample(db))
|
||||
rg.GET("/disease-events", traceRead, listDiseaseEvents(db))
|
||||
rg.POST("/disease-events", traceWrite, createDiseaseEvent(db))
|
||||
rg.PATCH("/disease-events/:id", traceWrite, updateDiseaseEvent(db))
|
||||
}
|
||||
|
||||
func listDetectionTasks(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
q := db.Model(&model.DetectionTask{})
|
||||
if status := c.Query("status"); status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
if room := c.Query("roomId"); room != "" {
|
||||
q = q.Where("room_id = ?", room)
|
||||
}
|
||||
if sourceType := c.Query("sourceType"); sourceType != "" {
|
||||
q = q.Where("source_type = ?", sourceType)
|
||||
}
|
||||
var list []model.DetectionTask
|
||||
q.Order("created_at DESC").Limit(200).Find(&list)
|
||||
fillDetectionTaskRoomNames(db, list)
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
}
|
||||
|
||||
func getDetectionTask(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var task model.DetectionTask
|
||||
if db.Where("id = ?", c.Param("id")).First(&task).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "detection task not found"})
|
||||
return
|
||||
}
|
||||
fillDetectionTaskRoomNames(db, []model.DetectionTask{task})
|
||||
c.JSON(http.StatusOK, task)
|
||||
}
|
||||
}
|
||||
|
||||
func createDetectionTask(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
SourceKey string `json:"sourceKey"`
|
||||
SourceType string `json:"sourceType"`
|
||||
SourceID string `json:"sourceId"`
|
||||
RoomID *string `json:"roomId"`
|
||||
BatchID *string `json:"batchId"`
|
||||
InspectionID *string `json:"inspectionId"`
|
||||
Disease string `json:"disease"`
|
||||
RecommendedMethod string `json:"recommendedMethod"`
|
||||
Method *string `json:"method"`
|
||||
Priority string `json:"priority"`
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if body.Disease == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "病种不能为空"})
|
||||
return
|
||||
}
|
||||
if body.Method != nil && !model.ValidDetectionMethod(*body.Method) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "method 仅支持 lamp/qpcr/sers/hyperspectral"})
|
||||
return
|
||||
}
|
||||
for _, id := range []*string{body.RoomID, body.BatchID, body.InspectionID} {
|
||||
if id != nil && *id != "" && !isUUID(*id) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "关联 ID 不是合法的 UUID"})
|
||||
return
|
||||
}
|
||||
}
|
||||
sourceKey := body.SourceKey
|
||||
if sourceKey == "" {
|
||||
sourceKey = fmt.Sprintf("manual-%d", time.Now().UnixNano())
|
||||
}
|
||||
sourceType := body.SourceType
|
||||
if sourceType == "" {
|
||||
sourceType = "manual"
|
||||
}
|
||||
priority := body.Priority
|
||||
if priority == "" {
|
||||
priority = "routine"
|
||||
}
|
||||
task := model.DetectionTask{
|
||||
SourceKey: sourceKey,
|
||||
SourceType: sourceType,
|
||||
SourceID: body.SourceID,
|
||||
RoomID: body.RoomID,
|
||||
BatchID: body.BatchID,
|
||||
InspectionID: body.InspectionID,
|
||||
Disease: body.Disease,
|
||||
RecommendedMethod: body.RecommendedMethod,
|
||||
Method: body.Method,
|
||||
Priority: priority,
|
||||
Status: "pending",
|
||||
CreatedBy: currentUserID(c),
|
||||
Note: body.Note,
|
||||
}
|
||||
if err := db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "source_key"}},
|
||||
DoNothing: true,
|
||||
}).Create(&task).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "创建检测任务失败"})
|
||||
return
|
||||
}
|
||||
if task.ID == "" {
|
||||
_ = db.Where("source_key = ?", sourceKey).First(&task).Error
|
||||
}
|
||||
c.JSON(http.StatusCreated, task)
|
||||
}
|
||||
}
|
||||
|
||||
func updateDetectionTask(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var task model.DetectionTask
|
||||
if db.Where("id = ?", c.Param("id")).First(&task).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "detection task not found"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
AssigneeID *string `json:"assigneeId"`
|
||||
Method *string `json:"method"`
|
||||
Status *string `json:"status"`
|
||||
Result *string `json:"result"`
|
||||
CancelledReason *string `json:"cancelledReason"`
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if body.Method != nil && !model.ValidDetectionMethod(*body.Method) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "method 仅支持 lamp/qpcr/sers/hyperspectral"})
|
||||
return
|
||||
}
|
||||
if body.Status != nil && !model.ValidDetectionTaskTransition(task.Status, *body.Status) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("检测任务状态不能从 %s 转到 %s", task.Status, *body.Status)})
|
||||
return
|
||||
}
|
||||
if body.Status != nil && *body.Status == "assigned" && body.AssigneeID == nil && task.AssigneeID == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "指派状态必须先分配负责人"})
|
||||
return
|
||||
}
|
||||
if body.Status != nil && *body.Status == "completed" && body.Result == nil && task.Result == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "完成检测任务前必须录入结果"})
|
||||
return
|
||||
}
|
||||
if body.Status != nil && *body.Status == "cancelled" && (body.CancelledReason == nil || strings.TrimSpace(*body.CancelledReason) == "") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "取消任务必须填写原因"})
|
||||
return
|
||||
}
|
||||
updates := map[string]interface{}{}
|
||||
if body.AssigneeID != nil {
|
||||
updates["assignee_id"] = *body.AssigneeID
|
||||
updates["assigned_at"] = time.Now()
|
||||
}
|
||||
if body.Method != nil {
|
||||
updates["method"] = *body.Method
|
||||
}
|
||||
if body.Note != nil {
|
||||
updates["note"] = *body.Note
|
||||
}
|
||||
if body.Result != nil {
|
||||
if task.Status != "review" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "结果需在复核阶段录入"})
|
||||
return
|
||||
}
|
||||
updates["result"] = *body.Result
|
||||
updates["resulted_at"] = time.Now()
|
||||
}
|
||||
if body.Status != nil {
|
||||
updates["status"] = *body.Status
|
||||
if *body.Status == "cancelled" {
|
||||
updates["cancelled_reason"] = *body.CancelledReason
|
||||
}
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
if err := db.Model(&model.DetectionTask{}).Where("id = ?", task.ID).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "更新检测任务失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
db.Where("id = ?", task.ID).First(&task)
|
||||
if task.Result != nil && *task.Result == "positive" {
|
||||
_ = ensureDiseaseEventFromDetectionTask(db, task)
|
||||
}
|
||||
fillDetectionTaskRoomNames(db, []model.DetectionTask{task})
|
||||
c.JSON(http.StatusOK, task)
|
||||
}
|
||||
}
|
||||
|
||||
func listSamples(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var samples []model.Sample
|
||||
db.Where("detection_task_id = ?", c.Param("id")).Order("created_at ASC").Find(&samples)
|
||||
c.JSON(http.StatusOK, samples)
|
||||
}
|
||||
}
|
||||
|
||||
func createSample(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var task model.DetectionTask
|
||||
if db.Where("id = ?", c.Param("id")).First(&task).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "detection task not found"})
|
||||
return
|
||||
}
|
||||
var existing model.Sample
|
||||
if db.Where("detection_task_id = ?", task.ID).First(&existing).Error == nil {
|
||||
c.JSON(http.StatusOK, existing)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
SampleNo string `json:"sampleNo"`
|
||||
RoomID *string `json:"roomId"`
|
||||
BatchID *string `json:"batchId"`
|
||||
TrayID *string `json:"trayId"`
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
sampleNo := body.SampleNo
|
||||
if sampleNo == "" {
|
||||
sampleNo = fmt.Sprintf("S-%s", strings.ToUpper(task.ID[:8]))
|
||||
}
|
||||
now := time.Now()
|
||||
sample := model.Sample{
|
||||
DetectionTaskID: task.ID,
|
||||
SampleNo: sampleNo,
|
||||
RoomID: body.RoomID,
|
||||
BatchID: body.BatchID,
|
||||
TrayID: body.TrayID,
|
||||
SampledBy: currentUserID(c),
|
||||
SampledAt: &now,
|
||||
State: "created",
|
||||
Note: body.Note,
|
||||
}
|
||||
if sample.RoomID == nil {
|
||||
sample.RoomID = task.RoomID
|
||||
}
|
||||
if sample.BatchID == nil {
|
||||
sample.BatchID = task.BatchID
|
||||
}
|
||||
if err := db.Create(&sample).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "创建样本失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, sample)
|
||||
}
|
||||
}
|
||||
|
||||
func updateSample(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var sample model.Sample
|
||||
if db.Where("id = ?", c.Param("id")).First(&sample).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "sample not found"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
State *string `json:"state"`
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updates := map[string]interface{}{}
|
||||
if body.Note != nil {
|
||||
updates["note"] = *body.Note
|
||||
}
|
||||
if body.State != nil {
|
||||
if !model.ValidSampleTransition(sample.State, *body.State) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("样本状态不能从 %s 转到 %s", sample.State, *body.State)})
|
||||
return
|
||||
}
|
||||
updates["state"] = *body.State
|
||||
now := time.Now()
|
||||
switch *body.State {
|
||||
case "collected":
|
||||
updates["collected_at"] = now
|
||||
case "handed_over":
|
||||
updates["handed_over_at"] = now
|
||||
updates["handed_over_by"] = currentUserID(c)
|
||||
case "received":
|
||||
updates["received_at"] = now
|
||||
updates["received_by"] = currentUserID(c)
|
||||
case "testing":
|
||||
updates["testing_started_at"] = now
|
||||
case "consumed":
|
||||
updates["consumed_at"] = now
|
||||
case "disposed":
|
||||
updates["disposed_at"] = now
|
||||
}
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
if err := db.Model(&model.Sample{}).Where("id = ?", sample.ID).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "更新样本失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
db.Where("id = ?", sample.ID).First(&sample)
|
||||
c.JSON(http.StatusOK, sample)
|
||||
}
|
||||
}
|
||||
|
||||
func listDiseaseEvents(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
q := db.Model(&model.DiseaseEvent{})
|
||||
if status := c.Query("status"); status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
if room := c.Query("roomId"); room != "" {
|
||||
q = q.Where("room_id = ?", room)
|
||||
}
|
||||
var list []model.DiseaseEvent
|
||||
q.Order("created_at DESC").Limit(200).Find(&list)
|
||||
fillDiseaseEventRoomNames(db, list)
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
}
|
||||
|
||||
func createDiseaseEvent(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
SourceKey string `json:"sourceKey"`
|
||||
RoomID *string `json:"roomId"`
|
||||
BatchID *string `json:"batchId"`
|
||||
DetectionTaskID *string `json:"detectionTaskId"`
|
||||
LampTestID *string `json:"lampTestId"`
|
||||
ConsultationID *string `json:"consultationId"`
|
||||
InspectionID *string `json:"inspectionId"`
|
||||
Disease string `json:"disease"`
|
||||
Status string `json:"status"`
|
||||
Evidence json.RawMessage `json:"evidence"`
|
||||
LossSummary *string `json:"lossSummary"`
|
||||
Measure *string `json:"measure"`
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if body.Disease == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "病种不能为空"})
|
||||
return
|
||||
}
|
||||
for _, id := range []*string{body.RoomID, body.BatchID, body.DetectionTaskID, body.LampTestID, body.ConsultationID, body.InspectionID} {
|
||||
if id != nil && *id != "" && !isUUID(*id) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "关联 ID 不是合法的 UUID"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if body.Status == "" {
|
||||
body.Status = "suspected"
|
||||
}
|
||||
event := model.DiseaseEvent{
|
||||
SourceKey: body.SourceKey,
|
||||
RoomID: body.RoomID,
|
||||
BatchID: body.BatchID,
|
||||
DetectionTaskID: body.DetectionTaskID,
|
||||
LampTestID: body.LampTestID,
|
||||
ConsultationID: body.ConsultationID,
|
||||
InspectionID: body.InspectionID,
|
||||
Disease: body.Disease,
|
||||
Status: body.Status,
|
||||
Evidence: body.Evidence,
|
||||
LossSummary: body.LossSummary,
|
||||
Measure: body.Measure,
|
||||
Note: body.Note,
|
||||
}
|
||||
if event.SourceKey == "" {
|
||||
event.SourceKey = fmt.Sprintf("manual-%d", time.Now().UnixNano())
|
||||
}
|
||||
if err := model.ValidateDiseaseEventEvidence(event); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if event.Status == "confirmed" {
|
||||
now := time.Now()
|
||||
event.ConfirmedAt = &now
|
||||
event.ConfirmedBy = currentUserID(c)
|
||||
}
|
||||
if err := db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "source_key"}},
|
||||
DoNothing: true,
|
||||
}).Create(&event).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "创建发病事件失败"})
|
||||
return
|
||||
}
|
||||
if event.ID == "" {
|
||||
_ = db.Where("source_key = ?", event.SourceKey).First(&event).Error
|
||||
}
|
||||
if event.Status == "confirmed" {
|
||||
_ = ensureTraceForDiseaseEvent(db, event)
|
||||
}
|
||||
c.JSON(http.StatusCreated, event)
|
||||
}
|
||||
}
|
||||
|
||||
func updateDiseaseEvent(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var event model.DiseaseEvent
|
||||
if db.Where("id = ?", c.Param("id")).First(&event).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "disease event not found"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Status *string `json:"status"`
|
||||
Evidence json.RawMessage `json:"evidence"`
|
||||
LossSummary *string `json:"lossSummary"`
|
||||
Measure *string `json:"measure"`
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updates := map[string]interface{}{}
|
||||
if body.Status != nil {
|
||||
if !model.ValidDiseaseEventTransition(event.Status, *body.Status) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("发病事件状态不能从 %s 转到 %s", event.Status, *body.Status)})
|
||||
return
|
||||
}
|
||||
updates["status"] = *body.Status
|
||||
if *body.Status == "confirmed" {
|
||||
if len(body.Evidence) == 0 && len(event.Evidence) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "确诊发病事件必须提供证据"})
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
updates["confirmed_at"] = now
|
||||
updates["confirmed_by"] = currentUserID(c)
|
||||
}
|
||||
}
|
||||
if len(body.Evidence) > 0 {
|
||||
updates["evidence"] = body.Evidence
|
||||
}
|
||||
if body.LossSummary != nil {
|
||||
updates["loss_summary"] = *body.LossSummary
|
||||
}
|
||||
if body.Measure != nil {
|
||||
updates["measure"] = *body.Measure
|
||||
}
|
||||
if body.Note != nil {
|
||||
updates["note"] = *body.Note
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
if err := db.Model(&model.DiseaseEvent{}).Where("id = ?", event.ID).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "更新发病事件失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
db.Where("id = ?", event.ID).First(&event)
|
||||
if event.Status == "confirmed" {
|
||||
_ = ensureTraceForDiseaseEvent(db, event)
|
||||
}
|
||||
c.JSON(http.StatusOK, event)
|
||||
}
|
||||
}
|
||||
|
||||
func fillDetectionTaskRoomNames(db *gorm.DB, list []model.DetectionTask) {
|
||||
var rooms []model.Room
|
||||
db.Select("id", "name").Find(&rooms)
|
||||
names := make(map[string]string, len(rooms))
|
||||
for _, r := range rooms {
|
||||
names[r.ID] = r.Name
|
||||
}
|
||||
for i := range list {
|
||||
if list[i].RoomID != nil {
|
||||
if name, ok := names[*list[i].RoomID]; ok {
|
||||
list[i].RoomName = &name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fillDiseaseEventRoomNames(db *gorm.DB, list []model.DiseaseEvent) {
|
||||
var rooms []model.Room
|
||||
db.Select("id", "name").Find(&rooms)
|
||||
names := make(map[string]string, len(rooms))
|
||||
for _, r := range rooms {
|
||||
names[r.ID] = r.Name
|
||||
}
|
||||
for i := range list {
|
||||
if list[i].RoomID != nil {
|
||||
if name, ok := names[*list[i].RoomID]; ok {
|
||||
list[i].RoomName = &name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ensureTraceForDiseaseEvent 确诊后自动创建关联溯源记录,重复调用不重复创建。
|
||||
func ensureTraceForDiseaseEvent(db *gorm.DB, event model.DiseaseEvent) error {
|
||||
if event.Status != "confirmed" {
|
||||
return nil
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&model.TraceRecord{}).Where("disease_event_id = ?", event.ID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
trace := model.TraceRecord{
|
||||
DiseaseEventID: &event.ID,
|
||||
RoomID: event.RoomID,
|
||||
LampTestID: event.LampTestID,
|
||||
Disease: event.Disease,
|
||||
Status: "pending",
|
||||
}
|
||||
return db.Create(&trace).Error
|
||||
}
|
||||
|
||||
func ensureDiseaseEventFromDetectionTask(db *gorm.DB, task model.DetectionTask) error {
|
||||
if task.Result == nil || *task.Result != "positive" {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
evidence, _ := json.Marshal(map[string]interface{}{
|
||||
"result": *task.Result,
|
||||
"method": task.Method,
|
||||
"detectionTaskId": task.ID,
|
||||
"resultedAt": task.ResultedAt,
|
||||
})
|
||||
sourceKey := "detection-task-" + task.ID
|
||||
event := model.DiseaseEvent{
|
||||
SourceKey: sourceKey,
|
||||
RoomID: task.RoomID,
|
||||
BatchID: task.BatchID,
|
||||
DetectionTaskID: &task.ID,
|
||||
InspectionID: task.InspectionID,
|
||||
Disease: task.Disease,
|
||||
Status: "confirmed",
|
||||
Evidence: evidence,
|
||||
ConfirmedAt: &now,
|
||||
ConfirmedBy: task.AssigneeID,
|
||||
}
|
||||
if err := db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "source_key"}},
|
||||
DoNothing: true,
|
||||
}).Create(&event).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if event.ID == "" {
|
||||
_ = db.Where("source_key = ?", sourceKey).First(&event).Error
|
||||
}
|
||||
return ensureTraceForDiseaseEvent(db, event)
|
||||
}
|
||||
@@ -24,6 +24,9 @@ func RegisterHealthProfileRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
rg.GET("/health-profiles/:roomId",
|
||||
middleware.RequirePermission(db, "room:read"),
|
||||
roomHealthProfile(db))
|
||||
rg.GET("/health-profiles/:roomId/effect",
|
||||
middleware.RequirePermission(db, "room:read"),
|
||||
roomEffectReport(db))
|
||||
}
|
||||
|
||||
// roomHealthProfile 单蚕房健康画像:巡检风险/检测结果/事件聚合 → 综合健康分
|
||||
@@ -44,7 +47,7 @@ func roomHealthProfile(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
db.Table("inspection_records").
|
||||
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").
|
||||
Scan(&riskRows)
|
||||
riskCounts := map[string]int64{}
|
||||
@@ -99,3 +102,54 @@ func roomHealthProfile(db *gorm.DB) gin.HandlerFunc {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// roomEffectReport 处置前后窗口效果报告。
|
||||
func roomEffectReport(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
roomID := c.Param("roomId")
|
||||
now := time.Now()
|
||||
parse := func(key string, fallback time.Time) time.Time {
|
||||
raw := c.Query(key)
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return t
|
||||
}
|
||||
window := service.EffectWindow{
|
||||
BeforeStart: parse("beforeStart", now.Add(-60*24*time.Hour)),
|
||||
BeforeEnd: parse("beforeEnd", now.Add(-30*24*time.Hour)),
|
||||
AfterStart: parse("afterStart", now.Add(-30*24*time.Hour)),
|
||||
AfterEnd: parse("afterEnd", now),
|
||||
}
|
||||
before, after := service.EffectMetrics{}, service.EffectMetrics{}
|
||||
loadEffectMetrics := func(start, end time.Time) service.EffectMetrics {
|
||||
metrics := service.EffectMetrics{}
|
||||
db.Table("inspection_records").
|
||||
Where("room_id = ? AND ai_status = 'done' AND COALESCE(is_mock, false) = false AND risk_score IS NOT NULL AND created_at >= ? AND created_at < ?", roomID, start, end).
|
||||
Pluck("risk_score", &metrics.RiskSamples)
|
||||
var lampTotal int64
|
||||
db.Model(&model.LampTest{}).
|
||||
Where("room_id = ? AND status = 'resulted' AND created_at >= ? AND created_at < ?", roomID, start, end).
|
||||
Count(&lampTotal)
|
||||
metrics.LampTotal = int(lampTotal)
|
||||
var lampPositive int64
|
||||
db.Model(&model.LampTest{}).
|
||||
Where("room_id = ? AND status = 'resulted' AND result = 'positive' AND created_at >= ? AND created_at < ?", roomID, start, end).
|
||||
Count(&lampPositive)
|
||||
metrics.LampPositive = int(lampPositive)
|
||||
return metrics
|
||||
}
|
||||
before = loadEffectMetrics(window.BeforeStart, window.BeforeEnd)
|
||||
after = loadEffectMetrics(window.AfterStart, window.AfterEnd)
|
||||
var recurrences int64
|
||||
db.Model(&model.DiseaseEvent{}).
|
||||
Where("room_id = ? AND status = 'reopened' AND created_at >= ? AND created_at < ?", roomID, window.AfterStart, window.AfterEnd).
|
||||
Count(&recurrences)
|
||||
after.Recurrences = int(recurrences)
|
||||
c.JSON(http.StatusOK, service.EvaluateControlEffect(roomID, window, before, after))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -26,8 +27,8 @@ func isUUID(s string) bool {
|
||||
}
|
||||
|
||||
// RegisterInspectionRoutes 注册 AI 巡检路由
|
||||
func RegisterInspectionRoutes(rg *gin.RouterGroup, db *gorm.DB, s3 *service.S3Service, ai *service.AIClient, imageBucket string, wechat *service.WechatService, inspectionTemplateID string) {
|
||||
rg.POST("/inspections", middleware.RequirePermission(db, "inspection:create"), createInspection(db, s3, ai, imageBucket, wechat, inspectionTemplateID))
|
||||
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, outbox, inspectionTemplateID, appEnv))
|
||||
rg.GET("/inspections", middleware.RequirePermission(db, "inspection:read"), listInspections(db))
|
||||
}
|
||||
|
||||
@@ -43,16 +44,37 @@ func currentUserID(c *gin.Context) *string {
|
||||
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 → 写记录。
|
||||
// 幂等:客户端传 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) {
|
||||
idemKey := strings.TrimSpace(c.GetHeader("Idempotency-Key"))
|
||||
roomID := strings.TrimSpace(c.PostForm("roomId"))
|
||||
userID := currentUserID(c)
|
||||
|
||||
if idemKey != "" {
|
||||
if idemKey != "" && userID != nil {
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -92,7 +114,7 @@ func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient,
|
||||
imageURL := s3.Endpoint() + "/" + bucket + "/" + key
|
||||
|
||||
rec := model.InspectionRecord{
|
||||
UserID: currentUserID(c),
|
||||
UserID: userID,
|
||||
ImageURL: &imageURL,
|
||||
AIStatus: "done",
|
||||
}
|
||||
@@ -113,57 +135,96 @@ func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient,
|
||||
} else {
|
||||
raw, _ := json.Marshal(detRes.Detections)
|
||||
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{
|
||||
AI: aiConf,
|
||||
Env: envCoef,
|
||||
Stage: stageCoef,
|
||||
|
||||
// 微信订阅消息(#11 骨架):Mock 不进入告警;业务事务内写 outbox,重启后仍可重试
|
||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&rec).Error; err != nil {
|
||||
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
|
||||
level := service.RiskLevel(score)
|
||||
rec.RiskLevel = &level
|
||||
|
||||
// 微信订阅消息(#11 骨架):风险非绿且用户已授权时异步推送
|
||||
if key := service.WechatTemplateKey(level); key != "" {
|
||||
go func(uid *string, lv string, sc float64) {
|
||||
if uid == nil || !wechat.Configured() || inspectionTemplateID == "" {
|
||||
return
|
||||
event := service.Event{
|
||||
ID: "inspection-" + rec.ID + "-" + *rec.RiskLevel,
|
||||
Type: service.OutboxEventWechatSubscribe,
|
||||
AggregateType: "inspection",
|
||||
AggregateID: rec.ID,
|
||||
Payload: payload,
|
||||
}
|
||||
var binding model.WechatBinding
|
||||
if db.Where("user_id = ?", *uid).First(&binding).Error != nil {
|
||||
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 := outbox.PublishTx(tx, event); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -175,17 +236,19 @@ func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient,
|
||||
}
|
||||
}
|
||||
|
||||
// loadRoomRisk 加载房间阶段系数与环境系数(无房间/无数据时返回 0)
|
||||
func loadRoomRisk(db *gorm.DB, roomID string) (stageCoef, envCoef float64) {
|
||||
// loadRoomRisk 加载房间阶段系数与环境系数(无房间/无数据时返回 nil)
|
||||
func loadRoomRisk(db *gorm.DB, roomID string) (*float64, *float64) {
|
||||
if roomID == "" {
|
||||
return 0, 0
|
||||
return nil, nil
|
||||
}
|
||||
var room model.Room
|
||||
if db.Where("id = ?", roomID).First(&room).Error != nil {
|
||||
return 0, 0
|
||||
return nil, nil
|
||||
}
|
||||
var stageCoef *float64
|
||||
if room.Stage != nil {
|
||||
stageCoef = service.StageCoefficient(*room.Stage)
|
||||
value := service.StageCoefficient(*room.Stage)
|
||||
stageCoef = &value
|
||||
}
|
||||
|
||||
var humidity, temperature *float64
|
||||
@@ -209,7 +272,11 @@ func loadRoomRisk(db *gorm.DB, roomID string) (stageCoef, envCoef float64) {
|
||||
First(&t).Error; err == nil {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"silk-server-go/internal/service"
|
||||
)
|
||||
|
||||
func TestIsUUID(t *testing.T) {
|
||||
valid := []string{
|
||||
@@ -19,3 +23,33 @@ func TestIsUUID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRiskInputUsesAbnormalProbability(t *testing.T) {
|
||||
in := buildRiskInput(&service.AIDetectResponse{
|
||||
ModelVersion: "silk-yolo-2026.08.1",
|
||||
Status: "abnormal",
|
||||
AbnormalProbability: 0.93,
|
||||
})
|
||||
if in.AI == nil || *in.AI != 0.93 {
|
||||
t.Fatalf("AI 异常概率应进入风险输入,实际 %v", in.AI)
|
||||
}
|
||||
if in.ModelVersion != "silk-yolo-2026.08.1" {
|
||||
t.Errorf("modelVersion = %s", in.ModelVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRiskInputUnknownKeepsAIMissing(t *testing.T) {
|
||||
in := buildRiskInput(&service.AIDetectResponse{Status: "unknown"})
|
||||
if in.AI != nil {
|
||||
t.Fatalf("unknown 状态不应把 0 当作 AI 组件,实际 %v", in.AI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRiskInputFallsBackToDetections(t *testing.T) {
|
||||
in := buildRiskInput(&service.AIDetectResponse{
|
||||
Detections: []service.AIDetection{{ClassName: "sick", Confidence: 0.9}},
|
||||
})
|
||||
if in.AI == nil || *in.AI != 0.9 {
|
||||
t.Fatalf("旧 AI 响应应从检测类别计算异常概率,实际 %v", in.AI)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,15 @@ func listDiseases(db *gorm.DB) gin.HandlerFunc {
|
||||
if enabled := c.Query("enabled"); enabled != "" {
|
||||
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
|
||||
q.Order("sort_order ASC, name ASC").Find(&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"})
|
||||
return
|
||||
}
|
||||
if d.Status != "published" && !canReviewKnowledge(c) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "disease not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, d)
|
||||
}
|
||||
}
|
||||
@@ -107,6 +120,7 @@ func createDisease(db *gorm.DB) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
d.ID = ""
|
||||
d.Status = "draft"
|
||||
if d.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "病种名称不能为空"})
|
||||
return
|
||||
@@ -134,6 +148,10 @@ func updateDisease(db *gorm.DB) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
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.Where("id = ?", id).First(&d)
|
||||
@@ -165,6 +183,15 @@ func listArticles(db *gorm.DB) gin.HandlerFunc {
|
||||
if enabled := c.Query("enabled"); enabled != "" {
|
||||
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
|
||||
q.Order("sort_order ASC, created_at DESC").Find(&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"})
|
||||
return
|
||||
}
|
||||
if a.Status != "published" && !canReviewKnowledge(c) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "article not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, a)
|
||||
}
|
||||
}
|
||||
@@ -192,6 +223,7 @@ func createArticle(db *gorm.DB) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
a.ID = ""
|
||||
a.Status = "draft"
|
||||
if a.Kind == "" || a.Title == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "文章类型和标题不能为空"})
|
||||
return
|
||||
@@ -219,6 +251,10 @@ func updateArticle(db *gorm.DB) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
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.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 删除知识文章
|
||||
func deleteArticle(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// RegisterLampRoutes 注册 LAMP 检测路由
|
||||
@@ -79,6 +80,10 @@ func createLampTest(db *gorm.DB) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "batchId 不是合法的 UUID"})
|
||||
return
|
||||
}
|
||||
if t.DetectionTaskID != nil && !isUUID(*t.DetectionTaskID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "detectionTaskId 不是合法的 UUID"})
|
||||
return
|
||||
}
|
||||
if t.Status == "" {
|
||||
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{}{
|
||||
"extra_data": raw, "result": result, "status": "resulted", "resulted_at": now,
|
||||
})
|
||||
_ = syncDetectionTaskFromLamp(db, id)
|
||||
_ = ensureDiseaseEventForLamp(db, id)
|
||||
_ = runCrossValidation(db, id, result)
|
||||
db.Where("id = ?", id).First(&t)
|
||||
c.JSON(http.StatusOK, t)
|
||||
@@ -312,12 +319,81 @@ func updateLampTest(db *gorm.DB) gin.HandlerFunc {
|
||||
// 交叉验证:结果落库后与同房间最近巡检比对
|
||||
if resultSet && resultValue != "" {
|
||||
_ = runCrossValidation(db, id, resultValue)
|
||||
_ = syncDetectionTaskFromLamp(db, id)
|
||||
_ = ensureDiseaseEventForLamp(db, id)
|
||||
}
|
||||
db.Where("id = ?", id).First(&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
|
||||
func runCrossValidation(db *gorm.DB, lampTestID, lampResult string) error {
|
||||
var t model.LampTest
|
||||
|
||||
@@ -1,61 +1,34 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"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 注册通知路由
|
||||
func RegisterNotificationRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
readPerm := middleware.RequirePermission(db, "alarm:read")
|
||||
rg.GET("/notifications", readPerm, listNotifications())
|
||||
rg.POST("/notifications", readPerm, createNotification())
|
||||
rg.GET("/notifications", readPerm, listNotifications(db))
|
||||
rg.POST("/notifications", readPerm, createNotification(db))
|
||||
}
|
||||
|
||||
// listNotifications 通知列表(上限200)
|
||||
func listNotifications() gin.HandlerFunc {
|
||||
func listNotifications(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
notificationMu.Lock()
|
||||
defer notificationMu.Unlock()
|
||||
|
||||
limit := 200
|
||||
if len(notificationStore) < limit {
|
||||
limit = len(notificationStore)
|
||||
}
|
||||
// 返回最新的 limit 条(存储已按新到旧排序)
|
||||
result := make([]notificationItem, limit)
|
||||
copy(result, notificationStore[:limit])
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
var list []model.Notification
|
||||
db.Order("created_at DESC").Limit(200).Find(&list)
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
}
|
||||
|
||||
// createNotification 手动发通知
|
||||
func createNotification() gin.HandlerFunc {
|
||||
func createNotification(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
Channel string `json:"channel"`
|
||||
@@ -68,24 +41,25 @@ func createNotification() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
ntf := notificationItem{
|
||||
ID: fmt.Sprintf("%d%d", time.Now().UnixNano(), rand.Intn(1000000)),
|
||||
ntf := model.Notification{
|
||||
UserID: currentUserID(c),
|
||||
Channel: body.Channel,
|
||||
Target: body.Target,
|
||||
Title: body.Title,
|
||||
Body: body.Body,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
Status: "sent",
|
||||
NextAttemptAt: time.Now(),
|
||||
}
|
||||
|
||||
notificationMu.Lock()
|
||||
// 插入到头部(最新在前)
|
||||
notificationStore = append([]notificationItem{ntf}, notificationStore...)
|
||||
// 保留最近 500 条
|
||||
if len(notificationStore) > 500 {
|
||||
notificationStore = notificationStore[:500]
|
||||
if ntf.Channel == "" {
|
||||
ntf.Channel = "manual"
|
||||
}
|
||||
if ntf.Target == "" {
|
||||
ntf.Target = "all"
|
||||
}
|
||||
if err := db.Create(&ntf).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建通知失败"})
|
||||
return
|
||||
}
|
||||
notificationMu.Unlock()
|
||||
|
||||
c.JSON(http.StatusCreated, ntf)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var opsStartedAt = time.Now()
|
||||
|
||||
// RegisterOpsRoutes 注册运维指标路由。
|
||||
func RegisterOpsRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
rg.GET("/ops/metrics", middleware.RequirePermission(db, "log:read"), opsMetrics())
|
||||
}
|
||||
|
||||
func opsMetrics() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"uptimeSeconds": int(time.Since(opsStartedAt).Seconds()),
|
||||
"dependencies": service.DependencySnapshot(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,7 @@ func createTraceRecord(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
RoomID *string `json:"roomId"`
|
||||
DiseaseEventID *string `json:"diseaseEventId"`
|
||||
LampTestID *string `json:"lampTestId"`
|
||||
ConsultationID *string `json:"consultationId"`
|
||||
Disease string `json:"disease"`
|
||||
@@ -138,7 +139,7 @@ func createTraceRecord(db *gorm.DB) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
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) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "关联 ID 不是合法的 UUID"})
|
||||
return
|
||||
@@ -149,7 +150,8 @@ func createTraceRecord(db *gorm.DB) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
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",
|
||||
}
|
||||
if err := db.Create(&rec).Error; err != nil {
|
||||
@@ -260,6 +262,35 @@ func autoTrace(db *gorm.DB) gin.HandlerFunc {
|
||||
"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)
|
||||
report["transmission"] = map[string]interface{}{"mode": mode, "source": source}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const videoTokenTTL = 5 * time.Minute
|
||||
|
||||
var videoTokenSecret []byte
|
||||
|
||||
// SetVideoTokenSecret 设置视频短时令牌签名密钥。
|
||||
func SetVideoTokenSecret(secret string) {
|
||||
videoTokenSecret = []byte(secret)
|
||||
}
|
||||
|
||||
// IssueVideoToken 签发绑定用户、资源类型和资源 ID 的短时视频令牌。
|
||||
func IssueVideoToken(userID, resourceType, resourceID string, ttl time.Duration) (string, error) {
|
||||
if len(videoTokenSecret) == 0 {
|
||||
return "", errors.New("video token secret not configured")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
claims := jwt.MapClaims{
|
||||
"sub": userID,
|
||||
"purpose": "video",
|
||||
"rtype": resourceType,
|
||||
"rid": resourceID,
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(ttl).Unix(),
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(videoTokenSecret)
|
||||
}
|
||||
|
||||
// ValidateVideoToken 校验视频令牌的资源类型、资源 ID、用途和有效期。
|
||||
func ValidateVideoToken(token, resourceType, resourceID string) error {
|
||||
claims, err := parseVideoToken(token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateVideoClaims(claims, resourceType, resourceID, "")
|
||||
}
|
||||
|
||||
// ValidateVideoTokenForUser 额外校验令牌所属用户,用于可以拿到当前 JWT 的调用方。
|
||||
func ValidateVideoTokenForUser(token, resourceType, resourceID, userID string) error {
|
||||
claims, err := parseVideoToken(token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateVideoClaims(claims, resourceType, resourceID, userID)
|
||||
}
|
||||
|
||||
// requireVideoToken 校验流代理 URL 上的 videoToken,缺失返回 401,错误/过期/资源错配返回 403。
|
||||
func requireVideoToken(c *gin.Context, resourceType, resourceID string) bool {
|
||||
token := c.Query("videoToken")
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing video token"})
|
||||
return false
|
||||
}
|
||||
if err := ValidateVideoToken(token, resourceType, resourceID); err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "invalid video token"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseVideoToken(token string) (jwt.MapClaims, error) {
|
||||
if len(videoTokenSecret) == 0 {
|
||||
return nil, errors.New("video token secret not configured")
|
||||
}
|
||||
claims := jwt.MapClaims{}
|
||||
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
return videoTokenSecret, nil
|
||||
}, jwt.WithValidMethods([]string{"HS256"}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !parsed.Valid {
|
||||
return nil, errors.New("invalid video token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func validateVideoClaims(claims jwt.MapClaims, resourceType, resourceID, userID string) error {
|
||||
if claims["purpose"] != "video" {
|
||||
return errors.New("invalid video token purpose")
|
||||
}
|
||||
if claims["rtype"] != resourceType {
|
||||
return errors.New("video token resource type mismatch")
|
||||
}
|
||||
if claims["rid"] != resourceID {
|
||||
return errors.New("video token resource mismatch")
|
||||
}
|
||||
if userID != "" && claims["sub"] != userID {
|
||||
return errors.New("video token user mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestCameraResponseNeverContainsSecrets(t *testing.T) {
|
||||
password := "secret-password"
|
||||
camera := model.Camera{
|
||||
PasswordEnc: &password,
|
||||
GbAuthPassword: &password,
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(camera)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal camera: %v", err)
|
||||
}
|
||||
body := string(raw)
|
||||
if strings.Contains(body, "passwordEnc") || strings.Contains(body, password) {
|
||||
t.Fatalf("camera JSON must not contain password fields: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "gbAuthPassword") {
|
||||
t.Fatalf("camera JSON must not contain gbAuthPassword: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueAndValidateVideoToken(t *testing.T) {
|
||||
SetVideoTokenSecret("test-secret")
|
||||
token, err := IssueVideoToken("user-1", "camera", "12", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("issue video token: %v", err)
|
||||
}
|
||||
if token == "" {
|
||||
t.Fatal("expected non-empty video token")
|
||||
}
|
||||
|
||||
if err := ValidateVideoToken(token, "camera", "12"); err != nil {
|
||||
t.Fatalf("valid video token rejected: %v", err)
|
||||
}
|
||||
if err := ValidateVideoToken(token, "camera", "13"); err == nil {
|
||||
t.Fatal("expected resource mismatch error")
|
||||
}
|
||||
if err := ValidateVideoToken(token, "clip", "12"); err == nil {
|
||||
t.Fatal("expected resource type mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateVideoTokenForUserRejectsMismatch(t *testing.T) {
|
||||
SetVideoTokenSecret("test-secret")
|
||||
token, err := IssueVideoToken("user-1", "camera", "12", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("issue video token: %v", err)
|
||||
}
|
||||
|
||||
if err := ValidateVideoTokenForUser(token, "camera", "12", "user-1"); err != nil {
|
||||
t.Fatalf("same-user token rejected: %v", err)
|
||||
}
|
||||
if err := ValidateVideoTokenForUser(token, "camera", "12", "user-2"); err == nil {
|
||||
t.Fatal("expected cross-user token rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoTokenExpires(t *testing.T) {
|
||||
SetVideoTokenSecret("test-secret")
|
||||
token, err := IssueVideoToken("user-1", "camera", "12", -time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("issue expired token: %v", err)
|
||||
}
|
||||
if err := ValidateVideoToken(token, "camera", "12"); err == nil {
|
||||
t.Fatal("expected expired token rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoStreamRequiresValidToken(t *testing.T) {
|
||||
SetVideoTokenSecret("test-secret")
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = &http.Request{URL: &url.URL{RawQuery: ""}}
|
||||
if requireVideoToken(c, "camera", "12") {
|
||||
t.Fatal("missing token should be rejected")
|
||||
}
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("missing token status = %d, want 401", rec.Code)
|
||||
}
|
||||
|
||||
token, err := IssueVideoToken("user-1", "camera", "13", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("issue token: %v", err)
|
||||
}
|
||||
rec2 := httptest.NewRecorder()
|
||||
c2, _ := gin.CreateTestContext(rec2)
|
||||
c2.Request = &http.Request{URL: &url.URL{RawQuery: "videoToken=" + token}}
|
||||
if requireVideoToken(c2, "camera", "12") {
|
||||
t.Fatal("wrong resource token should be rejected")
|
||||
}
|
||||
if rec2.Code != http.StatusForbidden {
|
||||
t.Fatalf("wrong resource token status = %d, want 403", rec2.Code)
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,11 @@ func listCameras(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
||||
slog.Warn("同步 WVP 设备信息失败,保留 DB 状态", "error", err)
|
||||
}
|
||||
|
||||
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"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, camera)
|
||||
c.JSON(http.StatusOK, toCameraPublic(camera))
|
||||
}
|
||||
}
|
||||
|
||||
// createCamera 新建摄像头
|
||||
func createCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var camera model.Camera
|
||||
if err := c.ShouldBindJSON(&camera); err != nil {
|
||||
var input CameraInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
camera := input.toModel()
|
||||
camera.ID = 0 // 让数据库自动生成
|
||||
if camera.RoomID == nil {
|
||||
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)
|
||||
func playCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := currentUserID(c)
|
||||
if userID == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供用户信息"})
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
var camera model.Camera
|
||||
if db.Where("id = ?", id).First(&camera).Error != nil {
|
||||
@@ -223,71 +234,45 @@ func playCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Format string `json:"format"`
|
||||
token, err := IssueVideoToken(*userID, "camera", strconv.FormatUint(uint64(camera.ID), 10), videoTokenTTL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成播放令牌失败"})
|
||||
return
|
||||
}
|
||||
c.ShouldBindJSON(&body)
|
||||
format := body.Format
|
||||
if format == "" {
|
||||
format = "hls"
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339)
|
||||
expiresAt := time.Now().Add(videoTokenTTL).UTC().Format(time.RFC3339)
|
||||
streamURL := fmt.Sprintf("/api/v1/video/cameras/%d/live/proxy?videoToken=%s", camera.ID, token)
|
||||
|
||||
// GB28181 摄像头:通过 WVP 媒体服务器播放
|
||||
if camera.GbDeviceID != nil && camera.GbChannelID != nil &&
|
||||
*camera.GbDeviceID != "" && *camera.GbChannelID != "" {
|
||||
result, err := media.StartPlay(*camera.GbDeviceID, *camera.GbChannelID)
|
||||
if err != nil {
|
||||
_, playErr := media.StartPlay(*camera.GbDeviceID, *camera.GbChannelID)
|
||||
if playErr != nil {
|
||||
// StartPlay 失败不等于摄像头离线,可能是 WVP/ZLM 瞬时问题,不修改 is_online
|
||||
slog.Warn("StartPlay 失败", "deviceId", *camera.GbDeviceID, "channelId", *camera.GbChannelID, "error", err)
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "播放失败: " + err.Error()})
|
||||
slog.Warn("StartPlay 失败", "deviceId", *camera.GbDeviceID, "channelId", *camera.GbChannelID, "error", playErr)
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "播放失败: " + playErr.Error()})
|
||||
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{
|
||||
"cameraId": camera.ID,
|
||||
"gbDeviceId": camera.GbDeviceID,
|
||||
"gbChannelId": camera.GbChannelID,
|
||||
"format": format,
|
||||
"url": url,
|
||||
"format": "flv",
|
||||
"url": streamURL,
|
||||
"expiresAt": expiresAt,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback 不再返回直连流地址,避免绕过令牌代理。
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"cameraId": camera.ID,
|
||||
"gbDeviceId": nil,
|
||||
"gbChannelId": nil,
|
||||
"format": format,
|
||||
"url": url,
|
||||
"format": "flv",
|
||||
"url": streamURL,
|
||||
"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)
|
||||
func playbackCamera(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := currentUserID(c)
|
||||
if userID == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供用户信息"})
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
|
||||
limit := 50
|
||||
@@ -318,7 +309,11 @@ func playbackCamera(db *gorm.DB) gin.HandlerFunc {
|
||||
|
||||
// 为每个片段设置 playbackUrl
|
||||
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
|
||||
}
|
||||
|
||||
@@ -342,7 +337,6 @@ func getWvpConfig(media *service.MediaService) gin.HandlerFunc {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"sipId": sip["id"],
|
||||
"sipDomain": sip["domain"],
|
||||
"sipPassword": sip["password"],
|
||||
"sipPort": sip["port"],
|
||||
"sipShowIp": sip["showIp"],
|
||||
})
|
||||
|
||||
@@ -25,6 +25,12 @@ func RegisterVideoClipRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Confi
|
||||
// listClips 录像片段列表(query: cameraId/from/to/limit,按 startAt DESC)
|
||||
func listClips(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := currentUserID(c)
|
||||
if userID == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供用户信息"})
|
||||
return
|
||||
}
|
||||
|
||||
limit := 50
|
||||
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 {
|
||||
limit = l
|
||||
@@ -49,7 +55,11 @@ func listClips(db *gorm.DB) gin.HandlerFunc {
|
||||
|
||||
// 为每个片段设置 playbackUrl
|
||||
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
|
||||
}
|
||||
|
||||
@@ -60,6 +70,12 @@ func listClips(db *gorm.DB) gin.HandlerFunc {
|
||||
// playClip 获取片段播放地址
|
||||
func playClip(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := currentUserID(c)
|
||||
if userID == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供用户信息"})
|
||||
return
|
||||
}
|
||||
|
||||
clipId := c.Param("clipId")
|
||||
var clip model.VideoClip
|
||||
if db.Where("id = ?", clipId).First(&clip).Error != nil {
|
||||
@@ -72,11 +88,17 @@ func playClip(db *gorm.DB) gin.HandlerFunc {
|
||||
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{
|
||||
"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,
|
||||
"expiresAt": time.Now().Add(60 * time.Minute).UTC().Format(time.RFC3339),
|
||||
"expiresAt": time.Now().Add(videoTokenTTL).UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/model"
|
||||
)
|
||||
|
||||
// CameraPublic 摄像头对外返回的非敏感字段。
|
||||
type CameraPublic struct {
|
||||
ID uint `json:"id"`
|
||||
RoomID *string `json:"roomId,omitempty"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
RtspURL *string `json:"rtspUrl,omitempty"`
|
||||
HTTPURL *string `json:"httpUrl,omitempty"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
Position *string `json:"position,omitempty"`
|
||||
Resolution *string `json:"resolution,omitempty"`
|
||||
FPS *int `json:"fps,omitempty"`
|
||||
IsOnline bool `json:"isOnline"`
|
||||
GbDeviceID *string `json:"gbDeviceId,omitempty"`
|
||||
GbChannelID *string `json:"gbChannelId,omitempty"`
|
||||
GbAuthID *string `json:"gbAuthId,omitempty"`
|
||||
GbStreamType *string `json:"gbStreamType,omitempty"`
|
||||
GbTransport *string `json:"gbTransport,omitempty"`
|
||||
GbAlarmChannelID *string `json:"gbAlarmChannelId,omitempty"`
|
||||
GbVoiceChannelID *string `json:"gbVoiceChannelId,omitempty"`
|
||||
GbManufacturer *string `json:"gbManufacturer,omitempty"`
|
||||
ManufacturerID *string `json:"manufacturerId,omitempty"`
|
||||
StreamURL *string `json:"streamUrl,omitempty"`
|
||||
HlsURL *string `json:"hlsUrl,omitempty"`
|
||||
FlvURL *string `json:"flvUrl,omitempty"`
|
||||
WebrtcURL *string `json:"webrtcUrl,omitempty"`
|
||||
SnapshotURL *string `json:"snapshotUrl,omitempty"`
|
||||
Online bool `json:"online"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// CameraInput 摄像头创建输入,允许接收密码字段,但不会作为公开响应。
|
||||
type CameraInput struct {
|
||||
ID uint `json:"id,omitempty"`
|
||||
RoomID *string `json:"roomId,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Name string `json:"name"`
|
||||
RtspURL *string `json:"rtspUrl,omitempty"`
|
||||
HTTPURL *string `json:"httpUrl,omitempty"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
PasswordEnc *string `json:"passwordEnc,omitempty"`
|
||||
Position *string `json:"position,omitempty"`
|
||||
Resolution *string `json:"resolution,omitempty"`
|
||||
FPS *int `json:"fps,omitempty"`
|
||||
IsOnline bool `json:"isOnline"`
|
||||
GbDeviceID *string `json:"gbDeviceId,omitempty"`
|
||||
GbChannelID *string `json:"gbChannelId,omitempty"`
|
||||
GbAuthID *string `json:"gbAuthId,omitempty"`
|
||||
GbAuthPassword *string `json:"gbAuthPassword,omitempty"`
|
||||
GbStreamType *string `json:"gbStreamType,omitempty"`
|
||||
GbTransport *string `json:"gbTransport,omitempty"`
|
||||
GbAlarmChannelID *string `json:"gbAlarmChannelId,omitempty"`
|
||||
GbVoiceChannelID *string `json:"gbVoiceChannelId,omitempty"`
|
||||
GbManufacturer *string `json:"gbManufacturer,omitempty"`
|
||||
ManufacturerID *string `json:"manufacturerId,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (in CameraInput) toModel() model.Camera {
|
||||
return model.Camera{
|
||||
RoomID: in.RoomID,
|
||||
Code: in.Code,
|
||||
Name: in.Name,
|
||||
RtspURL: in.RtspURL,
|
||||
HTTPURL: in.HTTPURL,
|
||||
Username: in.Username,
|
||||
PasswordEnc: in.PasswordEnc,
|
||||
Position: in.Position,
|
||||
Resolution: in.Resolution,
|
||||
FPS: in.FPS,
|
||||
IsOnline: in.IsOnline,
|
||||
GbDeviceID: in.GbDeviceID,
|
||||
GbChannelID: in.GbChannelID,
|
||||
GbAuthID: in.GbAuthID,
|
||||
GbAuthPassword: in.GbAuthPassword,
|
||||
GbStreamType: in.GbStreamType,
|
||||
GbTransport: in.GbTransport,
|
||||
GbAlarmChannelID: in.GbAlarmChannelID,
|
||||
GbVoiceChannelID: in.GbVoiceChannelID,
|
||||
GbManufacturer: in.GbManufacturer,
|
||||
ManufacturerID: in.ManufacturerID,
|
||||
Enabled: in.Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
func toCameraPublic(camera model.Camera) CameraPublic {
|
||||
return CameraPublic{
|
||||
ID: camera.ID,
|
||||
RoomID: camera.RoomID,
|
||||
Code: camera.Code,
|
||||
Name: camera.Name,
|
||||
RtspURL: camera.RtspURL,
|
||||
HTTPURL: camera.HTTPURL,
|
||||
Username: camera.Username,
|
||||
Position: camera.Position,
|
||||
Resolution: camera.Resolution,
|
||||
FPS: camera.FPS,
|
||||
IsOnline: camera.IsOnline,
|
||||
GbDeviceID: camera.GbDeviceID,
|
||||
GbChannelID: camera.GbChannelID,
|
||||
GbAuthID: camera.GbAuthID,
|
||||
GbStreamType: camera.GbStreamType,
|
||||
GbTransport: camera.GbTransport,
|
||||
GbAlarmChannelID: camera.GbAlarmChannelID,
|
||||
GbVoiceChannelID: camera.GbVoiceChannelID,
|
||||
GbManufacturer: camera.GbManufacturer,
|
||||
ManufacturerID: camera.ManufacturerID,
|
||||
StreamURL: camera.StreamURL,
|
||||
HlsURL: camera.HlsURL,
|
||||
FlvURL: camera.FlvURL,
|
||||
WebrtcURL: camera.WebrtcURL,
|
||||
SnapshotURL: camera.SnapshotURL,
|
||||
Online: camera.Online,
|
||||
Enabled: camera.Enabled,
|
||||
CreatedAt: camera.CreatedAt,
|
||||
UpdatedAt: camera.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,9 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterVideoStreamRoutes 注册视频流代理路由(公开接口,不需要 JWT)
|
||||
// RegisterVideoStreamRoutes 注册视频流代理路由;路由可免 Authorization,但必须校验 videoToken。
|
||||
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/cameras/:id/live/stream", streamLive(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 {
|
||||
return func(c *gin.Context) {
|
||||
clipId := c.Param("clipId")
|
||||
if !requireVideoToken(c, "clip", clipId) {
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "video/mp4")
|
||||
|
||||
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 {
|
||||
return func(c *gin.Context) {
|
||||
cameraId := c.Param("id")
|
||||
if !requireVideoToken(c, "camera", cameraId) {
|
||||
return
|
||||
}
|
||||
|
||||
var camera model.Camera
|
||||
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 {
|
||||
return func(c *gin.Context) {
|
||||
cameraId := c.Param("id")
|
||||
if !requireVideoToken(c, "camera", cameraId) {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 查摄像头
|
||||
var camera model.Camera
|
||||
|
||||
@@ -44,13 +44,13 @@ func Auth(cfg *config.Config) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// 视频片段流接口支持动态 ID,使用前缀+后缀匹配放行
|
||||
// 视频片段流接口仅放行播放器请求;handler 必须校验 videoToken
|
||||
if strings.HasPrefix(path, "/api/v1/video/clips/") && strings.HasSuffix(path, "/stream") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 直播流转码代理接口
|
||||
// 直播流转码代理接口仅放行播放器请求;handler 必须校验 videoToken
|
||||
if strings.HasPrefix(path, "/api/v1/video/cameras/") && (strings.HasSuffix(path, "/live/stream") || strings.HasSuffix(path, "/live/proxy")) {
|
||||
c.Next()
|
||||
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": "令牌已注销,请重新登录"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -17,13 +18,22 @@ func Logger() gin.HandlerFunc {
|
||||
|
||||
latency := time.Since(start)
|
||||
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("请求",
|
||||
"requestId", RequestID(c),
|
||||
"method", c.Request.Method,
|
||||
"path", path,
|
||||
"query", query,
|
||||
"status", status,
|
||||
"latency", latency.String(),
|
||||
"ip", c.ClientIP(),
|
||||
"authType", authPrefix,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,107 +1,38 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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 (
|
||||
maxFailures = 5 // 连续失败 5 次后锁定
|
||||
lockDuration = 15 * 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(小写)
|
||||
func limiterKey(c *gin.Context, username string) string {
|
||||
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 后调用)
|
||||
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)
|
||||
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{
|
||||
"error": "登录尝试过多,已锁定,请稍后再试",
|
||||
"retry": int(remain.Minutes()) + 1,
|
||||
@@ -112,11 +43,17 @@ func CheckLoginLock(c *gin.Context, username string) bool {
|
||||
}
|
||||
|
||||
// RecordLoginFail 记录登录失败
|
||||
func RecordLoginFail(c *gin.Context, username string) {
|
||||
defaultLoginLimiter.recordFailure(limiterKey(c, username))
|
||||
func RecordLoginFail(c *gin.Context, username string) error {
|
||||
if authState == nil {
|
||||
return stateUnavailable("记录登录失败")
|
||||
}
|
||||
return authState.RecordLoginFailure(context.Background(), limiterKey(c, username))
|
||||
}
|
||||
|
||||
// RecordLoginSuccess 登录成功后清空计数
|
||||
func RecordLoginSuccess(c *gin.Context, username string) {
|
||||
defaultLoginLimiter.recordSuccess(limiterKey(c, username))
|
||||
func RecordLoginSuccess(c *gin.Context, username string) error {
|
||||
if authState == nil {
|
||||
return stateUnavailable("清空登录失败计数")
|
||||
}
|
||||
return authState.RecordLoginSuccess(context.Background(), limiterKey(c, username))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const RequestIDHeader = "X-Request-ID"
|
||||
|
||||
// RequestID 返回当前请求 ID。
|
||||
func RequestID(c *gin.Context) string {
|
||||
value, _ := c.Get("requestId")
|
||||
if id, ok := value.(string); ok {
|
||||
return id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RequestIDMiddleware 生成或透传 X-Request-ID。
|
||||
func RequestIDMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.GetHeader(RequestIDHeader)
|
||||
if id == "" {
|
||||
id = randomRequestID()
|
||||
}
|
||||
c.Set("requestId", id)
|
||||
c.Header(RequestIDHeader, id)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func randomRequestID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "req-" + hex.EncodeToString([]byte(time.Now().Format(time.RFC3339Nano)))
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// RedactSensitiveQuery 脱敏 URL query 中的 token/password/secret。
|
||||
func RedactSensitiveQuery(rawQuery string) string {
|
||||
if rawQuery == "" {
|
||||
return ""
|
||||
}
|
||||
values, err := url.ParseQuery(rawQuery)
|
||||
if err != nil {
|
||||
return "[invalid-query]"
|
||||
}
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
for _, key := range keys {
|
||||
lower := strings.ToLower(key)
|
||||
if strings.Contains(lower, "token") || strings.Contains(lower, "password") || strings.Contains(lower, "secret") || strings.Contains(lower, "authorization") {
|
||||
values.Set(key, "[redacted]")
|
||||
}
|
||||
}
|
||||
return values.Encode()
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestRequestIDMiddlewarePassesThrough(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
c.Request.Header.Set(RequestIDHeader, "request-1")
|
||||
RequestIDMiddleware()(c)
|
||||
if rec.Header().Get(RequestIDHeader) != "request-1" {
|
||||
t.Fatalf("header = %s", rec.Header().Get(RequestIDHeader))
|
||||
}
|
||||
if RequestID(c) != "request-1" {
|
||||
t.Fatalf("request id = %s", RequestID(c))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestIDMiddlewareGenerates(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
RequestIDMiddleware()(c)
|
||||
if RequestID(c) == "" {
|
||||
t.Fatal("should generate request id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactSensitiveQuery(t *testing.T) {
|
||||
got := RedactSensitiveQuery("access_token=abc&password=secret&roomId=1")
|
||||
if got != "access_token=%5Bredacted%5D&password=%5Bredacted%5D&roomId=1" {
|
||||
t.Fatalf("redacted query = %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const statePrefix = "silk:auth:"
|
||||
|
||||
// StateStore 跨实例认证状态存储。
|
||||
type StateStore interface {
|
||||
RevokeToken(ctx context.Context, id string, exp time.Time) error
|
||||
IsTokenRevoked(ctx context.Context, id string) (bool, error)
|
||||
CheckLoginLock(ctx context.Context, key string) (bool, time.Duration, error)
|
||||
RecordLoginFailure(ctx context.Context, key string) error
|
||||
RecordLoginSuccess(ctx context.Context, key string) error
|
||||
}
|
||||
|
||||
var (
|
||||
authState StateStore
|
||||
appEnv string
|
||||
)
|
||||
|
||||
// InitState 设置认证状态存储;store 为 nil 时认证相关接口保守失败。
|
||||
func InitState(store StateStore, env string) {
|
||||
authState = store
|
||||
appEnv = env
|
||||
}
|
||||
|
||||
// RedisState Redis 实现。
|
||||
type RedisState struct {
|
||||
rdb *redis.Client
|
||||
prefix string
|
||||
}
|
||||
|
||||
// NewRedisState 创建 Redis 状态存储。
|
||||
func NewRedisState(rdb *redis.Client) *RedisState {
|
||||
return &RedisState{rdb: rdb, prefix: statePrefix}
|
||||
}
|
||||
|
||||
func (s *RedisState) RevokeToken(ctx context.Context, id string, exp time.Time) error {
|
||||
ttl := time.Until(exp)
|
||||
if ttl <= 0 {
|
||||
return nil
|
||||
}
|
||||
return s.rdb.Set(ctx, s.prefix+"revoked:"+id, "1", ttl).Err()
|
||||
}
|
||||
|
||||
func (s *RedisState) IsTokenRevoked(ctx context.Context, id string) (bool, error) {
|
||||
count, err := s.rdb.Exists(ctx, s.prefix+"revoked:"+id).Result()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (s *RedisState) CheckLoginLock(ctx context.Context, key string) (bool, time.Duration, error) {
|
||||
lockKey := s.prefix + "login-lock:" + key
|
||||
if _, err := s.rdb.Get(ctx, lockKey).Result(); err == redis.Nil {
|
||||
return false, 0, nil
|
||||
} else if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
ttl, err := s.rdb.TTL(ctx, lockKey).Result()
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
return true, ttl, nil
|
||||
}
|
||||
|
||||
var loginFailureScript = redis.NewScript(`
|
||||
local count = redis.call('INCR', KEYS[1])
|
||||
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
||||
if tonumber(count) >= tonumber(ARGV[2]) then
|
||||
redis.call('SET', KEYS[2], '1', 'PX', ARGV[3])
|
||||
end
|
||||
return count
|
||||
`)
|
||||
|
||||
func (s *RedisState) RecordLoginFailure(ctx context.Context, key string) error {
|
||||
return loginFailureScript.Run(ctx, s.rdb,
|
||||
[]string{s.prefix + "login-failures:" + key, s.prefix + "login-lock:" + key},
|
||||
int(failureWindow.Seconds()), maxFailures, int(lockDuration.Milliseconds()),
|
||||
).Err()
|
||||
}
|
||||
|
||||
func (s *RedisState) RecordLoginSuccess(ctx context.Context, key string) error {
|
||||
pipe := s.rdb.Pipeline()
|
||||
pipe.Del(ctx, s.prefix+"login-failures:"+key, s.prefix+"login-lock:"+key)
|
||||
_, err := pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func stateUnavailable(operation string) error {
|
||||
msg := "Redis 状态服务不可用,无法" + operation
|
||||
if appEnv == "production" {
|
||||
slog.Error(msg)
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type memoryState struct {
|
||||
mu sync.Mutex
|
||||
revoked map[string]time.Time
|
||||
failures map[string]int
|
||||
locked map[string]time.Time
|
||||
}
|
||||
|
||||
func newMemoryState() *memoryState {
|
||||
return &memoryState{
|
||||
revoked: map[string]time.Time{},
|
||||
failures: map[string]int{},
|
||||
locked: map[string]time.Time{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *memoryState) RevokeToken(_ context.Context, id string, exp time.Time) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.revoked[id] = exp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryState) IsTokenRevoked(_ context.Context, id string) (bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
exp, ok := m.revoked[id]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
if time.Now().After(exp) {
|
||||
delete(m.revoked, id)
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *memoryState) CheckLoginLock(_ context.Context, key string) (bool, time.Duration, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
until, ok := m.locked[key]
|
||||
if !ok {
|
||||
return false, 0, nil
|
||||
}
|
||||
if time.Now().After(until) {
|
||||
delete(m.locked, key)
|
||||
return false, 0, nil
|
||||
}
|
||||
return true, time.Until(until), nil
|
||||
}
|
||||
|
||||
func (m *memoryState) RecordLoginFailure(_ context.Context, key string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.failures[key]++
|
||||
if m.failures[key] >= maxFailures {
|
||||
m.locked[key] = time.Now().Add(lockDuration)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryState) RecordLoginSuccess(_ context.Context, key string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.failures, key)
|
||||
delete(m.locked, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func withMemoryState(t *testing.T) *memoryState {
|
||||
t.Helper()
|
||||
store := newMemoryState()
|
||||
InitState(store, "test")
|
||||
t.Cleanup(func() { InitState(nil, "test") })
|
||||
return store
|
||||
}
|
||||
|
||||
func TestRevokeTokenIsCrossInstanceState(t *testing.T) {
|
||||
withMemoryState(t)
|
||||
claims := &JWTClaims{}
|
||||
claims.ID = "token-1"
|
||||
if err := RevokeToken(claims, "token", time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatalf("RevokeToken failed: %v", err)
|
||||
}
|
||||
revoked, err := IsRevoked(claims)
|
||||
if err != nil {
|
||||
t.Fatalf("IsRevoked failed: %v", err)
|
||||
}
|
||||
if !revoked {
|
||||
t.Fatal("token should be revoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeTokenUnavailableFailsClosed(t *testing.T) {
|
||||
InitState(nil, "production")
|
||||
t.Cleanup(func() { InitState(nil, "test") })
|
||||
claims := &JWTClaims{}
|
||||
claims.ID = "x"
|
||||
if err := RevokeToken(claims, "token", time.Now().Add(time.Hour)); err == nil {
|
||||
t.Fatal("state unavailable should fail closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLockStateSemantics(t *testing.T) {
|
||||
store := withMemoryState(t)
|
||||
for i := 0; i < maxFailures; i++ {
|
||||
if err := store.RecordLoginFailure(context.Background(), "1.1.1.1|user"); err != nil {
|
||||
t.Fatalf("record failure failed: %v", err)
|
||||
}
|
||||
}
|
||||
locked, _, err := store.CheckLoginLock(context.Background(), "1.1.1.1|user")
|
||||
if err != nil {
|
||||
t.Fatalf("check lock failed: %v", err)
|
||||
}
|
||||
if !locked {
|
||||
t.Fatal("expected login lock after max failures")
|
||||
}
|
||||
if err := store.RecordLoginSuccess(context.Background(), "1.1.1.1|user"); err != nil {
|
||||
t.Fatalf("record success failed: %v", err)
|
||||
}
|
||||
locked, _, _ = store.CheckLoginLock(context.Background(), "1.1.1.1|user")
|
||||
if locked {
|
||||
t.Fatal("login lock should be cleared after success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLoginLockUnavailableReturns503(t *testing.T) {
|
||||
InitState(nil, "test")
|
||||
t.Cleanup(func() { InitState(nil, "test") })
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/auth/login", nil)
|
||||
if !CheckLoginLock(c, "user") {
|
||||
t.Fatal("state unavailable should abort login")
|
||||
}
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,26 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"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+签发时间)
|
||||
func RevokeToken(claims *JWTClaims, tokenStr string, exp time.Time) {
|
||||
id := tokenIdentifier(claims)
|
||||
defaultBlacklist.mu.Lock()
|
||||
defaultBlacklist.revoked[id] = exp
|
||||
defaultBlacklist.mu.Unlock()
|
||||
func RevokeToken(claims *JWTClaims, tokenStr string, exp time.Time) error {
|
||||
if authState == nil {
|
||||
return stateUnavailable("吊销令牌")
|
||||
}
|
||||
return authState.RevokeToken(context.Background(), tokenIdentifier(claims), exp)
|
||||
}
|
||||
|
||||
// IsRevoked 判断令牌是否已被吊销
|
||||
func IsRevoked(claims *JWTClaims) bool {
|
||||
id := tokenIdentifier(claims)
|
||||
defaultBlacklist.mu.RLock()
|
||||
exp, ok := defaultBlacklist.revoked[id]
|
||||
defaultBlacklist.mu.RUnlock()
|
||||
if !ok {
|
||||
return false
|
||||
func IsRevoked(claims *JWTClaims) (bool, error) {
|
||||
if authState == nil {
|
||||
return false, stateUnavailable("校验令牌吊销状态")
|
||||
}
|
||||
// 已过期的黑名单项自动清理
|
||||
if time.Now().After(exp) {
|
||||
defaultBlacklist.mu.Lock()
|
||||
delete(defaultBlacklist.revoked, id)
|
||||
defaultBlacklist.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
return authState.IsTokenRevoked(context.Background(), tokenIdentifier(claims))
|
||||
}
|
||||
|
||||
// ExtractClaims 从 token 字符串解析 claims(供 logout handler 使用)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const QRVersion = 1
|
||||
|
||||
// IdentityLink 不透明二维码映射,避免把内部 ID 或个人信息放进二维码。
|
||||
type IdentityLink struct {
|
||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
PublicID string `gorm:"column:public_id;size:64;uniqueIndex" json:"publicId"`
|
||||
EntityType string `gorm:"column:entity_type;size:16;index" json:"entityType"`
|
||||
EntityID string `gorm:"column:entity_id;size:128;uniqueIndex" json:"entityId"`
|
||||
Version int `gorm:"default:1" json:"version"`
|
||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (IdentityLink) TableName() string { return "identity_links" }
|
||||
|
||||
// SeedSource 蚕种来源与检疫链。
|
||||
type SeedSource struct {
|
||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
PublicID string `gorm:"column:public_id;size:64;uniqueIndex" json:"publicId"`
|
||||
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
|
||||
ParentID *string `gorm:"column:parent_id;type:uuid;index" json:"parentId,omitempty"`
|
||||
Supplier string `gorm:"size:128" json:"supplier"`
|
||||
SeedBatchNo string `gorm:"column:seed_batch_no;size:64" json:"seedBatchNo"`
|
||||
QuarantineNo *string `gorm:"column:quarantine_no;size:64" json:"quarantineNo,omitempty"`
|
||||
Variety *string `gorm:"size:64" json:"variety,omitempty"`
|
||||
CertificateURL *string `gorm:"column:certificate_url;size:512" json:"certificateUrl,omitempty"`
|
||||
EntryAt *time.Time `gorm:"column:entry_at;type:timestamptz" json:"entryAt,omitempty"`
|
||||
Note *string `gorm:"type:text" json:"note,omitempty"`
|
||||
CreatedBy *string `gorm:"column:created_by;type:uuid" json:"createdBy,omitempty"`
|
||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (SeedSource) TableName() string { return "seed_sources" }
|
||||
|
||||
// DisinfectionRecord 消毒计划与执行记录。
|
||||
type DisinfectionRecord struct {
|
||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"`
|
||||
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
|
||||
Kind string `gorm:"size:16;default:plan" json:"kind"` // plan/execution
|
||||
PlanID *string `gorm:"column:plan_id;type:uuid;index" json:"planId,omitempty"`
|
||||
Agent string `gorm:"size:128" json:"agent"`
|
||||
Concentration string `gorm:"size:64" json:"concentration"`
|
||||
Amount *string `gorm:"size:64" json:"amount,omitempty"`
|
||||
PlannedAt *time.Time `gorm:"column:planned_at;type:timestamptz" json:"plannedAt,omitempty"`
|
||||
ExecutedAt *time.Time `gorm:"column:executed_at;type:timestamptz" json:"executedAt,omitempty"`
|
||||
ExecutorID *string `gorm:"column:executor_id;type:uuid" json:"executorId,omitempty"`
|
||||
ReviewedAt *time.Time `gorm:"column:reviewed_at;type:timestamptz" json:"reviewedAt,omitempty"`
|
||||
ReviewerID *string `gorm:"column:reviewer_id;type:uuid" json:"reviewerId,omitempty"`
|
||||
PhotoURL *string `gorm:"column:photo_url;size:512" json:"photoUrl,omitempty"`
|
||||
Note *string `gorm:"type:text" json:"note,omitempty"`
|
||||
CreatedBy *string `gorm:"column:created_by;type:uuid" json:"createdBy,omitempty"`
|
||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (DisinfectionRecord) TableName() string { return "disinfection_records" }
|
||||
|
||||
// EncodeQRPayload 生成不透明二维码载荷。
|
||||
func EncodeQRPayload(entityType, publicID string, version int) string {
|
||||
return fmt.Sprintf("silk:v1:%s:%s:%d", entityType, publicID, version)
|
||||
}
|
||||
|
||||
// ParseQRPayload 解析并校验二维码载荷。
|
||||
func ParseQRPayload(payload string) (string, string, int, error) {
|
||||
parts := strings.Split(strings.TrimSpace(payload), ":")
|
||||
if len(parts) != 5 || parts[0] != "silk" || parts[1] != "v1" {
|
||||
return "", "", 0, errors.New("二维码格式不正确")
|
||||
}
|
||||
entityType := parts[2]
|
||||
publicID := parts[3]
|
||||
version, err := strconv.Atoi(parts[4])
|
||||
if err != nil || version <= 0 {
|
||||
return "", "", 0, errors.New("二维码版本不正确")
|
||||
}
|
||||
if !ValidQRIdentityType(entityType) || publicID == "" {
|
||||
return "", "", 0, errors.New("二维码身份无效")
|
||||
}
|
||||
return entityType, publicID, version, nil
|
||||
}
|
||||
|
||||
// ValidQRIdentityType 当前二维码支持的实体类型。
|
||||
func ValidQRIdentityType(entityType string) bool {
|
||||
switch entityType {
|
||||
case "batch", "tray", "sample":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateSeedSource 种源必填项。
|
||||
func ValidateSeedSource(source SeedSource) error {
|
||||
if strings.TrimSpace(source.Supplier) == "" {
|
||||
return errors.New("供应商不能为空")
|
||||
}
|
||||
if strings.TrimSpace(source.SeedBatchNo) == "" {
|
||||
return errors.New("蚕种批号不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateDisinfectionRecord 消毒必填项。
|
||||
func ValidateDisinfectionRecord(record DisinfectionRecord) error {
|
||||
if record.Kind != "plan" && record.Kind != "execution" {
|
||||
return errors.New("kind 仅支持 plan/execution")
|
||||
}
|
||||
if strings.TrimSpace(record.Agent) == "" {
|
||||
return errors.New("消毒药剂不能为空")
|
||||
}
|
||||
if strings.TrimSpace(record.Concentration) == "" {
|
||||
return errors.New("消毒浓度不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SeedSourceCycleError 检测种源链是否形成循环。
|
||||
func SeedSourceCycleError(chain map[string]string, id, parentID string) error {
|
||||
seen := map[string]bool{id: true}
|
||||
current := parentID
|
||||
for current != "" {
|
||||
if seen[current] {
|
||||
return errors.New("种源链不能形成循环")
|
||||
}
|
||||
seen[current] = true
|
||||
next := chain[current]
|
||||
if next == current {
|
||||
return errors.New("种源链不能自引用")
|
||||
}
|
||||
current = next
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -16,6 +16,13 @@ type Consultation struct {
|
||||
Snapshot json.RawMessage `gorm:"type:jsonb" json:"snapshot,omitempty"`
|
||||
Status string `gorm:"size:16;default:pending" json:"status"` // pending/consulting/resolved/archived
|
||||
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"`
|
||||
Plan *string `gorm:"type:text" json:"plan,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" }
|
||||
|
||||
// ValidConsultationTransition 状态流转规则:pending→consulting/resolved;consulting→resolved;resolved→archived
|
||||
// ValidConsultationTransition 状态流转规则:unassigned/assigned/accepted/needs_info/resolved/archived。
|
||||
func ValidConsultationTransition(from, to string) bool {
|
||||
switch from {
|
||||
case "pending":
|
||||
return to == "consulting" || to == "resolved"
|
||||
case "consulting":
|
||||
return to == "resolved"
|
||||
case "unassigned":
|
||||
return to == "assigned"
|
||||
case "assigned":
|
||||
return to == "accepted" || to == "needs_info"
|
||||
case "accepted":
|
||||
return to == "needs_info" || to == "resolved"
|
||||
case "needs_info":
|
||||
return to == "accepted" || to == "resolved"
|
||||
case "resolved":
|
||||
return to == "archived"
|
||||
return to == "archived" || to == "needs_info"
|
||||
default:
|
||||
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 病例快照(会诊时打包,避免引用数据变化)
|
||||
type ConsultationSnapshot struct {
|
||||
RoomName string `json:"roomName,omitempty"`
|
||||
|
||||
@@ -8,9 +8,11 @@ import (
|
||||
|
||||
func TestValidConsultationTransition(t *testing.T) {
|
||||
ok := [][2]string{
|
||||
{"pending", "consulting"},
|
||||
{"pending", "resolved"},
|
||||
{"consulting", "resolved"},
|
||||
{"unassigned", "assigned"},
|
||||
{"assigned", "accepted"},
|
||||
{"assigned", "needs_info"},
|
||||
{"accepted", "resolved"},
|
||||
{"needs_info", "resolved"},
|
||||
{"resolved", "archived"},
|
||||
}
|
||||
for _, c := range ok {
|
||||
@@ -19,7 +21,8 @@ func TestValidConsultationTransition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
bad := [][2]string{
|
||||
{"pending", "archived"},
|
||||
{"unassigned", "archived"},
|
||||
{"unassigned", "resolved"},
|
||||
{"resolved", "resolved"},
|
||||
{"archived", "pending"},
|
||||
{"", "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) {
|
||||
now := time.Now()
|
||||
s := ConsultationSnapshot{
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// DetectionTask 统一检测任务,覆盖 LAMP/qPCR/SERS/高光谱。
|
||||
type DetectionTask struct {
|
||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
SourceKey string `gorm:"column:source_key;size:128;uniqueIndex" json:"sourceKey,omitempty"`
|
||||
SourceType string `gorm:"column:source_type;size:32" json:"sourceType"`
|
||||
SourceID string `gorm:"column:source_id;size:128" json:"sourceId,omitempty"`
|
||||
RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"`
|
||||
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
|
||||
InspectionID *string `gorm:"column:inspection_id;type:uuid;index" json:"inspectionId,omitempty"`
|
||||
Disease string `gorm:"size:64" json:"disease"`
|
||||
RecommendedMethod string `gorm:"column:recommended_method;size:32" json:"recommendedMethod,omitempty"`
|
||||
Method *string `gorm:"size:32" json:"method,omitempty"`
|
||||
Priority string `gorm:"size:16;default:routine" json:"priority"`
|
||||
Status string `gorm:"size:16;default:pending;index" json:"status"`
|
||||
AssigneeID *string `gorm:"column:assignee_id;type:uuid" json:"assigneeId,omitempty"`
|
||||
AssignedAt *time.Time `gorm:"column:assigned_at;type:timestamptz" json:"assignedAt,omitempty"`
|
||||
Result *string `gorm:"size:16" json:"result,omitempty"`
|
||||
ResultedAt *time.Time `gorm:"column:resulted_at;type:timestamptz" json:"resultedAt,omitempty"`
|
||||
CancelledReason *string `gorm:"column:cancelled_reason;type:text" json:"cancelledReason,omitempty"`
|
||||
CreatedBy *string `gorm:"column:created_by;type:uuid" json:"createdBy,omitempty"`
|
||||
Note *string `gorm:"type:text" json:"note,omitempty"`
|
||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||
RoomName *string `gorm:"-" json:"roomName,omitempty"`
|
||||
}
|
||||
|
||||
func (DetectionTask) TableName() string { return "detection_tasks" }
|
||||
|
||||
// ValidDetectionTaskTransition 状态机:draft/pending/assigned/sampling/testing/review/completed/cancelled。
|
||||
func ValidDetectionTaskTransition(from, to string) bool {
|
||||
switch from {
|
||||
case "draft":
|
||||
return to == "pending" || to == "cancelled"
|
||||
case "pending":
|
||||
return to == "assigned" || to == "cancelled"
|
||||
case "assigned":
|
||||
return to == "sampling" || to == "cancelled"
|
||||
case "sampling":
|
||||
return to == "testing" || to == "cancelled"
|
||||
case "testing":
|
||||
return to == "review" || to == "cancelled"
|
||||
case "review":
|
||||
return to == "completed" || to == "cancelled"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Sample 检测样本链路。
|
||||
type Sample struct {
|
||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
DetectionTaskID string `gorm:"column:detection_task_id;type:uuid;uniqueIndex" json:"detectionTaskId"`
|
||||
SampleNo string `gorm:"column:sample_no;size:64;uniqueIndex" json:"sampleNo"`
|
||||
RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"`
|
||||
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
|
||||
TrayID *string `gorm:"column:tray_id;type:uuid" json:"trayId,omitempty"`
|
||||
SampledBy *string `gorm:"column:sampled_by;type:uuid" json:"sampledBy,omitempty"`
|
||||
SampledAt *time.Time `gorm:"column:sampled_at;type:timestamptz" json:"sampledAt,omitempty"`
|
||||
CollectedAt *time.Time `gorm:"column:collected_at;type:timestamptz" json:"collectedAt,omitempty"`
|
||||
HandedOverAt *time.Time `gorm:"column:handed_over_at;type:timestamptz" json:"handedOverAt,omitempty"`
|
||||
HandedOverBy *string `gorm:"column:handed_over_by;type:uuid" json:"handedOverBy,omitempty"`
|
||||
ReceivedAt *time.Time `gorm:"column:received_at;type:timestamptz" json:"receivedAt,omitempty"`
|
||||
ReceivedBy *string `gorm:"column:received_by;type:uuid" json:"receivedBy,omitempty"`
|
||||
TestingStartedAt *time.Time `gorm:"column:testing_started_at;type:timestamptz" json:"testingStartedAt,omitempty"`
|
||||
ConsumedAt *time.Time `gorm:"column:consumed_at;type:timestamptz" json:"consumedAt,omitempty"`
|
||||
DisposedAt *time.Time `gorm:"column:disposed_at;type:timestamptz" json:"disposedAt,omitempty"`
|
||||
State string `gorm:"size:16;default:created" json:"state"`
|
||||
Note *string `gorm:"type:text" json:"note,omitempty"`
|
||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (Sample) TableName() string { return "samples" }
|
||||
|
||||
// ValidSampleTransition 样本状态机:created/collected/handed_over/received/testing/consumed/disposed。
|
||||
func ValidSampleTransition(from, to string) bool {
|
||||
switch from {
|
||||
case "created":
|
||||
return to == "collected" || to == "disposed"
|
||||
case "collected":
|
||||
return to == "handed_over" || to == "disposed"
|
||||
case "handed_over":
|
||||
return to == "received" || to == "disposed"
|
||||
case "received":
|
||||
return to == "testing" || to == "disposed"
|
||||
case "testing":
|
||||
return to == "consumed" || to == "disposed"
|
||||
case "consumed":
|
||||
return to == "disposed"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package model
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDetectionTaskRejectsInvalidTransition(t *testing.T) {
|
||||
if ValidDetectionTaskTransition("pending", "testing") {
|
||||
t.Error("pending 不能直接跳 testing")
|
||||
}
|
||||
if ValidDetectionTaskTransition("draft", "completed") {
|
||||
t.Error("draft 不能直接 completed")
|
||||
}
|
||||
if ValidDetectionTaskTransition("completed", "review") {
|
||||
t.Error("completed 不能回退")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectionTaskAllowsRequiredTransitions(t *testing.T) {
|
||||
transitions := [][2]string{
|
||||
{"draft", "pending"},
|
||||
{"pending", "assigned"},
|
||||
{"assigned", "sampling"},
|
||||
{"sampling", "testing"},
|
||||
{"testing", "review"},
|
||||
{"review", "completed"},
|
||||
{"pending", "cancelled"},
|
||||
}
|
||||
for _, tr := range transitions {
|
||||
if !ValidDetectionTaskTransition(tr[0], tr[1]) {
|
||||
t.Errorf("expected %s -> %s", tr[0], tr[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSampleTransition(t *testing.T) {
|
||||
transitions := [][2]string{
|
||||
{"created", "collected"},
|
||||
{"collected", "handed_over"},
|
||||
{"handed_over", "received"},
|
||||
{"received", "testing"},
|
||||
{"testing", "consumed"},
|
||||
{"consumed", "disposed"},
|
||||
}
|
||||
for _, tr := range transitions {
|
||||
if !ValidSampleTransition(tr[0], tr[1]) {
|
||||
t.Errorf("expected %s -> %s", tr[0], tr[1])
|
||||
}
|
||||
}
|
||||
if ValidSampleTransition("created", "testing") {
|
||||
t.Error("created 不能直接 testing")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DiseaseEvent 独立发病事件,作为处置、会诊、溯源和效果评估主线。
|
||||
type DiseaseEvent struct {
|
||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
SourceKey string `gorm:"column:source_key;size:128;uniqueIndex" json:"sourceKey,omitempty"`
|
||||
RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"`
|
||||
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
|
||||
DetectionTaskID *string `gorm:"column:detection_task_id;type:uuid;index" json:"detectionTaskId,omitempty"`
|
||||
LampTestID *string `gorm:"column:lamp_test_id;type:uuid;index" json:"lampTestId,omitempty"`
|
||||
ConsultationID *string `gorm:"column:consultation_id;type:uuid;index" json:"consultationId,omitempty"`
|
||||
InspectionID *string `gorm:"column:inspection_id;type:uuid;index" json:"inspectionId,omitempty"`
|
||||
Disease string `gorm:"size:64" json:"disease"`
|
||||
Status string `gorm:"size:16;default:suspected;index" json:"status"`
|
||||
Evidence json.RawMessage `gorm:"type:jsonb" json:"evidence,omitempty"`
|
||||
ConfirmedAt *time.Time `gorm:"column:confirmed_at;type:timestamptz" json:"confirmedAt,omitempty"`
|
||||
ConfirmedBy *string `gorm:"column:confirmed_by;type:uuid" json:"confirmedBy,omitempty"`
|
||||
LossSummary *string `gorm:"column:loss_summary;type:text" json:"lossSummary,omitempty"`
|
||||
Measure *string `gorm:"type:text" json:"measure,omitempty"`
|
||||
Note *string `gorm:"type:text" json:"note,omitempty"`
|
||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||
RoomName *string `gorm:"-" json:"roomName,omitempty"`
|
||||
}
|
||||
|
||||
func (DiseaseEvent) TableName() string { return "disease_events" }
|
||||
|
||||
// ValidDiseaseEventTransition 状态机:suspected/confirmed/controlled/closed/reopened。
|
||||
func ValidDiseaseEventTransition(from, to string) bool {
|
||||
switch from {
|
||||
case "suspected":
|
||||
return to == "confirmed" || to == "closed"
|
||||
case "confirmed":
|
||||
return to == "controlled" || to == "closed"
|
||||
case "controlled":
|
||||
return to == "closed" || to == "reopened"
|
||||
case "closed":
|
||||
return to == "reopened"
|
||||
case "reopened":
|
||||
return to == "confirmed" || to == "controlled" || to == "closed"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateDiseaseEventEvidence 确诊必须有证据,不能仅凭状态字段确认。
|
||||
func ValidateDiseaseEventEvidence(event DiseaseEvent) error {
|
||||
if event.Status == "confirmed" && len(event.Evidence) == 0 {
|
||||
return errors.New("确诊发病事件必须提供证据")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user