重构了设备管理模块、

main
mula.liu 2026-02-06 15:57:34 +08:00
parent 883a1a2f1b
commit b60b4a7b6b
6 changed files with 170 additions and 3 deletions

View File

@ -0,0 +1,81 @@
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi import Request, Response
import time
from app.services.terminal_service import terminal_service
from app.services.jwt_service import jwt_service
from app.core.response import create_api_response
class TerminalCheckMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# 1. 检查是否有 Imei 头,没有则认为是普通请求,直接放行
imei = request.headers.get("Imei")
if not imei:
return await call_next(request)
# 2. 检查时间戳 (防重放/时钟同步)
# 优先从Header获取如果没有则尝试从Query Parameter获取
client_time_str = request.headers.get("time") or request.query_params.get("time")
if client_time_str:
try:
client_time = int(client_time_str)
server_time = int(time.time() * 1000)
# 允许 10 分钟的误差 (10 * 60 * 1000 = 600000 ms)
# 考虑到网络延迟和设备时间未校准,设置宽松一点
if abs(server_time - client_time) > 600000:
return create_api_response(
code="400",
message="设备时间与服务器时间差距过大,请校准时间"
)
except ValueError:
# 时间格式错误,暂时忽略或返回错误
pass
# 3. 提取其他设备信息
device_type = request.headers.get("deviceType", "UNKNOWN")
# device_info 可能是 "UNIS iMeeting a7"
device_info = request.headers.get("deviceInfo", "Unknown Device")
# 获取客户端IP (考虑代理)
client_ip = request.client.host
if "x-forwarded-for" in request.headers:
client_ip = request.headers["x-forwarded-for"].split(",")[0].strip()
elif "x-real-ip" in request.headers:
client_ip = request.headers["x-real-ip"]
# 获取当前用户ID (如果已登录)
user_id = None
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
try:
token = auth_header.split(" ")[1]
payload = jwt_service.verify_token(token)
if payload:
user_id = payload.get("user_id")
except Exception:
pass # 忽略token解析错误只记录设备在线状态
# 4. 调用服务进行检查和更新
# 注意:这里是同步调用数据库,但在 async 中可能会阻塞 loop
# 理想情况下 terminal_service 应该是 async 的,或者使用 run_in_executor
# 但由于数据库操作较快,且 mysql-connector 是同步的,暂时直接调用
# 如果并发高,建议将 service 改为 async
result = terminal_service.check_and_update_terminal(
imei=imei,
terminal_type=device_type,
terminal_name=device_info,
ip_address=client_ip,
user_id=user_id
)
if not result["allowed"]:
return create_api_response(
code="403",
message=result["reason"]
)
# 5. 放行请求
response = await call_next(request)
return response

View File

@ -14,6 +14,7 @@ from fastapi import FastAPI, Request, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.openapi.docs import get_swagger_ui_html from fastapi.openapi.docs import get_swagger_ui_html
from app.core.middleware import TerminalCheckMiddleware
from app.api.endpoints import auth, users, meetings, tags, admin, admin_dashboard, tasks, prompts, knowledge_base, client_downloads, voiceprint, audio, dict_data, hot_words, external_apps, terminals from app.api.endpoints import auth, users, meetings, tags, admin, admin_dashboard, tasks, prompts, knowledge_base, client_downloads, voiceprint, audio, dict_data, hot_words, external_apps, terminals
from app.core.config import UPLOAD_DIR, API_CONFIG from app.core.config import UPLOAD_DIR, API_CONFIG
@ -25,6 +26,9 @@ app = FastAPI(
redoc_url=None redoc_url=None
) )
# 添加终端检查中间件 (在CORS之前添加以便位于CORS内部)
app.add_middleware(TerminalCheckMiddleware)
# 添加CORS中间件 # 添加CORS中间件
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,

View File

