feat: 信标设备绑定 + 蓝牙模式管理 + 系统管理增强 + 数据导出
- 新增 DeviceBeaconBinding 模型,信标-设备多对多绑定 CRUD - 蓝牙打卡模式批量配置/恢复正常模式 API - 反向同步: 查询设备 BTMACSET 配置更新数据库绑定 (独立 session 解决事务隔离) - 设备列表快捷操作弹窗修复 (fire-and-forget IIFE 替代阻塞轮询) - 保存按钮防抖: 围栏/信标绑定保存点击后 disabled + 转圈防重复提交 - 审计日志中间件 + 系统配置/备份/固件 API - 设备分组管理 + 告警规则配置 - 5个数据导出 API (CSV UTF-8 BOM) - 位置热力图 + 告警条件删除 + 位置清理 via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
@@ -51,6 +51,7 @@ async def init_db() -> None:
|
||||
from app.models import ( # noqa: F401
|
||||
AlarmRecord,
|
||||
AttendanceRecord,
|
||||
AuditLog,
|
||||
BluetoothRecord,
|
||||
CommandLog,
|
||||
Device,
|
||||
|
||||
@@ -8,7 +8,7 @@ import hashlib
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import Depends, HTTPException, Security
|
||||
from fastapi import Depends, HTTPException, Request, Security
|
||||
from fastapi.security import APIKeyHeader
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -32,6 +32,7 @@ def _hash_key(key: str) -> str:
|
||||
|
||||
|
||||
async def verify_api_key(
|
||||
request: Request,
|
||||
api_key: str | None = Security(_api_key_header),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict | None:
|
||||
@@ -48,7 +49,9 @@ async def verify_api_key(
|
||||
|
||||
# Check master key
|
||||
if secrets.compare_digest(api_key, settings.API_KEY):
|
||||
return {"permissions": "admin", "key_id": None, "name": "master"}
|
||||
info = {"permissions": "admin", "key_id": None, "name": "master"}
|
||||
request.state.key_info = info
|
||||
return info
|
||||
|
||||
# Check in-memory cache first
|
||||
key_hash = _hash_key(api_key)
|
||||
@@ -77,6 +80,7 @@ async def verify_api_key(
|
||||
|
||||
key_info = {"permissions": db_key.permissions, "key_id": db_key.id, "name": db_key.name}
|
||||
_AUTH_CACHE[key_hash] = (key_info, now + _AUTH_CACHE_TTL)
|
||||
request.state.key_info = key_info
|
||||
return key_info
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from slowapi.errors import RateLimitExceeded
|
||||
from app.database import init_db, async_session, engine
|
||||
from app.tcp_server import tcp_manager
|
||||
from app.config import settings
|
||||
from app.routers import devices, locations, alarms, attendance, commands, bluetooth, beacons, fences, heartbeats, api_keys, ws, geocoding
|
||||
from app.routers import devices, locations, alarms, attendance, commands, bluetooth, beacons, fences, heartbeats, api_keys, ws, geocoding, device_groups, alert_rules, system
|
||||
from app.dependencies import verify_api_key, require_write, require_admin
|
||||
|
||||
import asyncio
|
||||
@@ -159,6 +159,10 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Audit logging middleware (records POST/PUT/DELETE to audit_logs table)
|
||||
from app.middleware import AuditMiddleware
|
||||
app.add_middleware(AuditMiddleware)
|
||||
|
||||
|
||||
# Global exception handler — prevent stack trace leaks
|
||||
@app.exception_handler(Exception)
|
||||
@@ -190,6 +194,9 @@ app.include_router(heartbeats.router, dependencies=[*_api_deps])
|
||||
app.include_router(api_keys.router, dependencies=[*_api_deps])
|
||||
app.include_router(ws.router) # WebSocket handles auth internally
|
||||
app.include_router(geocoding.router, dependencies=[*_api_deps])
|
||||
app.include_router(device_groups.router, dependencies=[*_api_deps])
|
||||
app.include_router(alert_rules.router, dependencies=[*_api_deps])
|
||||
app.include_router(system.router, dependencies=[*_api_deps])
|
||||
|
||||
_STATIC_DIR = Path(__file__).parent / "static"
|
||||
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
|
||||
|
||||
101
app/middleware.py
Normal file
101
app/middleware.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
Audit logging middleware.
|
||||
Records POST/PUT/DELETE requests to the audit_logs table.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.database import async_session
|
||||
from app.models import AuditLog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Methods to audit
|
||||
_AUDIT_METHODS = {"POST", "PUT", "DELETE"}
|
||||
|
||||
# Paths to skip (noisy or non-business endpoints)
|
||||
_SKIP_PREFIXES = ("/ws", "/health", "/docs", "/redoc", "/openapi.json")
|
||||
|
||||
# Max request body size to store (bytes)
|
||||
_MAX_BODY_SIZE = 4096
|
||||
|
||||
|
||||
def _get_client_ip(request: Request) -> str:
|
||||
"""Extract real client IP from proxy headers."""
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
cf_ip = request.headers.get("CF-Connecting-IP")
|
||||
if cf_ip:
|
||||
return cf_ip
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def _get_operator(request: Request) -> str | None:
|
||||
"""Extract operator name from request state (set by verify_api_key)."""
|
||||
key_info = getattr(request.state, "key_info", None)
|
||||
if key_info and isinstance(key_info, dict):
|
||||
return key_info.get("name")
|
||||
return None
|
||||
|
||||
|
||||
class AuditMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
if request.method not in _AUDIT_METHODS:
|
||||
return await call_next(request)
|
||||
|
||||
path = request.url.path
|
||||
if any(path.startswith(p) for p in _SKIP_PREFIXES):
|
||||
return await call_next(request)
|
||||
|
||||
# Only audit /api/ routes
|
||||
if not path.startswith("/api/"):
|
||||
return await call_next(request)
|
||||
|
||||
# Read request body for audit (cache it for downstream)
|
||||
body_bytes = await request.body()
|
||||
request_body = None
|
||||
if body_bytes and len(body_bytes) <= _MAX_BODY_SIZE:
|
||||
try:
|
||||
request_body = json.loads(body_bytes)
|
||||
# Redact sensitive fields
|
||||
if isinstance(request_body, dict):
|
||||
for key in ("password", "api_key", "key", "secret", "token"):
|
||||
if key in request_body:
|
||||
request_body[key] = "***REDACTED***"
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
request_body = None
|
||||
|
||||
start = time.monotonic()
|
||||
response = await call_next(request)
|
||||
duration_ms = int((time.monotonic() - start) * 1000)
|
||||
|
||||
# Extract operator from dependency injection result
|
||||
operator = _get_operator(request)
|
||||
|
||||
# Build response summary
|
||||
response_summary = f"HTTP {response.status_code}"
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
async with session.begin():
|
||||
session.add(AuditLog(
|
||||
method=request.method,
|
||||
path=path,
|
||||
status_code=response.status_code,
|
||||
operator=operator,
|
||||
client_ip=_get_client_ip(request),
|
||||
request_body=request_body,
|
||||
response_summary=response_summary,
|
||||
duration_ms=duration_ms,
|
||||
))
|
||||
except Exception:
|
||||
logger.debug("Failed to write audit log for %s %s", request.method, path)
|
||||
|
||||
return response
|
||||
110
app/models.py
110
app/models.py
@@ -36,6 +36,7 @@ class Device(Base):
|
||||
imsi: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
timezone: Mapped[str] = mapped_column(String(30), default="+8", nullable=False)
|
||||
language: Mapped[str] = mapped_column(String(10), default="cn", nullable=False)
|
||||
group_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, default=_utcnow, onupdate=_utcnow, nullable=True
|
||||
@@ -297,6 +298,27 @@ class BeaconConfig(Base):
|
||||
return f"<BeaconConfig(id={self.id}, mac={self.beacon_mac}, name={self.name})>"
|
||||
|
||||
|
||||
class DeviceBeaconBinding(Base):
|
||||
"""Many-to-many binding between devices and beacons."""
|
||||
|
||||
__tablename__ = "device_beacon_bindings"
|
||||
__table_args__ = (
|
||||
Index("ix_dbb_device_beacon", "device_id", "beacon_id", unique=True),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
beacon_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("beacon_configs.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<DeviceBeaconBinding(device_id={self.device_id}, beacon_id={self.beacon_id})>"
|
||||
|
||||
|
||||
class FenceConfig(Base):
|
||||
"""Geofence configuration for area monitoring."""
|
||||
|
||||
@@ -403,6 +425,94 @@ class CommandLog(Base):
|
||||
)
|
||||
|
||||
|
||||
class DeviceGroup(Base):
|
||||
"""Device groups for organizing devices."""
|
||||
|
||||
__tablename__ = "device_groups"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
color: Mapped[str] = mapped_column(String(20), default="#3b82f6", nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, default=_utcnow, onupdate=_utcnow, nullable=True
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<DeviceGroup(id={self.id}, name={self.name})>"
|
||||
|
||||
|
||||
class DeviceGroupMember(Base):
|
||||
"""Many-to-many: devices belong to groups."""
|
||||
|
||||
__tablename__ = "device_group_members"
|
||||
__table_args__ = (
|
||||
Index("ix_dgm_device_group", "device_id", "group_id", unique=True),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
group_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("device_groups.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<DeviceGroupMember(device_id={self.device_id}, group_id={self.group_id})>"
|
||||
|
||||
|
||||
class AlertRule(Base):
|
||||
"""Configurable alert rules for custom thresholds."""
|
||||
|
||||
__tablename__ = "alert_rules"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
rule_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
# rule_type values: low_battery, no_heartbeat, fence_stay, speed_limit, offline_duration
|
||||
conditions: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||
# e.g. {"threshold": 20} for low_battery, {"minutes": 30} for no_heartbeat
|
||||
is_active: Mapped[bool] = mapped_column(Integer, default=1, nullable=False)
|
||||
device_ids: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# comma-separated device IDs, null = all devices
|
||||
group_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# apply to a device group
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, default=_utcnow, onupdate=_utcnow, nullable=True
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AlertRule(id={self.id}, name={self.name}, type={self.rule_type})>"
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""Audit trail for write operations (POST/PUT/DELETE)."""
|
||||
|
||||
__tablename__ = "audit_logs"
|
||||
__table_args__ = (
|
||||
Index("ix_audit_created", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
method: Mapped[str] = mapped_column(String(10), nullable=False) # POST, PUT, DELETE
|
||||
path: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
status_code: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
operator: Mapped[str | None] = mapped_column(String(100), nullable=True) # API key name or "master"
|
||||
client_ip: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
request_body: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
response_summary: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AuditLog(id={self.id}, {self.method} {self.path})>"
|
||||
|
||||
|
||||
class ApiKey(Base):
|
||||
"""API keys for external system authentication."""
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select, case, extract
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -16,6 +17,7 @@ from app.dependencies import require_write
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import AlarmRecord
|
||||
from app.services.export_utils import build_csv_content, csv_filename
|
||||
from app.schemas import (
|
||||
AlarmAcknowledge,
|
||||
AlarmRecordResponse,
|
||||
@@ -94,6 +96,49 @@ async def list_alarms(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/export",
|
||||
summary="导出告警记录 CSV / Export alarm records CSV",
|
||||
)
|
||||
async def export_alarms(
|
||||
device_id: int | None = Query(default=None, description="设备ID"),
|
||||
alarm_type: str | None = Query(default=None, description="告警类型"),
|
||||
alarm_source: str | None = Query(default=None, description="告警来源"),
|
||||
acknowledged: bool | None = Query(default=None, description="是否已确认"),
|
||||
start_time: datetime | None = Query(default=None, description="开始时间 ISO 8601"),
|
||||
end_time: datetime | None = Query(default=None, description="结束时间 ISO 8601"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""导出告警记录为 CSV,支持类型/状态/时间筛选。最多导出 50000 条。"""
|
||||
query = select(AlarmRecord)
|
||||
if device_id is not None:
|
||||
query = query.where(AlarmRecord.device_id == device_id)
|
||||
if alarm_type:
|
||||
query = query.where(AlarmRecord.alarm_type == alarm_type)
|
||||
if alarm_source:
|
||||
query = query.where(AlarmRecord.alarm_source == alarm_source)
|
||||
if acknowledged is not None:
|
||||
query = query.where(AlarmRecord.acknowledged == acknowledged)
|
||||
if start_time:
|
||||
query = query.where(AlarmRecord.recorded_at >= start_time)
|
||||
if end_time:
|
||||
query = query.where(AlarmRecord.recorded_at <= end_time)
|
||||
query = query.order_by(AlarmRecord.recorded_at.desc()).limit(50000)
|
||||
|
||||
result = await db.execute(query)
|
||||
records = list(result.scalars().all())
|
||||
|
||||
headers = ["ID", "设备ID", "IMEI", "告警类型", "告警来源", "已确认", "纬度", "经度", "电量", "信号", "地址", "记录时间"]
|
||||
fields = ["id", "device_id", "imei", "alarm_type", "alarm_source", "acknowledged", "latitude", "longitude", "battery_level", "gsm_signal", "address", "recorded_at"]
|
||||
|
||||
content = build_csv_content(headers, records, fields)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f"attachment; filename={csv_filename('alarms')}"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
response_model=APIResponse[dict],
|
||||
@@ -210,6 +255,15 @@ async def batch_acknowledge_alarms(
|
||||
)
|
||||
|
||||
|
||||
class BatchDeleteAlarmRequest(BaseModel):
|
||||
alarm_ids: list[int] | None = Field(default=None, max_length=500, description="告警ID列表 (与条件删除二选一)")
|
||||
device_id: int | None = Field(default=None, description="按设备ID删除")
|
||||
alarm_type: str | None = Field(default=None, description="按告警类型删除")
|
||||
acknowledged: bool | None = Field(default=None, description="按确认状态删除")
|
||||
start_time: datetime | None = Field(default=None, description="开始时间")
|
||||
end_time: datetime | None = Field(default=None, description="结束时间")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/batch-delete",
|
||||
response_model=APIResponse[dict],
|
||||
@@ -217,23 +271,56 @@ async def batch_acknowledge_alarms(
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def batch_delete_alarms(
|
||||
body: dict,
|
||||
body: BatchDeleteAlarmRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""批量删除告警记录,最多500条。 / Batch delete alarm records (max 500)."""
|
||||
alarm_ids = body.get("alarm_ids", [])
|
||||
if not alarm_ids:
|
||||
raise HTTPException(status_code=400, detail="alarm_ids is required")
|
||||
if len(alarm_ids) > 500:
|
||||
raise HTTPException(status_code=400, detail="Max 500 records per request")
|
||||
result = await db.execute(
|
||||
select(AlarmRecord).where(AlarmRecord.id.in_(alarm_ids))
|
||||
"""
|
||||
批量删除告警记录。两种模式:
|
||||
1. 按ID删除: 传 alarm_ids (最多500条)
|
||||
2. 按条件删除: 传 device_id/alarm_type/acknowledged/start_time/end_time 组合
|
||||
"""
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
if body.alarm_ids:
|
||||
# Mode 1: by IDs
|
||||
result = await db.execute(
|
||||
sql_delete(AlarmRecord).where(AlarmRecord.id.in_(body.alarm_ids))
|
||||
)
|
||||
await db.flush()
|
||||
return APIResponse(
|
||||
message=f"已删除 {result.rowcount} 条告警",
|
||||
data={"deleted": result.rowcount, "requested": len(body.alarm_ids)},
|
||||
)
|
||||
|
||||
# Mode 2: by filters (at least one filter required)
|
||||
conditions = []
|
||||
if body.device_id is not None:
|
||||
conditions.append(AlarmRecord.device_id == body.device_id)
|
||||
if body.alarm_type:
|
||||
conditions.append(AlarmRecord.alarm_type == body.alarm_type)
|
||||
if body.acknowledged is not None:
|
||||
conditions.append(AlarmRecord.acknowledged == body.acknowledged)
|
||||
if body.start_time:
|
||||
conditions.append(AlarmRecord.recorded_at >= body.start_time)
|
||||
if body.end_time:
|
||||
conditions.append(AlarmRecord.recorded_at <= body.end_time)
|
||||
|
||||
if not conditions:
|
||||
raise HTTPException(status_code=400, detail="需提供 alarm_ids 或至少一个筛选条件")
|
||||
|
||||
# Count first
|
||||
count = (await db.execute(
|
||||
select(func.count(AlarmRecord.id)).where(*conditions)
|
||||
)).scalar() or 0
|
||||
|
||||
if count > 0:
|
||||
await db.execute(sql_delete(AlarmRecord).where(*conditions))
|
||||
await db.flush()
|
||||
|
||||
return APIResponse(
|
||||
message=f"已删除 {count} 条告警",
|
||||
data={"deleted": count},
|
||||
)
|
||||
records = list(result.scalars().all())
|
||||
for r in records:
|
||||
await db.delete(r)
|
||||
await db.flush()
|
||||
return APIResponse(data={"deleted": len(records)})
|
||||
|
||||
|
||||
@router.get(
|
||||
|
||||
109
app/routers/alert_rules.py
Normal file
109
app/routers/alert_rules.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Alert Rules Router - 告警规则配置接口
|
||||
API endpoints for alert rule CRUD operations.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import require_write
|
||||
from app.models import AlertRule
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
AlertRuleCreate,
|
||||
AlertRuleResponse,
|
||||
AlertRuleUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/alert-rules", tags=["Alert Rules / 告警规则"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=APIResponse[list[AlertRuleResponse]],
|
||||
summary="获取告警规则列表 / List alert rules",
|
||||
)
|
||||
async def list_rules(db: AsyncSession = Depends(get_db)):
|
||||
"""获取所有告警规则。"""
|
||||
result = await db.execute(select(AlertRule).order_by(AlertRule.id))
|
||||
rules = list(result.scalars().all())
|
||||
return APIResponse(data=[AlertRuleResponse.model_validate(r) for r in rules])
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=APIResponse[AlertRuleResponse],
|
||||
status_code=201,
|
||||
summary="创建告警规则 / Create alert rule",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def create_rule(body: AlertRuleCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""创建新告警规则。"""
|
||||
rule = AlertRule(
|
||||
name=body.name,
|
||||
rule_type=body.rule_type,
|
||||
conditions=body.conditions,
|
||||
is_active=body.is_active,
|
||||
device_ids=body.device_ids,
|
||||
group_id=body.group_id,
|
||||
description=body.description,
|
||||
)
|
||||
db.add(rule)
|
||||
await db.flush()
|
||||
await db.refresh(rule)
|
||||
return APIResponse(data=AlertRuleResponse.model_validate(rule))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{rule_id}",
|
||||
response_model=APIResponse[AlertRuleResponse],
|
||||
summary="获取告警规则详情 / Get alert rule",
|
||||
)
|
||||
async def get_rule(rule_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""获取告警规则详情。"""
|
||||
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
|
||||
rule = result.scalar_one_or_none()
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail=f"规则 {rule_id} 不存在")
|
||||
return APIResponse(data=AlertRuleResponse.model_validate(rule))
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{rule_id}",
|
||||
response_model=APIResponse[AlertRuleResponse],
|
||||
summary="更新告警规则 / Update alert rule",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def update_rule(
|
||||
rule_id: int, body: AlertRuleUpdate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""更新告警规则。"""
|
||||
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
|
||||
rule = result.scalar_one_or_none()
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail=f"规则 {rule_id} 不存在")
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for k, v in update_data.items():
|
||||
setattr(rule, k, v)
|
||||
await db.flush()
|
||||
await db.refresh(rule)
|
||||
return APIResponse(data=AlertRuleResponse.model_validate(rule))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{rule_id}",
|
||||
response_model=APIResponse,
|
||||
summary="删除告警规则 / Delete alert rule",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def delete_rule(rule_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""删除告警规则。"""
|
||||
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
|
||||
rule = result.scalar_one_or_none()
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail=f"规则 {rule_id} 不存在")
|
||||
await db.delete(rule)
|
||||
await db.flush()
|
||||
return APIResponse(message="规则已删除")
|
||||
@@ -7,11 +7,13 @@ import math
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import func, select, case
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import AttendanceRecord
|
||||
from app.services.export_utils import build_csv_content, csv_filename
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
AttendanceRecordResponse,
|
||||
@@ -83,6 +85,46 @@ async def list_attendance(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/export",
|
||||
summary="导出考勤记录 CSV / Export attendance records CSV",
|
||||
)
|
||||
async def export_attendance(
|
||||
device_id: int | None = Query(default=None, description="设备ID"),
|
||||
attendance_type: str | None = Query(default=None, description="考勤类型 (clock_in/clock_out)"),
|
||||
attendance_source: str | None = Query(default=None, description="来源 (device/bluetooth/fence)"),
|
||||
start_time: datetime | None = Query(default=None, description="开始时间 ISO 8601"),
|
||||
end_time: datetime | None = Query(default=None, description="结束时间 ISO 8601"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""导出考勤记录为 CSV,支持设备/类型/来源/时间筛选。最多导出 50000 条。"""
|
||||
query = select(AttendanceRecord)
|
||||
if device_id is not None:
|
||||
query = query.where(AttendanceRecord.device_id == device_id)
|
||||
if attendance_type:
|
||||
query = query.where(AttendanceRecord.attendance_type == attendance_type)
|
||||
if attendance_source:
|
||||
query = query.where(AttendanceRecord.attendance_source == attendance_source)
|
||||
if start_time:
|
||||
query = query.where(AttendanceRecord.recorded_at >= start_time)
|
||||
if end_time:
|
||||
query = query.where(AttendanceRecord.recorded_at <= end_time)
|
||||
query = query.order_by(AttendanceRecord.recorded_at.desc()).limit(50000)
|
||||
|
||||
result = await db.execute(query)
|
||||
records = list(result.scalars().all())
|
||||
|
||||
headers = ["ID", "设备ID", "IMEI", "考勤类型", "来源", "纬度", "经度", "电量", "信号", "地址", "记录时间"]
|
||||
fields = ["id", "device_id", "imei", "attendance_type", "attendance_source", "latitude", "longitude", "battery_level", "gsm_signal", "address", "recorded_at"]
|
||||
|
||||
content = build_csv_content(headers, records, fields)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f"attachment; filename={csv_filename('attendance')}"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
response_model=APIResponse[dict],
|
||||
@@ -252,6 +294,71 @@ async def attendance_report(
|
||||
})
|
||||
|
||||
|
||||
@router.get(
|
||||
"/report/export",
|
||||
summary="导出考勤报表 CSV / Export attendance report CSV",
|
||||
)
|
||||
async def export_attendance_report(
|
||||
device_id: int | None = Query(default=None, description="设备ID (可选)"),
|
||||
start_date: str = Query(..., description="开始日期 YYYY-MM-DD"),
|
||||
end_date: str = Query(..., description="结束日期 YYYY-MM-DD"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""导出考勤日报表 CSV(每设备每天汇总)。"""
|
||||
from datetime import datetime as dt
|
||||
|
||||
try:
|
||||
s_date = dt.strptime(start_date, "%Y-%m-%d")
|
||||
e_date = dt.strptime(end_date, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="日期格式需为 YYYY-MM-DD")
|
||||
|
||||
if s_date > e_date:
|
||||
raise HTTPException(status_code=400, detail="start_date must be <= end_date")
|
||||
|
||||
filters = [
|
||||
AttendanceRecord.recorded_at >= s_date,
|
||||
AttendanceRecord.recorded_at <= e_date,
|
||||
]
|
||||
if device_id is not None:
|
||||
filters.append(AttendanceRecord.device_id == device_id)
|
||||
|
||||
result = await db.execute(
|
||||
select(
|
||||
AttendanceRecord.device_id,
|
||||
AttendanceRecord.imei,
|
||||
func.date(AttendanceRecord.recorded_at).label("day"),
|
||||
func.count(AttendanceRecord.id).label("punch_count"),
|
||||
func.min(AttendanceRecord.recorded_at).label("first_punch"),
|
||||
func.max(AttendanceRecord.recorded_at).label("last_punch"),
|
||||
func.group_concat(AttendanceRecord.attendance_source).label("sources"),
|
||||
)
|
||||
.where(*filters)
|
||||
.group_by(AttendanceRecord.device_id, AttendanceRecord.imei, "day")
|
||||
.order_by(AttendanceRecord.device_id, "day")
|
||||
)
|
||||
|
||||
rows = result.all()
|
||||
|
||||
headers = ["设备ID", "IMEI", "日期", "打卡次数", "首次打卡", "末次打卡", "来源"]
|
||||
extractors = [
|
||||
lambda r: r[0],
|
||||
lambda r: r[1],
|
||||
lambda r: str(r[2]),
|
||||
lambda r: r[3],
|
||||
lambda r: r[4],
|
||||
lambda r: r[5],
|
||||
lambda r: ",".join(set(r[6].split(","))) if r[6] else "",
|
||||
]
|
||||
|
||||
content = build_csv_content(headers, rows, extractors)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f"attachment; filename={csv_filename('attendance_report')}"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/device/{device_id}",
|
||||
response_model=APIResponse[PaginatedList[AttendanceRecordResponse]],
|
||||
|
||||
@@ -16,6 +16,8 @@ from app.schemas import (
|
||||
BeaconConfigCreate,
|
||||
BeaconConfigResponse,
|
||||
BeaconConfigUpdate,
|
||||
BeaconDeviceDetail,
|
||||
DeviceBeaconBindRequest,
|
||||
PaginatedList,
|
||||
)
|
||||
from app.services import beacon_service
|
||||
@@ -49,6 +51,76 @@ async def list_beacons(
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/setup-bluetooth-mode",
|
||||
response_model=APIResponse,
|
||||
summary="批量配置蓝牙打卡模式 / Setup BT clock-in mode for devices",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def setup_bluetooth_mode(
|
||||
device_ids: list[int] | None = Query(default=None, description="指定设备ID,不传则所有在线设备"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await beacon_service.setup_bluetooth_mode(db, device_ids)
|
||||
if result["error"]:
|
||||
return APIResponse(code=1, message=result["error"], data=result)
|
||||
return APIResponse(
|
||||
message=f"共 {result['total']} 台: {result['sent']} 台已配置, {result['failed']} 台失败",
|
||||
data=result,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/restore-normal-mode",
|
||||
response_model=APIResponse,
|
||||
summary="恢复正常模式 / Restore devices to normal (smart) mode",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def restore_normal_mode(
|
||||
device_ids: list[int] | None = Query(default=None, description="指定设备ID,不传则所有在线设备"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await beacon_service.restore_normal_mode(db, device_ids)
|
||||
if result["error"]:
|
||||
return APIResponse(code=1, message=result["error"], data=result)
|
||||
return APIResponse(
|
||||
message=f"共 {result['total']} 台: {result['sent']} 台已恢复, {result['failed']} 台失败",
|
||||
data=result,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/reverse-sync",
|
||||
response_model=APIResponse,
|
||||
summary="从设备反向同步信标配置 / Query devices and update DB bindings",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def reverse_sync_beacons(db: AsyncSession = Depends(get_db)):
|
||||
result = await beacon_service.reverse_sync_from_devices(db)
|
||||
if result["error"]:
|
||||
return APIResponse(code=1, message=result["error"], data=result)
|
||||
return APIResponse(
|
||||
message=f"查询 {result['queried']} 台设备,{result['responded']} 台响应,{result['updated']} 台有变更",
|
||||
data=result,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sync-device/{device_id}",
|
||||
response_model=APIResponse,
|
||||
summary="同步信标配置到设备 / Sync beacon MACs to device via BTMACSET",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def sync_device_beacons(device_id: int, db: AsyncSession = Depends(get_db)):
|
||||
result = await beacon_service.sync_device_beacons(db, device_id)
|
||||
if result["error"]:
|
||||
return APIResponse(code=1, message=result["error"], data=result)
|
||||
return APIResponse(
|
||||
message=f"已发送 {len(result['commands'])} 条指令,共 {result['mac_count']} 个信标MAC",
|
||||
data=result,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{beacon_id}",
|
||||
response_model=APIResponse[BeaconConfigResponse],
|
||||
@@ -102,3 +174,52 @@ async def delete_beacon(beacon_id: int, db: AsyncSession = Depends(get_db)):
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Beacon not found")
|
||||
return APIResponse(message="Beacon deleted")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device-Beacon Binding endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{beacon_id}/devices",
|
||||
response_model=APIResponse[list[BeaconDeviceDetail]],
|
||||
summary="获取信标绑定的设备 / Get beacon devices",
|
||||
)
|
||||
async def get_beacon_devices(beacon_id: int, db: AsyncSession = Depends(get_db)):
|
||||
beacon = await beacon_service.get_beacon(db, beacon_id)
|
||||
if beacon is None:
|
||||
raise HTTPException(status_code=404, detail="Beacon not found")
|
||||
items = await beacon_service.get_beacon_devices(db, beacon_id)
|
||||
return APIResponse(data=[BeaconDeviceDetail(**item) for item in items])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{beacon_id}/devices",
|
||||
response_model=APIResponse,
|
||||
summary="绑定设备到信标 / Bind devices to beacon",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def bind_devices(
|
||||
beacon_id: int, body: DeviceBeaconBindRequest, db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await beacon_service.bind_devices_to_beacon(db, beacon_id, body.device_ids)
|
||||
if result.get("error"):
|
||||
raise HTTPException(status_code=404, detail=result["error"])
|
||||
return APIResponse(
|
||||
message=f"绑定完成: 新增{result['created']}, 已绑定{result['already_bound']}, 未找到{result['not_found']}",
|
||||
data=result,
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{beacon_id}/devices",
|
||||
response_model=APIResponse,
|
||||
summary="解绑设备 / Unbind devices from beacon",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def unbind_devices(
|
||||
beacon_id: int, body: DeviceBeaconBindRequest, db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
count = await beacon_service.unbind_devices_from_beacon(db, beacon_id, body.device_ids)
|
||||
return APIResponse(message=f"已解绑 {count} 个设备")
|
||||
|
||||
@@ -8,11 +8,13 @@ from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import BluetoothRecord
|
||||
from app.services.export_utils import build_csv_content, csv_filename
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
BluetoothRecordResponse,
|
||||
@@ -86,6 +88,46 @@ async def list_bluetooth_records(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/export",
|
||||
summary="导出蓝牙记录 CSV / Export bluetooth records CSV",
|
||||
)
|
||||
async def export_bluetooth(
|
||||
device_id: int | None = Query(default=None, description="设备ID"),
|
||||
record_type: str | None = Query(default=None, description="记录类型 (punch/location)"),
|
||||
beacon_mac: str | None = Query(default=None, description="信标MAC"),
|
||||
start_time: datetime | None = Query(default=None, description="开始时间 ISO 8601"),
|
||||
end_time: datetime | None = Query(default=None, description="结束时间 ISO 8601"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""导出蓝牙记录为 CSV,支持设备/类型/信标/时间筛选。最多导出 50000 条。"""
|
||||
query = select(BluetoothRecord)
|
||||
if device_id is not None:
|
||||
query = query.where(BluetoothRecord.device_id == device_id)
|
||||
if record_type:
|
||||
query = query.where(BluetoothRecord.record_type == record_type)
|
||||
if beacon_mac:
|
||||
query = query.where(BluetoothRecord.beacon_mac == beacon_mac)
|
||||
if start_time:
|
||||
query = query.where(BluetoothRecord.recorded_at >= start_time)
|
||||
if end_time:
|
||||
query = query.where(BluetoothRecord.recorded_at <= end_time)
|
||||
query = query.order_by(BluetoothRecord.recorded_at.desc()).limit(50000)
|
||||
|
||||
result = await db.execute(query)
|
||||
records = list(result.scalars().all())
|
||||
|
||||
headers = ["ID", "设备ID", "IMEI", "记录类型", "信标MAC", "UUID", "Major", "Minor", "RSSI", "电量", "考勤类型", "纬度", "经度", "记录时间"]
|
||||
fields = ["id", "device_id", "imei", "record_type", "beacon_mac", "beacon_uuid", "beacon_major", "beacon_minor", "rssi", "beacon_battery", "attendance_type", "latitude", "longitude", "recorded_at"]
|
||||
|
||||
content = build_csv_content(headers, records, fields)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f"attachment; filename={csv_filename('bluetooth')}"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
response_model=APIResponse[dict],
|
||||
|
||||
210
app/routers/device_groups.py
Normal file
210
app/routers/device_groups.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
Device Groups Router - 设备分组管理接口
|
||||
API endpoints for device group CRUD and membership management.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import require_write
|
||||
from app.models import Device, DeviceGroup, DeviceGroupMember
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
DeviceGroupCreate,
|
||||
DeviceGroupResponse,
|
||||
DeviceGroupUpdate,
|
||||
DeviceGroupWithCount,
|
||||
DeviceResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/groups", tags=["Device Groups / 设备分组"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=APIResponse[list[DeviceGroupWithCount]],
|
||||
summary="获取分组列表 / List device groups",
|
||||
)
|
||||
async def list_groups(db: AsyncSession = Depends(get_db)):
|
||||
"""获取所有设备分组及各组设备数。"""
|
||||
result = await db.execute(
|
||||
select(
|
||||
DeviceGroup,
|
||||
func.count(DeviceGroupMember.id).label("cnt"),
|
||||
)
|
||||
.outerjoin(DeviceGroupMember, DeviceGroup.id == DeviceGroupMember.group_id)
|
||||
.group_by(DeviceGroup.id)
|
||||
.order_by(DeviceGroup.name)
|
||||
)
|
||||
groups = []
|
||||
for row in result.all():
|
||||
g = DeviceGroupResponse.model_validate(row[0])
|
||||
groups.append(DeviceGroupWithCount(**g.model_dump(), device_count=row[1]))
|
||||
return APIResponse(data=groups)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=APIResponse[DeviceGroupResponse],
|
||||
status_code=201,
|
||||
summary="创建分组 / Create group",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def create_group(body: DeviceGroupCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""创建新设备分组。"""
|
||||
existing = await db.execute(
|
||||
select(DeviceGroup).where(DeviceGroup.name == body.name)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail=f"分组名 '{body.name}' 已存在")
|
||||
group = DeviceGroup(name=body.name, description=body.description, color=body.color)
|
||||
db.add(group)
|
||||
await db.flush()
|
||||
await db.refresh(group)
|
||||
return APIResponse(data=DeviceGroupResponse.model_validate(group))
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{group_id}",
|
||||
response_model=APIResponse[DeviceGroupResponse],
|
||||
summary="更新分组 / Update group",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def update_group(
|
||||
group_id: int, body: DeviceGroupUpdate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""更新分组信息。"""
|
||||
result = await db.execute(select(DeviceGroup).where(DeviceGroup.id == group_id))
|
||||
group = result.scalar_one_or_none()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail=f"分组 {group_id} 不存在")
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for k, v in update_data.items():
|
||||
setattr(group, k, v)
|
||||
await db.flush()
|
||||
await db.refresh(group)
|
||||
return APIResponse(data=DeviceGroupResponse.model_validate(group))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{group_id}",
|
||||
response_model=APIResponse,
|
||||
summary="删除分组 / Delete group",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def delete_group(group_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""删除分组(级联删除成员关系,不删除设备)。"""
|
||||
result = await db.execute(select(DeviceGroup).where(DeviceGroup.id == group_id))
|
||||
group = result.scalar_one_or_none()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail=f"分组 {group_id} 不存在")
|
||||
# Clear group_id on devices
|
||||
devices_result = await db.execute(select(Device).where(Device.group_id == group_id))
|
||||
for d in devices_result.scalars().all():
|
||||
d.group_id = None
|
||||
await db.delete(group)
|
||||
await db.flush()
|
||||
return APIResponse(message="分组已删除")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{group_id}/devices",
|
||||
response_model=APIResponse[list[DeviceResponse]],
|
||||
summary="获取分组设备 / Get group devices",
|
||||
)
|
||||
async def get_group_devices(group_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""获取分组内的设备列表。"""
|
||||
result = await db.execute(
|
||||
select(Device)
|
||||
.join(DeviceGroupMember, Device.id == DeviceGroupMember.device_id)
|
||||
.where(DeviceGroupMember.group_id == group_id)
|
||||
.order_by(Device.name)
|
||||
)
|
||||
devices = list(result.scalars().all())
|
||||
return APIResponse(data=[DeviceResponse.model_validate(d) for d in devices])
|
||||
|
||||
|
||||
class GroupMemberRequest(BaseModel):
|
||||
device_ids: list[int] = Field(..., min_length=1, max_length=500, description="设备ID列表")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{group_id}/devices",
|
||||
response_model=APIResponse[dict],
|
||||
summary="添加设备到分组 / Add devices to group",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def add_devices_to_group(
|
||||
group_id: int, body: GroupMemberRequest, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""添加设备到分组(幂等,已存在的跳过)。"""
|
||||
# Verify group exists
|
||||
group = (await db.execute(
|
||||
select(DeviceGroup).where(DeviceGroup.id == group_id)
|
||||
)).scalar_one_or_none()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail=f"分组 {group_id} 不存在")
|
||||
|
||||
# Get existing members
|
||||
existing = await db.execute(
|
||||
select(DeviceGroupMember.device_id).where(
|
||||
DeviceGroupMember.group_id == group_id,
|
||||
DeviceGroupMember.device_id.in_(body.device_ids),
|
||||
)
|
||||
)
|
||||
existing_ids = {row[0] for row in existing.all()}
|
||||
|
||||
added = 0
|
||||
for did in body.device_ids:
|
||||
if did not in existing_ids:
|
||||
db.add(DeviceGroupMember(device_id=did, group_id=group_id))
|
||||
added += 1
|
||||
# Also update device.group_id
|
||||
if body.device_ids:
|
||||
devices = await db.execute(
|
||||
select(Device).where(Device.id.in_(body.device_ids))
|
||||
)
|
||||
for d in devices.scalars().all():
|
||||
d.group_id = group_id
|
||||
|
||||
await db.flush()
|
||||
return APIResponse(
|
||||
message=f"已添加 {added} 台设备到分组",
|
||||
data={"added": added, "already_in_group": len(existing_ids)},
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{group_id}/devices",
|
||||
response_model=APIResponse[dict],
|
||||
summary="从分组移除设备 / Remove devices from group",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def remove_devices_from_group(
|
||||
group_id: int, body: GroupMemberRequest, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""从分组移除设备。"""
|
||||
result = await db.execute(
|
||||
delete(DeviceGroupMember).where(
|
||||
DeviceGroupMember.group_id == group_id,
|
||||
DeviceGroupMember.device_id.in_(body.device_ids),
|
||||
)
|
||||
)
|
||||
# Clear group_id on removed devices
|
||||
devices = await db.execute(
|
||||
select(Device).where(
|
||||
Device.id.in_(body.device_ids),
|
||||
Device.group_id == group_id,
|
||||
)
|
||||
)
|
||||
for d in devices.scalars().all():
|
||||
d.group_id = None
|
||||
|
||||
await db.flush()
|
||||
return APIResponse(
|
||||
message=f"已移除 {result.rowcount} 台设备",
|
||||
data={"removed": result.rowcount},
|
||||
)
|
||||
@@ -6,9 +6,13 @@ API endpoints for device CRUD operations and statistics.
|
||||
import math
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Device
|
||||
from app.services.export_utils import build_csv_content, csv_filename
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
BatchDeviceCreateRequest,
|
||||
@@ -74,6 +78,40 @@ async def device_stats(db: AsyncSession = Depends(get_db)):
|
||||
return APIResponse(data=stats)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/export",
|
||||
summary="导出设备列表 CSV / Export devices CSV",
|
||||
)
|
||||
async def export_devices(
|
||||
status: str | None = Query(default=None, description="状态过滤 (online/offline)"),
|
||||
search: str | None = Query(default=None, description="搜索IMEI或名称"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""导出设备列表为 CSV 文件,支持状态/搜索筛选。"""
|
||||
from sqlalchemy import or_
|
||||
|
||||
query = select(Device)
|
||||
if status:
|
||||
query = query.where(Device.status == status)
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
query = query.where(or_(Device.imei.ilike(pattern), Device.name.ilike(pattern)))
|
||||
query = query.order_by(Device.id)
|
||||
|
||||
result = await db.execute(query)
|
||||
devices = list(result.scalars().all())
|
||||
|
||||
headers = ["ID", "IMEI", "名称", "类型", "状态", "电量%", "信号", "ICCID", "IMSI", "最后心跳", "最后登录", "创建时间"]
|
||||
fields = ["id", "imei", "name", "device_type", "status", "battery_level", "gsm_signal", "iccid", "imsi", "last_heartbeat", "last_login", "created_at"]
|
||||
|
||||
content = build_csv_content(headers, devices, fields)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f"attachment; filename={csv_filename('devices')}"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/imei/{imei}",
|
||||
response_model=APIResponse[DeviceResponse],
|
||||
|
||||
@@ -7,12 +7,14 @@ import math
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import func, select, delete, case, extract
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import require_write
|
||||
from app.database import get_db
|
||||
from app.models import LocationRecord
|
||||
from app.services.export_utils import build_csv_content, csv_filename
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
LocationRecordResponse,
|
||||
@@ -129,6 +131,93 @@ async def location_stats(
|
||||
})
|
||||
|
||||
|
||||
@router.get(
|
||||
"/export",
|
||||
summary="导出位置记录 CSV / Export location records CSV",
|
||||
)
|
||||
async def export_locations(
|
||||
device_id: int | None = Query(default=None, description="设备ID"),
|
||||
location_type: str | None = Query(default=None, description="定位类型 (gps/lbs/wifi)"),
|
||||
start_time: datetime | None = Query(default=None, description="开始时间 ISO 8601"),
|
||||
end_time: datetime | None = Query(default=None, description="结束时间 ISO 8601"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""导出位置记录为 CSV,支持设备/类型/时间筛选。最多导出 50000 条。"""
|
||||
query = select(LocationRecord)
|
||||
if device_id is not None:
|
||||
query = query.where(LocationRecord.device_id == device_id)
|
||||
if location_type:
|
||||
query = query.where(LocationRecord.location_type == location_type)
|
||||
if start_time:
|
||||
query = query.where(LocationRecord.recorded_at >= start_time)
|
||||
if end_time:
|
||||
query = query.where(LocationRecord.recorded_at <= end_time)
|
||||
query = query.order_by(LocationRecord.recorded_at.desc()).limit(50000)
|
||||
|
||||
result = await db.execute(query)
|
||||
records = list(result.scalars().all())
|
||||
|
||||
headers = ["ID", "设备ID", "IMEI", "定位类型", "纬度", "经度", "速度", "航向", "卫星数", "地址", "记录时间", "创建时间"]
|
||||
fields = ["id", "device_id", "imei", "location_type", "latitude", "longitude", "speed", "course", "gps_satellites", "address", "recorded_at", "created_at"]
|
||||
|
||||
content = build_csv_content(headers, records, fields)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f"attachment; filename={csv_filename('locations')}"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/heatmap",
|
||||
response_model=APIResponse[list[dict]],
|
||||
summary="热力图数据 / Heatmap data",
|
||||
)
|
||||
async def location_heatmap(
|
||||
device_id: int | None = Query(default=None, description="设备ID (可选)"),
|
||||
start_time: datetime | None = Query(default=None, description="开始时间"),
|
||||
end_time: datetime | None = Query(default=None, description="结束时间"),
|
||||
precision: int = Query(default=3, ge=1, le=6, description="坐标精度 (小数位数, 3≈100m)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
返回位置热力图数据:按坐标网格聚合,返回 [{lat, lng, weight}]。
|
||||
precision=3 约100m网格,precision=4 约10m网格。
|
||||
"""
|
||||
filters = [
|
||||
LocationRecord.latitude.is_not(None),
|
||||
LocationRecord.longitude.is_not(None),
|
||||
]
|
||||
if device_id is not None:
|
||||
filters.append(LocationRecord.device_id == device_id)
|
||||
if start_time:
|
||||
filters.append(LocationRecord.recorded_at >= start_time)
|
||||
if end_time:
|
||||
filters.append(LocationRecord.recorded_at <= end_time)
|
||||
|
||||
factor = 10 ** precision
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.round(LocationRecord.latitude * factor).label("lat_grid"),
|
||||
func.round(LocationRecord.longitude * factor).label("lng_grid"),
|
||||
func.count(LocationRecord.id).label("weight"),
|
||||
)
|
||||
.where(*filters)
|
||||
.group_by("lat_grid", "lng_grid")
|
||||
.order_by(func.count(LocationRecord.id).desc())
|
||||
.limit(10000)
|
||||
)
|
||||
points = [
|
||||
{
|
||||
"lat": round(row[0] / factor, precision),
|
||||
"lng": round(row[1] / factor, precision),
|
||||
"weight": row[2],
|
||||
}
|
||||
for row in result.all()
|
||||
]
|
||||
return APIResponse(data=points)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/track-summary/{device_id}",
|
||||
response_model=APIResponse[dict],
|
||||
@@ -352,6 +441,43 @@ async def delete_no_coord_locations(
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/cleanup",
|
||||
response_model=APIResponse[dict],
|
||||
summary="清理旧位置记录 / Cleanup old location records",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def cleanup_locations(
|
||||
days: int = Body(..., ge=1, le=3650, description="删除N天前的记录", embed=True),
|
||||
device_id: int | None = Body(default=None, description="限定设备ID (可选)", embed=True),
|
||||
location_type: str | None = Body(default=None, description="限定定位类型 (可选, 如 lbs/wifi)", embed=True),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
删除 N 天前的旧位置记录,支持按设备和定位类型筛选。
|
||||
Delete location records older than N days, with optional device/type filters.
|
||||
"""
|
||||
cutoff = datetime.now() - timedelta(days=days)
|
||||
conditions = [LocationRecord.created_at < cutoff]
|
||||
if device_id is not None:
|
||||
conditions.append(LocationRecord.device_id == device_id)
|
||||
if location_type:
|
||||
conditions.append(LocationRecord.location_type == location_type)
|
||||
|
||||
count = (await db.execute(
|
||||
select(func.count(LocationRecord.id)).where(*conditions)
|
||||
)).scalar() or 0
|
||||
|
||||
if count > 0:
|
||||
await db.execute(delete(LocationRecord).where(*conditions))
|
||||
await db.flush()
|
||||
|
||||
return APIResponse(
|
||||
message=f"已清理 {count} 条 {days} 天前的位置记录",
|
||||
data={"deleted": count, "cutoff_days": days},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{location_id}",
|
||||
response_model=APIResponse[LocationRecordResponse],
|
||||
|
||||
299
app/routers/system.py
Normal file
299
app/routers/system.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
System Management Router - 系统管理接口
|
||||
Audit logs, runtime config, database backup, firmware info.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import select, func, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings, now_cst
|
||||
from app.database import get_db
|
||||
from app.dependencies import require_admin
|
||||
from app.models import AuditLog, Device
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
AuditLogResponse,
|
||||
PaginatedList,
|
||||
SystemConfigResponse,
|
||||
SystemConfigUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/system", tags=["System / 系统管理"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audit Logs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get(
|
||||
"/audit-logs",
|
||||
response_model=APIResponse[PaginatedList[AuditLogResponse]],
|
||||
summary="获取审计日志 / List audit logs",
|
||||
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
|
||||
)
|
||||
async def list_audit_logs(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
method: str | None = Query(None, description="Filter by HTTP method (POST/PUT/DELETE)"),
|
||||
path_contains: str | None = Query(None, description="Filter by path keyword"),
|
||||
operator: str | None = Query(None, description="Filter by operator name"),
|
||||
start_time: datetime | None = None,
|
||||
end_time: datetime | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""查询操作审计日志 (admin only)。"""
|
||||
query = select(AuditLog)
|
||||
count_query = select(func.count(AuditLog.id))
|
||||
|
||||
if method:
|
||||
query = query.where(AuditLog.method == method.upper())
|
||||
count_query = count_query.where(AuditLog.method == method.upper())
|
||||
if path_contains:
|
||||
query = query.where(AuditLog.path.contains(path_contains))
|
||||
count_query = count_query.where(AuditLog.path.contains(path_contains))
|
||||
if operator:
|
||||
query = query.where(AuditLog.operator == operator)
|
||||
count_query = count_query.where(AuditLog.operator == operator)
|
||||
if start_time:
|
||||
query = query.where(AuditLog.created_at >= start_time)
|
||||
count_query = count_query.where(AuditLog.created_at >= start_time)
|
||||
if end_time:
|
||||
query = query.where(AuditLog.created_at <= end_time)
|
||||
count_query = count_query.where(AuditLog.created_at <= end_time)
|
||||
|
||||
total = (await db.execute(count_query)).scalar() or 0
|
||||
total_pages = (total + page_size - 1) // page_size
|
||||
|
||||
rows = await db.execute(
|
||||
query.order_by(AuditLog.id.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
items = [AuditLogResponse.model_validate(r) for r in rows.scalars().all()]
|
||||
|
||||
return APIResponse(
|
||||
data=PaginatedList(
|
||||
items=items, total=total, page=page,
|
||||
page_size=page_size, total_pages=total_pages,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/audit-logs",
|
||||
response_model=APIResponse,
|
||||
summary="清理审计日志 / Clean audit logs",
|
||||
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
|
||||
)
|
||||
async def clean_audit_logs(
|
||||
days: int = Query(..., ge=1, le=3650, description="Delete logs older than N days"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除N天前的审计日志 (admin only)。"""
|
||||
cutoff = now_cst() - timedelta(days=days)
|
||||
result = await db.execute(
|
||||
delete(AuditLog).where(AuditLog.created_at < cutoff)
|
||||
)
|
||||
return APIResponse(
|
||||
message=f"已删除 {result.rowcount} 条 {days} 天前的审计日志",
|
||||
data={"deleted": result.rowcount},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get(
|
||||
"/config",
|
||||
response_model=APIResponse[SystemConfigResponse],
|
||||
summary="获取运行时配置 / Get runtime config",
|
||||
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
|
||||
)
|
||||
async def get_config():
|
||||
"""获取当前运行时配置参数 (admin only)。"""
|
||||
return APIResponse(data=SystemConfigResponse(
|
||||
data_retention_days=settings.DATA_RETENTION_DAYS,
|
||||
data_cleanup_interval_hours=settings.DATA_CLEANUP_INTERVAL_HOURS,
|
||||
tcp_idle_timeout=settings.TCP_IDLE_TIMEOUT,
|
||||
fence_check_enabled=settings.FENCE_CHECK_ENABLED,
|
||||
fence_lbs_tolerance_meters=settings.FENCE_LBS_TOLERANCE_METERS,
|
||||
fence_wifi_tolerance_meters=settings.FENCE_WIFI_TOLERANCE_METERS,
|
||||
fence_min_inside_seconds=settings.FENCE_MIN_INSIDE_SECONDS,
|
||||
rate_limit_default=settings.RATE_LIMIT_DEFAULT,
|
||||
rate_limit_write=settings.RATE_LIMIT_WRITE,
|
||||
track_max_points=settings.TRACK_MAX_POINTS,
|
||||
geocoding_cache_size=settings.GEOCODING_CACHE_SIZE,
|
||||
))
|
||||
|
||||
|
||||
@router.put(
|
||||
"/config",
|
||||
response_model=APIResponse[SystemConfigResponse],
|
||||
summary="更新运行时配置 / Update runtime config",
|
||||
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
|
||||
)
|
||||
async def update_config(body: SystemConfigUpdate):
|
||||
"""更新运行时配置参数 (admin only)。仅影响当前进程,重启后恢复 .env 值。"""
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
attr_name = key.upper()
|
||||
if hasattr(settings, attr_name):
|
||||
object.__setattr__(settings, attr_name, value)
|
||||
|
||||
return APIResponse(
|
||||
message=f"已更新 {len(update_data)} 项配置 (进程级,重启后恢复)",
|
||||
data=SystemConfigResponse(
|
||||
data_retention_days=settings.DATA_RETENTION_DAYS,
|
||||
data_cleanup_interval_hours=settings.DATA_CLEANUP_INTERVAL_HOURS,
|
||||
tcp_idle_timeout=settings.TCP_IDLE_TIMEOUT,
|
||||
fence_check_enabled=settings.FENCE_CHECK_ENABLED,
|
||||
fence_lbs_tolerance_meters=settings.FENCE_LBS_TOLERANCE_METERS,
|
||||
fence_wifi_tolerance_meters=settings.FENCE_WIFI_TOLERANCE_METERS,
|
||||
fence_min_inside_seconds=settings.FENCE_MIN_INSIDE_SECONDS,
|
||||
rate_limit_default=settings.RATE_LIMIT_DEFAULT,
|
||||
rate_limit_write=settings.RATE_LIMIT_WRITE,
|
||||
track_max_points=settings.TRACK_MAX_POINTS,
|
||||
geocoding_cache_size=settings.GEOCODING_CACHE_SIZE,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database Backup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post(
|
||||
"/backup",
|
||||
summary="创建数据库备份 / Create database backup",
|
||||
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
|
||||
)
|
||||
async def create_backup():
|
||||
"""
|
||||
触发 SQLite 数据库备份,返回备份文件下载 (admin only)。
|
||||
使用 SQLite 原生 backup API 确保一致性。
|
||||
"""
|
||||
import aiosqlite
|
||||
|
||||
db_path = settings.DATABASE_URL.replace("sqlite+aiosqlite:///", "")
|
||||
if not Path(db_path).exists():
|
||||
return APIResponse(code=500, message="Database file not found")
|
||||
|
||||
backup_dir = Path(db_path).parent / "backups"
|
||||
backup_dir.mkdir(exist_ok=True)
|
||||
|
||||
timestamp = now_cst().strftime("%Y%m%d_%H%M%S")
|
||||
backup_name = f"badge_admin_backup_{timestamp}.db"
|
||||
backup_path = backup_dir / backup_name
|
||||
|
||||
# Use SQLite online backup API for consistency
|
||||
async with aiosqlite.connect(db_path) as source:
|
||||
async with aiosqlite.connect(str(backup_path)) as dest:
|
||||
await source.backup(dest)
|
||||
|
||||
size_mb = round(backup_path.stat().st_size / 1024 / 1024, 2)
|
||||
|
||||
return FileResponse(
|
||||
path=str(backup_path),
|
||||
filename=backup_name,
|
||||
media_type="application/octet-stream",
|
||||
headers={
|
||||
"X-Backup-Size-MB": str(size_mb),
|
||||
"X-Backup-Timestamp": timestamp,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/backups",
|
||||
response_model=APIResponse[list[dict]],
|
||||
summary="列出数据库备份 / List backups",
|
||||
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
|
||||
)
|
||||
async def list_backups():
|
||||
"""列出所有已有的数据库备份文件 (admin only)。"""
|
||||
db_path = settings.DATABASE_URL.replace("sqlite+aiosqlite:///", "")
|
||||
backup_dir = Path(db_path).parent / "backups"
|
||||
|
||||
if not backup_dir.exists():
|
||||
return APIResponse(data=[])
|
||||
|
||||
backups = []
|
||||
for f in sorted(backup_dir.glob("*.db"), reverse=True):
|
||||
backups.append({
|
||||
"filename": f.name,
|
||||
"size_mb": round(f.stat().st_size / 1024 / 1024, 2),
|
||||
"created_at": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
|
||||
})
|
||||
return APIResponse(data=backups)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/backups/{filename}",
|
||||
response_model=APIResponse,
|
||||
summary="删除数据库备份 / Delete backup",
|
||||
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
|
||||
)
|
||||
async def delete_backup(filename: str):
|
||||
"""删除指定的数据库备份文件 (admin only)。"""
|
||||
db_path = settings.DATABASE_URL.replace("sqlite+aiosqlite:///", "")
|
||||
backup_path = Path(db_path).parent / "backups" / filename
|
||||
|
||||
if not backup_path.exists() or not backup_path.suffix == ".db":
|
||||
return APIResponse(code=404, message="Backup not found")
|
||||
|
||||
# Prevent path traversal
|
||||
if ".." in filename or "/" in filename:
|
||||
return APIResponse(code=400, message="Invalid filename")
|
||||
|
||||
backup_path.unlink()
|
||||
return APIResponse(message=f"已删除备份 {filename}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device Firmware Info
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get(
|
||||
"/firmware",
|
||||
response_model=APIResponse[list[dict]],
|
||||
summary="设备固件信息 / Device firmware info",
|
||||
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
|
||||
)
|
||||
async def list_firmware_info(
|
||||
status: str | None = Query(None, description="Filter by device status"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取所有设备的固件相关信息 (型号、ICCID、IMSI等)。
|
||||
实际固件版本需通过 VERSION# 指令查询 (admin only)。
|
||||
"""
|
||||
query = select(Device).order_by(Device.id)
|
||||
if status:
|
||||
query = query.where(Device.status == status)
|
||||
|
||||
result = await db.execute(query)
|
||||
devices = result.scalars().all()
|
||||
|
||||
firmware_list = []
|
||||
for d in devices:
|
||||
firmware_list.append({
|
||||
"device_id": d.id,
|
||||
"imei": d.imei,
|
||||
"name": d.name,
|
||||
"device_type": d.device_type,
|
||||
"status": d.status,
|
||||
"iccid": d.iccid,
|
||||
"imsi": d.imsi,
|
||||
"last_login": d.last_login.isoformat() if d.last_login else None,
|
||||
"last_heartbeat": d.last_heartbeat.isoformat() if d.last_heartbeat else None,
|
||||
})
|
||||
|
||||
return APIResponse(data=firmware_list)
|
||||
145
app/schemas.py
145
app/schemas.py
@@ -59,6 +59,7 @@ class DeviceUpdate(BaseModel):
|
||||
imsi: str | None = Field(None, max_length=20)
|
||||
timezone: str | None = Field(None, max_length=30)
|
||||
language: str | None = Field(None, max_length=10)
|
||||
group_id: int | None = None
|
||||
|
||||
|
||||
class DeviceResponse(DeviceBase):
|
||||
@@ -72,6 +73,7 @@ class DeviceResponse(DeviceBase):
|
||||
last_login: datetime | None = None
|
||||
iccid: str | None = None
|
||||
imsi: str | None = None
|
||||
group_id: int | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
@@ -374,6 +376,23 @@ class BeaconConfigResponse(BaseModel):
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device-Beacon Binding schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DeviceBeaconBindRequest(BaseModel):
|
||||
device_ids: list[int] = Field(..., min_length=1, max_length=100, description="设备ID列表")
|
||||
|
||||
|
||||
class BeaconDeviceDetail(BaseModel):
|
||||
"""Binding detail with device info."""
|
||||
binding_id: int
|
||||
device_id: int
|
||||
device_name: str | None = None
|
||||
imei: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fence Config schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -588,6 +607,132 @@ class CommandListResponse(APIResponse[PaginatedList[CommandResponse]]):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device Group schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DeviceGroupCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100, description="分组名称")
|
||||
description: str | None = Field(None, max_length=500, description="描述")
|
||||
color: str = Field(default="#3b82f6", max_length=20, description="颜色")
|
||||
|
||||
|
||||
class DeviceGroupUpdate(BaseModel):
|
||||
name: str | None = Field(None, max_length=100)
|
||||
description: str | None = None
|
||||
color: str | None = Field(None, max_length=20)
|
||||
|
||||
|
||||
class DeviceGroupResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
description: str | None = None
|
||||
color: str
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class DeviceGroupWithCount(DeviceGroupResponse):
|
||||
device_count: int = 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Alert Rule schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AlertRuleCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100, description="规则名称")
|
||||
rule_type: Literal["low_battery", "no_heartbeat", "fence_stay", "speed_limit", "offline_duration"] = Field(
|
||||
..., description="规则类型"
|
||||
)
|
||||
conditions: dict = Field(..., description="条件参数, 如 {\"threshold\": 20}")
|
||||
is_active: bool = Field(default=True)
|
||||
device_ids: str | None = Field(None, description="适用设备ID (逗号分隔), null=全部")
|
||||
group_id: int | None = Field(None, description="适用分组ID")
|
||||
description: str | None = Field(None, max_length=500)
|
||||
|
||||
|
||||
class AlertRuleUpdate(BaseModel):
|
||||
name: str | None = Field(None, max_length=100)
|
||||
rule_type: Literal["low_battery", "no_heartbeat", "fence_stay", "speed_limit", "offline_duration"] | None = None
|
||||
conditions: dict | None = None
|
||||
is_active: bool | None = None
|
||||
device_ids: str | None = None
|
||||
group_id: int | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class AlertRuleResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
rule_type: str
|
||||
conditions: dict
|
||||
is_active: bool
|
||||
device_ids: str | None = None
|
||||
group_id: int | None = None
|
||||
description: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audit Log schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AuditLogResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
method: str
|
||||
path: str
|
||||
status_code: int
|
||||
operator: str | None = None
|
||||
client_ip: str | None = None
|
||||
request_body: dict[str, Any] | None = None
|
||||
response_summary: str | None = None
|
||||
duration_ms: int | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System Config schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SystemConfigResponse(BaseModel):
|
||||
"""Current runtime configuration (read-only and writable fields)."""
|
||||
data_retention_days: int
|
||||
data_cleanup_interval_hours: int
|
||||
tcp_idle_timeout: int
|
||||
fence_check_enabled: bool
|
||||
fence_lbs_tolerance_meters: int
|
||||
fence_wifi_tolerance_meters: int
|
||||
fence_min_inside_seconds: int
|
||||
rate_limit_default: str
|
||||
rate_limit_write: str
|
||||
track_max_points: int
|
||||
geocoding_cache_size: int
|
||||
|
||||
|
||||
class SystemConfigUpdate(BaseModel):
|
||||
"""Fields that can be updated at runtime."""
|
||||
data_retention_days: int | None = Field(None, ge=1, le=3650)
|
||||
data_cleanup_interval_hours: int | None = Field(None, ge=1, le=720)
|
||||
tcp_idle_timeout: int | None = Field(None, ge=0, le=86400)
|
||||
fence_check_enabled: bool | None = None
|
||||
fence_lbs_tolerance_meters: int | None = Field(None, ge=0, le=10000)
|
||||
fence_wifi_tolerance_meters: int | None = Field(None, ge=0, le=10000)
|
||||
fence_min_inside_seconds: int | None = Field(None, ge=0, le=3600)
|
||||
track_max_points: int | None = Field(None, ge=100, le=100000)
|
||||
|
||||
|
||||
class ApiKeyCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100, description="Key name / 名称")
|
||||
permissions: Literal["read", "write", "admin"] = Field(default="read", description="Permission level")
|
||||
|
||||
@@ -3,13 +3,19 @@ Beacon Service - 蓝牙信标管理服务
|
||||
Provides CRUD operations for Bluetooth beacon configuration.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select, or_
|
||||
from sqlalchemy import delete as sa_delete, func, select, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import BeaconConfig
|
||||
from app.models import BeaconConfig, CommandLog, Device, DeviceBeaconBinding
|
||||
from app.schemas import BeaconConfigCreate, BeaconConfigUpdate
|
||||
from app.services import tcp_command_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_beacons(
|
||||
@@ -93,3 +99,455 @@ async def delete_beacon(db: AsyncSession, beacon_id: int) -> bool:
|
||||
await db.delete(beacon)
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device-Beacon Binding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_beacon_devices(db: AsyncSession, beacon_id: int) -> list[dict]:
|
||||
"""Get devices bound to a beacon."""
|
||||
result = await db.execute(
|
||||
select(
|
||||
DeviceBeaconBinding.id.label("binding_id"),
|
||||
DeviceBeaconBinding.device_id,
|
||||
Device.name.label("device_name"),
|
||||
Device.imei,
|
||||
)
|
||||
.join(Device, Device.id == DeviceBeaconBinding.device_id)
|
||||
.where(DeviceBeaconBinding.beacon_id == beacon_id)
|
||||
.order_by(Device.name)
|
||||
)
|
||||
return [row._asdict() for row in result.all()]
|
||||
|
||||
|
||||
async def bind_devices_to_beacon(
|
||||
db: AsyncSession, beacon_id: int, device_ids: list[int],
|
||||
) -> dict:
|
||||
"""Bind multiple devices to a beacon. Idempotent."""
|
||||
beacon = await get_beacon(db, beacon_id)
|
||||
if beacon is None:
|
||||
return {"created": 0, "already_bound": 0, "not_found": len(device_ids), "error": "Beacon not found"}
|
||||
|
||||
result = await db.execute(
|
||||
select(Device.id).where(Device.id.in_(device_ids))
|
||||
)
|
||||
existing_device_ids = set(row[0] for row in result.all())
|
||||
|
||||
result = await db.execute(
|
||||
select(DeviceBeaconBinding.device_id).where(
|
||||
DeviceBeaconBinding.beacon_id == beacon_id,
|
||||
DeviceBeaconBinding.device_id.in_(device_ids),
|
||||
)
|
||||
)
|
||||
already_bound_ids = set(row[0] for row in result.all())
|
||||
|
||||
created = 0
|
||||
for did in device_ids:
|
||||
if did not in existing_device_ids or did in already_bound_ids:
|
||||
continue
|
||||
db.add(DeviceBeaconBinding(device_id=did, beacon_id=beacon_id))
|
||||
created += 1
|
||||
|
||||
await db.flush()
|
||||
return {
|
||||
"created": created,
|
||||
"already_bound": len(already_bound_ids & existing_device_ids),
|
||||
"not_found": len(set(device_ids) - existing_device_ids),
|
||||
}
|
||||
|
||||
|
||||
async def unbind_devices_from_beacon(
|
||||
db: AsyncSession, beacon_id: int, device_ids: list[int],
|
||||
) -> int:
|
||||
"""Unbind devices from a beacon."""
|
||||
result = await db.execute(
|
||||
sa_delete(DeviceBeaconBinding).where(
|
||||
DeviceBeaconBinding.beacon_id == beacon_id,
|
||||
DeviceBeaconBinding.device_id.in_(device_ids),
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return result.rowcount
|
||||
|
||||
|
||||
async def sync_device_beacons(db: AsyncSession, device_id: int) -> dict:
|
||||
"""Query all beacons bound to a device and send BTMACSET commands via TCP.
|
||||
|
||||
BTMACSET supports up to 10 MACs per slot, 5 slots total (default + 1-4).
|
||||
Returns {"sent": bool, "mac_count": int, "commands": [...], "error": str|None}.
|
||||
"""
|
||||
# Get device IMEI
|
||||
result = await db.execute(select(Device).where(Device.id == device_id))
|
||||
device = result.scalar_one_or_none()
|
||||
if device is None:
|
||||
return {"sent": False, "mac_count": 0, "commands": [], "error": "设备不存在"}
|
||||
|
||||
# Get all beacons bound to this device
|
||||
result = await db.execute(
|
||||
select(BeaconConfig.beacon_mac)
|
||||
.join(DeviceBeaconBinding, DeviceBeaconBinding.beacon_id == BeaconConfig.id)
|
||||
.where(DeviceBeaconBinding.device_id == device_id)
|
||||
.order_by(BeaconConfig.id)
|
||||
)
|
||||
macs = [row[0] for row in result.all()]
|
||||
|
||||
if not tcp_command_service.is_device_online(device.imei):
|
||||
return {"sent": False, "mac_count": len(macs), "commands": [], "error": "设备离线,无法发送指令"}
|
||||
|
||||
# Build BTMACSET commands: up to 10 MACs per slot
|
||||
# Slot names: BTMACSET (default), BTMACSET1, BTMACSET2, BTMACSET3, BTMACSET4
|
||||
slot_names = ["BTMACSET", "BTMACSET1", "BTMACSET2", "BTMACSET3", "BTMACSET4"]
|
||||
commands_sent = []
|
||||
|
||||
if not macs:
|
||||
# Clear default slot
|
||||
cmd = "BTMACSET,#"
|
||||
await tcp_command_service.send_command(device.imei, "online_cmd", cmd)
|
||||
commands_sent.append(cmd)
|
||||
else:
|
||||
for i in range(0, min(len(macs), 50), 10):
|
||||
slot_idx = i // 10
|
||||
chunk = macs[i:i + 10]
|
||||
cmd = f"{slot_names[slot_idx]},{','.join(chunk)}#"
|
||||
await tcp_command_service.send_command(device.imei, "online_cmd", cmd)
|
||||
commands_sent.append(cmd)
|
||||
|
||||
return {"sent": True, "mac_count": len(macs), "commands": commands_sent, "error": None}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reverse sync: query devices → update DB bindings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MAC_PATTERN = re.compile(r"([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})")
|
||||
|
||||
|
||||
def _parse_btmacset_response(text: str) -> list[str]:
|
||||
"""Extract MAC addresses from BTMACSET query response.
|
||||
|
||||
Example responses:
|
||||
'setting OK.bt mac address:1,C3:00:00:34:43:5E;'
|
||||
'bt mac address:1,C3:00:00:34:43:5E,AA:BB:CC:DD:EE:FF;'
|
||||
"""
|
||||
return [m.upper() for m in _MAC_PATTERN.findall(text)]
|
||||
|
||||
|
||||
async def reverse_sync_from_devices(db: AsyncSession) -> dict:
|
||||
"""Send BTMACSET# query to all online devices, parse responses, update bindings.
|
||||
|
||||
Uses separate DB sessions for command creation and polling to avoid
|
||||
transaction isolation issues with the TCP handler's independent session.
|
||||
"""
|
||||
from app.database import async_session as make_session
|
||||
from app.services import command_service
|
||||
from app.config import now_cst
|
||||
|
||||
# Get all online devices
|
||||
result = await db.execute(
|
||||
select(Device).where(Device.status == "online")
|
||||
)
|
||||
devices = list(result.scalars().all())
|
||||
|
||||
if not devices:
|
||||
return {"queried": 0, "responded": 0, "updated": 0, "details": [], "error": "没有在线设备"}
|
||||
|
||||
# Build beacon MAC → id lookup
|
||||
result = await db.execute(select(BeaconConfig.id, BeaconConfig.beacon_mac))
|
||||
mac_to_beacon_id = {row[1].upper(): row[0] for row in result.all()}
|
||||
|
||||
# --- Phase 1: Create CommandLogs and send commands (committed session) ---
|
||||
sent_devices: list[tuple[int, str, str | None, int]] = [] # (dev_id, imei, name, cmd_log_id)
|
||||
|
||||
async with make_session() as cmd_session:
|
||||
async with cmd_session.begin():
|
||||
for dev in devices:
|
||||
if not tcp_command_service.is_device_online(dev.imei):
|
||||
continue
|
||||
cmd_log = await command_service.create_command(
|
||||
cmd_session, device_id=dev.id,
|
||||
command_type="online_cmd", command_content="BTMACSET#",
|
||||
)
|
||||
try:
|
||||
ok = await tcp_command_service.send_command(dev.imei, "online_cmd", "BTMACSET#")
|
||||
if ok:
|
||||
cmd_log.status = "sent"
|
||||
cmd_log.sent_at = now_cst()
|
||||
sent_devices.append((dev.id, dev.imei, dev.name, cmd_log.id))
|
||||
else:
|
||||
cmd_log.status = "failed"
|
||||
except Exception:
|
||||
cmd_log.status = "failed"
|
||||
# Transaction committed here — TCP handler can now see these CommandLogs
|
||||
|
||||
if not sent_devices:
|
||||
return {"queried": 0, "responded": 0, "updated": 0, "details": [], "error": "无法发送指令到任何设备"}
|
||||
|
||||
# --- Phase 2: Poll for responses (fresh session each iteration) ---
|
||||
responded: dict[int, str] = {}
|
||||
for attempt in range(10):
|
||||
await asyncio.sleep(1)
|
||||
pending_ids = [cid for _, _, _, cid in sent_devices if _ not in responded]
|
||||
# Rebuild pending from device IDs not yet responded
|
||||
pending_cmd_ids = [cid for did, _, _, cid in sent_devices if did not in responded]
|
||||
if not pending_cmd_ids:
|
||||
break
|
||||
async with make_session() as poll_session:
|
||||
result = await poll_session.execute(
|
||||
select(CommandLog.device_id, CommandLog.response_content).where(
|
||||
CommandLog.id.in_(pending_cmd_ids),
|
||||
CommandLog.status == "success",
|
||||
)
|
||||
)
|
||||
for row in result.all():
|
||||
responded[row[0]] = row[1] or ""
|
||||
|
||||
# --- Phase 3: Parse responses and update bindings ---
|
||||
details = []
|
||||
updated_count = 0
|
||||
for dev_id, imei, name, cmd_id in sent_devices:
|
||||
resp_text = responded.get(dev_id)
|
||||
if resp_text is None:
|
||||
details.append({"device_id": dev_id, "imei": imei, "name": name, "status": "无响应"})
|
||||
continue
|
||||
|
||||
found_macs = _parse_btmacset_response(resp_text)
|
||||
matched_beacon_ids = set()
|
||||
for mac in found_macs:
|
||||
bid = mac_to_beacon_id.get(mac)
|
||||
if bid:
|
||||
matched_beacon_ids.add(bid)
|
||||
|
||||
# Get current bindings for this device
|
||||
result = await db.execute(
|
||||
select(DeviceBeaconBinding.beacon_id).where(
|
||||
DeviceBeaconBinding.device_id == dev_id
|
||||
)
|
||||
)
|
||||
current_bindings = set(row[0] for row in result.all())
|
||||
|
||||
to_add = matched_beacon_ids - current_bindings
|
||||
for bid in to_add:
|
||||
db.add(DeviceBeaconBinding(device_id=dev_id, beacon_id=bid))
|
||||
|
||||
to_remove = current_bindings - matched_beacon_ids
|
||||
if to_remove:
|
||||
await db.execute(
|
||||
sa_delete(DeviceBeaconBinding).where(
|
||||
DeviceBeaconBinding.device_id == dev_id,
|
||||
DeviceBeaconBinding.beacon_id.in_(to_remove),
|
||||
)
|
||||
)
|
||||
|
||||
changes = len(to_add) + len(to_remove)
|
||||
updated_count += 1 if changes else 0
|
||||
details.append({
|
||||
"device_id": dev_id, "imei": imei, "name": name,
|
||||
"status": "已同步",
|
||||
"device_macs": found_macs,
|
||||
"matched_beacons": len(matched_beacon_ids),
|
||||
"added": len(to_add), "removed": len(to_remove),
|
||||
"response": resp_text,
|
||||
})
|
||||
|
||||
await db.flush()
|
||||
return {
|
||||
"queried": len(sent_devices),
|
||||
"responded": len(responded),
|
||||
"updated": updated_count,
|
||||
"details": details,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Setup Bluetooth clock-in mode for devices
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Full config sequence per P241 docs:
|
||||
# CLOCKWAY,3# → manual + Bluetooth clock
|
||||
# MODE,2# → Bluetooth positioning mode
|
||||
# BTMACSET,...# → write bound beacon MACs
|
||||
# BTMP3SW,1# → enable voice broadcast
|
||||
|
||||
_BT_SETUP_STEPS = [
|
||||
("CLOCKWAY,3#", "设置打卡方式: 手动+蓝牙"),
|
||||
# MODE,2# inserted dynamically
|
||||
# BTMACSET,...# inserted dynamically
|
||||
("BTMP3SW,1#", "开启语音播报"),
|
||||
]
|
||||
|
||||
|
||||
async def setup_bluetooth_mode(
|
||||
db: AsyncSession,
|
||||
device_ids: list[int] | None = None,
|
||||
) -> dict:
|
||||
"""Configure devices for Bluetooth beacon clock-in mode.
|
||||
|
||||
Sends the full command sequence to each device:
|
||||
1. CLOCKWAY,3# (manual + BT clock)
|
||||
2. MODE,2# (BT positioning)
|
||||
3. BTMACSET,... (bound beacon MACs)
|
||||
4. BTMP3SW,1# (voice broadcast on)
|
||||
|
||||
If device_ids is None, targets all online devices.
|
||||
"""
|
||||
if device_ids:
|
||||
result = await db.execute(
|
||||
select(Device).where(Device.id.in_(device_ids))
|
||||
)
|
||||
else:
|
||||
result = await db.execute(
|
||||
select(Device).where(Device.status == "online")
|
||||
)
|
||||
devices = list(result.scalars().all())
|
||||
|
||||
if not devices:
|
||||
return {"total": 0, "sent": 0, "failed": 0, "details": [], "error": "没有可操作的设备"}
|
||||
|
||||
# Pre-load all beacon bindings: device_id → [mac1, mac2, ...]
|
||||
all_device_ids = [d.id for d in devices]
|
||||
result = await db.execute(
|
||||
select(DeviceBeaconBinding.device_id, BeaconConfig.beacon_mac)
|
||||
.join(BeaconConfig, BeaconConfig.id == DeviceBeaconBinding.beacon_id)
|
||||
.where(DeviceBeaconBinding.device_id.in_(all_device_ids))
|
||||
.order_by(DeviceBeaconBinding.device_id, BeaconConfig.id)
|
||||
)
|
||||
device_macs: dict[int, list[str]] = {}
|
||||
for row in result.all():
|
||||
device_macs.setdefault(row[0], []).append(row[1])
|
||||
|
||||
details = []
|
||||
sent_count = 0
|
||||
fail_count = 0
|
||||
|
||||
for dev in devices:
|
||||
if not tcp_command_service.is_device_online(dev.imei):
|
||||
details.append({
|
||||
"device_id": dev.id, "imei": dev.imei, "name": dev.name,
|
||||
"status": "离线", "commands": [],
|
||||
})
|
||||
fail_count += 1
|
||||
continue
|
||||
|
||||
macs = device_macs.get(dev.id, [])
|
||||
# Build command sequence
|
||||
commands = [
|
||||
"CLOCKWAY,3#",
|
||||
"MODE,2#",
|
||||
]
|
||||
# BTMACSET: split into slots of 10
|
||||
slot_names = ["BTMACSET", "BTMACSET1", "BTMACSET2", "BTMACSET3", "BTMACSET4"]
|
||||
if macs:
|
||||
for i in range(0, min(len(macs), 50), 10):
|
||||
slot_idx = i // 10
|
||||
chunk = macs[i:i + 10]
|
||||
commands.append(f"{slot_names[slot_idx]},{','.join(chunk)}#")
|
||||
commands.append("BTMP3SW,1#")
|
||||
|
||||
# Send commands sequentially with small delay
|
||||
sent_cmds = []
|
||||
has_error = False
|
||||
for cmd in commands:
|
||||
try:
|
||||
ok = await tcp_command_service.send_command(dev.imei, "online_cmd", cmd)
|
||||
sent_cmds.append({"cmd": cmd, "ok": ok})
|
||||
if not ok:
|
||||
has_error = True
|
||||
# Small delay between commands to avoid overwhelming device
|
||||
await asyncio.sleep(0.3)
|
||||
except Exception as e:
|
||||
sent_cmds.append({"cmd": cmd, "ok": False, "error": str(e)})
|
||||
has_error = True
|
||||
|
||||
if has_error:
|
||||
fail_count += 1
|
||||
else:
|
||||
sent_count += 1
|
||||
|
||||
details.append({
|
||||
"device_id": dev.id, "imei": dev.imei, "name": dev.name,
|
||||
"status": "部分失败" if has_error else "已配置",
|
||||
"beacon_count": len(macs),
|
||||
"commands": sent_cmds,
|
||||
})
|
||||
|
||||
return {
|
||||
"total": len(devices),
|
||||
"sent": sent_count,
|
||||
"failed": fail_count,
|
||||
"details": details,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
async def restore_normal_mode(
|
||||
db: AsyncSession,
|
||||
device_ids: list[int] | None = None,
|
||||
) -> dict:
|
||||
"""Restore devices from Bluetooth clock-in mode to normal (smart) mode.
|
||||
|
||||
Sends:
|
||||
1. CLOCKWAY,1# (manual clock only)
|
||||
2. MODE,3# (smart positioning)
|
||||
3. BTMP3SW,0# (voice broadcast off)
|
||||
"""
|
||||
if device_ids:
|
||||
result = await db.execute(
|
||||
select(Device).where(Device.id.in_(device_ids))
|
||||
)
|
||||
else:
|
||||
result = await db.execute(
|
||||
select(Device).where(Device.status == "online")
|
||||
)
|
||||
devices = list(result.scalars().all())
|
||||
|
||||
if not devices:
|
||||
return {"total": 0, "sent": 0, "failed": 0, "details": [], "error": "没有可操作的设备"}
|
||||
|
||||
commands = ["CLOCKWAY,1#", "MODE,3#", "BTMP3SW,0#"]
|
||||
details = []
|
||||
sent_count = 0
|
||||
fail_count = 0
|
||||
|
||||
for dev in devices:
|
||||
if not tcp_command_service.is_device_online(dev.imei):
|
||||
details.append({
|
||||
"device_id": dev.id, "imei": dev.imei, "name": dev.name,
|
||||
"status": "离线", "commands": [],
|
||||
})
|
||||
fail_count += 1
|
||||
continue
|
||||
|
||||
sent_cmds = []
|
||||
has_error = False
|
||||
for cmd in commands:
|
||||
try:
|
||||
ok = await tcp_command_service.send_command(dev.imei, "online_cmd", cmd)
|
||||
sent_cmds.append({"cmd": cmd, "ok": ok})
|
||||
if not ok:
|
||||
has_error = True
|
||||
await asyncio.sleep(0.3)
|
||||
except Exception as e:
|
||||
sent_cmds.append({"cmd": cmd, "ok": False, "error": str(e)})
|
||||
has_error = True
|
||||
|
||||
if has_error:
|
||||
fail_count += 1
|
||||
else:
|
||||
sent_count += 1
|
||||
|
||||
details.append({
|
||||
"device_id": dev.id, "imei": dev.imei, "name": dev.name,
|
||||
"status": "部分失败" if has_error else "已恢复",
|
||||
"commands": sent_cmds,
|
||||
})
|
||||
|
||||
return {
|
||||
"total": len(devices),
|
||||
"sent": sent_count,
|
||||
"failed": fail_count,
|
||||
"details": details,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
59
app/services/export_utils.py
Normal file
59
app/services/export_utils.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
CSV Export Utilities - CSV 数据导出工具
|
||||
Shared helpers for streaming CSV responses.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _format_value(value: Any) -> str:
|
||||
"""Format a single value for CSV output."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, datetime):
|
||||
return value.strftime("%Y-%m-%d %H:%M:%S")
|
||||
if isinstance(value, bool):
|
||||
return "是" if value else "否"
|
||||
if isinstance(value, float):
|
||||
return f"{value:.6f}" if abs(value) < 1000 else f"{value:.2f}"
|
||||
if isinstance(value, (dict, list)):
|
||||
import json
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
return str(value)
|
||||
|
||||
|
||||
def build_csv_content(
|
||||
headers: list[str],
|
||||
rows: Sequence[Any],
|
||||
field_extractors: list[Any],
|
||||
) -> str:
|
||||
"""Build complete CSV string with BOM for Excel compatibility.
|
||||
|
||||
Args:
|
||||
headers: Column header names (Chinese).
|
||||
rows: ORM model instances or row tuples.
|
||||
field_extractors: List of callables or attribute name strings.
|
||||
"""
|
||||
buf = io.StringIO()
|
||||
buf.write("\ufeff") # UTF-8 BOM for Excel
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(headers)
|
||||
for row in rows:
|
||||
values = []
|
||||
for ext in field_extractors:
|
||||
if callable(ext):
|
||||
values.append(_format_value(ext(row)))
|
||||
else:
|
||||
values.append(_format_value(getattr(row, ext, "")))
|
||||
writer.writerow(values)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def csv_filename(prefix: str) -> str:
|
||||
"""Generate a timestamped CSV filename."""
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
return f"{prefix}_{ts}.csv"
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user