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:
2026-04-01 07:06:37 +00:00
parent 9daa81621c
commit 9cd9dd9d76
19 changed files with 3403 additions and 100 deletions

View File

@@ -51,6 +51,7 @@ async def init_db() -> None:
from app.models import ( # noqa: F401
AlarmRecord,
AttendanceRecord,
AuditLog,
BluetoothRecord,
CommandLog,
Device,

View File

@@ -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

View File

@@ -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
View 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

View File

@@ -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."""

View File

@@ -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
View 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="规则已删除")

View File

@@ -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]],

View File

@@ -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} 个设备")

View File

@@ -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],

View 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},
)

View File

@@ -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],

View File

@@ -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
View 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)

View File

@@ -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")

View File

@@ -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,
}

View 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