AI实验室后端

This commit is contained in:
BBIT-Kai
2026-02-04 13:58:18 +08:00
parent f9536dd0b4
commit 646e312a4c
24 changed files with 962 additions and 86 deletions
+15 -4
View File
@@ -1,11 +1,13 @@
import asyncio
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from uvicorn import Config, Server
from config.emqx import mqtt_client_async
from config.emqx import mqtt_client_runner
from config.yolo import YOLOSingleton
from models.BaseResponse import BaseResponse
from routers.AnnualMeeting import amRouter
from routers.Bot import botRouter
from routers.Chat import chatRouter
from routers.Datasource import reportDataRouter
@@ -51,16 +53,25 @@ async def ai_lab():
app.include_router(r, prefix="/llm", tags=["llm"])
app.include_router(visionRouter, prefix="/cv", tags=["cv"])
app.include_router(systemRouter, prefix="/system", tags=["system"])
app.include_router(amRouter, prefix="/am", tags=["annual_meeting"])
app.include_router(iot_router, prefix="/iot", tags=["iot"])
app.include_router(sentinel_router, prefix="/iot/sentinel", tags=["iot_sentinel"])
app.include_router(iot_ws_router, prefix="/iot/ws", tags=["iot_ws"])
app.include_router(publicRouter, prefix="/api/public", tags=["api"])
config = Config(app=app, host="0.0.0.0", port=13011, log_level="debug")
# ----------- 全局异常捕获 ---------
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
return BaseResponse(status=False, message=str(exc), data=None)
config = Config(app=app, host="0.0.0.0", port=13011, log_level="warning")
server = Server(config)
await server.serve()
async def main():
# 初始化模型
YOLOSingleton.init_model()
# 主干AI实验室FastAPI服务
@@ -68,7 +79,7 @@ async def main():
# RabbitMQ服务
task_mq = asyncio.create_task(sentinel_pull_analysis_async())
# 等 HTTP 服务启动后再启动 MQTT
task_mqtt = asyncio.create_task(mqtt_client_async())
task_mqtt = asyncio.create_task(mqtt_client_runner())
await asyncio.gather(task_api, task_mq, task_mqtt)
# MCP服务-ailab
+106 -4
View File
@@ -1,15 +1,20 @@
import asyncio
import json
import os
import socket
import ssl
import sys
import uuid
from datetime import timezone
from aiomqtt import Client
from aiomqtt import Client, Will
import utils
from config.redis import redis_client
from db.postgres import get_dept_id_by_iot_user_name, get_device_type_by_iot_user_name
from models.MqttTopic import MqttTopic
from routers.Iot import pending_commands
from routers.WS import ws_manager
# ================= 配置区域 =================
MQTT_BROKER = "ai.ronsunny.cn"
@@ -19,13 +24,22 @@ TLS_CONTEXT = ssl.create_default_context()
# 默认连接后要订阅的 topic 配置
DEFAULT_SUBSCRIPTIONS = [
# 状态信息回执
MqttTopic.from_parts(
dept_id=None,
domain="status",
device_type=None,
device_id=None,
resource="info",
)
),
# 其他信息回执(指令)
MqttTopic.from_parts(
dept_id=None,
domain="receipt",
device_type=None,
device_id=None,
resource="info",
),
]
# ===========================================
@@ -50,7 +64,6 @@ def get_device_id_simple():
return hostname
# todo 这里需要订阅状态信息 设备发送信息 这里回复 vue前端发送指令 后端发送指令 设备接收指令
# ------------------ MQTT 封装 ------------------
@@ -93,11 +106,11 @@ async def _mqtt_handle_messages():
async for message in MQTT_CLIENT.messages:
topic = MqttTopic(message.topic)
print("收到消息:" + str(topic))
payload = json.loads(message.payload.decode())
# 处理基础状态信息
if topic.is_status():
# 这里收到的数据是这样的:"x/status/x/deviceID/info"
payload = json.loads(message.payload.decode())
redis_client.set_device_info(topic.device_id, payload)
dept_id = get_dept_id_by_iot_user_name(topic.device_id)
dept_edge = get_device_type_by_iot_user_name(topic.device_id)
@@ -112,12 +125,35 @@ async def _mqtt_handle_messages():
"receipt",
payload_str,
)
print("设备" + topic.device_id + "变化:" + str(payload["online"]))
# 通知vue更新在线状态
await ws_manager.noticeOnlineStatus(
{
"deviceId": topic.device_id,
"online": payload["online"],
"type": "status",
}
)
# 如果是设备回复
elif topic.is_response() and "request_id" in payload:
req_id = payload["request_id"]
future = pending_commands.get(req_id)
if future and not future.done():
future.set_result(payload) # 唤醒等待的 HTTP 接口
async def mqtt_client_async():
global DEVICE_ID, MQTT_CLIENT
DEVICE_ID = get_device_id_simple()
print("服务端EMQX账号:", DEVICE_ID)
lwt_topic, lwt_payload = get_status_info_topic_payload(False)
will = Will(
topic=lwt_topic,
payload=lwt_payload,
qos=1, # 通常 LWT 用 QoS 1
retain=False,
)
async with Client(
MQTT_BROKER,
port=MQTT_PORT,
@@ -125,8 +161,11 @@ async def mqtt_client_async():
password=MQTT_PASSWORD,
tls_context=TLS_CONTEXT,
identifier=DEVICE_ID,
will=will,
) as client:
MQTT_CLIENT = client # 保存全局客户端
client.on_connect = on_connect
client.on_disconnect = on_disconnect
print("MQTT client connected")
# 订阅默认 topic
@@ -134,10 +173,73 @@ async def mqtt_client_async():
await MQTT_CLIENT.subscribe(topic.to_topic())
print(f"Subscribed to default topic: {topic.to_topic()}")
# 发送基础消息:"x/status/x/deviceID/info"
await public_device_status()
# 启动消息处理循环
await _mqtt_handle_messages()
def on_connect(client, flags, rc, properties):
print("MQTT connected")
def on_disconnect(client, packet, exc=None):
print("MQTT disconnected:", exc)
INITIAL_RECONNECT_INTERVAL = 5
MAX_RECONNECT_INTERVAL = 60
async def mqtt_client_runner():
global MQTT_CLIENT
reconnect_interval = INITIAL_RECONNECT_INTERVAL
while True:
try:
await mqtt_client_async()
# 如果 mqtt_client_async 正常返回,说明是主动退出
reconnect_interval = INITIAL_RECONNECT_INTERVAL
except Exception as e:
print("MQTT 连接异常:", e)
print(f"等待 {reconnect_interval}s 后重连...")
await asyncio.sleep(reconnect_interval)
# ⭐ 指数退避放在这里
reconnect_interval = min(reconnect_interval * 2, MAX_RECONNECT_INTERVAL)
def get_status_info_topic_payload(is_online: bool):
info = {
"version": "1.0.0", # 替换成你的 APP 版本
"online": is_online,
"ip": utils.get_local_ip(),
"hostname": socket.gethostname(),
"mac": utils.get_mac_address(),
"os": utils.platform.platform(),
"cpu": utils.get_cpu_info(),
"memory_total": utils.get_memory_total(),
"disk_total": utils.get_disk_total(),
"last_seen": utils.datetime.now(timezone.utc).isoformat(),
}
topic = "x/status/x/" + get_device_id_simple() + "/info"
payload = json.dumps(info)
return topic, payload
async def public_device_status():
_, payload = get_status_info_topic_payload(True)
await mqtt_publish(
"x",
"status",
"server",
get_device_id_simple(),
"info",
payload,
)
# ------------------ 示例主程序 ------------------
+57
View File
@@ -0,0 +1,57 @@
import httpx
from fastapi import HTTPException
class HttpClient:
def __init__(self, timeout: int = 10):
self.timeout = timeout
self.client = httpx.AsyncClient(timeout=timeout)
async def request(
self,
method: str,
url: str,
params: dict = None,
data: dict = None,
json: dict = None,
headers: dict = None,
):
"""
通用请求方法
method: GET / POST / PUT / DELETE 等
url: 请求地址
params: URL 参数
data: form 数据
json: JSON 数据
headers: 请求头
"""
try:
response = await self.client.request(
method=method.upper(),
url=url,
params=params,
data=data,
json=json,
headers=headers,
)
response.raise_for_status()
return response.json()
except httpx.RequestError as e:
raise HTTPException(status_code=500, detail=f"请求失败: {e}")
except httpx.HTTPStatusError as e:
raise HTTPException(
status_code=e.response.status_code, detail=f"外部接口返回错误: {e}"
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"解析失败: {e}")
async def get(self, url: str, params: dict = None, headers: dict = None):
return await self.request("GET", url, params=params, headers=headers)
async def post(
self, url: str, data: dict = None, json: dict = None, headers: dict = None
):
return await self.request("POST", url, data=data, json=json, headers=headers)
async def close(self):
await self.client.aclose()
+37 -13
View File
@@ -1,3 +1,5 @@
import json
import redis
@@ -5,7 +7,7 @@ import redis
class RedisClient:
def __init__(self, config_path="config.yaml"):
def __init__(self):
self.redis = redis.Redis(
"10.10.12.101",
6379,
@@ -13,17 +15,14 @@ class RedisClient:
decode_responses=True,
)
def set_online(self, device_id: str):
key = f"device:online:{device_id}"
self.redis.set(key, 1)
def set_offline(self, device_id: str):
key = f"device:online:{device_id}"
self.redis.delete(key)
def is_device_online(self, device_id: str) -> bool:
key = f"device:online:{device_id}"
return self.redis.exists(key) == 1
# 已废弃 之前使用webhookds的方式通知 但是因为通知不及时的原因,现在在线状态全盘交给device status
# def set_online(self, device_id: str):
# key = f"device:online:{device_id}"
# self.redis.set(key, 1)
#
# def set_offline(self, device_id: str):
# key = f"device:online:{device_id}"
# self.redis.delete(key)
def set_device_info(self, device_id: str, info: dict):
"""
@@ -41,7 +40,32 @@ class RedisClient:
def get_device_info(self, device_id: str) -> dict:
key = f"device:info:{device_id}"
return self.redis.hgetall(key)
raw_info = self.redis.hgetall(key)
return raw_info
def get_value(self, key: str):
"""
获取 Redis 中的值,如果是 JSON 字符串自动解析为 dict
"""
value = self.redis.get(key)
if not value:
return None
try:
return json.loads(value)
except json.JSONDecodeError:
return value
def set_value(self, key: str, value, expire: int = None):
"""
存储 key-valuevalue 可以是 dict,自动序列化为 JSON
expire 单位秒
"""
if isinstance(value, (dict, list)):
value = json.dumps(value)
if expire:
self.redis.set(key, value, ex=expire)
else:
self.redis.set(key, value)
redis_client = RedisClient()
+1
View File
@@ -1,6 +1,7 @@
from .aimessage import *
from .aiprofile import *
from .aisession import *
from .annual_meeting import *
from .image_ysa import *
from .iot import *
from .knowledge import *
+224
View File
@@ -0,0 +1,224 @@
from uuid import UUID
from config.minIO import get_temp_url
from config.pgDb import pg_pool
from utils import format_datetime
def get_all_exchange_records():
"""获取 annual_meeting_exchange 表所有记录"""
with pg_pool.getConn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, gift_code, name, created_at, is_finished, sort
FROM annual_meeting_exchange
ORDER BY sort
"""
)
rows = cur.fetchall()
return [
{
"id": row[0],
"gift_code": row[1],
"name": row[2],
"created_at": row[3],
"is_finished": row[4],
"sort": row[5],
}
for row in rows
]
import random
import time
def reset_all_exchange_status():
"""将所有记录 is_finished 置为 False,并随机 position(以当前时间作为随机种子)"""
with pg_pool.getConn() as conn:
with conn.cursor() as cur:
# 获取总记录数
cur.execute("SELECT id FROM annual_meeting_exchange")
ids = [row[0] for row in cur.fetchall()]
# 用当前时间戳作为随机种子
seed = int(time.time() * 1000) # 毫秒级
random.seed(seed)
# 生成随机顺序的 position
positions = list(range(1, len(ids) + 1))
random.shuffle(positions)
# 更新每条记录
for record_id, pos in zip(ids, positions):
cur.execute(
"""
UPDATE annual_meeting_exchange
SET is_finished = FALSE, sort = %s
WHERE id = %s
""",
(pos, record_id),
)
conn.commit()
return {"updated_count": len(ids), "seed_used": seed}
def reset_user_status(target_user_id: str):
"""重置指定用户的 is_finished"""
with pg_pool.getConn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE annual_meeting_exchange
SET is_finished = True
WHERE id = %s
""",
(target_user_id,),
)
conn.commit()
return {"updated": cur.rowcount}
def get_all_lottery():
"""获取 annual_meeting_lottery 全部数据"""
with pg_pool.getConn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT
id,
name,
is_opened,
oss,
created_at,
remark,
sort
FROM annual_meeting_lottery
ORDER BY sort ASC
"""
)
rows = cur.fetchall()
return [
{
"id": row[0],
"name": row[1],
"is_opened": 1 if row[2] else 0,
"oss_url": get_temp_url("image-annual-lottery", row[3]),
"created_at": format_datetime(row[4]),
"remark": row[5],
"sort": row[6],
}
for row in rows
]
def add_lottery(name: str, sort: int, oss: str, is_opened: bool, remark: str):
"""新增礼品"""
with pg_pool.getConn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO annual_meeting_lottery
(id, name, sort, oss, is_opened, remark, created_at)
VALUES (gen_random_uuid(), %s, %s, %s, %s, %s, now())
RETURNING id
""",
(name, sort, oss, is_opened, remark),
)
new_id = cur.fetchone()[0]
conn.commit()
return {"id": new_id}
def update_lottery(
id: str,
name: str = None,
sort: int = None,
oss: str = None,
is_opened: bool = None,
remark: str = None,
):
"""更新礼品信息"""
update_fields = []
params = []
if name is not None:
update_fields.append("name = %s")
params.append(name)
if sort is not None:
update_fields.append("sort = %s")
params.append(sort)
if oss is not None:
update_fields.append("oss = %s")
params.append(oss)
if is_opened is not None:
update_fields.append("is_opened = %s")
params.append(is_opened)
if remark is not None:
update_fields.append("remark = %s")
params.append(remark)
if not update_fields:
return {"updated": 0}
params.append(id)
sql = f"""
UPDATE annual_meeting_lottery
SET {", ".join(update_fields)}
WHERE id = %s
"""
with pg_pool.getConn() as conn:
with conn.cursor() as cur:
cur.execute(sql, tuple(params))
conn.commit()
return {"updated": cur.rowcount}
def delete_lottery(id: str):
"""删除礼品"""
with pg_pool.getConn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
DELETE FROM annual_meeting_lottery
WHERE id = %s
""",
(id,),
)
conn.commit()
return {"deleted": cur.rowcount}
def reset_lottery_item(item_id: UUID):
"""将单个奖品标记为已开启"""
with pg_pool.getConn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE annual_meeting_lottery
SET is_opened = TRUE
WHERE id = %s
""",
(item_id,),
)
conn.commit()
return cur.rowcount
def reset_all_lottery_db():
"""将所有奖品 is_opened 置为 False"""
with pg_pool.getConn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE annual_meeting_lottery
SET is_opened = FALSE
"""
)
conn.commit()
return cur.rowcount
+2 -1
View File
@@ -157,10 +157,11 @@ def update_device_db(id: str, data: dict) -> int:
cursor.execute(
"""
UPDATE iot_users
SET remark=%s, is_active=%s, dept_id=%s, is_superuser=%s
SET name=%s, remark=%s, is_active=%s, dept_id=%s, is_superuser=%s
WHERE id=%s;
""",
(
data.get("name"),
data.get("remark"),
bool(data.get("status", 1)),
data.get("dept_id"),
+71
View File
@@ -149,6 +149,77 @@ def get_sentinel_record_list_db_page(
return result, total
def get_sentinel_record_by_id(record_id):
if not record_id:
return None
sql = """
SELECT
r.id,
r.license_plate,
r.vehicle_type,
r.license_plate_image,
r.vehicle_image,
r.livestock_type,
r.livestock_source,
r.is_inspected,
r.dept_id,
sd.name AS dept_name,
r.created_at,
r.updated_at,
r.remark
FROM sentinel_records r
LEFT JOIN sys_dept sd ON r.dept_id = sd.id
WHERE r.id = %s;
"""
with pg_pool.getConn() as conn:
with conn.cursor() as cursor:
cursor.execute(sql, [record_id])
row = cursor.fetchone()
if not row:
return None
(
record_id,
license_plate,
vehicle_type,
license_plate_image,
vehicle_image,
livestock_type,
livestock_source,
is_inspected,
dept_id,
dept_name,
created_at,
updated_at,
remark,
) = row
result = {
"id": str(record_id),
"license_plate": license_plate,
"vehicle_type": vehicle_type,
"license_plate_image": get_temp_url_dict(
"sentinel", "license_plate", license_plate_image
),
"vehicle_image": get_temp_url_dict(
"sentinel", "vehicle_image", vehicle_image
),
"livestock_type": livestock_type,
"livestock_source": livestock_source,
"is_inspected": 1 if is_inspected else 0,
"dept_id": str(dept_id),
"dept_name": dept_name,
"created_at": format_datetime(created_at),
"updated_at": format_datetime(updated_at),
"remark": remark,
}
return result
def insert_sentinel_record(data: dict, dept_id) -> str:
with pg_pool.getConn() as conn:
with conn.cursor() as cursor:
+19 -7
View File
@@ -583,6 +583,7 @@ def patch_user_db(id: str, data: dict) -> int:
sql = f"UPDATE sys_users SET {', '.join(fields)} WHERE id = %s"
params.append(id)
cursor.execute(sql, tuple(params))
updated_count = cursor.rowcount # 保存这次的结果
# ------------------------
# 2. roles 单独处理
@@ -597,7 +598,7 @@ def patch_user_db(id: str, data: dict) -> int:
)
conn.commit()
return cursor.rowcount
return updated_count
# 删除用户
@@ -954,14 +955,24 @@ def get_dept_id_by_user_id(user_id: str) -> str:
return str(dept_id)
def get_dept_name_by_dept_id(user_id: str) -> str:
with pg_pool.getConn() as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT name FROM sys_dept 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: str) -> 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 = str(dept_id[0])
return dept_id
row = cursor.fetchone()
if row is None:
return None # 或者抛出自定义异常
return str(row[0])
def get_device_type_by_iot_user_name(user_id: str) -> str:
@@ -969,9 +980,10 @@ def get_device_type_by_iot_user_name(user_id: str) -> str:
with pg_pool.getConn() as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT type FROM iot_users WHERE name = %s", (user_id,))
type = cursor.fetchone()
type = str(type[0])
return type
row = cursor.fetchone()
if row is None:
return None # 或者抛出自定义异常
return str(row[0])
from typing import List
+28 -3
View File
@@ -4,13 +4,17 @@ from uuid import UUID
from fastapi import WebSocket
PROJ_IOT_ONLINE_STATE = 0
PROJ_SENTINEL_VEHICLE_STATES = 1
PROJ_SENTINEL_MONITOR_STATUS = 2
class ConnectionManager:
def __init__(self):
self.active_connections: List[dict] = [] # 保存 websocket 和用户信息
self.lock = asyncio.Lock()
# proj_id:0:在线状态 1:畜牧车辆进入
# proj_id:0:在线状态 1:畜牧车辆进入 2:畜牧监控大屏
async def connect(
self, websocket: WebSocket, user_id: UUID, dept_id: str, proj_id: int
):
@@ -24,6 +28,9 @@ class ConnectionManager:
"proj_id": proj_id,
}
)
print(
"Device" + str(user_id) + " in project " + str(proj_id) + " connected"
)
async def disconnect(self, websocket: WebSocket):
async with self.lock:
@@ -34,7 +41,7 @@ class ConnectionManager:
async def noticeOnlineStatus(self, message: dict):
async with self.lock:
for conn in self.active_connections:
if conn["proj_id"] == 0:
if conn["proj_id"] == PROJ_IOT_ONLINE_STATE:
await conn["ws"].send_json(message)
async def noticeSentinel(
@@ -46,5 +53,23 @@ class ConnectionManager:
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:
if (
conn["proj_id"] == PROJ_SENTINEL_VEHICLE_STATES
and conn["dept_id"] in target_departments
):
await conn["ws"].send_json(message)
async def noticeSentinelMonitorStatus(
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"] == PROJ_SENTINEL_MONITOR_STATUS
and conn["dept_id"] in target_departments
):
await conn["ws"].send_json(message)
+11
View File
@@ -0,0 +1,11 @@
from typing import Optional
from pydantic import BaseModel
class LotteryCreateReq(BaseModel):
name: str
sort: int
oss: Optional[str] = None
is_opened: bool = False
remark: Optional[str] = None
+12
View File
@@ -0,0 +1,12 @@
from typing import Optional
from pydantic import BaseModel
class LotteryUpdateReq(BaseModel):
id: str # 必传,用来指定要更新哪条记录
name: Optional[str] = None
sort: Optional[int] = None
oss: Optional[str] = None
is_opened: Optional[bool] = None
remark: Optional[str] = None
+3
View File
@@ -103,5 +103,8 @@ class MqttTopic:
def is_status(self) -> bool:
return self.domain == "status" and self.resource == "info"
def is_response(self) -> bool:
return self.domain == "receipt"
def is_cmd(self) -> bool:
return self.domain == "cmd"
+122
View File
@@ -0,0 +1,122 @@
import pathlib
import uuid
from uuid import UUID
from fastapi import Depends, APIRouter
import db.postgres as pg
from config.minIO import get_upload_token
from config.security import get_user_id_from_token
from models.BaseResponse import BaseResponse
from models.LotteryCreateReq import LotteryCreateReq
from models.LotteryUpdateReq import LotteryUpdateReq
amRouter = APIRouter()
@amRouter.get("/ExGetList")
def AmExGetList(user_id: UUID = Depends(get_user_id_from_token)):
if not user_id:
return {"error": "userId is required"}
data = pg.get_all_exchange_records()
return BaseResponse(data=data)
@amRouter.get("/ExReset")
def AMExReset(user_id: UUID = Depends(get_user_id_from_token)):
if not user_id:
return {"error": "userId is required"}
data = pg.reset_all_exchange_status()
return BaseResponse(data=data, message="已重新打乱顺序")
@amRouter.put("/ExResetTargetStatus")
def AMExResetTargetStatus(
target_user_id: str, user_id: UUID = Depends(get_user_id_from_token)
):
if not user_id:
return {"error": "userId is required"}
data = pg.reset_user_status(target_user_id)
return BaseResponse(data=data)
@amRouter.get("/Lottery/getUploadUrl")
def getUploadUrl(
filename: str | None = None,
user_id: UUID = Depends(get_user_id_from_token),
):
if not user_id:
return {"error": "userId is required"}
# 生成唯一文件名,避免覆盖
ext = pathlib.Path(filename).suffix if filename else "" # 获取文件后缀
object_name = f"{uuid.uuid4()}{ext}" # 拼接到 UUID 后面
return BaseResponse(
data={
"uploadUrl": get_upload_token("image-annual-lottery", object_name),
"id": object_name,
}
)
@amRouter.get("/Lottery/List")
def AMGetLotteryList(user_id: UUID = Depends(get_user_id_from_token)):
if not user_id:
return {"error": "userId is required"}
data = pg.get_all_lottery()
return BaseResponse(data=data)
@amRouter.post("/Lottery/Add")
def AMAddLottery(
data: LotteryCreateReq,
user_id: UUID = Depends(get_user_id_from_token),
):
if not user_id:
return {"error": "userId is required"}
new_data = pg.add_lottery(
name=data.name,
sort=data.sort,
oss=data.oss,
is_opened=data.is_opened,
remark=data.remark,
)
return BaseResponse(data=new_data)
@amRouter.put("/Lottery/Update")
def AMUpdateLottery(
data: LotteryUpdateReq,
user_id: UUID = Depends(get_user_id_from_token),
):
if not user_id:
return {"error": "userId is required"}
updated = pg.update_lottery(
id=data.id,
name=data.name,
sort=data.sort,
oss=data.oss,
is_opened=data.is_opened,
remark=data.remark,
)
return BaseResponse(data=updated)
@amRouter.delete("/Lottery/Delete")
def AMDeleteLottery(id: str, user_id: UUID = Depends(get_user_id_from_token)):
if not user_id:
return {"error": "userId is required"}
data = pg.delete_lottery(id)
return BaseResponse(data=data)
@amRouter.patch("/Lottery/open/{item_id}")
def open_lottery_item(item_id: UUID):
updated = pg.reset_lottery_item(item_id)
return BaseResponse(data=updated)
@amRouter.patch("/Lottery/resetAll")
def reset_all_lottery():
count = pg.reset_all_lottery_db()
return BaseResponse(data=count)
+71 -43
View File
@@ -1,4 +1,5 @@
import asyncio
import json
import pathlib
import uuid
from uuid import UUID
@@ -6,14 +7,11 @@ from uuid import UUID
from fastapi import APIRouter
from fastapi import Depends
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()
from config.security import get_user_id_from_token
@@ -21,43 +19,44 @@ from config.security import get_user_id_from_token
# -------------------- 设备接口 --------------------
@iot_router.post("/common/webhook")
async def emqx_webhook(data: EMQXWebhook):
device_id = data.clientid
event = data.event
if event == "client.connected":
redis_client.set_online(device_id)
# 这里刻意等1s 是因为设备连接后这里首先接到通知,但是状态信息设备来没来得及通过mqtt发送来,所以在此等待
# 没有直接在mqtt发送来的消息中获取在线状态是因为 这里是通过emqx的webhooks通知的,两种通知方式不同,一方面防止其中一种逻辑失效,另一方面在mqtt消息接收中设置在线状态会存在滞后性,同时也需要设置遗嘱消息,较为
await asyncio.sleep(1)
await ws_manager.noticeOnlineStatus(
{
"deviceId": device_id,
"online": True,
"type": "status",
}
)
print(f"[新设备在线] {device_id}")
elif event == "client.disconnected":
redis_client.set_offline(device_id)
await ws_manager.noticeOnlineStatus(
{
"deviceId": device_id,
"online": False,
"type": "status",
}
)
print(f"[设备离线] {device_id}")
else:
# 其他事件直接忽略
print(f"[其他事件] {event}")
return {"ok": True}
# 已废弃 Webhooks的离线通知不及时(突然断电断网)
# @iot_router.post("/common/webhook")
# async def emqx_webhook(data: EMQXWebhook):
# device_id = data.clientid
# event = data.event
#
# if event == "client.connected":
# redis_client.set_online(device_id)
# # 这里刻意等1s 是因为设备连接后这里首先接到通知,但是状态信息设备来没来得及通过mqtt发送来,所以在此等待
# # 没有直接在mqtt发送来的消息中获取在线状态是因为 这里是通过emqx的webhooks通知的,两种通知方式不同,一方面防止其中一种逻辑失效,另一方面在mqtt消息接收中设置在线状态会存在滞后性,同时也需要设置遗嘱消息,较为
# await asyncio.sleep(1)
# await ws_manager.noticeOnlineStatus(
# {
# "deviceId": device_id,
# "online": True,
# "type": "status",
# }
# )
#
# print(f"[新设备在线] {device_id}")
#
# elif event == "client.disconnected":
# redis_client.set_offline(device_id)
# await ws_manager.noticeOnlineStatus(
# {
# "deviceId": device_id,
# "online": False,
# "type": "status",
# }
# )
#
# print(f"[设备离线] {device_id}")
#
# else:
# # 其他事件直接忽略
# print(f"[其他事件] {event}")
#
# return {"ok": True}
@iot_router.get("/common/device/list")
@@ -83,9 +82,9 @@ async def get_device_list(
# ===== 👇 核心:补在线状态 =====
for d in devices:
device_id = d["name"] # 账号
d["online"] = redis_client.is_device_online(device_id) == 1
info_json = redis_client.get_device_info(device_id)
d["online"] = info_json.get("online", "0") == "1"
d["version"] = info_json.get("version", "")
d["ip"] = info_json.get("ip", "")
d["hostname"] = info_json.get("hostname", "")
@@ -261,6 +260,10 @@ def getUploadUrl(
return BaseResponse(data=get_update_package(deviceID))
# request_id -> asyncio.Future
pending_commands: dict[str, asyncio.Future] = {}
@iot_router.post("/common/device/command")
async def command(
data: IotDeviceCommandRequest, user_id: UUID = Depends(get_user_id_from_token)
@@ -268,7 +271,32 @@ async def command(
if not user_id:
return {"error": "userId is required"}
request_id = str(uuid.uuid4())
payload = {"request_id": request_id}
loop = asyncio.get_running_loop()
future = loop.create_future()
pending_commands[request_id] = future
from config.emqx import mqtt_publish
await mqtt_publish(
data.dept_id, "cmd", data.device_type, data.id, data.command, "{}"
data.dept_id,
"cmd",
data.device_type,
data.id,
data.command,
json.dumps(payload),
)
return BaseResponse(data=None)
try:
result = await asyncio.wait_for(future, timeout=5)
return BaseResponse(data=result.get("massage"))
except asyncio.TimeoutError:
return BaseResponse(data=None, message="Device did not respond in time")
except asyncio.CancelledError:
# 请求被中断,必须清理,但不要吞
pending_commands.pop(request_id, None)
raise
finally:
pending_commands.pop(request_id, None)
+94
View File
@@ -2,6 +2,9 @@ from uuid import UUID
from fastapi import Depends, APIRouter
from config.httpClient import HttpClient
from config.minIO import get_temp_url
from config.redis import redis_client
from config.security import get_user_id_from_token
from db.postgres import get_dept_ids_by_user_id, get_dept_id_by_user_id
from db.postgres.sentinel import *
@@ -96,3 +99,94 @@ async def delete_sentinel_record(
if deleted == 0:
return BaseResponse(status=False, message="记录不存在", data=None)
return BaseResponse(data=True)
@sentinel_router.get("/monitor/promotional/list")
async def get_sentinel_monitor_promotional_list(
user_id: UUID = Depends(get_user_id_from_token),
):
if not user_id:
return {"error": "userId is required"}
return BaseResponse(
data=[
{
"id": 1,
"remark": "人员公示及岗位职责",
"url": get_temp_url("sentinel", "promotional/promotional (2).jpg"),
},
{
"id": 2,
"remark": "入川动物监督检查工作流程图",
"url": get_temp_url("sentinel", "promotional/promotional (1).jpg"),
},
{
"id": 3,
"remark": "四川省人民政府关于设立人川动物运输指定通道的通告",
"url": get_temp_url("sentinel", "promotional/promotional (3).jpg"),
},
]
)
http_client = HttpClient()
@sentinel_router.get("/monitor/list")
async def get_sentinel_monitor_list(
user_id: UUID = Depends(get_user_id_from_token),
):
if not user_id:
return {"error": "userId is required"}
# 尝试从 Redis 获取 accessToken
access_token = redis_client.get_value("ys7:access_token")
if not access_token:
url = "https://open.ys7.com/api/lapp/token/get"
payload = {
"appKey": "c85e53559223457f90f06cd215513c3d",
"appSecret": "9424419da5292707eff2007e9ae37f0d",
}
result = await http_client.post(url, data=payload)
access_token = result["data"]["accessToken"]
redis_client.set_value(
"ys7:access_token", access_token, expire=7 * 24 * 60 * 60 # 7天过期
)
url = "https://open.ys7.com/api/lapp/v2/live/address/get"
# device_serials = ["BG2493625"]
device_serials = ["BG2493625", "GH3713250", "GH3714496", "GH3714497"]
video_expire_time = 25 * 24 * 60 * 60 # 25 天
res = []
for device_serial in device_serials:
live_key = f"ys7:live:{device_serial}"
cached_live = redis_client.get_value(live_key)
if cached_live:
video_id = cached_live.get("id")
video_url = cached_live.get("url")
else:
payload = {
"accessToken": access_token,
"deviceSerial": device_serial,
"protocol": 4, # 流播放协议,1-ezopen、2-hls、3-rtmp、4-flv,默认为1
"expireTime": video_expire_time, # 25天
"supportH265": 0,
"quality": 2,
}
result = await http_client.post(url, data=payload)
video_id = result["data"]["id"]
video_url = result["data"]["url"]
# 存到 Redis,自动序列化为 JSON,过期 25天
redis_client.set_value(
live_key,
{"id": video_id, "url": video_url},
expire=video_expire_time,
)
res.append(
{
"id": video_id,
"url": video_url,
}
)
return BaseResponse(data=res)
+1 -1
View File
@@ -41,7 +41,7 @@ async def dept_add(data: dict, user_id: UUID = Depends(get_user_id_from_token)):
parent_id = data.get("pid")
name = data.get("name")
comment = data.get("comment")
comment = data.get("remark")
if not name:
return BaseResponse(status=False, message="部门名不能为空", data=None)
+2 -2
View File
@@ -127,10 +127,10 @@ async def createSilkwormCocoonAnalysisTask(
return {"error": "userId is required"}
try:
contents = await file.read()
await MyUtils.async_task(
res = await MyUtils.async_task(
process_silkworm_cocoon_image, contents, file.filename, projectName, user_id
)
return BaseResponse(data=None)
return BaseResponse(data=res)
except Exception as e:
return BaseResponse(status=False, message=f"解析失败: {str(e)}", data=None)
+20
View File
@@ -49,3 +49,23 @@ async def websocket_sentinel_record(
await websocket.receive_text()
except WebSocketDisconnect:
await ws_manager.disconnect(websocket)
# Vue 牧安云哨 监控大屏 消息通知
@iot_ws_router.websocket("/sentinel_record_notice")
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, 2)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
await ws_manager.disconnect(websocket)
+21 -7
View File
@@ -5,10 +5,13 @@ 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.minIO import minio_client, get_temp_url
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 db.postgres import (
get_dept_id_by_iot_user_name,
get_dept_ids_by_dept_id,
)
from db.postgres.sentinel import update_sentinel_record, get_sentinel_record_by_id
from llm.ticketLLM import *
from llm.ticketLLMv2 import get_ticket_response_v2
from models.SentinelRecordRequest import SentinelRecordRequest
@@ -173,7 +176,7 @@ def process_silkworm_cocoon_image(
)
return {
"resolution": resolution,
"size": size_kb,
"size": size_kb / 1024,
"cocoon_count": results_json.get("total_objects"),
"max_confidence": results_json.get("max_confidence"),
"min_confidence": results_json.get("min_confidence"),
@@ -182,6 +185,7 @@ def process_silkworm_cocoon_image(
"inference_time_ms": speed_json.get("inference"),
"postprocess_time_ms": speed_json.get("postprocess"),
"details": results_json.get("class_counts"),
"imageUrl": get_temp_url(bucket_name, "ai/" + after_object_name),
}
@@ -190,14 +194,18 @@ async def process_vehicle_animal_image(
):
# 通过设备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", "")
# 保存到数据库
update_sentinel_record(data.Id, livestock_type, remark, dept_id)
# 可以通知的部门ids
available_departments = get_dept_ids_by_dept_id(dept_id)
# 通知控制界面
await ws_manager.noticeSentinel(
{
"content": f"载有{livestock_type}的车辆即将进入关卡,请准备检查",
@@ -205,5 +213,11 @@ async def process_vehicle_animal_image(
},
available_departments,
)
# 保存到数据库
return update_sentinel_record(data.Id, livestock_type, remark, dept_id)
# 通知大屏界面
await ws_manager.noticeSentinelMonitorStatus(
{
"content": get_sentinel_record_by_id(data.Id),
"type": "vehicle_alert",
},
available_departments,
)
+35
View File
@@ -1,7 +1,10 @@
import asyncio
import platform
import socket
import threading
from datetime import datetime
import psutil
import pytz
@@ -65,3 +68,35 @@ def is_valid_uuid(value: str):
return True
except:
return False
def get_local_ip():
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
return "127.0.0.1"
def get_mac_address():
# 获取第一个网卡的 MAC
for iface, addrs in psutil.net_if_addrs().items():
for addr in addrs:
if addr.family == psutil.AF_LINK:
return addr.address
return "00:00:00:00:00:00"
def get_cpu_info():
return platform.processor()
def get_memory_total():
return psutil.virtual_memory().total
def get_disk_total():
return psutil.disk_usage("/").total
+1
View File
@@ -0,0 +1 @@
from .MyUtils import *