@ -45,9 +45,12 @@ class TerminalService:
SELECT SELECT
t.*, t.*,
u.username as creator_username, u.username as creator_username,
cu.username as current_username,
cu.caption as current_user_caption,
dd.label_cn as terminal_type_name dd.label_cn as terminal_type_name
FROM terminals t FROM terminals t
LEFT JOIN users u ON t.created_by = u.user_id LEFT JOIN users u ON t.created_by = u.user_id
LEFT JOIN users cu ON t.current_user_id = cu.user_id
LEFT JOIN dict_data dd ON t.terminal_type = dd.dict_code AND dd.dict_type = 'terminal_type' LEFT JOIN dict_data dd ON t.terminal_type = dd.dict_code AND dd.dict_type = 'terminal_type'
WHERE {where_clause} WHERE {where_clause}
ORDER BY t.created_at DESC ORDER BY t.created_at DESC
@ -188,4 +191,61 @@ class TerminalService:
cursor.close() cursor.close()
return updated return updated
def check_and_update_terminal(self, imei: str, terminal_type: str, terminal_name: str, ip_address: str, user_id: Optional[int] = None) -> dict:
"""
检查并更新终端状态中间件调用
"""
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
# 检查是否存在
cursor.execute("SELECT id, status, is_activated FROM terminals WHERE imei = %s", (imei,))
existing = cursor.fetchone()
current_time = datetime.datetime.now()
if existing:
# 检查是否被停用
if existing['status'] == 0:
return {"allowed": False, "reason": "设备已被停用", "terminal_id": existing['id']}
# 更新在线时间、IP、名称如果变了、当前用户
update_query = """
UPDATE terminals
SET last_online_at = %s,
ip_address = %s,
current_user_id = %s,
# 如果设备没名字,尝试用上报的名字填充
terminal_name = IF(terminal_name IS NULL OR terminal_name = '', %s, terminal_name),
# 如果设备没类型,尝试用上报的类型填充
terminal_type = IF(terminal_type IS NULL OR terminal_type = '', %s, terminal_type)
WHERE id = %s
"""
cursor.execute(update_query, (current_time, ip_address, user_id, terminal_name, terminal_type, existing['id']))
conn.commit()
return {"allowed": True, "reason": "", "terminal_id": existing['id']}
else:
# 新设备自动注册并激活
insert_query = """
INSERT INTO terminals (
imei, terminal_name, terminal_type, status,
is_activated, activated_at, last_online_at, ip_address, created_at, current_user_id
) VALUES (%s, %s, %s, 1, 1, %s, %s, %s, %s, %s)
"""
cursor.execute(insert_query, (
imei, terminal_name, terminal_type,
current_time, current_time, ip_address, current_time, user_id
))
new_id = cursor.lastrowid
conn.commit()
return {"allowed": True, "reason": "", "terminal_id": new_id}
except Exception as e:
print(f"Error in check_and_update_terminal: {e}")
# 数据库错误暂时放行,或者根据安全策略决定
return {"allowed": True, "reason": f"DB Error: {str(e)}", "terminal_id": None}
terminal_service = TerminalService() terminal_service = TerminalService()

View File

@ -0,0 +1,4 @@
-- 为terminals表添加当前绑定用户ID字段
ALTER TABLE `terminals`
ADD COLUMN `current_user_id` INT DEFAULT NULL COMMENT '当前绑定/使用的用户ID',
ADD CONSTRAINT `fk_terminals_current_user` FOREIGN KEY (`current_user_id`) REFERENCES `users` (`user_id`) ON DELETE SET NULL;

View File

@ -215,6 +215,13 @@
vertical-align: middle; vertical-align: middle;
} }
.truncate-cell {
max-width: 180px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.data-table tr:last-child td { .data-table tr:last-child td {
border-bottom: none; border-bottom: none;
} }

View File

@ -283,6 +283,7 @@ const TerminalManagement = () => {
<th>IMEI</th> <th>IMEI</th>
<th>终端名称</th> <th>终端名称</th>
<th>类型</th> <th>类型</th>
<th>当前绑定账号</th>
<th>状态</th> <th>状态</th>
<th>激活状态</th> <th>激活状态</th>
<th>最后在线</th> <th>最后在线</th>
@ -293,18 +294,28 @@ const TerminalManagement = () => {
<tbody> <tbody>
{paginatedTerminals.length === 0 ? ( {paginatedTerminals.length === 0 ? (
<tr> <tr>
<td colSpan="8" className="empty-state">暂无数据</td> <td colSpan="9" className="empty-state">暂无数据</td>
</tr> </tr>
) : ( ) : (
paginatedTerminals.map(terminal => ( paginatedTerminals.map(terminal => (
<tr key={terminal.id}> <tr key={terminal.id}>
<td className="font-mono">{terminal.imei}</td> <td className="font-mono truncate-cell" title={terminal.imei}>{terminal.imei}</td>
<td>{terminal.terminal_name || '-'}</td> <td className="truncate-cell" title={terminal.terminal_name}>{terminal.terminal_name || '-'}</td>
<td> <td>
<span className="type-badge"> <span className="type-badge">
{getTerminalTypeLabel(terminal.terminal_type)} {getTerminalTypeLabel(terminal.terminal_type)}
</span> </span>
</td> </td>
<td>
{terminal.current_user_caption ? (
<div className="user-info truncate-cell" title={`${terminal.current_user_caption} (${terminal.current_username || ''})`}>
<span className="user-name">{terminal.current_user_caption}</span>
{terminal.current_username && <span className="user-sub text-xs text-gray-500">({terminal.current_username})</span>}
</div>
) : (
<span className="text-gray-400">-</span>
)}
</td>
<td> <td>
<div className="status-toggle" onClick={() => handleToggleStatus(terminal)}> <div className="status-toggle" onClick={() => handleToggleStatus(terminal)}>
<div className={`toggle-switch ${terminal.status === 1 ? 'on' : 'off'}`}> <div className={`toggle-switch ${terminal.status === 1 ? 'on' : 'off'}`}>