完善牧安云哨-后端

This commit is contained in:
BBIT-Kai
2025-12-29 16:30:36 +08:00
parent cd7aa35960
commit b9b8d30ebf
23 changed files with 1074 additions and 41 deletions
+79
View File
@@ -0,0 +1,79 @@
import json
import re
from typing import TypedDict
from langchain_core.messages import HumanMessage
from langgraph.graph import StateGraph, END
from config.llm import llmVision
# -------- 定义状态 --------
class State(TypedDict):
image_url: str # 图像
content: str # 最终内容
def send_analyze(state: State, prompt_text: str):
messages = [
HumanMessage(
content=[
{"type": "text", "text": prompt_text},
{"type": "image_url", "image_url": {"url": state["image_url"]}},
]
)
]
return llmVision.invoke(messages).content
def analysis(state: State):
state["content"] = send_analyze(
state,
"""
提示词示例
你是一个图像分析助手。现在给你一张车的侧身照片,请你从图中分析车上运输的牲畜种类。
要求:
1. 牲畜种类可能是:牛、羊、猪、鸡、鸭、鹅。
2. 如果图中无法判断牲畜类型,请在备注字段 remark 中写明“无法识别”或你观察到的情况。
3. 不允许输出多余文字,直接返回 JSON。
JSON 示例格式:
{
"livestock_type": "<牲畜种类>", // 如果能识别就填牛/羊/猪/鸡/鸭/鹅
"remark": "<备注>" // 如果无法识别,写明原因;否则可留空
}
请确保输出的 JSON 可以被严格解析。
""",
)
return state
# ------------------------------------------------------------------------ 构建有向图 --------
workflow = StateGraph(State)
# 必须先从 START 指向 analysis
workflow.add_node("analysis", analysis)
workflow.set_entry_point("analysis")
workflow.add_edge("analysis", END)
graph = workflow.compile()
# 执行函数
async def get_vehicle_response(image_url: str):
final_state = graph.invoke(
{
"image_url": image_url,
}
)
# 去掉 ```json 和 ``` 包裹
content_str = re.sub(r"^```json\s*|\s*```$", "", final_state["content"].strip())
# 把 JSON 字符串转为字典
try:
content_dict = json.loads(content_str)
except json.JSONDecodeError:
print("JSON解析失败")
content_dict = {}
return content_dict
+7 -4
View File
@@ -4,6 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from uvicorn import Config, Server
from config.emqx import mqtt_client_async
from config.yolo import YOLOSingleton
from routers.Bot import botRouter
from routers.Chat import chatRouter
@@ -18,6 +19,7 @@ from routers.Service import serviceRouter
from routers.System import systemRouter
from routers.Vision import visionRouter
from routers.WS import iot_ws_router
from service.RabbitMQ import sentinel_pull_analysis_async
async def ai_lab():
@@ -63,6 +65,11 @@ async def main():
YOLOSingleton.init_model()
# 主干AI实验室FastAPI服务
task_api = asyncio.create_task(ai_lab())
# RabbitMQ服务
task_mq = asyncio.create_task(sentinel_pull_analysis_async())
# 等 HTTP 服务启动后再启动 MQTT
task_mqtt = asyncio.create_task(mqtt_client_async())
await asyncio.gather(task_api, task_mq, task_mqtt)
# MCP服务-ailab
# endpoint_url_ai_lab = "wss://ai.ronsunny.cn:8090/aimcp/mcp_endpoint/mcp/?token=TsSP9lBq6Oa1WMkachHoS2TtNt4GKV/Gli24pk5Rjpk%3D"
@@ -73,11 +80,7 @@ async def main():
# endpoint_url_ql = "wss://ai.ronsunny.cn:8090/aimcp/mcp_endpoint/mcp/?token=8ZmCzp7FzsbxwHOg2%2FvBQkxrC3QWJiI%2B4iTfouExinjcT8ZgLwQfFUtgcMInI7St"
# task_mcp2 = asyncio.create_task(init_mcp_server(endpoint_url_ql))
# RabbitMQ服务
# task_mq = asyncio.create_task(mq_pull_analysis_async())
# await asyncio.gather(task_api, task_mcp1, task_mcp2, task_mq)
await asyncio.gather(task_api)
if __name__ == "__main__":
+143
View File
@@ -0,0 +1,143 @@
import json
import os
import socket
import ssl
import sys
import uuid
from aiomqtt import Client
from config.redis import redis_client
from models.MqttTopic import MqttTopic
# ================= 配置区域 =================
MQTT_BROKER = "ai.ronsunny.cn"
MQTT_PORT = 8093
MQTT_PASSWORD = "123456"
TLS_CONTEXT = ssl.create_default_context()
# 默认连接后要订阅的 topic 配置
DEFAULT_SUBSCRIPTIONS = [
MqttTopic.from_parts(
project=None,
domain="status",
device_type="edge",
device_id=None,
resource="info",
)
]
# ===========================================
DEVICE_ID = None
MQTT_CLIENT: Client | None = None # 全局客户端
# Windows 平台下切换到 SelectorEventLoop
if sys.platform.lower() == "win32" or os.name.lower() == "nt":
from asyncio import set_event_loop_policy, WindowsSelectorEventLoopPolicy
set_event_loop_policy(WindowsSelectorEventLoopPolicy())
def get_device_id_simple():
try:
with open("/etc/machine-id") as f:
mid = f.read().strip()
if mid:
return mid
except Exception:
pass
hostname = socket.gethostname()
mac = uuid.getnode()
mac_str = ":".join(f"{(mac >> ele) & 0xff:02x}" for ele in range(40, -1, -8))
return f"{hostname}|{mac_str}"
# todo 这里需要订阅状态信息 设备发送信息 这里回复 vue前端发送指令 后端发送指令 设备接收指令
# ------------------ MQTT 封装 ------------------
async def mqtt_publish(
project: str,
domain: str,
device_type: str,
device_id: str,
resource: str,
payload: str,
qos: int = 1,
):
"""发布消息(使用全局客户端)"""
if not MQTT_CLIENT:
raise RuntimeError("MQTT client is not initialized")
topic = f"{project}/{domain}/{device_type}/{device_id}/{resource}"
await MQTT_CLIENT.publish(topic, payload, qos=qos)
print(f"Published to {topic}: {payload}")
async def mqtt_publish_multiple(
targets: list[dict], resource: str, payload: str, qos: int = 1
):
"""群发消息"""
for target in targets:
await mqtt_publish(
domain=target["domain"],
device_type=target["device_type"],
device_id=target["device_id"],
resource=resource,
payload=payload,
qos=qos,
)
async def _mqtt_handle_messages():
"""后台循环处理消息"""
if not MQTT_CLIENT:
return
async for message in MQTT_CLIENT.messages:
topic = MqttTopic(message.topic)
print("收到消息:" + str(topic))
# 处理基础状态信息
if topic.domain == "status" and topic.resource == "info":
payload = json.loads(message.payload.decode())
redis_client.set_device_info(topic.device_id, payload)
async def mqtt_client_async():
global DEVICE_ID, MQTT_CLIENT
DEVICE_ID = get_device_id_simple()
print("服务端EMQX账号:", DEVICE_ID)
async with Client(
MQTT_BROKER,
port=MQTT_PORT,
username=DEVICE_ID,
password=MQTT_PASSWORD,
tls_context=TLS_CONTEXT,
identifier=DEVICE_ID,
) as client:
MQTT_CLIENT = client # 保存全局客户端
print("MQTT client connected")
# 订阅默认 topic
for topic in DEFAULT_SUBSCRIPTIONS:
await MQTT_CLIENT.subscribe(topic.to_topic())
print(f"Subscribed to default topic: {topic.to_topic()}")
# 启动消息处理循环
await _mqtt_handle_messages()
# ------------------ 示例主程序 ------------------
# async def main():
# await mqtt_client_async()
#
# # 示例:发布消息
# await mqtt_publish("status", "edge", DEVICE_ID, "heartbeat", '{"alive":true}')
#
# # 示例:群发
# targets = [
# {"domain": "cmd", "device_type": "edge", "device_id": "edge01"},
# {"domain": "cmd", "device_type": "edge", "device_id": "edge02"},
# ]
# await mqtt_publish_multiple(targets, "restart", '{"action":"restart"}')
+1 -1
View File
@@ -22,7 +22,7 @@ def push_file(bucket_name, object_name, file_bytes, contents, content_type):
)
def get_upload_token(user_id, bucket_name, object_name, xpires=timedelta(minutes=15)):
def get_upload_token(bucket_name, object_name, xpires=timedelta(minutes=15)):
return minio_client.presigned_put_object(
bucket_name=bucket_name, object_name=object_name, expires=xpires
)
+3 -1
View File
@@ -1,7 +1,9 @@
from utils.GlobalVariable import LOCAL_IP
RABBIT_HOST = LOCAL_IP
RABBIT_VHOST = "bbit_ai"
RABBIT_USER = "ai_lab"
RABBIT_PASSWORD = "123456"
QUEUE_NAME = "analysis_queue"
RABBIT_VHOST = "bbit_ai"
SENTINEL_VHOST = "sentinel"
+24 -1
View File
@@ -1,8 +1,10 @@
import redis
class RedisClient:
# ---------------- Redis Client ----------------
class RedisClient:
def __init__(self, config_path="config.yaml"):
self.redis = redis.Redis(
"10.10.12.101",
@@ -22,3 +24,24 @@ class RedisClient:
def is_device_online(self, device_id: str) -> bool:
key = f"device:online:{device_id}"
return self.redis.exists(key) == 1
def set_device_info(self, device_id: str, info: dict):
"""
存储完整设备信息到 redis hash
将 bool 转为 int
"""
key = f"device:info:{device_id}"
# 转换 bool 为 int
sanitized_info = {
k: (int(v) if isinstance(v, bool) else v) for k, v in info.items()
}
self.redis.hmset(key, sanitized_info)
def get_device_info(self, device_id: str) -> dict:
key = f"device:info:{device_id}"
return self.redis.hgetall(key)
redis_client = RedisClient()
+33 -6
View File
@@ -1,13 +1,16 @@
import jwt
from jwt import PyJWTError
from uuid import UUID
from fastapi import Header, HTTPException, Depends
JWT_SECRET = "secret_jwt"
import jwt
from fastapi import Header, HTTPException
from jwt import PyJWTError
from starlette.websockets import WebSocketDisconnect
JWT_SECRET = "secret_jwt"
JWT_ALGORITHM = "HS256"
JWT_AUDIENCE = "snowflake-ink"
JWT_ISSUER = "https://snowflake.ink/"
def get_user_id_from_token(token: str = Header(..., alias="Authorization")) -> UUID:
"""
从 Authorization 头解析 token,并返回 user_id
@@ -24,7 +27,7 @@ def get_user_id_from_token(token: str = Header(..., alias="Authorization")) -> U
JWT_SECRET,
algorithms=[JWT_ALGORITHM],
audience=JWT_AUDIENCE,
issuer=JWT_ISSUER
issuer=JWT_ISSUER,
)
except PyJWTError:
raise HTTPException(status_code=401, detail="Token is missing or invalid")
@@ -36,4 +39,28 @@ def get_user_id_from_token(token: str = Header(..., alias="Authorization")) -> U
if not user_id:
raise HTTPException(status_code=401, detail="User ID not found in token")
return UUID(user_id)
return UUID(user_id)
def get_user_id_from_token_from_ws(token: str) -> UUID:
if token.startswith("Bearer "):
token = token[7:]
try:
payload = jwt.decode(
token,
JWT_SECRET,
algorithms=[JWT_ALGORITHM],
audience=JWT_AUDIENCE,
issuer=JWT_ISSUER,
)
except PyJWTError:
raise WebSocketDisconnect() # token 无效就断开
if payload.get("token_type") != "access_token":
raise WebSocketDisconnect()
user_id = payload.get("user_id")
if not user_id:
raise WebSocketDisconnect()
return UUID(user_id)
+211
View File
@@ -1,5 +1,6 @@
from hashlib import sha256
from config.minIO import get_temp_url
from config.pgDb import pg_pool
from utils.MyUtils import format_datetime, is_valid_uuid
@@ -201,3 +202,213 @@ def delete_device_db(id: str) -> int:
cursor.execute("DELETE FROM iot_users WHERE id=%s;", (id,))
conn.commit()
return cursor.rowcount
def delete_update_db(id: str) -> int:
with pg_pool.getConn() as conn:
with conn.cursor() as cursor:
cursor.execute(
"DELETE FROM iot_update WHERE id = %s;",
(id,),
)
conn.commit()
return cursor.rowcount
def get_update_list_db_page(
page: int,
page_size: int,
id=None,
code=None,
dept_id=None,
startTime=None,
endTime=None,
):
offset = (page - 1) * page_size
conditions = []
params = []
if id is not None:
conditions.append("u.id::text LIKE %s")
params.append(f"%{id}%")
# ---- 版本 / 升级代码 ----
if code is not None:
conditions.append("u.code = %s")
params.append(code)
# ---- 部门 ----
if dept_id and is_valid_uuid(dept_id):
conditions.append("u.dept_id = %s")
params.append(dept_id)
# ---- 时间过滤 ----
if startTime:
conditions.append("u.created_at >= %s")
params.append(startTime)
if endTime:
conditions.append("u.created_at <= %s")
params.append(endTime)
where_clause = " WHERE " + " AND ".join(conditions) if conditions else ""
with pg_pool.getConn() as conn:
with conn.cursor() as cursor:
# ---- 总数 ----
count_sql = f"""
SELECT COUNT(*)
FROM iot_update u
{where_clause};
"""
cursor.execute(count_sql, params)
total = cursor.fetchone()[0]
# ---- 列表 ----
list_sql = f"""
SELECT
u.id,
u.code,
u.dept_id,
sd.name AS dept_name,
u.remark,
u.oss,
u.size,
u.created_at
FROM iot_update u
LEFT JOIN sys_dept sd ON u.dept_id = sd.id
{where_clause}
ORDER BY u.created_at DESC
LIMIT %s OFFSET %s;
"""
cursor.execute(list_sql, params + [page_size, offset])
rows = cursor.fetchall()
result = []
for r in rows:
(
update_id,
code,
dept_id,
dept_name,
remark,
oss,
size,
created_at,
) = r
result.append(
{
"id": update_id,
"code": code,
"dept_id": dept_id,
"dept_name": dept_name,
"remark": remark,
"oss_url": oss,
"size": size,
"created_at": format_datetime(created_at),
}
)
return result, total
def insert_update(data: dict) -> str:
with pg_pool.getConn() as conn:
with conn.cursor() as cursor:
cursor.execute(
"""
INSERT INTO iot_update
(code, dept_id, remark, oss, size)
VALUES
(%s, %s, %s, %s, %s)
RETURNING id;
""",
(
data.get("code"),
data.get("dept_id"),
data.get("remark"),
data.get("uploadId"),
data.get("size"),
),
)
update_id = cursor.fetchone()[0]
conn.commit()
return update_id
def get_update_package(device_id: str | None = None):
"""
根据设备 ID 获取所属组织最新版本的更新包信息
返回示例:
{
"version": 1001,
"url": "https://xxx",
"notes": "更新内容描述"
}
"""
if not device_id:
return None
sql_get_dept = """
SELECT dept_id
FROM iot_users
WHERE name = %s
LIMIT 1
"""
sql_get_package = """
SELECT code, oss, remark
FROM iot_update
WHERE dept_id = %s
ORDER BY code DESC
LIMIT 1
"""
with pg_pool.getConn() as conn:
with conn.cursor() as cursor:
# 1. 查询设备所属组织
cursor.execute(sql_get_dept, (device_id,))
row = cursor.fetchone()
if not row:
return None
dept_id = row[0]
# 2. 查询该组织最新更新包
cursor.execute(sql_get_package, (dept_id,))
row = cursor.fetchone()
if not row:
return None
version, oss_path, content = row
return {
"version": version,
"url": get_temp_url("iot-update", oss_path),
"notes": content,
}
def getMaxCodeByDeptId(dept_id: str | None = None) -> int:
"""
根据组织ID获取 iot_update_package 最大 code,并在结果上加 1
返回整数,如果没有记录则返回 1
"""
if not dept_id:
return 0 # dept_id 为空直接返回初始版本号 1
sql = """
SELECT MAX(code)
FROM iot_update
WHERE dept_id = %s
"""
with pg_pool.getConn() as conn:
with conn.cursor() as cursor:
cursor.execute(sql, (dept_id,))
row = cursor.fetchone()
max_code = row[0] if row and row[0] is not None else 0
return max_code
+53
View File
@@ -1,5 +1,6 @@
from config.minIO import get_temp_url_dict
from config.pgDb import pg_pool
from models.SentinelRecordRequest import SentinelRecordRequest
from utils.MyUtils import format_datetime
@@ -259,3 +260,55 @@ def delete_sentinel_record_db(id: str) -> int:
cursor.execute("DELETE FROM sentinel_records WHERE id=%s;", (id,))
conn.commit()
return cursor.rowcount
def saveSentinelRecord(data: SentinelRecordRequest) -> str:
sql = """
INSERT INTO sentinel_records (
license_plate,
license_plate_image,
vehicle_type,
vehicle_image
)
VALUES (%s, %s, %s, %s)
RETURNING id;
"""
with pg_pool.getConn() as conn:
with conn.cursor() as cursor:
cursor.execute(
sql,
(
data.LicensePlate,
data.LicensePlateImage,
data.VehicleType,
data.VehicleImage,
),
)
new_id = cursor.fetchone()[0]
conn.commit()
return str(new_id)
def update_sentinel_record(
id: str, livestock_type: str, remark: str, dept_id: str
) -> bool:
"""
根据 id 更新 sentinel_records 表中的 livestock_type 和 dept_id
"""
sql = """
UPDATE sentinel_records
SET livestock_type = %s,
remark = %s,
dept_id = %s,
updated_at = now()
WHERE id = %s
RETURNING id;
"""
with pg_pool.getConn() as conn:
with conn.cursor() as cursor:
cursor.execute(sql, (livestock_type, remark, dept_id, id))
record = cursor.fetchone()
conn.commit()
return record is not None
+46 -2
View File
@@ -942,11 +942,55 @@ def get_dept_ids_by_user_id(user_id: UUID) -> list:
return dept_ids
def get_dept_id_by_user_id(user_id: UUID) -> list:
# 第一步:通过 user_id 查找其所属的 dept_id
def get_dept_id_by_user_id(user_id: str) -> str:
# 通过 user_id 查找其所属的 dept_id
with pg_pool.getConn() as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT dept_id FROM users WHERE id = %s", (user_id,))
dept_id = cursor.fetchone()
dept_id = dept_id[0]
return str(dept_id)
def get_dept_id_by_iot_user_name(user_id: UUID) -> str:
# 通过 iot_user_id 查找其所属的 dept_id
with pg_pool.getConn() as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT dept_id FROM iot_users WHERE name = %s", (user_id,))
dept_id = cursor.fetchone()
dept_id = dept_id[0]
return dept_id
from typing import List
def get_dept_ids_by_dept_id(dept_id: str) -> List[str]:
"""
获取当前部门 ID 以及其所有父部门 ID(递归向上)
返回顺序:从当前部门一直到最顶层父部门
"""
with pg_pool.getConn() as conn:
with conn.cursor() as cursor:
cursor.execute(
"""
WITH RECURSIVE dept_tree AS (
-- 起点:当前部门
SELECT id, parent_id
FROM sys_dept
WHERE id = %s
UNION ALL
-- 向上递归找父部门
SELECT d.id, d.parent_id
FROM sys_dept d
INNER JOIN dept_tree dt ON d.id = dt.parent_id
)
SELECT id FROM dept_tree;
""",
(dept_id,),
)
rows = cursor.fetchall()
return [str(row[0]) for row in rows]
+33 -8
View File
@@ -1,25 +1,50 @@
import asyncio
from typing import List
from uuid import UUID
from fastapi import WebSocket
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
self.active_connections: List[dict] = [] # 保存 websocket 和用户信息
self.lock = asyncio.Lock()
async def connect(self, websocket: WebSocket):
# proj_id:0:在线状态 1:畜牧车辆进入
async def connect(
self, websocket: WebSocket, user_id: UUID, dept_id: str, proj_id: int
):
await websocket.accept()
async with self.lock:
self.active_connections.append(websocket)
self.active_connections.append(
{
"ws": websocket,
"user_id": user_id,
"dept_id": dept_id,
"proj_id": proj_id,
}
)
async def disconnect(self, websocket: WebSocket):
async with self.lock:
if websocket in self.active_connections:
self.active_connections.remove(websocket)
self.active_connections = [
conn for conn in self.active_connections if conn["ws"] != websocket
]
async def broadcast(self, message: dict):
async def noticeOnlineStatus(self, message: dict):
async with self.lock:
for ws in self.active_connections:
await ws.send_json(message)
for conn in self.active_connections:
if conn["proj_id"] == 0:
await conn["ws"].send_json(message)
async def noticeSentinel(
self, message: dict, target_departments: List[UUID] = None
):
"""
target_departments: 指定哪些部门能收到消息
"""
async with self.lock:
for conn in self.active_connections:
if target_departments:
if conn["proj_id"] == 1 and conn["dept_id"] in target_departments:
await conn["ws"].send_json(message)
@@ -0,0 +1,8 @@
from pydantic import BaseModel
class IotDeviceCommandRequest(BaseModel):
id: str | None = None
command: str | None = None
project: str | None = None
device_type: str | None = None
+107
View File
@@ -0,0 +1,107 @@
from typing import Optional
class MqttTopic:
"""
封装 MQTT topic,根据规则:
project/domain/deviceType/deviceId/resource
"""
LEVELS = 5
def __init__(self, topic: str):
self.raw = str(topic)
parts = self.raw.split("/")
# 不足的层级用 None 补齐,避免属性缺失
parts += [None] * (self.LEVELS - len(parts))
self.project: Optional[str] = parts[0]
self.domain: Optional[str] = parts[1]
self.device_type: Optional[str] = parts[2]
self.device_id: Optional[str] = parts[3]
self.resource: Optional[str] = parts[4]
@classmethod
def from_parts(
cls,
project: Optional[str] = None,
domain: Optional[str] = None,
device_type: Optional[str] = None,
device_id: Optional[str] = None,
resource: Optional[str] = None,
) -> "MqttTopic":
"""
通过结构化参数构造 topic
None -> '+'
"""
def _v(v: Optional[str]) -> str:
return "+" if v is None else str(v)
topic = "/".join(
map(
_v,
[
project,
domain,
device_type,
device_id,
resource,
],
)
)
return cls(topic)
def to_topic(self) -> str:
"""
根据当前字段生成 topic(允许 '+')
"""
def _v(v: Optional[str]) -> str:
return "+" if v is None else v
return "/".join(
map(
_v,
[
self.project,
self.domain,
self.device_type,
self.device_id,
self.resource,
],
)
)
def build(self) -> str:
"""
生成严格 topic(不允许 None / '+'
用于 publish 场景
"""
parts = [
self.project,
self.domain,
self.device_type,
self.device_id,
self.resource,
]
if any(p in (None, "+") for p in parts):
raise ValueError(
f"Cannot build strict topic, wildcard exists: {self.to_topic()}"
)
return "/".join(parts)
def is_wildcard(self) -> bool:
return "+" in self.to_topic() or "#" in self.to_topic()
def __repr__(self):
return f"<MqttTopic {self.to_topic()}>"
def is_status(self) -> bool:
return self.domain == "status"
def is_cmd(self) -> bool:
return self.domain == "cmd"
@@ -0,0 +1,10 @@
from pydantic import BaseModel
class SentinelRecordRequest(BaseModel):
Id: str | None = None
DeviceId: str
LicensePlate: str | None = None
LicensePlateImage: str | None = None
VehicleType: str | None = None
VehicleImage: str | None = None
+138 -5
View File
@@ -1,17 +1,19 @@
import uuid
from uuid import UUID
from fastapi import APIRouter
from fastapi import Depends
from config.redis import RedisClient
from config.emqx import mqtt_publish
from config.minIO import get_upload_token
from config.redis import redis_client
from db.postgres.iot import *
from models.BaseResponse import BaseResponse
from models.EMQXWebhook import EMQXWebhook
from models.IotDeviceCommandRequest import IotDeviceCommandRequest
from routers.WS import ws_manager
iot_router = APIRouter()
redis_client = RedisClient()
from config.security import get_user_id_from_token
# -------------------- 设备接口 --------------------
@@ -25,14 +27,14 @@ async def emqx_webhook(data: EMQXWebhook):
if event == "client.connected":
redis_client.set_online(device_id)
await ws_manager.broadcast({"deviceId": device_id, "online": True})
await ws_manager.noticeOnlineStatus({"deviceId": device_id, "online": True})
print(f"[ONLINE] {device_id}")
elif event == "client.disconnected":
redis_client.set_offline(device_id)
await ws_manager.broadcast({"deviceId": device_id, "online": False})
await ws_manager.noticeOnlineStatus({"deviceId": device_id, "online": False})
print(f"[OFFLINE] {device_id}")
@@ -68,6 +70,19 @@ async def get_device_list(
device_id = d["name"] # 账号
d["online"] = redis_client.is_device_online(device_id) == 1
info_json = redis_client.get_device_info(device_id)
d["version"] = info_json.get("version", "")
d["ip"] = info_json.get("ip", "")
d["hostname"] = info_json.get("hostname", "")
d["mac"] = info_json.get("mac", "")
d["os"] = info_json.get("os", "")
d["cpu"] = info_json.get("cpu", "")
d["memory_total"] = info_json.get("memory_total", "")
d["disk_total"] = info_json.get("disk_total", "")
d["last_seen"] = info_json.get("last_seen", "")
d["project"] = info_json.get("project", "")
d["device_type"] = info_json.get("deviceType", "")
return BaseResponse(data={"list": devices, "total": total})
@@ -121,3 +136,121 @@ async def delete_device(
if deleted == 0:
return BaseResponse(status=False, message="设备不存在", data=None)
return BaseResponse(data=True)
@iot_router.get("/common/update/list")
async def get_update_list(
page: int = 1,
pageSize: int = 10,
id: str | None = None,
code: str | None = None,
dept_id: str | None = None,
startTime: str | None = None,
endTime: str | None = None,
user_id: UUID = Depends(get_user_id_from_token),
):
if not user_id:
return {"error": "userId is required"}
if code == "" or code is None:
code = None
else:
code = int(code)
updates, total = get_update_list_db_page(
page, pageSize, id, code, dept_id, startTime, endTime
)
return BaseResponse(data={"list": updates, "total": total})
@iot_router.post("/common/update")
async def create_update(data: dict, user_id: UUID = Depends(get_user_id_from_token)):
if not user_id:
return {"error": "userId is required"}
dept_id = data.get("dept_id")
if not dept_id:
return {"error": "dept_id is required"}
# 前端传来的版本号
try:
new_code = int(data.get("code", 0))
except (TypeError, ValueError):
return BaseResponse(
status=False,
message="无效的版本号",
data=None,
)
# 获取该组织当前最大版本号
max_code = getMaxCodeByDeptId(dept_id)
if new_code <= max_code:
return BaseResponse(
status=False,
message=f"新版本号必须大于当前最大版本号 {max_code}",
data=None,
)
# 插入数据库
new_id = insert_update(data)
return BaseResponse(data={"id": new_id})
@iot_router.delete("/common/update/{id}")
async def delete_update(
id: str,
user_id: UUID = Depends(get_user_id_from_token),
):
if not user_id:
return {"error": "userId is required"}
deleted = delete_update_db(id)
if deleted == 0:
return BaseResponse(status=False, message="更新记录不存在", data=None)
return BaseResponse(data=True)
@iot_router.get("/common/update/getUploadUrl")
def getUploadUrl(
user_id: UUID = Depends(get_user_id_from_token),
):
# 生成唯一文件名,避免覆盖
object_name = f"{uuid.uuid4()}"
return BaseResponse(
data={
"uploadUrl": get_upload_token("iot-update", object_name),
"id": object_name,
}
)
@iot_router.get("/common/update/getMaxCodeByDeptId")
def updateGetMaxCodeByDeptId(
user_id: UUID = Depends(get_user_id_from_token),
dept_id: str | None = None,
):
# 生成唯一文件名,避免覆盖
return BaseResponse(data=getMaxCodeByDeptId(dept_id))
@iot_router.get("/common/update/check")
def getUploadUrl(
deviceID: str | None = None,
):
# 生成唯一文件名,避免覆盖
return BaseResponse(data=get_update_package(deviceID))
@iot_router.post("/common/device/command")
async def command(
data: IotDeviceCommandRequest, user_id: UUID = Depends(get_user_id_from_token)
):
if not user_id:
return {"error": "userId is required"}
await mqtt_publish(
data.project, "cmd", data.device_type, data.id, data.command, "{}"
)
return BaseResponse(data=None)
+12
View File
@@ -3,9 +3,12 @@ import base64
from fastapi import APIRouter
from config.app import F8_SERVER_USER_ID
from db.postgres.sentinel import saveSentinelRecord
from models.BaseResponse import BaseResponse
from models.F8ImageRequest import F8ImageRequest
from models.F8ImageRequestV2 import F8ImageRequestV2
from models.SentinelRecordRequest import SentinelRecordRequest
from service.RabbitMQ import sentinel_new_analysis
from service.vision import (
process_ticket_image,
process_license_image,
@@ -78,3 +81,12 @@ async def recognize_silkworm_cocoon(data: F8ImageRequest):
return BaseResponse(data=json_data)
except Exception as e:
return BaseResponse(status=False, message=f"解析失败: {str(e)}", data=None)
@publicRouter.post("/sentinel-record-analytics")
async def delete_sentinel_record(data: SentinelRecordRequest):
# 保存部分数据到数据库
data.Id = saveSentinelRecord(data)
# 发送请求给RabbitMQ
res = await sentinel_new_analysis(data)
return BaseResponse(data=res)
+4 -7
View File
@@ -1,12 +1,9 @@
from fastapi import APIRouter
from models.AnalysisRequest import AnalysisRequest
from service.Analyze import mq_new_analysis
rqRouter = APIRouter()
@rqRouter.post("/analyze")
def send_analysis_request(req: AnalysisRequest):
mq_new_analysis(req)
return {"status": "queued"}
# @rqRouter.post("/analyze")
# def send_analysis_request(req: AnalysisRequest):
# mq_new_analysis(req)
# return {"status": "queued"}
+2 -2
View File
@@ -17,7 +17,7 @@ serviceRouter = APIRouter()
# 对话列表
@serviceRouter.get("/sessionsForService")
def getSessions(user_id: UUID = Depends(get_user_id_from_token)):
async def getSessions(user_id: UUID = Depends(get_user_id_from_token)):
if not user_id:
return {"error": "userId is required"}
return BaseResponse(data=pg.get_sessions(user_id, "service"))
@@ -25,7 +25,7 @@ def getSessions(user_id: UUID = Depends(get_user_id_from_token)):
# 对话
@serviceRouter.post("/chatForService")
def chat(req: ChatRequest, user_id: UUID = Depends(get_user_id_from_token)):
async def chat(req: ChatRequest, user_id: UUID = Depends(get_user_id_from_token)):
if not user_id:
return {"error": "userId is required"}
if not req.aiId:
+3
View File
@@ -113,6 +113,9 @@ async def menu_list(plat_id: int, user_id: UUID = Depends(get_user_id_from_token
m["createTime"] = format_datetime(m.get("created_at"))
m["updateTime"] = format_datetime(m.get("updated_at"))
m["children"] = []
# 删除created_at updated_at
m.pop("createTime", None)
m.pop("updateTime", None)
# 5. 构建菜单树
tree = build_menu_tree(menus)
+1 -1
View File
@@ -162,7 +162,7 @@ def getIVASCUploadToken(
):
# 生成唯一文件名,避免覆盖
object_name = f"raw/{uuid.uuid4()}"
return BaseResponse(data=get_upload_token(user_id, "video-sca", object_name))
return BaseResponse(data=get_upload_token("video-sca", object_name))
@visionRouter.get("/getScVideoList")
+29 -3
View File
@@ -1,6 +1,8 @@
from fastapi import APIRouter
from fastapi import APIRouter, Query
from starlette.websockets import WebSocket, WebSocketDisconnect
from config.security import get_user_id_from_token_from_ws
from db.postgres import get_dept_id_by_user_id
from db.postgres.ws_manager import ConnectionManager
ws_manager = ConnectionManager()
@@ -10,8 +12,13 @@ iot_ws_router = APIRouter()
@iot_ws_router.websocket("/device-status")
async def websocket_device_status(websocket: WebSocket):
await ws_manager.connect(websocket)
async def websocket_device_status(
websocket: WebSocket,
token: str = Query(...),
):
user_id = get_user_id_from_token_from_ws(token)
dept_id = get_dept_id_by_user_id(user_id) # 查数据库或缓存
await ws_manager.connect(websocket, user_id, dept_id, 0)
print("[WS] client connected")
try:
@@ -21,3 +28,22 @@ async def websocket_device_status(websocket: WebSocket):
except WebSocketDisconnect:
await ws_manager.disconnect(websocket)
print("[WS] client disconnected")
@iot_ws_router.websocket("/sentinel_record")
async def websocket_sentinel_record(
websocket: WebSocket,
token: str = Query(...),
):
user_id = get_user_id_from_token_from_ws(token)
dept_id = get_dept_id_by_user_id(user_id) # 查数据库或缓存
print("user_id:", user_id)
print("dept_id:", dept_id)
print("已接入")
await ws_manager.connect(websocket, user_id, dept_id, 1)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
await ws_manager.disconnect(websocket)
+98
View File
@@ -0,0 +1,98 @@
# consumer.py
import asyncio
import json
import aio_pika
from config.rabbitMQ import *
from models.AnalysisRequest import AnalysisRequest
from models.SentinelRecordRequest import SentinelRecordRequest
from service.vision import process_vehicle_animal_image
async def mq_new_analysis_test(req: dict):
"""将分析请求发送到 RabbitMQ 队列(异步版)"""
connection = await aio_pika.connect_robust(
f"amqp://{RABBIT_USER}:{RABBIT_PASSWORD}@{RABBIT_HOST}/{RABBIT_VHOST}"
)
async with connection:
channel = await connection.channel()
# 声明队列,确保队列存在
queue = await channel.declare_queue(QUEUE_NAME, durable=True)
message_body = json.dumps(req)
message = aio_pika.Message(
body=message_body.encode(),
delivery_mode=aio_pika.DeliveryMode.PERSISTENT, # 持久化
)
await channel.default_exchange.publish(message, routing_key=QUEUE_NAME)
async def mq_pull_analysis_async_test():
"""
从队列拉取分析任务并处理
process_func: 一个函数,接收 AnalysisRequest 对象处理分析逻辑
"""
connection = await aio_pika.connect_robust(
f"amqp://{RABBIT_USER}:{RABBIT_PASSWORD}@{RABBIT_HOST}/{RABBIT_VHOST}"
)
async with connection:
queue_name = QUEUE_NAME
channel = await connection.channel()
await channel.set_qos(prefetch_count=1)
queue = await channel.declare_queue(queue_name, durable=True)
async with queue.iterator() as queue_iter:
async for message in queue_iter:
async with message.process():
data = json.loads(message.body)
req = AnalysisRequest(**data)
print(f"收到任务: {req}")
await asyncio.sleep(5) # 模拟处理
print(f"完成任务: {req}")
async def sentinel_new_analysis(req: SentinelRecordRequest):
"""将分析请求发送到 RabbitMQ 队列(异步版)"""
connection = await aio_pika.connect_robust(
f"amqp://{RABBIT_USER}:{RABBIT_PASSWORD}@{RABBIT_HOST}/{SENTINEL_VHOST}"
)
async with connection:
channel = await connection.channel()
# 声明队列,确保队列存在
queue = await channel.declare_queue(QUEUE_NAME, durable=True)
message_body = json.dumps(req.model_dump())
message = aio_pika.Message(
body=message_body.encode(),
delivery_mode=aio_pika.DeliveryMode.PERSISTENT, # 持久化
)
await channel.default_exchange.publish(message, routing_key=QUEUE_NAME)
async def sentinel_pull_analysis_async():
"""
从队列拉取分析任务并处理
process_func: 一个函数,接收 AnalysisRequest 对象处理分析逻辑
"""
connection = await aio_pika.connect_robust(
f"amqp://{RABBIT_USER}:{RABBIT_PASSWORD}@{RABBIT_HOST}/{SENTINEL_VHOST}"
)
async with connection:
queue_name = QUEUE_NAME
channel = await connection.channel()
await channel.set_qos(prefetch_count=1)
queue = await channel.declare_queue(queue_name, durable=True)
async with queue.iterator() as queue_iter:
async for message in queue_iter:
async with message.process():
data = json.loads(message.body)
req = SentinelRecordRequest(**data)
print(f"收到任务: {req}")
await process_vehicle_animal_image(req) # 处理
print(f"完成任务: {req}")
+29
View File
@@ -4,10 +4,15 @@ from uuid import UUID
import config.minIO as minIO
import db.postgres as pg
from agent.licenseImageAgent import get_license_response
from agent.vehicleImageAgent import get_vehicle_response
from config.minIO import minio_client
from config.yolo import YOLOSingleton
from db.postgres import get_dept_id_by_iot_user_name, get_dept_ids_by_dept_id
from db.postgres.sentinel import update_sentinel_record
from llm.ticketLLM import *
from llm.ticketLLMv2 import get_ticket_response_v2
from models.SentinelRecordRequest import SentinelRecordRequest
from routers.WS import ws_manager
def process_ticket_image(
@@ -178,3 +183,27 @@ def process_silkworm_cocoon_image(
"postprocess_time_ms": speed_json.get("postprocess"),
"details": results_json.get("class_counts"),
}
async def process_vehicle_animal_image(
data: SentinelRecordRequest,
):
# 通过设备id获得组织id
dept_id = get_dept_id_by_iot_user_name(data.DeviceId)
# 得到动物类型
oss_url = minIO.get_temp_url("sentinel", "vehicle_image/" + data.VehicleImage)
analysis_result = await get_vehicle_response(oss_url)
livestock_type = analysis_result.get("livestock_type", "")
remark = analysis_result.get("remark", "")
available_departments = get_dept_ids_by_dept_id(dept_id)
await ws_manager.noticeSentinel(
{
"content": f"载有{livestock_type}的车辆即将进入关卡,请准备检查",
"type": "vehicle_alert",
},
available_departments,
)
# 保存到数据库
return update_sentinel_record(data.Id, livestock_type, remark, dept_id)