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

This commit is contained in:
weijuesen
2026-08-14 17:52:35 +08:00
168 changed files with 12279 additions and 1020 deletions
+3
View File
@@ -26,8 +26,11 @@ __pycache__/
/wvp/*.sh
*.sh
*.ps1
!scripts/verify.ps1
*.py
*.sql
!server-go/migrations/
!server-go/migrations/*.sql
# ai-servicePython 推理服务)文件例外
!ai-service/
!ai-service/app/
+21 -11
View File
@@ -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 handlerauth/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
View File
@@ -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 后端计算(#90.5×AI 置信度 + 0.2×环境 + 0.15×阶段 + 0.15×整齐度)。
- 只做检测并返回 `abnormalProbability`风险评分在 Go 后端计算(#90.5×AI 异常概率 + 0.2×环境 + 0.15×阶段 + 0.15×整齐度,缺失项归一化)。
- `healthy` 高置信度不贡献异常概率;空检测或 `unknown` 返回 `unknown`,不自动视为健康。
- ONNX 后处理按 YOLOv8 常见输出格式实现(含 letterbox 与 NMS),训练产物出来后需用真实模型校准验证。
+9
View File
@@ -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"))
+46
View File
@@ -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
View File
@@ -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="缺少 urlrtsp/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),
+101
View File
@@ -0,0 +1,101 @@
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from urllib.parse import urlparse
from . import config
from .detector import MockDetector, ONNXDetector
def _detector():
if config.MODEL_MODE == "onnx":
return ONNXDetector(config.MODEL_PATH, tuple(config.MODEL_LABELS))
return MockDetector(class_name=config.MOCK_CLASS, confidence=config.MOCK_CONFIDENCE)
def is_allowed_stream_ref(stream_ref):
url = (stream_ref or {}).get("url", "")
parsed = urlparse(url)
if parsed.scheme not in ("rtsp", "http", "https"):
return False
if parsed.username or parsed.password:
return False
host = (parsed.hostname or "").lower()
allowed = {item.strip().lower() for item in config.ALLOWED_STREAM_HOSTS}
return host in allowed
class StreamTaskWorker:
def __init__(self, max_workers=2):
self.executor = ThreadPoolExecutor(max_workers=max_workers)
self.tasks = {}
self.lock = threading.Lock()
def submit(self, task_id, stream_ref):
with self.lock:
self.tasks[task_id] = {
"taskId": task_id,
"status": "queued",
"startedAt": time.time(),
"finishedAt": None,
"frames": 0,
"detections": [],
"error": None,
}
self.executor.submit(self._run, task_id, stream_ref)
def get(self, task_id):
with self.lock:
task = self.tasks.get(task_id)
return dict(task) if task else None
def _run(self, task_id, stream_ref):
try:
with self.lock:
task = self.tasks.get(task_id)
if task:
task["status"] = "running"
try:
import cv2
except ImportError:
self._fail(task_id, "OpenCV 未安装,无法拉流")
return
capture = cv2.VideoCapture(stream_ref.get("url", ""))
if not capture.isOpened():
self._fail(task_id, "无法连接视频流")
return
max_frames = min(int(stream_ref.get("maxFrames", config.STREAM_TASK_MAX_FRAMES)), config.STREAM_TASK_MAX_FRAMES)
detector = _detector()
frames = 0
detections = []
try:
while frames < max_frames:
ok, frame = capture.read()
if not ok:
break
ok_encode, buf = cv2.imencode(".jpg", frame)
if ok_encode:
detections.extend(detector.detect(buf.tobytes()))
frames += 1
finally:
capture.release()
with self.lock:
task = self.tasks.get(task_id)
if task:
task["status"] = "completed"
task["frames"] = frames
task["detections"] = detections[:10]
task["finishedAt"] = time.time()
except Exception as exc:
self._fail(task_id, str(exc))
def _fail(self, task_id, message):
with self.lock:
task = self.tasks.get(task_id)
if task:
task["status"] = "failed"
task["error"] = message
task["finishedAt"] = time.time()
+72 -1
View File
@@ -1,8 +1,14 @@
import base64
import 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
+20 -1
View File
@@ -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"
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
root: true,
extends: '@react-native',
rules: {
'prettier/prettier': 'warn',
},
};
+3292 -274
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -17,14 +17,13 @@
"@react-navigation/native-stack": "^6.10.0",
"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 -1
View File
@@ -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 -2
View File
@@ -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 -4
View File
@@ -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[]>([]);
+2 -3
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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 -3
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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';
+11 -7
View File
@@ -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`);
navigation.navigate('VideoPlayer', {
cameraId: camera.id,
cameraName: camera.name,
streamUrl,
});
try {
const info = await playCamera(camera.id, 'flv');
navigation.navigate('VideoPlayer', {
cameraId: camera.id,
cameraName: camera.name,
streamUrl: resolveUrl(info.url),
});
} catch (err: any) {
Alert.alert('提示', err?.message || '获取播放地址失败');
}
};
const handlePlayClip = async (clip: VideoClip) => {
+19 -5
View File
@@ -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) {
+62
View File
@@ -0,0 +1,62 @@
# 核心端到端验收用例
执行环境:开发服务器或隔离试点环境。每条用例记录:日期、执行人、环境、结果、失败原因、证据截图/日志。
## E2E-01 healthy 图片不触发检测
1. 使用真实或 mock healthy 图片上传巡检。
2. 期望 `abnormalProbability=0`,风险不因 healthy 高置信度升高。
3. 期望不创建待确认检测任务。
## E2E-02 sick/橙色风险只创建一个待确认任务
1. 上传产生橙色风险的非 mock 巡检。
2. 等待 Outbox worker。
3. 查询 `/detection-tasks?sourceType=inspection`,同巡检只出现一个任务。
## E2E-03 AI 失败可重试且不产生虚假风险
1. 停止 AI 服务后上传巡检。
2. 期望 `aiStatus=failed`、无风险分。
3. 恢复 AI 后重试,期望成功且使用同一幂等键不重复记录。
## E2E-04 qPCR 无效不确诊
1. 按当前规则录入无有效对照或无效结果。
2. 期望 `result=invalid`,不创建 `DiseaseEvent`
## E2E-05 有效阳性创建发病事件并自动溯源
1. LAMP 或统一检测任务录入 `positive`
2. 期望创建 `DiseaseEvent`
3. 期望自动创建关联 `TraceRecord`,状态为 `pending`
## E2E-06 无权用户访问受保护资源返回 403
1. 使用无权限用户访问视频、设备控制、WebSocket 设备订阅。
2. 期望业务接口 403,WebSocket 返回拒绝事件。
## E2E-07 离线巡检重启后仅同步一次
1. 断网拍照入队。
2. 重启小程序。
3. 联网后触发同步。
4. 期望服务端仅一条记录,离线队列转为 `synced`
## E2E-08 通知失败重试且状态可查
1. 制造微信/通道失败事件。
2. 等待重试或最终失败。
3. 期望 `notifications` 状态、尝试次数、错误可查询。
## E2E-09 服务重启后任务/通知/吊销状态保持
1. 创建 pending 事件、Outbox、token 吊销。
2. 重启 Go 服务。
3. 期望任务和通知继续处理,吊销仍生效。
## E2E-10 备份恢复后核心记录与对象引用一致
1.`docs/operations/backup-restore-drill.md` 恢复。
2. 核对巡检、检测、会诊、溯源、图片/光谱/录像引用。
3. 记录 RPO/RTO 和失败项。
+31
View File
@@ -0,0 +1,31 @@
# 发布门禁清单
## 发布前
- [ ] 确认发布基线 commit 和回滚点。
- [ ] 执行 `powershell -ExecutionPolicy Bypass -File scripts/verify.ps1`exit 0。
- [ ] 执行 `git diff --check`exit 0。
- [ ] 涉及 schema 时完成 `pg_dump` 备份并记录恢复命令。
- [ ] 检查磁盘、端口、服务进程和依赖健康。
- [ ] 核对需求追踪矩阵:本次涉及的需求 ID 有实现、测试和限制说明。
## 发布中
- [ ] 保留旧二进制和旧 `dist`
- [ ] 上传新构建产物。
- [ ] 执行数据库迁移。
- [ ] 重启服务。
- [ ] 健康检查和核心接口冒烟。
## 发布后
- [ ] 记录部署 commit、迁移版本、备份路径、冒烟结果。
- [ ] 更新 `变更记录.md``开发交接记录.md`
- [ ] 执行核心端到端用例中受影响的 E2E。
- [ ] 如失败,按回滚预案恢复并记录原因。
## 回滚
- 代码:恢复旧二进制/旧 dist 或 `git checkout <pre-commit>`
- 数据库:`pg_restore` 恢复备份。
- 回滚后重新健康检查并记录结论。
@@ -0,0 +1,86 @@
# 需求追踪矩阵
追踪列:需求 ID、实现、自动测试、手工用例、环境、证据、状态、限制、负责人。
状态约定:
- `已实现`:代码和单元/构建证据存在,但未在目标环境完成最终验收。
- `部分可用`:主链路可用,仍有明确限制。
- `延后`:按决策暂不实施。
- `未完成`:未实现或实现不足。
## Wave 1:安全与正确性
| 需求 ID | 实现 | 自动测试 | 手工用例 | 环境 | 证据 | 状态 | 限制 | 负责人 |
|---|---|---|---|---|---|---|---|---|
| IAM-001 | `handler/auth.go``middleware/token_blacklist.go` | `middleware/state_test.go` | 登录/刷新/登出/跨实例吊销 | 开发/生产 | `go test ./...` | 部分可用 | Redis 不可用时保守失败 | 后端 |
| IAM-002 | `middleware/permission.go``permission_seed.go` | 无专项测试 | 角色访问矩阵 | 开发 | 路由权限检查 | 部分可用 | 缺少对象级 ACL | 后端 |
| IAM-003 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 需组织/区域/房间 ACL | 后端 |
| IAM-004 | 延后 Task 3 | 无 | 无 | 无 | 决策记录 | 延后 | 默认凭据和强制改密待最后阶段 | 后端+运维 |
| AI-INS-002 | `service/risk.go``ai-service` | `risk_test.go`、AI pytest | healthy/sick 图片 | 本地 | verify.ps1 | 部分可用 | 真实模型未验收 | AI+后端 |
| AI-INS-003 | `inspection.go``is_mock` | `inspection_test.go` | Mock 标识与统计排除 | 本地 | verify.ps1 | 部分可用 | 历史 mock 需人工审阅 | 后端 |
| AI-INS-004 | `RiskAssessment` | `risk_test.go` | 风险解释展示 | 本地 | verify.ps1 | 部分可用 | 权重待试点校准 | 后端 |
| AI-INS-005 | `offlineQueue.ts` | `offlineQueue.test.ts` | 离线→重启→联网 | 微信开发者工具 | miniapp test | 部分可用 | 未做截图取证 | 小程序 |
| RISK-001 | `RiskLevel` | `risk_test.go` | 边界用例 | 本地 | go test | 已实现 | 无 | 后端 |
| RISK-002 | `RiskInput` 指针 | `risk_test.go` | 缺失组件展示 | 本地 | go test | 已实现 | 无 | 后端 |
| RISK-003 | `risk_assessment` | `risk_test.go` | 巡检详情 | 本地 | go test | 已实现 | 无 | 后端 |
| RISK-004 | `risk-v2-2026.08.14` | 无 | 试点校准 | 待试点 | 无 | 部分可用 | 权重非科学结论 | 后端+专家 |
| RISK-005 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 人工纠正等级 | 后端 |
## Wave 2:工程可靠性
| 需求 ID | 实现 | 自动测试 | 手工用例 | 环境 | 证据 | 状态 | 限制 | 负责人 |
|---|---|---|---|---|---|---|---|---|
| DET-001 | `detection_task.go` | `detection_task_test.go` | 任务中心 | 本地 | verify.ps1 | 部分可用 | 未端到端联调 | 后端 |
| DET-002 | `sample.go` | `detection_task_test.go` | 样本流转 | 本地 | go test | 部分可用 | 单样本/任务 | 后端 |
| DET-003 | `molecular.go` | `molecular_test.go` | 结果枚举 | 本地 | go test | 部分可用 | qPCR 四态延后 | 后端 |
| DET-004 | 延后 Task 7 | 无 | 无 | 无 | 决策记录 | 延后 | 专家确认 | 后端+专家 |
| DET-005 | `lamp.go` | `lamp_test_test.go` | LAMP 5 步 | 本地 | go test | 部分可用 | 对照/照片质控待完善 | 后端 |
| DET-006 | `cross_validate.go` | `cross_validate_test.go` | 交叉验证 | 本地 | go test | 部分可用 | 真实数据待联调 | 后端 |
| DET-007 | `detection_recommend.go` | `detection_recommend_test.go` | 推荐接口 | 本地 | go test | 部分可用 | 未绑定组织可用设备 | 后端 |
| DET-008 | `consumable.go` | `consumable_test.go` | 耗材预警 | 本地 | go test | 部分可用 | 无批次/领用 | 后端 |
| DET-009 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 设备回传协议 | 后端 |
| DET-010 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | LAMP 图像判读 | AI |
| DET-011 | `spectrum_entries` | 无专项测试 | 光谱上传 | 本地 | 构建 | 部分可用 | 方法学验证未做 | AI |
| MSG-001 | `outbox.go``notification.go` | `outbox_test.go` | 通知重试 | 本地 | verify.ps1 | 部分可用 | 未真实联调 | 后端 |
| MSG-002 | `wechat.go`、outbox | `wechat_test.go` | 微信订阅 | 本地 | pytest/go test | 部分可用 | 缺真实凭证 | 后端 |
## Wave 3:业务闭环
| 需求 ID | 实现 | 自动测试 | 手工用例 | 环境 | 证据 | 状态 | 限制 | 负责人 |
|---|---|---|---|---|---|---|---|---|
| FARM-001 | `tray_batch.go` | `knowledge_seed_test.go` 等 | 蚕房/批次 CRUD | 本地 | go test | 已实现 | 无 | 后端 |
| FARM-002 | `identity_links.go` | `biosecurity_test.go` | 二维码签发/解析 | 本地 | go test | 部分可用 | 未打印扫码 | 后端+小程序 |
| FARM-003 | `rearing_records` | 无专项测试 | 饲养记录 | 本地 | go test | 部分可用 | 缺死亡/淘汰字段 | 后端 |
| FARM-004 | `disinfection_records` | `biosecurity_test.go` | 消毒计划/执行 | 本地 | verify.ps1 | 部分可用 | 未现场联调 | 后端+小程序 |
| FARM-005 | `seed_sources` | `biosecurity_test.go` | 种源链 | 本地 | go test | 部分可用 | 供应商凭证待接入 | 后端 |
| FARM-006 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 产量/损失/成本 | 后端 |
| ENV-001 | `telemetry.go``mqtt.go` | 无专项测试 | 遥测查询 | 开发 | 构建 | 已实现 | 数据质量标识不足 | 后端 |
| ENV-002 | `threshold.go``alarm.go` | 无专项测试 | 阈值告警 | 开发 | 构建 | 已实现 | 无对象级授权 | 后端 |
| ENV-003 | `rule_engine.go` | `rule_engine_test.go` | 过期数据 | 本地 | go test | 部分可用 | 未接入生产遥测 | 后端 |
| ENV-004 | `rule_engine.go``weather_risk.go` | `rule_engine_test.go` | 连续时窗 | 本地 | go test | 部分可用 | 密度/通风等输入待接入 | 后端 |
| ENV-005 | `rule_engine.go` | `rule_engine_test.go` | 规则解释 | 本地 | go test | 部分可用 | 结果持久化未全链路 | 后端 |
| ENV-006 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 设备校准/维护 | 后端 |
| ENV-007 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 安全控制建议 | 后端 |
| EXP-001 | `consultation.go` | `consultation_test.go` | 病例快照 | 本地 | go test | 已实现 | 环境/天气摘要待增强 | 后端 |
| EXP-002 | `consultation.go` | `consultation_test.go` | 会诊状态机 | 本地 | go test | 已实现 | 无 | 后端 |
| EXP-003 | `ConsultationSLAState` | `consultation_test.go` | SLA 超时 | 本地 | go test | 部分可用 | 无升级通知 | 后端 |
| EXP-004 | `consultation_opinion_versions` | `consultation_test.go` | 意见版本 | 本地 | go test | 已实现 | 无 | 后端 |
| EXP-005 | `health_profile.go` effect | `health_profile_test.go` | 效果报告 | 本地 | go test | 部分可用 | 损失/成本缺失 | 后端 |
| KB-001 | `knowledge.go` | `knowledge_governance_test.go` | 审核发布 | 本地 | go test | 部分可用 | 审核权限基于角色 | 后端+Web |
| KB-002 | `knowledge.go` source 字段 | 无专项测试 | 来源展示 | 本地 | 构建 | 部分可用 | 专家签名待接入 | 后端 |
| KB-003 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 案例沉淀 | 后端 |
| TRACE-001 | `disease_events` | `disease_event_test.go` | 发病事件 | 本地 | go test | 部分可用 | 未端到端 | 后端 |
| TRACE-002 | `trace.go` auto | 无专项测试 | 自动溯源 | 本地 | go test | 部分可用 | 空间/样本链不足 | 后端 |
| TRACE-003 | `trace.go` checklist | 无专项测试 | 二级排查 | 本地 | go test | 部分可用 | 无版本化清单 | 后端 |
| TRACE-004 | `trace.go` lab note | 无专项测试 | 三级溯源 | 本地 | go test | 部分可用 | 结构化实验室数据未做 | 后端 |
| TRACE-005 | `trace.go` | `trace_test.go` | 来源结论 | 本地 | go test | 部分可用 | 专家确认流程未做 | 后端 |
| TRACE-006 | `region_stat.go` | `region_stat_test.go` | 区域统计 | 本地 | go test | 部分可用 | 小样本脱敏未做 | 后端 |
| TRACE-007 | `seed_sources` + 复发字段 | `biosecurity_test.go` | 复发关联 | 本地 | go test | 部分可用 | 人工确认未做 | 后端 |
| VIDEO-001 | `video_auth.go` | `video_auth_test.go` | 播放授权 | 本地 | go test | 部分可用 | 对象级授权待后续 | 后端 |
| VIDEO-002 | DTO 脱敏 | `video_dto.go` | 密钥不返回 | 本地 | go test | 部分可用 | 历史配置审计 | 后端 |
| VIDEO-003 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 录制状态持久化 | 后端 |
| VIDEO-004 | `stream_tasks.py` | AI pytest | 受限拉流 | 本地 | pytest | 部分可用 | 摄像头真实视角待接入 | AI |
| ANA-001 | `health_profile.go` | `health_profile_test.go` | 健康画像 | 本地 | go test | 部分可用 | 公式未回测 | 后端 |
| ANA-002 | `health_profile.go` effect | `health_profile_test.go` | 效果评估 | 本地 | go test | 部分可用 | 损失/成本缺失 | 后端 |
| ANA-003 | `region_stat.go` | `region_stat_test.go` | 区域图 | 本地 | go test | 部分可用 | 地图/脱敏未做 | 后端 |
@@ -0,0 +1,22 @@
# 2026-08-13 整改决策记录
> 范围:对应《项目整改实施计划 V1.0》Task 0 与《蚕病智能防控平台规格说明书 V2.2》第 13 章开放决策。
> 执行原则:项目负责人已指示开始整改;未获得明确产品/合规/专业确认的事项,本记录采用“保持现状 + 明确复审触发条件”,不擅自进入生产部署。
| Decision ID | 结论 | 备选方案 | 选择理由 | 影响 | 负责人 | 复审条件 |
|---|---|---|---|---|---|---|
| DEC-001 | Go 数据库迁移采用 `golang-migrate/migrate/v4`SQL 迁移文件是唯一 schema 事实来源,命名 `NNNNNN_name.up.sql` / `NNNNNN_name.down.sql` | goose;自研 runnerGORM AutoMigrate | 与计划中 `000001_baseline.up.sql` 命名一致;支持嵌入式 SQL、版本表、up/down 和 PostgreSQL;可复用 Go 标准 `embed` | Task 2 起引入迁移依赖;生产启动执行迁移并禁止 AutoMigrate | 项目负责人 + 后端 | golang-migrate 升级、迁移目录规则变更或生产多实例发现锁冲突时 |
| DEC-002 | CI 保持现状:不新增远程 CI;本地门禁使用 `scripts/verify.ps1`;候选平台为 GitHub Actions | GitLab CIJenkinsGitea Actions | 仓库当前无 remote,无法配置并验证外部 CI;先保留可复现本地门禁 | Task 1 的 CI 接入步骤暂不完成;CI 配置待平台确定 | 项目负责人 + 运维 | 仓库获得 remote 或团队确认 CI 平台时 |
| DEC-003 | 制品保持现状:构建产物不入库,沿用 `web/dist``miniapp/dist`、二进制等 gitignore 规则 | GitHub Releases/ArtifactsNASGit LFS | 尚未确定 CI 与发布平台,先避免生成产物污染仓库 | 发布继续按 AGENTS.md 手动备份/回滚 | 项目负责人 + 运维 | 进入正式发布流程前 |
| DEC-004 | 数据保留期限保持现状:不自动删除历史数据;具体 bucket/表保留期在 Task 13/14 前评审 | 固定 30/90/365 天 | 当前试点数据量有限,且未获合规/业务确认,不应擅自删数 | 存储增长需纳入容量监控 | 项目负责人 + 运维 | 生产试点或容量告警触发时 |
| DEC-005 | qPCR 保持“只录入、不自动判读”;Task 7 延后到最后处理,领域专家确认 protocolVersion、对照规则和阈值后才启用自动判读 | 沿用现有“无 Ct 即阴性”;当前 Wave 1 实施四态结果 | 当前自动判读存在误判风险,且用户 2026-08-14 明确要求跳过 Task 7 并留到最后 | 本轮不实现 positive/negative/invalid/indeterminate;专家签字前不输出业务结论 | 领域专家 + 后端 | 用户恢复 Task 7,或专家签字并给出 protocolVersion 时 |
| DEC-006 | APP 保持现状:作为环境监控端,不扩展为全量蚕病功能 | 全量同步 Web/小程序蚕病功能 | 当前 APP 源码未实现蚕病功能,扩大同步会显著增加维护面 | Task 0 后不新增 APP 蚕病页面 | 产品负责人 | 试点用户明确提出 APP 蚕病需求时 |
| DEC-007 | 真实模型二分类转多病种指标门槛保持“未定义,不允许上生产”;待数据集修复和训练恢复后按冻结测试集、mAP、混淆矩阵评审 | 先预设 mAP 0.5 | 当前无真实模型,不能凭空设置验收门槛 | AI 生产继续 mock/不接受为真实验收 | AI 负责人 + 项目负责人 | 数据集修复完成或模型训练恢复时 |
| DEC-008 | 生产云厂商、对象存储、域名和证书保持现状:不迁移云,继续沿用物理机开发环境 | 云 OSS/COS;云 RDS | 当前无生产试点和上云评审;上云属于发布决策 | 部署仍按 `部署指南(物理机).md` | 项目负责人 + 运维 | 正式生产试点前 |
| DEC-009 | Task 3 移除默认密钥/默认管理员密码延后到最后处理;本轮跳过,不执行密钥轮换和客户端密码预填删除 | 当前 Wave 1 执行;最后阶段集中执行 | 用户 2026-08-13 明确要求跳过并留到最后 | 默认凭据风险保持开放;最后阶段需安排密钥轮换窗口 | 项目负责人 + 后端 + 运维 | 进入最后整改阶段时 |
## 生效与复审
- 生效日期:2026-08-13。
- 所有“保持现状”决策不是永久结论,仅在本轮整改期内有效。
- 任何决策变化必须更新本表,并在 `开发交接记录.md` 追加影响与回滚点。
+36
View File
@@ -0,0 +1,36 @@
# 备份恢复演练
## 目标
验证 PostgreSQL、IoTDB、Ceph/S3 和业务配置的可恢复性,记录 RPO/RTO。
## 演练前
1. 记录当前版本、commit、schema 版本和服务清单。
2. 使用 `pg_dump` 备份 PostgreSQL 到隔离命名空间。
3. 备份 IoTDB 数据目录或按现有运维流程导出。
4. 记录 Ceph/S3 bucket 清单和对象数量。
## 恢复步骤
1. 在隔离环境创建空 PostgreSQL 数据库。
2. 执行 `pg_restore` 恢复备份。
3. 恢复 IoTDB 数据并启动服务。
4. 校验图片、光谱、录像对象引用与数据库记录一致。
5. 启动 Go/AI/Web 服务并执行核心接口冒烟。
## 记录项
- 备份开始/结束时间
- 备份体积
- 恢复开始/结束时间
- RPO(最近可用备份到故障时间)
- RTO(故障到服务可用)
- 数据点核对:巡检、遥测、检测、通知、任务、会诊、溯源
- 失败项与原因
## 验收
- 核心业务记录无缺失。
- 图片/光谱/录像对象引用可访问。
- 至少完成一次真实恢复演练后才允许执行正式生产迁移。
+39
View File
@@ -0,0 +1,39 @@
# 负载测试场景
目标环境:Go 后端 + PostgreSQL + Redis + IoTDB + AI 服务。结果需记录测试日期、版本、机器配置、并发、时长、p50/p95、失败率和观察到的限制。
## A. 500 在线用户 API 混合负载
比例:
- 80% 查询类:房间、设备、遥测最新值、告警列表
- 10% 遥测趋势:历史/聚合查询
- 5% 上传类:图片巡检上传
- 5% 管理操作:阈值、批次、检测任务、会诊
验收:API 错误率 <1%,业务接口 p95 <1s;上传成功且巡检闭环可追踪。
## B. 1000 WebSocket 连接
- 1000 个连接按授权设备订阅
- 持续 30 分钟
- 记录连接成功率、掉线率、消息延迟、CPU/内存
验收:连接成功率 >99%,消息 P95 延迟 <500ms,无内存持续增长。
## C. AI 峰值
固定条件:
- GPU:目标 T4 16G
- 模型版本和 ONNX 文件 hash
- 输入尺寸
- 并发数(建议 1/4/8/16
记录 p50/p95、失败率、GPU 显存和单帧延迟。结果用于设置生产并发上限和推理告警阈值。
## 禁止事项
- 不在生产库执行写入类负载。
- 不使用真实用户隐私图片做公开压测。
- 压测前必须备份数据库并记录环境状态。
+39
View File
@@ -0,0 +1,39 @@
# 可观测性 SLO 与告警阈值
## 日志与请求 ID
- Go 后端每个 HTTP 请求生成或透传 `X-Request-ID`,日志统一记录 `requestId/method/path/query/status/latency/ip/authType`
- 日志不记录 Authorization 原始值;query 中 `token/password/secret/authorization` 类参数统一替换为 `[redacted]`
- AI 服务 `/detect` 同样透传 `X-Request-ID`,便于跨 Go/Python 全链路追踪。
## 依赖指标
`GET /api/v1/ops/metrics``log:read`)返回依赖指标,至少记录请求数、失败数、最近延迟、P50/P95、最近成功/失败时间和最近错误。目标覆盖:
- PostgreSQL
- Redis
- MQTT
- IoTDB
- S3
- AI 服务
- WVP/ZLM
- 微信
- 天气
## 初始告警阈值
| 指标 | 目标 | 告警阈值 | 负责人 |
|---|---|---|---|
| API 可用性 | 99.5% | 5 分钟错误率 >5% | 后端 + 运维 |
| API p95 延迟 | <500ms(业务接口) | 5 分钟 p95 >1s | 后端 |
| 登录失败率 | 正常用户 <2% | 5 分钟失败率 >10% | 后端 + 安全 |
| Redis | 可用 | Ping 失败 3 次 | 运维 |
| PostgreSQL | 可用 | 连接/查询失败 3 次 | 后端 + 运维 |
| IoTDB | 可用或明确降级 | 持续不可用且遥测回退 | 后端 |
| AI 推理失败率 | <1% | 5 分钟失败率 >5% | AI + 后端 |
| Ceph 容量 | 使用率 <80% | 使用率 >80% | 运维 |
| Outbox 积压 | 常态 <50 | pending+retry >200 | 后端 |
## 模拟故障验证
每次发布前至少验证一次通知链:断开 Redis → 确认认证接口返回 503 → 恢复 Redis → 确认自动恢复;如部署告警工具,则同时验证告警通道可送达负责人。
+2
View File
@@ -10,6 +10,8 @@
"framework": "React"
},
"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",
+33
View File
@@ -0,0 +1,33 @@
import { get, post } from './request';
export interface QRResolveResult {
entityType: string;
publicId: string;
entity: {
id: string;
name?: string;
roomId?: string;
batchId?: string;
sampleNo?: string;
state?: string;
};
}
export interface DisinfectionRecord {
roomId?: string;
batchId?: string;
kind: 'plan' | 'execution';
agent: string;
concentration: string;
amount?: string;
note?: string;
}
export const resolveQR = (payload: string) =>
post<QRResolveResult>('/biosecurity/qr/resolve', { payload });
export const createDisinfectionRecord = (data: DisinfectionRecord) =>
post('/biosecurity/disinfection-records', data as unknown as Record<string, unknown>);
export const listDisinfectionRecords = (params?: any) =>
get<DisinfectionRecord[]>('/biosecurity/disinfection-records', { params });
+4 -4
View File
@@ -43,24 +43,24 @@ export function getServerUrl(): string {
// 保留导出以兼容现有代码(模块加载时的默认值,实际请求请使用 getApiBaseUrl()
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;
+18 -4
View File
@@ -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);
+29
View File
@@ -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');
+1
View File
@@ -16,6 +16,7 @@ export default defineAppConfig({
'pages/inspection/index',
'pages/notification/index',
'pages/lamp/index',
'pages/biosecurity/index',
],
window: {
backgroundTextStyle: 'dark',
+1 -1
View File
@@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import { useEffect } from 'react';
import { useDidShow, useDidHide } from '@tarojs/taro';
import { useStore } from '@/store/useStore';
import './app.scss';
+1 -1
View File
@@ -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;
}
+127
View File
@@ -0,0 +1,127 @@
import React, { useState } from 'react';
import { Button, Input, Text, View } from '@tarojs/components';
import Taro from '@tarojs/taro';
import styles from './index.module.scss';
import { createDisinfectionRecord, resolveQR, type QRResolveResult } from '@/api/biosecurity';
const BiosecurityPage: React.FC = () => {
const [entity, setEntity] = useState<QRResolveResult | null>(null);
const [agent, setAgent] = useState('');
const [concentration, setConcentration] = useState('');
const [amount, setAmount] = useState('');
const [note, setNote] = useState('');
const [submitting, setSubmitting] = useState(false);
const handleScan = async () => {
try {
const res = await Taro.scanCode({ scanType: ['qrCode'] });
if (!res.result) throw new Error('未识别到二维码');
const data = await resolveQR(res.result);
setEntity(data);
Taro.showToast({ title: '扫码成功', icon: 'success' });
} catch (err) {
Taro.showToast({
title: err instanceof Error ? err.message : '扫码失败',
icon: 'none',
});
}
};
const handleSubmit = async () => {
if (!entity) {
Taro.showToast({ title: '请先扫码', icon: 'none' });
return;
}
if (!agent || !concentration) {
Taro.showToast({ title: '请填写药剂和浓度', icon: 'none' });
return;
}
setSubmitting(true);
try {
await createDisinfectionRecord({
kind: 'execution',
roomId: entity.entity.roomId,
batchId: entity.entityType === 'batch' ? entity.entity.id : entity.entity.batchId,
agent,
concentration,
amount: amount || undefined,
note: note || undefined,
});
Taro.showToast({ title: '消毒记录已提交', icon: 'success' });
setAgent('');
setConcentration('');
setAmount('');
setNote('');
} catch (err) {
Taro.showToast({
title: err instanceof Error ? err.message : '提交失败',
icon: 'none',
});
} finally {
setSubmitting(false);
}
};
return (
<View className={styles.page}>
<View className={styles.card}>
<Text className={styles.title}></Text>
<Button className={styles.btnPrimary} onClick={handleScan}>
//
</Button>
{entity ? (
<View className={styles.entityBox}>
<Text className={styles.entityType}>{entity.entityType}</Text>
<Text className={styles.entityName}>
{entity.entity.name || entity.entity.sampleNo || entity.entity.id}
</Text>
</View>
) : null}
</View>
{entity ? (
<View className={styles.card}>
<Text className={styles.title}></Text>
<Text className={styles.label}></Text>
<Input
className={styles.input}
value={agent}
placeholder="例如:漂白粉"
onInput={(e) => setAgent(e.detail.value)}
/>
<Text className={styles.label}></Text>
<Input
className={styles.input}
value={concentration}
placeholder="例如:1%"
onInput={(e) => setConcentration(e.detail.value)}
/>
<Text className={styles.label}></Text>
<Input
className={styles.input}
value={amount}
placeholder="可选"
onInput={(e) => setAmount(e.detail.value)}
/>
<Text className={styles.label}></Text>
<Input
className={styles.input}
value={note}
placeholder="可选"
onInput={(e) => setNote(e.detail.value)}
/>
<Button
className={styles.btnPrimary}
loading={submitting}
disabled={submitting}
onClick={handleSubmit}
>
</Button>
</View>
) : null}
</View>
);
};
export default BiosecurityPage;
+2 -2
View File
@@ -285,7 +285,7 @@ const DashboardPage: React.FC = () => {
className={styles.sectionAction}
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}>
+2 -2
View File
@@ -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;
+68 -6
View File
@@ -2,7 +2,10 @@ import React, { useCallback, useEffect, useState } from 'react';
import { Button, Image, ScrollView, Text, View } from '@tarojs/components';
import 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,13 +42,38 @@ 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 = () => {
Taro.chooseImage({
count: 1,
@@ -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 -1
View File
@@ -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,
+4 -7
View File
@@ -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}
+1
View File
@@ -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') {
+8
View File
@@ -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>
+14 -11
View File
@@ -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`);
console.log('[VideoPlayer] 获取播放地址成功:', streamUrl);
setVideoUrl(streamUrl);
setLoading(false);
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 }[] = [
+99
View File
@@ -0,0 +1,99 @@
import * as assert from 'node:assert/strict';
import { test } from 'node:test';
import { createOfflineQueue, type QueueStorage } from './offlineQueue';
function memoryStorage(): QueueStorage {
const data = new Map<string, string>();
return {
getItem: (key) => data.get(key) ?? null,
setItem: (key, value) => void data.set(key, value),
removeItem: (key) => void data.delete(key),
};
}
test('offline queue persists and restores pending items', () => {
const storage = memoryStorage();
const queue = createOfflineQueue({
storage,
uploader: async () => ({ id: 'server-1' }),
now: () => 1000,
idGenerator: () => 'id-1',
});
queue.enqueue('/tmp/a.png', 'room-1');
const restored = createOfflineQueue({
storage,
uploader: async () => ({ id: 'server-1' }),
now: () => 2000,
idGenerator: () => 'id-2',
});
assert.equal(restored.list().length, 1);
assert.equal(restored.list()[0].idempotencyKey, 'insp-id-1');
});
test('offline queue deduplicates repeated clicks', () => {
const queue = createOfflineQueue({
storage: memoryStorage(),
uploader: async () => ({ id: 'server-1' }),
now: () => 1000,
idGenerator: () => 'id-1',
});
const first = queue.enqueue('/tmp/a.png', 'room-1');
const second = queue.enqueue('/tmp/a.png', 'room-1');
assert.equal(first.id, second.id);
assert.equal(queue.stats().total, 1);
});
test('offline queue marks permanent 4xx as failed', async () => {
const queue = createOfflineQueue({
storage: memoryStorage(),
uploader: async () => {
const err: Error & { statusCode?: number } = new Error('bad request');
err.statusCode = 400;
throw err;
},
now: () => 1000,
idGenerator: () => 'id-1',
});
queue.enqueue('/tmp/a.png');
const result = await queue.syncOnce();
assert.equal(result.synced, 0);
assert.equal(queue.list()[0].state, 'failed');
});
test('offline queue retries with backoff and fails permanently', async () => {
let attempts = 0;
let now = 1000;
const queue = createOfflineQueue({
storage: memoryStorage(),
uploader: async () => {
attempts += 1;
throw new Error('network');
},
now: () => now,
idGenerator: () => 'id-1',
maxAttempts: 2,
backoffBase: 1000,
});
queue.enqueue('/tmp/a.png');
await queue.syncOnce();
assert.equal(queue.list()[0].state, 'pending');
assert.equal(queue.list()[0].attempts, 1);
now = 5000;
await queue.syncOnce();
assert.equal(queue.list()[0].state, 'failed');
assert.equal(queue.list()[0].attempts, 2);
});
test('offline queue removes synced references', async () => {
const queue = createOfflineQueue({
storage: memoryStorage(),
uploader: async () => ({ id: 'server-1' }),
now: () => 1000,
idGenerator: () => 'id-1',
});
queue.enqueue('/tmp/a.png');
await queue.syncOnce();
assert.equal(queue.stats().synced, 1);
assert.equal(queue.clearSynced(), 1);
assert.equal(queue.stats().total, 0);
});
+196
View File
@@ -0,0 +1,196 @@
export type OfflineState = 'pending' | 'uploading' | 'synced' | 'conflict' | 'failed';
export interface OfflineInspectionItem {
id: string;
idempotencyKey: string;
imagePath: string;
roomId?: string;
state: OfflineState;
attempts: number;
nextAttemptAt: number;
error?: string;
serverId?: string;
createdAt: number;
updatedAt: number;
}
export interface QueueStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
export interface QueueUploadInput {
imagePath: string;
roomId?: string;
idempotencyKey: string;
}
export interface QueueUploader {
(input: QueueUploadInput): Promise<{ id: string }>;
}
export interface OfflineQueueDeps {
storage: QueueStorage;
uploader: QueueUploader;
now?: () => number;
idGenerator?: () => string;
maxItems?: number;
maxAttempts?: number;
backoffBase?: number;
}
const STORAGE_KEY = 'offline_inspection_queue';
export const OFFLINE_QUEUE_STORAGE_KEY = STORAGE_KEY;
function loadQueue(storage: QueueStorage): OfflineInspectionItem[] {
const raw = storage.getItem(STORAGE_KEY);
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function saveQueue(storage: QueueStorage, items: OfflineInspectionItem[]): void {
storage.setItem(STORAGE_KEY, JSON.stringify(items));
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
export function createOfflineQueue(deps: OfflineQueueDeps) {
const now = deps.now || (() => Date.now());
const idGenerator = deps.idGenerator || (() => `offline-${now()}-${Math.random().toString(36).slice(2, 10)}`);
const maxItems = deps.maxItems || 30;
const maxAttempts = deps.maxAttempts || 8;
const backoffBase = deps.backoffBase || 30000;
const enqueue = (imagePath: string, roomId?: string): OfflineInspectionItem => {
const items = loadQueue(deps.storage);
const duplicate = items.find(
(item) =>
item.imagePath === imagePath &&
item.roomId === roomId &&
(item.state === 'pending' || item.state === 'uploading'),
);
if (duplicate) return duplicate;
if (items.length >= maxItems) {
throw new Error('离线巡检队列已满,请先清理已同步记录');
}
const timestamp = now();
const item: OfflineInspectionItem = {
id: idGenerator(),
idempotencyKey: `insp-${idGenerator()}`,
imagePath,
roomId,
state: 'pending',
attempts: 0,
nextAttemptAt: timestamp,
createdAt: timestamp,
updatedAt: timestamp,
};
items.unshift(item);
saveQueue(deps.storage, items);
return item;
};
const list = (): OfflineInspectionItem[] => loadQueue(deps.storage);
const remove = (id: string): void => {
saveQueue(deps.storage, loadQueue(deps.storage).filter((item) => item.id !== id));
};
const clearSynced = (): number => {
const items = loadQueue(deps.storage);
const before = items.length;
saveQueue(deps.storage, items.filter((item) => item.state !== 'synced'));
return before - loadQueue(deps.storage).length;
};
const retry = (id: string): OfflineInspectionItem | null => {
const items = loadQueue(deps.storage);
const item = items.find((entry) => entry.id === id);
if (!item || item.state === 'synced') return null;
item.state = 'pending';
item.error = undefined;
item.nextAttemptAt = now();
item.updatedAt = now();
saveQueue(deps.storage, items);
return item;
};
const syncOnce = async (): Promise<{
synced: number;
item?: OfflineInspectionItem;
error?: string;
}> => {
const items = loadQueue(deps.storage);
const item = items.find((entry) => entry.state === 'pending' && entry.nextAttemptAt <= now());
if (!item) return { synced: 0 };
item.state = 'uploading';
item.updatedAt = now();
saveQueue(deps.storage, items);
try {
const result = await deps.uploader({
imagePath: item.imagePath,
roomId: item.roomId,
idempotencyKey: item.idempotencyKey,
});
const updated = loadQueue(deps.storage);
const current = updated.find((entry) => entry.id === item.id);
if (current) {
current.state = 'synced';
current.serverId = result.id;
current.error = undefined;
current.updatedAt = now();
saveQueue(deps.storage, updated);
}
return { synced: 1, item: current || item };
} catch (err) {
const updated = loadQueue(deps.storage);
const current = updated.find((entry) => entry.id === item.id);
if (!current) return { synced: 0, error: errorMessage(err) };
current.attempts += 1;
current.error = errorMessage(err);
const status = (err as { statusCode?: number }).statusCode;
const permanent =
status !== undefined &&
status >= 400 &&
status < 500 &&
status !== 401 &&
status !== 408 &&
status !== 429;
if (permanent) {
current.state = status === 409 ? 'conflict' : 'failed';
} else if (current.attempts >= maxAttempts) {
current.state = 'failed';
} else {
current.state = 'pending';
current.nextAttemptAt = now() + backoffBase * Math.min(Math.pow(2, current.attempts - 1), 16);
}
current.updatedAt = now();
saveQueue(deps.storage, updated);
return { synced: 0, item: current, error: current.error };
}
};
const stats = () => {
const items = loadQueue(deps.storage);
return {
total: items.length,
pending: items.filter((item) => item.state === 'pending' || item.state === 'uploading').length,
synced: items.filter((item) => item.state === 'synced').length,
failed: items.filter((item) => item.state === 'failed').length,
conflict: items.filter((item) => item.state === 'conflict').length,
remaining: Math.max(0, maxItems - items.length),
};
};
return { enqueue, list, remove, clearSynced, retry, syncOnce, stats };
}
export type OfflineQueue = ReturnType<typeof createOfflineQueue>;
+19
View File
@@ -0,0 +1,19 @@
import Taro from '@tarojs/taro';
import { uploadInspection } from '@/api/inspections';
import { createOfflineQueue, OFFLINE_QUEUE_STORAGE_KEY } from './offlineQueue';
const storage = {
getItem: (key: string) => {
const value = Taro.getStorageSync(key);
return typeof value === 'string' && value ? value : null;
},
setItem: (key: string, value: string) => Taro.setStorageSync(key, value),
removeItem: (key: string) => Taro.removeStorageSync(key),
};
export const offlineQueue = createOfflineQueue({
storage,
uploader: (input) => uploadInspection(input.imagePath, input.roomId, input.idempotencyKey),
});
export { OFFLINE_QUEUE_STORAGE_KEY };
+9
View File
@@ -3,6 +3,7 @@ import Taro from '@tarojs/taro';
import type { User } from '@/types';
import { 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);
},
}));
+11
View File
@@ -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
View File
@@ -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());
+1
View File
@@ -12,6 +12,7 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"strictNullChecks": true,
"skipLibCheck": true,
"sourceMap": true,
"baseUrl": "",
"rootDir": "",
+27
View File
@@ -0,0 +1,27 @@
-- Task 6 Step 5: 只读历史数据评估报告
-- 仅统计,不更新数据;未经人工确认不得批量重算或覆盖历史 risk_score/risk_level。
WITH real_inspections AS (
SELECT
id,
risk_level,
risk_score,
detections
FROM inspection_records
WHERE ai_status = 'done'
AND (is_mock IS NULL OR is_mock = false)
)
SELECT
risk_level,
count(*) AS records,
count(*) FILTER (
WHERE EXISTS (
SELECT 1
FROM jsonb_array_elements(detections) AS d
WHERE d->>'class' = 'healthy'
AND COALESCE((d->>'confidence')::numeric, 0) >= 0.80
)
) AS healthy_high_conf_records
FROM real_inspections
GROUP BY risk_level
ORDER BY risk_level;
+95
View File
@@ -0,0 +1,95 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$script:failed = @()
function Resolve-Go {
$cmd = Get-Command go -ErrorAction SilentlyContinue
if ($cmd) {
return $cmd.Source
}
foreach ($candidate in @('C:\Program Files\Go\bin\go.exe', 'C:\Go\bin\go.exe')) {
if (Test-Path -LiteralPath $candidate) {
return $candidate
}
}
throw 'Go executable not found. Install Go 1.23 or add it to PATH.'
}
function Invoke-Check {
param(
[Parameter(Mandatory = $true)]
[string]$Name,
[Parameter(Mandatory = $true)]
[scriptblock]$Command
)
Write-Host "[$Name]"
try {
& $Command
if ($LASTEXITCODE -ne 0) {
Write-Host "FAILED: $Name (exit $LASTEXITCODE)"
$script:failed += $Name
}
} catch {
Write-Host "FAILED: $Name - $($_.Exception.Message)"
$script:failed += $Name
}
}
$goExe = Resolve-Go
Push-Location (Join-Path $root 'server-go')
try {
Invoke-Check 'server-go test' { & $goExe test ./... }
Invoke-Check 'server-go vet' { & $goExe vet ./... }
Invoke-Check 'server-go build' { & $goExe build ./... }
} finally {
Pop-Location
}
Push-Location (Join-Path $root 'web')
try {
Invoke-Check 'web test' { npm test }
Invoke-Check 'web lint' { npm run lint }
Invoke-Check 'web build' { npm run build }
} finally {
Pop-Location
}
Push-Location (Join-Path $root 'miniapp')
try {
Invoke-Check 'miniapp test' { npm test }
Invoke-Check 'miniapp typecheck' { npm run typecheck }
Invoke-Check 'miniapp build:weapp' { npm run build:weapp }
} finally {
Pop-Location
}
Push-Location (Join-Path $root 'app')
try {
Invoke-Check 'app typecheck' { npm run tsc }
Invoke-Check 'app lint' { npm run lint }
} finally {
Pop-Location
}
$aiPython = Join-Path $root 'ai-service\.venv\Scripts\python.exe'
if (-not (Test-Path -LiteralPath $aiPython)) {
$aiPython = 'python'
}
Push-Location (Join-Path $root 'ai-service')
try {
Invoke-Check 'ai-service pytest' { & $aiPython -m pytest }
} finally {
Pop-Location
}
if ($script:failed.Count -gt 0) {
Write-Error "Verification failed for: $($script:failed -join ', ')"
exit 1
}
Write-Host 'All verification checks passed.'
exit 0
+27 -6
View File
@@ -5,6 +5,7 @@ import (
"log/slog"
"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
View File
@@ -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
View File
@@ -1,3 +1,9 @@
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY=
github.com/aws/aws-sdk-go-v2 v1.30.3/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=
+30 -27
View File
@@ -6,37 +6,40 @@ import (
// Config 全局配置,从环境变量加载
type Config struct {
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"`
JWTExpiresIn string `env:"JWT_EXPIRES_IN" envDefault:"2h"`
MQTT string `env:"MQTT" envDefault:"mqtt://pan:pan@localhost:1883"`
IoTDBURL string `env:"IOTDB_URL" envDefault:"http://127.0.0.1:18081"`
S3Endpoint string `env:"S3_ENDPOINT" envDefault:"http://100.83.103.1:7480"`
S3AccessKey string `env:"S3_ACCESS_KEY" envDefault:"silk-app"`
S3SecretKey string `env:"S3_SECRET_KEY" envDefault:"Silk-App-Secret-2026!"`
S3Bucket string `env:"S3_BUCKET" envDefault:"silk-video-events"`
S3BucketImages string `env:"S3_BUCKET_IMAGES" envDefault:"silk-images"`
S3Region string `env:"S3_REGION" envDefault:"us-east-1"`
WVPAPIBase string `env:"WVP_API_BASE" envDefault:"http://localhost:18978"`
WVPUsername string `env:"WVP_USERNAME" envDefault:"admin"`
WVPPassword string `env:"WVP_PASSWORD" envDefault:"admin"`
ZLMAPIBase string `env:"ZLM_API_BASE" envDefault:"http://100.83.103.1:8081"`
ZLMSecret string `env:"ZLM_SECRET" envDefault:"su6TiedN2rVAmBbIDX0aa0QTiBJLBdcf"`
RecorderAPIBase string `env:"RECORDER_API_BASE" envDefault:"http://localhost:9090"`
AIServiceBase string `env:"AI_SERVICE_BASE" envDefault:"http://localhost:8000"`
WechatAppID string `env:"WECHAT_APPID" envDefault:""`
WechatSecret string `env:"WECHAT_SECRET" envDefault:""`
WechatTemplateAlarm string `env:"WECHAT_TEMPLATE_ALARM" envDefault:""`
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"`
JWTExpiresIn string `env:"JWT_EXPIRES_IN" envDefault:"2h"`
MQTT string `env:"MQTT" envDefault:"mqtt://pan:pan@localhost:1883"`
IoTDBURL string `env:"IOTDB_URL" envDefault:"http://127.0.0.1:18081"`
S3Endpoint string `env:"S3_ENDPOINT" envDefault:"http://100.83.103.1:7480"`
S3AccessKey string `env:"S3_ACCESS_KEY" envDefault:"silk-app"`
S3SecretKey string `env:"S3_SECRET_KEY" envDefault:"Silk-App-Secret-2026!"`
S3Bucket string `env:"S3_BUCKET" envDefault:"silk-video-events"`
S3BucketImages string `env:"S3_BUCKET_IMAGES" envDefault:"silk-images"`
S3Region string `env:"S3_REGION" envDefault:"us-east-1"`
WVPAPIBase string `env:"WVP_API_BASE" envDefault:"http://localhost:18978"`
WVPUsername string `env:"WVP_USERNAME" envDefault:"admin"`
WVPPassword string `env:"WVP_PASSWORD" envDefault:"admin"`
ZLMAPIBase string `env:"ZLM_API_BASE" envDefault:"http://100.83.103.1:8081"`
ZLMSecret string `env:"ZLM_SECRET" envDefault:"su6TiedN2rVAmBbIDX0aa0QTiBJLBdcf"`
RecorderAPIBase string `env:"RECORDER_API_BASE" envDefault:"http://localhost:9090"`
AIServiceBase string `env:"AI_SERVICE_BASE" envDefault:"http://localhost:8000"`
WechatAppID string `env:"WECHAT_APPID" envDefault:""`
WechatSecret string `env:"WECHAT_SECRET" envDefault:""`
WechatTemplateAlarm string `env:"WECHAT_TEMPLATE_ALARM" envDefault:""`
WechatTemplateInspection string `env:"WECHAT_TEMPLATE_INSPECTION" envDefault:""`
QWeatherAPIKey string `env:"QWEATHER_API_KEY" envDefault:""`
QWeatherLocation string `env:"QWEATHER_LOCATION" envDefault:""`
QWeatherIntervalMin int `env:"QWEATHER_INTERVAL_MIN" envDefault:"30"`
InternalAPIKey string `env:"INTERNAL_API_KEY" envDefault:"silk-internal-2026"`
Port int `env:"PORT" envDefault:"3000"`
DefaultAdminUsername string `env:"DEFAULT_ADMIN_USERNAME" envDefault:"admin"`
DefaultAdminPassword string `env:"DEFAULT_ADMIN_PASSWORD" envDefault:"silk@123"`
DefaultAdminEmail string `env:"DEFAULT_ADMIN_EMAIL" envDefault:"admin@silk.local"`
InternalAPIKey string `env:"INTERNAL_API_KEY" envDefault:"silk-internal-2026"`
Port int `env:"PORT" envDefault:"3000"`
DefaultAdminUsername string `env:"DEFAULT_ADMIN_USERNAME" envDefault:"admin"`
DefaultAdminPassword string `env:"DEFAULT_ADMIN_PASSWORD" envDefault:"silk@123"`
DefaultAdminEmail string `env:"DEFAULT_ADMIN_EMAIL" envDefault:"admin@silk.local"`
}
// Load 从环境变量加载配置
+45 -21
View File
@@ -1,6 +1,7 @@
package database
import (
"fmt"
"log/slog"
"silk-server-go/internal/config"
@@ -21,30 +22,49 @@ func Init(cfg *config.Config) error {
return err
}
DB = db
// 自动迁移(错误不阻止启动,仅记录警告)
if err := db.AutoMigrate(
&model.User{}, &model.Room{}, &model.Device{}, &model.Sensor{},
&model.Threshold{}, &model.Alarm{}, &model.Camera{}, &model.VideoClip{},
&model.AuditLog{}, &model.Telemetry{},
&model.Permission{}, &model.RolePermission{},
&model.Disease{}, &model.KnowledgeArticle{},
&model.InspectionRecord{},
&model.Tray{}, &model.Batch{}, &model.RearingRecord{},
&model.WechatBinding{},
&model.WeatherAlert{},
&model.LampTest{}, &model.LampTestStep{},
&model.Consumable{},
&model.Consultation{},
&model.SpectrumEntry{},
&model.TraceRecord{},
); err != nil {
slog.Warn("自动迁移有警告(可忽略)", "err", err)
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{},
&model.AuditLog{}, &model.Telemetry{},
&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{},
&model.LampTest{}, &model.LampTestStep{},
&model.Consumable{},
&model.Consultation{},
&model.SpectrumEntry{},
&model.TraceRecord{},
); err != nil {
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)
}
}
+64
View File
@@ -0,0 +1,64 @@
package database
import (
"errors"
"fmt"
"strconv"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
"gorm.io/gorm"
"silk-server-go/migrations"
)
// CurrentSchemaVersion 是当前后端代码期望的迁移版本。
const CurrentSchemaVersion = "8"
// RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。
func RunMigrations(db *gorm.DB) error {
sqlDB, err := db.DB()
if err != nil {
return fmt.Errorf("获取数据库连接: %w", err)
}
sourceDriver, err := iofs.New(migrations.FS, ".")
if err != nil {
return fmt.Errorf("加载嵌入式迁移文件: %w", err)
}
defer sourceDriver.Close()
databaseDriver, err := postgres.WithInstance(sqlDB, &postgres.Config{})
if err != nil {
return fmt.Errorf("初始化 PostgreSQL 迁移驱动: %w", err)
}
m, err := migrate.NewWithInstance("iofs", sourceDriver, "postgres", databaseDriver)
if err != nil {
return fmt.Errorf("创建迁移实例: %w", err)
}
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("执行数据库迁移: %w", err)
}
return nil
}
// CheckSchemaVersion 校验 schema_migrations 当前版本与 expected 一致,且不是 dirty。
func CheckSchemaVersion(db *gorm.DB, expected string) error {
var version int64
var dirty bool
row := db.Raw("SELECT version, dirty FROM schema_migrations ORDER BY version DESC LIMIT 1").Row()
if err := row.Scan(&version, &dirty); err != nil {
return fmt.Errorf("读取 schema_migrations: %w", err)
}
if dirty {
return fmt.Errorf("schema 迁移处于 dirty 状态,版本 %d", version)
}
if strconv.FormatInt(version, 10) != expected {
return fmt.Errorf("schema 版本不匹配:当前 %d,期望 %s", version, expected)
}
return nil
}
+127
View File
@@ -0,0 +1,127 @@
package database
import (
"testing"
"github.com/DATA-DOG/go-sqlmock"
"silk-server-go/migrations"
"github.com/golang-migrate/migrate/v4/source/iofs"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func newMockGormDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock) {
t.Helper()
sqlDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("create sqlmock: %v", err)
}
gdb, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}), &gorm.Config{})
if err != nil {
t.Fatalf("open gorm with sqlmock: %v", err)
}
return gdb, mock
}
func TestCheckSchemaVersionRejectsMismatch(t *testing.T) {
db, mock := newMockGormDB(t)
defer func() {
sqlDB, _ := db.DB()
_ = sqlDB.Close()
}()
mock.ExpectQuery("SELECT version, dirty FROM schema_migrations").
WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}).AddRow(1, false))
if err := CheckSchemaVersion(db, "2"); err == nil {
t.Fatal("expected schema mismatch error")
}
}
func TestCheckSchemaVersionAcceptsMatch(t *testing.T) {
db, mock := newMockGormDB(t)
defer func() {
sqlDB, _ := db.DB()
_ = sqlDB.Close()
}()
mock.ExpectQuery("SELECT version, dirty FROM schema_migrations").
WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}).AddRow(1, false))
if err := CheckSchemaVersion(db, "1"); err != nil {
t.Fatalf("expected schema version match, got %v", err)
}
}
func TestCheckSchemaVersionRejectsMissingVersion(t *testing.T) {
db, mock := newMockGormDB(t)
defer func() {
sqlDB, _ := db.DB()
_ = sqlDB.Close()
}()
mock.ExpectQuery("SELECT version, dirty FROM schema_migrations").
WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}))
if err := CheckSchemaVersion(db, "1"); err == nil {
t.Fatal("expected missing schema version error")
}
}
func TestCheckSchemaVersionRejectsDirty(t *testing.T) {
db, mock := newMockGormDB(t)
defer func() {
sqlDB, _ := db.DB()
_ = sqlDB.Close()
}()
mock.ExpectQuery("SELECT version, dirty FROM schema_migrations").
WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}).AddRow(1, true))
if err := CheckSchemaVersion(db, "1"); err == nil {
t.Fatal("expected dirty migration error")
}
}
func TestEmbeddedMigrationsIncludeBaseline(t *testing.T) {
driver, err := iofs.New(migrations.FS, ".")
if err != nil {
t.Fatalf("load embedded migrations: %v", err)
}
version, err := driver.First()
if err != nil {
t.Fatalf("read first migration: %v", err)
}
if version != 1 {
t.Fatalf("expected baseline migration version 1, got %d", version)
}
next, err := driver.Next(version)
if err != nil || next != 2 {
t.Fatalf("expected risk assessment migration version 2, got %d (err %v)", next, err)
}
next, err = driver.Next(next)
if err != nil || next != 4 {
t.Fatalf("expected notifications/outbox migration version 4, got %d (err %v)", next, err)
}
next, err = driver.Next(next)
if err != nil || next != 5 {
t.Fatalf("expected detection/disease migration version 5, got %d (err %v)", next, err)
}
next, err = driver.Next(next)
if err != nil || next != 6 {
t.Fatalf("expected biosecurity migration version 6, got %d (err %v)", next, err)
}
next, err = driver.Next(next)
if err != nil || next != 7 {
t.Fatalf("expected inspection idempotency migration version 7, got %d (err %v)", next, err)
}
next, err = driver.Next(next)
if err != nil || next != 8 {
t.Fatalf("expected governance migration version 8, got %d (err %v)", next, err)
}
}
+12 -1
View File
@@ -3,6 +3,7 @@ package handler
import (
"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
}
+27 -7
View File
@@ -72,7 +72,7 @@ func registerHandler(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
}
// 强制角色为 viewer,防止垂直越权(注册接口不允许自选角色)
role := model.RoleViewer
role := model.RoleViewer
user := model.User{
Username: body.Username,
Email: body.Email,
@@ -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
}
}
}
}
+413
View File
@@ -0,0 +1,413 @@
package handler
import (
"crypto/rand"
"encoding/hex"
"net/http"
"time"
"silk-server-go/internal/middleware"
"silk-server-go/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// RegisterBiosecurityRoutes 注册种源、消毒和二维码身份路由。
func RegisterBiosecurityRoutes(rg *gin.RouterGroup, db *gorm.DB) {
read := middleware.RequirePermission(db, "biosecurity:read")
write := middleware.RequirePermission(db, "biosecurity:write")
rg.GET("/biosecurity/seed-sources", read, listSeedSources(db))
rg.POST("/biosecurity/seed-sources", write, createSeedSource(db))
rg.PATCH("/biosecurity/seed-sources/:id", write, updateSeedSource(db))
rg.GET("/biosecurity/disinfection-records", read, listDisinfectionRecords(db))
rg.POST("/biosecurity/disinfection-records", write, createDisinfectionRecord(db))
rg.PATCH("/biosecurity/disinfection-records/:id", write, updateDisinfectionRecord(db))
rg.POST("/biosecurity/qr", write, issueQR(db))
rg.POST("/biosecurity/qr/resolve", read, resolveQR(db))
}
func listSeedSources(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
q := db.Model(&model.SeedSource{})
if batch := c.Query("batchId"); batch != "" {
q = q.Where("batch_id = ?", batch)
}
var list []model.SeedSource
q.Order("created_at DESC").Limit(200).Find(&list)
c.JSON(http.StatusOK, list)
}
}
func createSeedSource(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var body struct {
BatchID *string `json:"batchId"`
ParentID *string `json:"parentId"`
Supplier string `json:"supplier"`
SeedBatchNo string `json:"seedBatchNo"`
QuarantineNo *string `json:"quarantineNo"`
Variety *string `json:"variety"`
CertificateURL *string `json:"certificateUrl"`
EntryAt *time.Time `json:"entryAt"`
Note *string `json:"note"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
for _, id := range []*string{body.BatchID, body.ParentID} {
if id != nil && !isUUID(*id) {
c.JSON(http.StatusBadRequest, gin.H{"error": "关联 ID 不是合法的 UUID"})
return
}
}
source := model.SeedSource{
PublicID: randomPublicID(),
BatchID: body.BatchID,
ParentID: body.ParentID,
Supplier: body.Supplier,
SeedBatchNo: body.SeedBatchNo,
QuarantineNo: body.QuarantineNo,
Variety: body.Variety,
CertificateURL: body.CertificateURL,
EntryAt: body.EntryAt,
Note: body.Note,
CreatedBy: currentUserID(c),
}
if err := model.ValidateSeedSource(source); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if source.ParentID != nil {
if err := model.SeedSourceCycleError(seedParentChain(db), source.ID, *source.ParentID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
if err := db.Create(&source).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "创建种源失败"})
return
}
linkSeedSourceToBatch(db, source)
c.JSON(http.StatusCreated, source)
}
}
func updateSeedSource(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var source model.SeedSource
if db.Where("id = ?", c.Param("id")).First(&source).Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "seed source not found"})
return
}
var body struct {
BatchID *string `json:"batchId"`
ParentID *string `json:"parentId"`
Supplier *string `json:"supplier"`
SeedBatchNo *string `json:"seedBatchNo"`
QuarantineNo *string `json:"quarantineNo"`
Variety *string `json:"variety"`
CertificateURL *string `json:"certificateUrl"`
EntryAt *time.Time `json:"entryAt"`
Note *string `json:"note"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updates := map[string]interface{}{}
if body.BatchID != nil {
updates["batch_id"] = *body.BatchID
}
if body.ParentID != nil {
if *body.ParentID != "" {
if err := model.SeedSourceCycleError(seedParentChain(db), source.ID, *body.ParentID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updates["parent_id"] = *body.ParentID
} else {
updates["parent_id"] = nil
}
}
if body.Supplier != nil {
updates["supplier"] = *body.Supplier
}
if body.SeedBatchNo != nil {
updates["seed_batch_no"] = *body.SeedBatchNo
}
if body.QuarantineNo != nil {
updates["quarantine_no"] = *body.QuarantineNo
}
if body.Variety != nil {
updates["variety"] = *body.Variety
}
if body.CertificateURL != nil {
updates["certificate_url"] = *body.CertificateURL
}
if body.EntryAt != nil {
updates["entry_at"] = *body.EntryAt
}
if body.Note != nil {
updates["note"] = *body.Note
}
if len(updates) > 0 {
if err := db.Model(&model.SeedSource{}).Where("id = ?", source.ID).Updates(updates).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "更新种源失败"})
return
}
}
db.Where("id = ?", source.ID).First(&source)
if body.BatchID != nil {
linkSeedSourceToBatch(db, source)
}
c.JSON(http.StatusOK, source)
}
}
func listDisinfectionRecords(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
q := db.Model(&model.DisinfectionRecord{})
if room := c.Query("roomId"); room != "" {
q = q.Where("room_id = ?", room)
}
if batch := c.Query("batchId"); batch != "" {
q = q.Where("batch_id = ?", batch)
}
if kind := c.Query("kind"); kind != "" {
q = q.Where("kind = ?", kind)
}
var list []model.DisinfectionRecord
q.Order("created_at DESC").Limit(200).Find(&list)
c.JSON(http.StatusOK, list)
}
}
func createDisinfectionRecord(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var body model.DisinfectionRecord
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
body.ID = ""
body.CreatedBy = currentUserID(c)
if err := model.ValidateDisinfectionRecord(body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
now := time.Now()
if body.Kind == "plan" && body.PlannedAt == nil {
body.PlannedAt = &now
}
if body.Kind == "execution" {
body.ExecutorID = currentUserID(c)
if body.ExecutedAt == nil {
body.ExecutedAt = &now
}
}
if body.RoomID != nil && !isUUID(*body.RoomID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "roomId 不是合法的 UUID"})
return
}
if body.BatchID != nil && !isUUID(*body.BatchID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "batchId 不是合法的 UUID"})
return
}
if err := db.Create(&body).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "创建消毒记录失败"})
return
}
c.JSON(http.StatusCreated, body)
}
}
func updateDisinfectionRecord(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var record model.DisinfectionRecord
if db.Where("id = ?", c.Param("id")).First(&record).Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "disinfection record not found"})
return
}
var body struct {
ExecutedAt *time.Time `json:"executedAt"`
ReviewedAt *time.Time `json:"reviewedAt"`
ReviewerID *string `json:"reviewerId"`
PhotoURL *string `json:"photoUrl"`
Note *string `json:"note"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updates := map[string]interface{}{}
now := time.Now()
if body.ExecutedAt != nil {
updates["executed_at"] = now
updates["executor_id"] = currentUserID(c)
}
if body.ReviewedAt != nil {
updates["reviewed_at"] = *body.ReviewedAt
}
if body.ReviewerID != nil {
updates["reviewer_id"] = *body.ReviewerID
}
if body.PhotoURL != nil {
updates["photo_url"] = *body.PhotoURL
}
if body.Note != nil {
updates["note"] = *body.Note
}
if len(updates) > 0 {
if err := db.Model(&model.DisinfectionRecord{}).Where("id = ?", record.ID).Updates(updates).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "更新消毒记录失败"})
return
}
}
db.Where("id = ?", record.ID).First(&record)
c.JSON(http.StatusOK, record)
}
}
func issueQR(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var body struct {
EntityType string `json:"entityType"`
EntityID string `json:"entityId"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !model.ValidQRIdentityType(body.EntityType) || !isUUID(body.EntityID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "entityType/entityId 无效"})
return
}
if !entityExists(db, body.EntityType, body.EntityID) {
c.JSON(http.StatusNotFound, gin.H{"error": "二维码关联实体不存在"})
return
}
var link model.IdentityLink
if db.Where("entity_type = ? AND entity_id = ?", body.EntityType, body.EntityID).First(&link).Error != nil {
link = model.IdentityLink{PublicID: randomPublicID(), EntityType: body.EntityType, EntityID: body.EntityID, Version: model.QRVersion}
if err := db.Create(&link).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建二维码失败"})
return
}
}
c.JSON(http.StatusOK, gin.H{
"publicId": link.PublicID,
"entityType": link.EntityType,
"entityId": link.EntityID,
"payload": model.EncodeQRPayload(link.EntityType, link.PublicID, link.Version),
})
}
}
func resolveQR(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var body struct {
Payload string `json:"payload"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
entityType, publicID, version, err := model.ParseQRPayload(body.Payload)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var link model.IdentityLink
if db.Where("public_id = ? AND entity_type = ?", publicID, entityType).First(&link).Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "二维码身份不存在或已失效"})
return
}
if link.Version != version {
c.JSON(http.StatusConflict, gin.H{"error": "二维码版本不匹配"})
return
}
summary, ok := entitySummary(db, link.EntityType, link.EntityID)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "二维码关联实体不存在"})
return
}
c.JSON(http.StatusOK, gin.H{
"entityType": link.EntityType,
"publicId": link.PublicID,
"entity": summary,
})
}
}
func seedParentChain(db *gorm.DB) map[string]string {
var rows []struct {
ID string `gorm:"column:id"`
ParentID *string `gorm:"column:parent_id"`
}
db.Table("seed_sources").Select("id, parent_id").Scan(&rows)
chain := make(map[string]string, len(rows))
for _, row := range rows {
if row.ParentID != nil {
chain[row.ID] = *row.ParentID
}
}
return chain
}
func linkSeedSourceToBatch(db *gorm.DB, source model.SeedSource) {
if source.BatchID == nil {
return
}
_ = db.Model(&model.Batch{}).Where("id = ?", *source.BatchID).Update("seed_source_id", source.ID).Error
}
func entityExists(db *gorm.DB, entityType, entityID string) bool {
switch entityType {
case "batch":
var batch model.Batch
return db.Where("id = ?", entityID).First(&batch).Error == nil
case "tray":
var tray model.Tray
return db.Where("id = ?", entityID).First(&tray).Error == nil
case "sample":
var sample model.Sample
return db.Where("id = ?", entityID).First(&sample).Error == nil
default:
return false
}
}
func entitySummary(db *gorm.DB, entityType, entityID string) (map[string]interface{}, bool) {
switch entityType {
case "batch":
var batch model.Batch
if db.Where("id = ?", entityID).First(&batch).Error != nil {
return nil, false
}
return gin.H{"id": batch.ID, "name": batch.Name, "roomId": batch.RoomID}, true
case "tray":
var tray model.Tray
if db.Where("id = ?", entityID).First(&tray).Error != nil {
return nil, false
}
return gin.H{"id": tray.ID, "name": tray.Name, "roomId": tray.RoomID}, true
case "sample":
var sample model.Sample
if db.Where("id = ?", entityID).First(&sample).Error != nil {
return nil, false
}
return gin.H{"id": sample.ID, "sampleNo": sample.SampleNo, "detectionTaskId": sample.DetectionTaskID, "state": sample.State}, true
default:
return nil, false
}
}
func randomPublicID() string {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return hex.EncodeToString([]byte(time.Now().Format(time.RFC3339Nano)))
}
return hex.EncodeToString(b)
}
@@ -0,0 +1,66 @@
package handler
import (
"testing"
"silk-server-go/internal/model"
)
func TestQRPayloadRoundTripAndTamper(t *testing.T) {
payload := model.EncodeQRPayload("batch", "public-abc", model.QRVersion)
entityType, publicID, version, err := model.ParseQRPayload(payload)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if entityType != "batch" || publicID != "public-abc" || version != model.QRVersion {
t.Fatalf("payload = %s/%s/%d", entityType, publicID, version)
}
if _, _, _, err := model.ParseQRPayload("batch:public-abc:1"); err == nil {
t.Fatal("tampered format should fail")
}
if _, _, _, err := model.ParseQRPayload(model.EncodeQRPayload("batch", "public-abc", 2)); err != nil {
t.Fatal("different version should parse but must be rejected by resolver")
}
}
func TestSeedSourceCycleRejected(t *testing.T) {
chain := map[string]string{
"a": "b",
"b": "c",
"c": "a",
}
if err := model.SeedSourceCycleError(chain, "d", "a"); err == nil {
t.Fatal("cycle should be rejected")
}
if err := model.SeedSourceCycleError(chain, "a", "a"); err == nil {
t.Fatal("self reference should be rejected")
}
}
func TestSeedSourceCycleAllowsAcyclic(t *testing.T) {
chain := map[string]string{"a": "b", "b": "c"}
if err := model.SeedSourceCycleError(chain, "d", "a"); err != nil {
t.Fatalf("acyclic chain should pass: %v", err)
}
}
func TestDisinfectionRequiredFields(t *testing.T) {
record := model.DisinfectionRecord{Kind: "execution", Agent: "漂白粉", Concentration: "1%"}
if err := model.ValidateDisinfectionRecord(record); err != nil {
t.Fatalf("valid record should pass: %v", err)
}
record.Agent = ""
if err := model.ValidateDisinfectionRecord(record); err == nil {
t.Fatal("missing agent should fail")
}
record.Agent = "漂白粉"
record.Concentration = ""
if err := model.ValidateDisinfectionRecord(record); err == nil {
t.Fatal("missing concentration should fail")
}
record.Concentration = "1%"
record.Kind = "unknown"
if err := model.ValidateDisinfectionRecord(record); err == nil {
t.Fatal("invalid kind should fail")
}
}
+43 -7
View File
@@ -71,10 +71,10 @@ func getConsultation(db *gorm.DB) gin.HandlerFunc {
func createConsultation(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var body struct {
Title string `json:"title"`
Summary *string `json:"summary"`
RoomID *string `json:"roomId"`
BatchID *string `json:"batchId"`
Title string `json:"title"`
Summary *string `json:"summary"`
RoomID *string `json:"roomId"`
BatchID *string `json:"batchId"`
LampTestID *string `json:"lampTestId"`
}
if err := c.ShouldBindJSON(&body); err != nil {
@@ -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)
}
+55 -1
View File
@@ -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))
}
}
+124 -57
View File
@@ -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
// 风险评分(#9):AI 置信度取检测结果最大值;环境/阶段系数在有 roomId 时按房间数据计算
aiConf := 0.0
for _, d := range detRes.Detections {
if d.Confidence > aiConf {
aiConf = d.Confidence
}
isMock := detRes.IsMock
rec.IsMock = &isMock
modelVersion := detRes.ModelVersion
if modelVersion == "" {
modelVersion = "unknown"
}
stageCoef, envCoef := loadRoomRisk(db, roomID)
score := service.ComputeRiskScore(service.RiskInput{
AI: aiConf,
Env: envCoef,
Stage: stageCoef,
})
rec.RiskScore = &score
level := service.RiskLevel(score)
rec.RiskLevel = &level
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
// 微信订阅消息(#11 骨架):风险非绿且用户已授权时异步推送
if key := service.WechatTemplateKey(level); key != "" {
go func(uid *string, lv string, sc float64) {
if uid == nil || !wechat.Configured() || inspectionTemplateID == "" {
return
}
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 := db.Create(&rec).Error; err != nil {
// 微信订阅消息(#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,
})
event := service.Event{
ID: "inspection-" + rec.ID + "-" + *rec.RiskLevel,
Type: service.OutboxEventWechatSubscribe,
AggregateType: "inspection",
AggregateID: rec.ID,
Payload: payload,
}
if err := outbox.PublishTx(tx, event); err != nil {
return err
}
}
}
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
}
+35 -1
View File
@@ -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)
}
}
+49
View File
@@ -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) {
+78 -2
View File
@@ -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
@@ -376,8 +452,8 @@ func getLampCrossValidation(db *gorm.DB) gin.HandlerFunc {
}
}
c.JSON(http.StatusOK, gin.H{
"crossStatus": t.CrossStatus,
"crossReason": t.CrossReason,
"crossStatus": t.CrossStatus,
"crossReason": t.CrossReason,
"relatedInspection": related,
})
}
+25 -51
View File
@@ -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)),
Channel: body.Channel,
Target: body.Target,
Title: body.Title,
Body: body.Body,
CreatedAt: time.Now().Format(time.RFC3339),
ntf := model.Notification{
UserID: currentUserID(c),
Channel: body.Channel,
Target: body.Target,
Title: body.Title,
Body: body.Body,
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)
}
}
+28
View File
@@ -0,0 +1,28 @@
package handler
import (
"net/http"
"time"
"silk-server-go/internal/middleware"
"silk-server-go/internal/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
var opsStartedAt = time.Now()
// RegisterOpsRoutes 注册运维指标路由。
func RegisterOpsRoutes(rg *gin.RouterGroup, db *gorm.DB) {
rg.GET("/ops/metrics", middleware.RequirePermission(db, "log:read"), opsMetrics())
}
func opsMetrics() gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"uptimeSeconds": int(time.Since(opsStartedAt).Seconds()),
"dependencies": service.DependencySnapshot(),
})
}
}
+34 -3
View File
@@ -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 {
@@ -204,7 +206,7 @@ func autoTrace(db *gorm.DB) gin.HandlerFunc {
return
}
report := map[string]interface{}{
"disease": t.Disease,
"disease": t.Disease,
"generatedAt": time.Now(),
}
origin := "unknown"
@@ -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}
+102
View File
@@ -0,0 +1,102 @@
package handler
import (
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
const videoTokenTTL = 5 * time.Minute
var videoTokenSecret []byte
// SetVideoTokenSecret 设置视频短时令牌签名密钥。
func SetVideoTokenSecret(secret string) {
videoTokenSecret = []byte(secret)
}
// IssueVideoToken 签发绑定用户、资源类型和资源 ID 的短时视频令牌。
func IssueVideoToken(userID, resourceType, resourceID string, ttl time.Duration) (string, error) {
if len(videoTokenSecret) == 0 {
return "", errors.New("video token secret not configured")
}
now := time.Now()
claims := jwt.MapClaims{
"sub": userID,
"purpose": "video",
"rtype": resourceType,
"rid": resourceID,
"iat": now.Unix(),
"exp": now.Add(ttl).Unix(),
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(videoTokenSecret)
}
// ValidateVideoToken 校验视频令牌的资源类型、资源 ID、用途和有效期。
func ValidateVideoToken(token, resourceType, resourceID string) error {
claims, err := parseVideoToken(token)
if err != nil {
return err
}
return validateVideoClaims(claims, resourceType, resourceID, "")
}
// ValidateVideoTokenForUser 额外校验令牌所属用户,用于可以拿到当前 JWT 的调用方。
func ValidateVideoTokenForUser(token, resourceType, resourceID, userID string) error {
claims, err := parseVideoToken(token)
if err != nil {
return err
}
return validateVideoClaims(claims, resourceType, resourceID, userID)
}
// requireVideoToken 校验流代理 URL 上的 videoToken,缺失返回 401,错误/过期/资源错配返回 403。
func requireVideoToken(c *gin.Context, resourceType, resourceID string) bool {
token := c.Query("videoToken")
if token == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing video token"})
return false
}
if err := ValidateVideoToken(token, resourceType, resourceID); err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": "invalid video token"})
return false
}
return true
}
func parseVideoToken(token string) (jwt.MapClaims, error) {
if len(videoTokenSecret) == 0 {
return nil, errors.New("video token secret not configured")
}
claims := jwt.MapClaims{}
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
return videoTokenSecret, nil
}, jwt.WithValidMethods([]string{"HS256"}))
if err != nil {
return nil, err
}
if !parsed.Valid {
return nil, errors.New("invalid video token")
}
return claims, nil
}
func validateVideoClaims(claims jwt.MapClaims, resourceType, resourceID, userID string) error {
if claims["purpose"] != "video" {
return errors.New("invalid video token purpose")
}
if claims["rtype"] != resourceType {
return errors.New("video token resource type mismatch")
}
if claims["rid"] != resourceID {
return errors.New("video token resource mismatch")
}
if userID != "" && claims["sub"] != userID {
return errors.New("video token user mismatch")
}
return nil
}
@@ -0,0 +1,111 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"silk-server-go/internal/model"
"github.com/gin-gonic/gin"
)
func TestCameraResponseNeverContainsSecrets(t *testing.T) {
password := "secret-password"
camera := model.Camera{
PasswordEnc: &password,
GbAuthPassword: &password,
}
raw, err := json.Marshal(camera)
if err != nil {
t.Fatalf("marshal camera: %v", err)
}
body := string(raw)
if strings.Contains(body, "passwordEnc") || strings.Contains(body, password) {
t.Fatalf("camera JSON must not contain password fields: %s", body)
}
if strings.Contains(body, "gbAuthPassword") {
t.Fatalf("camera JSON must not contain gbAuthPassword: %s", body)
}
}
func TestIssueAndValidateVideoToken(t *testing.T) {
SetVideoTokenSecret("test-secret")
token, err := IssueVideoToken("user-1", "camera", "12", time.Minute)
if err != nil {
t.Fatalf("issue video token: %v", err)
}
if token == "" {
t.Fatal("expected non-empty video token")
}
if err := ValidateVideoToken(token, "camera", "12"); err != nil {
t.Fatalf("valid video token rejected: %v", err)
}
if err := ValidateVideoToken(token, "camera", "13"); err == nil {
t.Fatal("expected resource mismatch error")
}
if err := ValidateVideoToken(token, "clip", "12"); err == nil {
t.Fatal("expected resource type mismatch error")
}
}
func TestValidateVideoTokenForUserRejectsMismatch(t *testing.T) {
SetVideoTokenSecret("test-secret")
token, err := IssueVideoToken("user-1", "camera", "12", time.Minute)
if err != nil {
t.Fatalf("issue video token: %v", err)
}
if err := ValidateVideoTokenForUser(token, "camera", "12", "user-1"); err != nil {
t.Fatalf("same-user token rejected: %v", err)
}
if err := ValidateVideoTokenForUser(token, "camera", "12", "user-2"); err == nil {
t.Fatal("expected cross-user token rejection")
}
}
func TestVideoTokenExpires(t *testing.T) {
SetVideoTokenSecret("test-secret")
token, err := IssueVideoToken("user-1", "camera", "12", -time.Second)
if err != nil {
t.Fatalf("issue expired token: %v", err)
}
if err := ValidateVideoToken(token, "camera", "12"); err == nil {
t.Fatal("expected expired token rejection")
}
}
func TestVideoStreamRequiresValidToken(t *testing.T) {
SetVideoTokenSecret("test-secret")
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = &http.Request{URL: &url.URL{RawQuery: ""}}
if requireVideoToken(c, "camera", "12") {
t.Fatal("missing token should be rejected")
}
if rec.Code != http.StatusUnauthorized {
t.Fatalf("missing token status = %d, want 401", rec.Code)
}
token, err := IssueVideoToken("user-1", "camera", "13", time.Minute)
if err != nil {
t.Fatalf("issue token: %v", err)
}
rec2 := httptest.NewRecorder()
c2, _ := gin.CreateTestContext(rec2)
c2.Request = &http.Request{URL: &url.URL{RawQuery: "videoToken=" + token}}
if requireVideoToken(c2, "camera", "12") {
t.Fatal("wrong resource token should be rejected")
}
if rec2.Code != http.StatusForbidden {
t.Fatalf("wrong resource token status = %d, want 403", rec2.Code)
}
}
+44 -50
View File
@@ -86,7 +86,11 @@ func listCameras(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
slog.Warn("同步 WVP 设备信息失败,保留 DB 状态", "error", err)
}
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 -3
View File
@@ -25,6 +25,12 @@ func RegisterVideoClipRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Confi
// listClips 录像片段列表(query: cameraId/from/to/limit,按 startAt DESC
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),
})
}
}
+128
View File
@@ -0,0 +1,128 @@
package handler
import (
"time"
"silk-server-go/internal/model"
)
// CameraPublic 摄像头对外返回的非敏感字段。
type CameraPublic struct {
ID uint `json:"id"`
RoomID *string `json:"roomId,omitempty"`
Code string `json:"code"`
Name string `json:"name"`
RtspURL *string `json:"rtspUrl,omitempty"`
HTTPURL *string `json:"httpUrl,omitempty"`
Username *string `json:"username,omitempty"`
Position *string `json:"position,omitempty"`
Resolution *string `json:"resolution,omitempty"`
FPS *int `json:"fps,omitempty"`
IsOnline bool `json:"isOnline"`
GbDeviceID *string `json:"gbDeviceId,omitempty"`
GbChannelID *string `json:"gbChannelId,omitempty"`
GbAuthID *string `json:"gbAuthId,omitempty"`
GbStreamType *string `json:"gbStreamType,omitempty"`
GbTransport *string `json:"gbTransport,omitempty"`
GbAlarmChannelID *string `json:"gbAlarmChannelId,omitempty"`
GbVoiceChannelID *string `json:"gbVoiceChannelId,omitempty"`
GbManufacturer *string `json:"gbManufacturer,omitempty"`
ManufacturerID *string `json:"manufacturerId,omitempty"`
StreamURL *string `json:"streamUrl,omitempty"`
HlsURL *string `json:"hlsUrl,omitempty"`
FlvURL *string `json:"flvUrl,omitempty"`
WebrtcURL *string `json:"webrtcUrl,omitempty"`
SnapshotURL *string `json:"snapshotUrl,omitempty"`
Online bool `json:"online"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// CameraInput 摄像头创建输入,允许接收密码字段,但不会作为公开响应。
type CameraInput struct {
ID uint `json:"id,omitempty"`
RoomID *string `json:"roomId,omitempty"`
Code string `json:"code,omitempty"`
Name string `json:"name"`
RtspURL *string `json:"rtspUrl,omitempty"`
HTTPURL *string `json:"httpUrl,omitempty"`
Username *string `json:"username,omitempty"`
PasswordEnc *string `json:"passwordEnc,omitempty"`
Position *string `json:"position,omitempty"`
Resolution *string `json:"resolution,omitempty"`
FPS *int `json:"fps,omitempty"`
IsOnline bool `json:"isOnline"`
GbDeviceID *string `json:"gbDeviceId,omitempty"`
GbChannelID *string `json:"gbChannelId,omitempty"`
GbAuthID *string `json:"gbAuthId,omitempty"`
GbAuthPassword *string `json:"gbAuthPassword,omitempty"`
GbStreamType *string `json:"gbStreamType,omitempty"`
GbTransport *string `json:"gbTransport,omitempty"`
GbAlarmChannelID *string `json:"gbAlarmChannelId,omitempty"`
GbVoiceChannelID *string `json:"gbVoiceChannelId,omitempty"`
GbManufacturer *string `json:"gbManufacturer,omitempty"`
ManufacturerID *string `json:"manufacturerId,omitempty"`
Enabled bool `json:"enabled"`
}
func (in CameraInput) toModel() model.Camera {
return model.Camera{
RoomID: in.RoomID,
Code: in.Code,
Name: in.Name,
RtspURL: in.RtspURL,
HTTPURL: in.HTTPURL,
Username: in.Username,
PasswordEnc: in.PasswordEnc,
Position: in.Position,
Resolution: in.Resolution,
FPS: in.FPS,
IsOnline: in.IsOnline,
GbDeviceID: in.GbDeviceID,
GbChannelID: in.GbChannelID,
GbAuthID: in.GbAuthID,
GbAuthPassword: in.GbAuthPassword,
GbStreamType: in.GbStreamType,
GbTransport: in.GbTransport,
GbAlarmChannelID: in.GbAlarmChannelID,
GbVoiceChannelID: in.GbVoiceChannelID,
GbManufacturer: in.GbManufacturer,
ManufacturerID: in.ManufacturerID,
Enabled: in.Enabled,
}
}
func toCameraPublic(camera model.Camera) CameraPublic {
return CameraPublic{
ID: camera.ID,
RoomID: camera.RoomID,
Code: camera.Code,
Name: camera.Name,
RtspURL: camera.RtspURL,
HTTPURL: camera.HTTPURL,
Username: camera.Username,
Position: camera.Position,
Resolution: camera.Resolution,
FPS: camera.FPS,
IsOnline: camera.IsOnline,
GbDeviceID: camera.GbDeviceID,
GbChannelID: camera.GbChannelID,
GbAuthID: camera.GbAuthID,
GbStreamType: camera.GbStreamType,
GbTransport: camera.GbTransport,
GbAlarmChannelID: camera.GbAlarmChannelID,
GbVoiceChannelID: camera.GbVoiceChannelID,
GbManufacturer: camera.GbManufacturer,
ManufacturerID: camera.ManufacturerID,
StreamURL: camera.StreamURL,
HlsURL: camera.HlsURL,
FlvURL: camera.FlvURL,
WebrtcURL: camera.WebrtcURL,
SnapshotURL: camera.SnapshotURL,
Online: camera.Online,
Enabled: camera.Enabled,
CreatedAt: camera.CreatedAt,
UpdatedAt: camera.UpdatedAt,
}
}
+11 -1
View File
@@ -14,8 +14,9 @@ import (
"gorm.io/gorm"
)
// 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
+18 -13
View File
@@ -20,16 +20,16 @@ type JWTClaims struct {
// 白名单路径,无需鉴权
var whitelist = map[string]bool{
"/api/v1/health": true,
"/health": true,
"/api/health": true,
"/api/v1/auth/login": true,
"/auth/login": true,
"/api/v1/auth/register": true,
"/auth/register": true,
"/api/v1/auth/refresh": true,
"/auth/refresh": true,
"/api/v1/video/clips/internal": true,
"/api/v1/health": true,
"/health": true,
"/api/health": true,
"/api/v1/auth/login": true,
"/auth/login": true,
"/api/v1/auth/register": true,
"/auth/register": true,
"/api/v1/auth/refresh": true,
"/auth/refresh": true,
"/api/v1/video/clips/internal": true,
"/api/v1/video/recordings/internal/end": true,
}
@@ -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
}
+10
View File
@@ -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,
)
}
}
+26 -89
View File
@@ -1,110 +1,41 @@
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
maxFailures = 5 // 连续失败 5 次后锁定
lockDuration = 15 * time.Minute
failureWindow = 10 * 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,
"error": "登录尝试过多,已锁定,请稍后再试",
"retry": int(remain.Minutes()) + 1,
})
return true
}
@@ -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)
}
}
+104
View File
@@ -0,0 +1,104 @@
package middleware
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/redis/go-redis/v9"
)
const statePrefix = "silk:auth:"
// StateStore 跨实例认证状态存储。
type StateStore interface {
RevokeToken(ctx context.Context, id string, exp time.Time) error
IsTokenRevoked(ctx context.Context, id string) (bool, error)
CheckLoginLock(ctx context.Context, key string) (bool, time.Duration, error)
RecordLoginFailure(ctx context.Context, key string) error
RecordLoginSuccess(ctx context.Context, key string) error
}
var (
authState StateStore
appEnv string
)
// InitState 设置认证状态存储;store 为 nil 时认证相关接口保守失败。
func InitState(store StateStore, env string) {
authState = store
appEnv = env
}
// RedisState Redis 实现。
type RedisState struct {
rdb *redis.Client
prefix string
}
// NewRedisState 创建 Redis 状态存储。
func NewRedisState(rdb *redis.Client) *RedisState {
return &RedisState{rdb: rdb, prefix: statePrefix}
}
func (s *RedisState) RevokeToken(ctx context.Context, id string, exp time.Time) error {
ttl := time.Until(exp)
if ttl <= 0 {
return nil
}
return s.rdb.Set(ctx, s.prefix+"revoked:"+id, "1", ttl).Err()
}
func (s *RedisState) IsTokenRevoked(ctx context.Context, id string) (bool, error) {
count, err := s.rdb.Exists(ctx, s.prefix+"revoked:"+id).Result()
if err != nil {
return false, err
}
return count > 0, nil
}
func (s *RedisState) CheckLoginLock(ctx context.Context, key string) (bool, time.Duration, error) {
lockKey := s.prefix + "login-lock:" + key
if _, err := s.rdb.Get(ctx, lockKey).Result(); err == redis.Nil {
return false, 0, nil
} else if err != nil {
return false, 0, err
}
ttl, err := s.rdb.TTL(ctx, lockKey).Result()
if err != nil {
return false, 0, err
}
return true, ttl, nil
}
var loginFailureScript = redis.NewScript(`
local count = redis.call('INCR', KEYS[1])
redis.call('EXPIRE', KEYS[1], ARGV[1])
if tonumber(count) >= tonumber(ARGV[2]) then
redis.call('SET', KEYS[2], '1', 'PX', ARGV[3])
end
return count
`)
func (s *RedisState) RecordLoginFailure(ctx context.Context, key string) error {
return loginFailureScript.Run(ctx, s.rdb,
[]string{s.prefix + "login-failures:" + key, s.prefix + "login-lock:" + key},
int(failureWindow.Seconds()), maxFailures, int(lockDuration.Milliseconds()),
).Err()
}
func (s *RedisState) RecordLoginSuccess(ctx context.Context, key string) error {
pipe := s.rdb.Pipeline()
pipe.Del(ctx, s.prefix+"login-failures:"+key, s.prefix+"login-lock:"+key)
_, err := pipe.Exec(ctx)
return err
}
func stateUnavailable(operation string) error {
msg := "Redis 状态服务不可用,无法" + operation
if appEnv == "production" {
slog.Error(msg)
}
return fmt.Errorf("%s", msg)
}
+152
View File
@@ -0,0 +1,152 @@
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
)
type memoryState struct {
mu sync.Mutex
revoked map[string]time.Time
failures map[string]int
locked map[string]time.Time
}
func newMemoryState() *memoryState {
return &memoryState{
revoked: map[string]time.Time{},
failures: map[string]int{},
locked: map[string]time.Time{},
}
}
func (m *memoryState) RevokeToken(_ context.Context, id string, exp time.Time) error {
m.mu.Lock()
defer m.mu.Unlock()
m.revoked[id] = exp
return nil
}
func (m *memoryState) IsTokenRevoked(_ context.Context, id string) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
exp, ok := m.revoked[id]
if !ok {
return false, nil
}
if time.Now().After(exp) {
delete(m.revoked, id)
return false, nil
}
return true, nil
}
func (m *memoryState) CheckLoginLock(_ context.Context, key string) (bool, time.Duration, error) {
m.mu.Lock()
defer m.mu.Unlock()
until, ok := m.locked[key]
if !ok {
return false, 0, nil
}
if time.Now().After(until) {
delete(m.locked, key)
return false, 0, nil
}
return true, time.Until(until), nil
}
func (m *memoryState) RecordLoginFailure(_ context.Context, key string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.failures[key]++
if m.failures[key] >= maxFailures {
m.locked[key] = time.Now().Add(lockDuration)
}
return nil
}
func (m *memoryState) RecordLoginSuccess(_ context.Context, key string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.failures, key)
delete(m.locked, key)
return nil
}
func withMemoryState(t *testing.T) *memoryState {
t.Helper()
store := newMemoryState()
InitState(store, "test")
t.Cleanup(func() { InitState(nil, "test") })
return store
}
func TestRevokeTokenIsCrossInstanceState(t *testing.T) {
withMemoryState(t)
claims := &JWTClaims{}
claims.ID = "token-1"
if err := RevokeToken(claims, "token", time.Now().Add(time.Hour)); err != nil {
t.Fatalf("RevokeToken failed: %v", err)
}
revoked, err := IsRevoked(claims)
if err != nil {
t.Fatalf("IsRevoked failed: %v", err)
}
if !revoked {
t.Fatal("token should be revoked")
}
}
func TestRevokeTokenUnavailableFailsClosed(t *testing.T) {
InitState(nil, "production")
t.Cleanup(func() { InitState(nil, "test") })
claims := &JWTClaims{}
claims.ID = "x"
if err := RevokeToken(claims, "token", time.Now().Add(time.Hour)); err == nil {
t.Fatal("state unavailable should fail closed")
}
}
func TestLoginLockStateSemantics(t *testing.T) {
store := withMemoryState(t)
for i := 0; i < maxFailures; i++ {
if err := store.RecordLoginFailure(context.Background(), "1.1.1.1|user"); err != nil {
t.Fatalf("record failure failed: %v", err)
}
}
locked, _, err := store.CheckLoginLock(context.Background(), "1.1.1.1|user")
if err != nil {
t.Fatalf("check lock failed: %v", err)
}
if !locked {
t.Fatal("expected login lock after max failures")
}
if err := store.RecordLoginSuccess(context.Background(), "1.1.1.1|user"); err != nil {
t.Fatalf("record success failed: %v", err)
}
locked, _, _ = store.CheckLoginLock(context.Background(), "1.1.1.1|user")
if locked {
t.Fatal("login lock should be cleared after success")
}
}
func TestCheckLoginLockUnavailableReturns503(t *testing.T) {
InitState(nil, "test")
t.Cleanup(func() { InitState(nil, "test") })
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/auth/login", nil)
if !CheckLoginLock(c, "user") {
t.Fatal("state unavailable should abort login")
}
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rec.Code)
}
}
@@ -1,45 +1,26 @@
package middleware
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 使用)
+144
View File
@@ -0,0 +1,144 @@
package model
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
)
const QRVersion = 1
// IdentityLink 不透明二维码映射,避免把内部 ID 或个人信息放进二维码。
type IdentityLink struct {
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
PublicID string `gorm:"column:public_id;size:64;uniqueIndex" json:"publicId"`
EntityType string `gorm:"column:entity_type;size:16;index" json:"entityType"`
EntityID string `gorm:"column:entity_id;size:128;uniqueIndex" json:"entityId"`
Version int `gorm:"default:1" json:"version"`
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
}
func (IdentityLink) TableName() string { return "identity_links" }
// SeedSource 蚕种来源与检疫链。
type SeedSource struct {
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
PublicID string `gorm:"column:public_id;size:64;uniqueIndex" json:"publicId"`
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
ParentID *string `gorm:"column:parent_id;type:uuid;index" json:"parentId,omitempty"`
Supplier string `gorm:"size:128" json:"supplier"`
SeedBatchNo string `gorm:"column:seed_batch_no;size:64" json:"seedBatchNo"`
QuarantineNo *string `gorm:"column:quarantine_no;size:64" json:"quarantineNo,omitempty"`
Variety *string `gorm:"size:64" json:"variety,omitempty"`
CertificateURL *string `gorm:"column:certificate_url;size:512" json:"certificateUrl,omitempty"`
EntryAt *time.Time `gorm:"column:entry_at;type:timestamptz" json:"entryAt,omitempty"`
Note *string `gorm:"type:text" json:"note,omitempty"`
CreatedBy *string `gorm:"column:created_by;type:uuid" json:"createdBy,omitempty"`
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
}
func (SeedSource) TableName() string { return "seed_sources" }
// DisinfectionRecord 消毒计划与执行记录。
type DisinfectionRecord struct {
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"`
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
Kind string `gorm:"size:16;default:plan" json:"kind"` // plan/execution
PlanID *string `gorm:"column:plan_id;type:uuid;index" json:"planId,omitempty"`
Agent string `gorm:"size:128" json:"agent"`
Concentration string `gorm:"size:64" json:"concentration"`
Amount *string `gorm:"size:64" json:"amount,omitempty"`
PlannedAt *time.Time `gorm:"column:planned_at;type:timestamptz" json:"plannedAt,omitempty"`
ExecutedAt *time.Time `gorm:"column:executed_at;type:timestamptz" json:"executedAt,omitempty"`
ExecutorID *string `gorm:"column:executor_id;type:uuid" json:"executorId,omitempty"`
ReviewedAt *time.Time `gorm:"column:reviewed_at;type:timestamptz" json:"reviewedAt,omitempty"`
ReviewerID *string `gorm:"column:reviewer_id;type:uuid" json:"reviewerId,omitempty"`
PhotoURL *string `gorm:"column:photo_url;size:512" json:"photoUrl,omitempty"`
Note *string `gorm:"type:text" json:"note,omitempty"`
CreatedBy *string `gorm:"column:created_by;type:uuid" json:"createdBy,omitempty"`
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
}
func (DisinfectionRecord) TableName() string { return "disinfection_records" }
// EncodeQRPayload 生成不透明二维码载荷。
func EncodeQRPayload(entityType, publicID string, version int) string {
return fmt.Sprintf("silk:v1:%s:%s:%d", entityType, publicID, version)
}
// ParseQRPayload 解析并校验二维码载荷。
func ParseQRPayload(payload string) (string, string, int, error) {
parts := strings.Split(strings.TrimSpace(payload), ":")
if len(parts) != 5 || parts[0] != "silk" || parts[1] != "v1" {
return "", "", 0, errors.New("二维码格式不正确")
}
entityType := parts[2]
publicID := parts[3]
version, err := strconv.Atoi(parts[4])
if err != nil || version <= 0 {
return "", "", 0, errors.New("二维码版本不正确")
}
if !ValidQRIdentityType(entityType) || publicID == "" {
return "", "", 0, errors.New("二维码身份无效")
}
return entityType, publicID, version, nil
}
// ValidQRIdentityType 当前二维码支持的实体类型。
func ValidQRIdentityType(entityType string) bool {
switch entityType {
case "batch", "tray", "sample":
return true
default:
return false
}
}
// ValidateSeedSource 种源必填项。
func ValidateSeedSource(source SeedSource) error {
if strings.TrimSpace(source.Supplier) == "" {
return errors.New("供应商不能为空")
}
if strings.TrimSpace(source.SeedBatchNo) == "" {
return errors.New("蚕种批号不能为空")
}
return nil
}
// ValidateDisinfectionRecord 消毒必填项。
func ValidateDisinfectionRecord(record DisinfectionRecord) error {
if record.Kind != "plan" && record.Kind != "execution" {
return errors.New("kind 仅支持 plan/execution")
}
if strings.TrimSpace(record.Agent) == "" {
return errors.New("消毒药剂不能为空")
}
if strings.TrimSpace(record.Concentration) == "" {
return errors.New("消毒浓度不能为空")
}
return nil
}
// SeedSourceCycleError 检测种源链是否形成循环。
func SeedSourceCycleError(chain map[string]string, id, parentID string) error {
seen := map[string]bool{id: true}
current := parentID
for current != "" {
if seen[current] {
return errors.New("种源链不能形成循环")
}
seen[current] = true
next := chain[current]
if next == current {
return errors.New("种源链不能自引用")
}
current = next
}
return nil
}
+49 -27
View File
@@ -7,46 +7,68 @@ import (
// Consultation 专家会诊单
type Consultation 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"`
LampTestID *string `gorm:"column:lamp_test_id;type:uuid;index" json:"lampTestId,omitempty"`
Title string `gorm:"size:128" json:"title"`
Summary *string `gorm:"type:text" json:"summary,omitempty"`
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"`
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"`
ArchivedAt *time.Time `gorm:"column:archived_at;type:timestamptz" json:"archivedAt,omitempty"`
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
RoomName *string `gorm:"-" json:"roomName,omitempty"`
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"`
LampTestID *string `gorm:"column:lamp_test_id;type:uuid;index" json:"lampTestId,omitempty"`
Title string `gorm:"size:128" json:"title"`
Summary *string `gorm:"type:text" json:"summary,omitempty"`
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"`
ArchivedAt *time.Time `gorm:"column:archived_at;type:timestamptz" json:"archivedAt,omitempty"`
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
RoomName *string `gorm:"-" json:"roomName,omitempty"`
}
func (Consultation) TableName() string { return "consultations" }
// ValidConsultationTransition 状态流转规则:pending→consulting/resolvedconsulting→resolvedresolvedarchived
// ValidConsultationTransition 状态流转规则:unassigned/assigned/accepted/needs_info/resolved/archived
func ValidConsultationTransition(from, to string) bool {
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"`
LampTest *LampTest `json:"lampTest,omitempty"`
RoomName string `json:"roomName,omitempty"`
LampTest *LampTest `json:"lampTest,omitempty"`
Inspection *InspectionRecord `json:"inspection,omitempty"`
Batch *Batch `json:"batch,omitempty"`
WeatherAlerts []WeatherAlert `json:"weatherAlerts,omitempty"`
CreatedAt time.Time `json:"createdAt"`
Batch *Batch `json:"batch,omitempty"`
WeatherAlerts []WeatherAlert `json:"weatherAlerts,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
+38 -9
View File
@@ -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,15 +34,41 @@ 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{
RoomName: "蚕房1#",
LampTest: &LampTest{ID: "lamp-1", Status: "resulted"},
Inspection: &InspectionRecord{ID: "insp-1", AIStatus: "done"},
Batch: &Batch{ID: "batch-1", Name: "批次A"},
RoomName: "蚕房1#",
LampTest: &LampTest{ID: "lamp-1", Status: "resulted"},
Inspection: &InspectionRecord{ID: "insp-1", AIStatus: "done"},
Batch: &Batch{ID: "batch-1", Name: "批次A"},
WeatherAlerts: []WeatherAlert{{Disease: "白僵病", Level: "orange"}},
CreatedAt: now,
CreatedAt: now,
}
raw, err := json.Marshal(s)
if err != nil {
@@ -0,0 +1,97 @@
package model
import "time"
// DetectionTask 统一检测任务,覆盖 LAMP/qPCR/SERS/高光谱。
type DetectionTask struct {
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
SourceKey string `gorm:"column:source_key;size:128;uniqueIndex" json:"sourceKey,omitempty"`
SourceType string `gorm:"column:source_type;size:32" json:"sourceType"`
SourceID string `gorm:"column:source_id;size:128" json:"sourceId,omitempty"`
RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"`
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
InspectionID *string `gorm:"column:inspection_id;type:uuid;index" json:"inspectionId,omitempty"`
Disease string `gorm:"size:64" json:"disease"`
RecommendedMethod string `gorm:"column:recommended_method;size:32" json:"recommendedMethod,omitempty"`
Method *string `gorm:"size:32" json:"method,omitempty"`
Priority string `gorm:"size:16;default:routine" json:"priority"`
Status string `gorm:"size:16;default:pending;index" json:"status"`
AssigneeID *string `gorm:"column:assignee_id;type:uuid" json:"assigneeId,omitempty"`
AssignedAt *time.Time `gorm:"column:assigned_at;type:timestamptz" json:"assignedAt,omitempty"`
Result *string `gorm:"size:16" json:"result,omitempty"`
ResultedAt *time.Time `gorm:"column:resulted_at;type:timestamptz" json:"resultedAt,omitempty"`
CancelledReason *string `gorm:"column:cancelled_reason;type:text" json:"cancelledReason,omitempty"`
CreatedBy *string `gorm:"column:created_by;type:uuid" json:"createdBy,omitempty"`
Note *string `gorm:"type:text" json:"note,omitempty"`
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
RoomName *string `gorm:"-" json:"roomName,omitempty"`
}
func (DetectionTask) TableName() string { return "detection_tasks" }
// ValidDetectionTaskTransition 状态机:draft/pending/assigned/sampling/testing/review/completed/cancelled。
func ValidDetectionTaskTransition(from, to string) bool {
switch from {
case "draft":
return to == "pending" || to == "cancelled"
case "pending":
return to == "assigned" || to == "cancelled"
case "assigned":
return to == "sampling" || to == "cancelled"
case "sampling":
return to == "testing" || to == "cancelled"
case "testing":
return to == "review" || to == "cancelled"
case "review":
return to == "completed" || to == "cancelled"
default:
return false
}
}
// Sample 检测样本链路。
type Sample struct {
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
DetectionTaskID string `gorm:"column:detection_task_id;type:uuid;uniqueIndex" json:"detectionTaskId"`
SampleNo string `gorm:"column:sample_no;size:64;uniqueIndex" json:"sampleNo"`
RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"`
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
TrayID *string `gorm:"column:tray_id;type:uuid" json:"trayId,omitempty"`
SampledBy *string `gorm:"column:sampled_by;type:uuid" json:"sampledBy,omitempty"`
SampledAt *time.Time `gorm:"column:sampled_at;type:timestamptz" json:"sampledAt,omitempty"`
CollectedAt *time.Time `gorm:"column:collected_at;type:timestamptz" json:"collectedAt,omitempty"`
HandedOverAt *time.Time `gorm:"column:handed_over_at;type:timestamptz" json:"handedOverAt,omitempty"`
HandedOverBy *string `gorm:"column:handed_over_by;type:uuid" json:"handedOverBy,omitempty"`
ReceivedAt *time.Time `gorm:"column:received_at;type:timestamptz" json:"receivedAt,omitempty"`
ReceivedBy *string `gorm:"column:received_by;type:uuid" json:"receivedBy,omitempty"`
TestingStartedAt *time.Time `gorm:"column:testing_started_at;type:timestamptz" json:"testingStartedAt,omitempty"`
ConsumedAt *time.Time `gorm:"column:consumed_at;type:timestamptz" json:"consumedAt,omitempty"`
DisposedAt *time.Time `gorm:"column:disposed_at;type:timestamptz" json:"disposedAt,omitempty"`
State string `gorm:"size:16;default:created" json:"state"`
Note *string `gorm:"type:text" json:"note,omitempty"`
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
}
func (Sample) TableName() string { return "samples" }
// ValidSampleTransition 样本状态机:created/collected/handed_over/received/testing/consumed/disposed。
func ValidSampleTransition(from, to string) bool {
switch from {
case "created":
return to == "collected" || to == "disposed"
case "collected":
return to == "handed_over" || to == "disposed"
case "handed_over":
return to == "received" || to == "disposed"
case "received":
return to == "testing" || to == "disposed"
case "testing":
return to == "consumed" || to == "disposed"
case "consumed":
return to == "disposed"
default:
return false
}
}
@@ -0,0 +1,51 @@
package model
import "testing"
func TestDetectionTaskRejectsInvalidTransition(t *testing.T) {
if ValidDetectionTaskTransition("pending", "testing") {
t.Error("pending 不能直接跳 testing")
}
if ValidDetectionTaskTransition("draft", "completed") {
t.Error("draft 不能直接 completed")
}
if ValidDetectionTaskTransition("completed", "review") {
t.Error("completed 不能回退")
}
}
func TestDetectionTaskAllowsRequiredTransitions(t *testing.T) {
transitions := [][2]string{
{"draft", "pending"},
{"pending", "assigned"},
{"assigned", "sampling"},
{"sampling", "testing"},
{"testing", "review"},
{"review", "completed"},
{"pending", "cancelled"},
}
for _, tr := range transitions {
if !ValidDetectionTaskTransition(tr[0], tr[1]) {
t.Errorf("expected %s -> %s", tr[0], tr[1])
}
}
}
func TestSampleTransition(t *testing.T) {
transitions := [][2]string{
{"created", "collected"},
{"collected", "handed_over"},
{"handed_over", "received"},
{"received", "testing"},
{"testing", "consumed"},
{"consumed", "disposed"},
}
for _, tr := range transitions {
if !ValidSampleTransition(tr[0], tr[1]) {
t.Errorf("expected %s -> %s", tr[0], tr[1])
}
}
if ValidSampleTransition("created", "testing") {
t.Error("created 不能直接 testing")
}
}
+58
View File
@@ -0,0 +1,58 @@
package model
import (
"encoding/json"
"errors"
"time"
)
// DiseaseEvent 独立发病事件,作为处置、会诊、溯源和效果评估主线。
type DiseaseEvent struct {
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
SourceKey string `gorm:"column:source_key;size:128;uniqueIndex" json:"sourceKey,omitempty"`
RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"`
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
DetectionTaskID *string `gorm:"column:detection_task_id;type:uuid;index" json:"detectionTaskId,omitempty"`
LampTestID *string `gorm:"column:lamp_test_id;type:uuid;index" json:"lampTestId,omitempty"`
ConsultationID *string `gorm:"column:consultation_id;type:uuid;index" json:"consultationId,omitempty"`
InspectionID *string `gorm:"column:inspection_id;type:uuid;index" json:"inspectionId,omitempty"`
Disease string `gorm:"size:64" json:"disease"`
Status string `gorm:"size:16;default:suspected;index" json:"status"`
Evidence json.RawMessage `gorm:"type:jsonb" json:"evidence,omitempty"`
ConfirmedAt *time.Time `gorm:"column:confirmed_at;type:timestamptz" json:"confirmedAt,omitempty"`
ConfirmedBy *string `gorm:"column:confirmed_by;type:uuid" json:"confirmedBy,omitempty"`
LossSummary *string `gorm:"column:loss_summary;type:text" json:"lossSummary,omitempty"`
Measure *string `gorm:"type:text" json:"measure,omitempty"`
Note *string `gorm:"type:text" json:"note,omitempty"`
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
RoomName *string `gorm:"-" json:"roomName,omitempty"`
}
func (DiseaseEvent) TableName() string { return "disease_events" }
// ValidDiseaseEventTransition 状态机:suspected/confirmed/controlled/closed/reopened。
func ValidDiseaseEventTransition(from, to string) bool {
switch from {
case "suspected":
return to == "confirmed" || to == "closed"
case "confirmed":
return to == "controlled" || to == "closed"
case "controlled":
return to == "closed" || to == "reopened"
case "closed":
return to == "reopened"
case "reopened":
return to == "confirmed" || to == "controlled" || to == "closed"
default:
return false
}
}
// ValidateDiseaseEventEvidence 确诊必须有证据,不能仅凭状态字段确认。
func ValidateDiseaseEventEvidence(event DiseaseEvent) error {
if event.Status == "confirmed" && len(event.Evidence) == 0 {
return errors.New("确诊发病事件必须提供证据")
}
return nil
}

Some files were not shown because too many files have changed in this diff Show More