Initial commit: migrate badge-admin from /tmp to /home/gpsystem

via HAPI (https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
2026-03-17 01:14:40 +00:00
commit 8a18a5ff16
61 changed files with 13106 additions and 0 deletions

0
app/routers/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

172
app/routers/alarms.py Normal file
View File

@@ -0,0 +1,172 @@
"""
Alarms Router - 报警管理接口
API endpoints for alarm record queries, acknowledgement, and statistics.
"""
import math
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import AlarmRecord
from app.schemas import (
AlarmAcknowledge,
AlarmRecordResponse,
APIResponse,
PaginatedList,
)
router = APIRouter(prefix="/api/alarms", tags=["Alarms / 报警管理"])
@router.get(
"",
response_model=APIResponse[PaginatedList[AlarmRecordResponse]],
summary="获取报警记录列表 / List alarms",
)
async def list_alarms(
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
alarm_type: str | None = Query(default=None, description="报警类型 / Alarm type"),
acknowledged: bool | None = Query(default=None, description="是否已确认 / Acknowledged status"),
start_time: datetime | None = Query(default=None, description="开始时间 / Start time (ISO 8601)"),
end_time: datetime | None = Query(default=None, description="结束时间 / End time (ISO 8601)"),
page: int = Query(default=1, ge=1, description="页码 / Page number"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
db: AsyncSession = Depends(get_db),
):
"""
获取报警记录列表,支持按设备、报警类型、确认状态、时间范围过滤。
List alarm records with filters for device, alarm type, acknowledged status, and time range.
"""
query = select(AlarmRecord)
count_query = select(func.count(AlarmRecord.id))
if device_id is not None:
query = query.where(AlarmRecord.device_id == device_id)
count_query = count_query.where(AlarmRecord.device_id == device_id)
if alarm_type:
query = query.where(AlarmRecord.alarm_type == alarm_type)
count_query = count_query.where(AlarmRecord.alarm_type == alarm_type)
if acknowledged is not None:
query = query.where(AlarmRecord.acknowledged == acknowledged)
count_query = count_query.where(AlarmRecord.acknowledged == acknowledged)
if start_time:
query = query.where(AlarmRecord.recorded_at >= start_time)
count_query = count_query.where(AlarmRecord.recorded_at >= start_time)
if end_time:
query = query.where(AlarmRecord.recorded_at <= end_time)
count_query = count_query.where(AlarmRecord.recorded_at <= end_time)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
offset = (page - 1) * page_size
query = query.order_by(AlarmRecord.recorded_at.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
alarms = list(result.scalars().all())
return APIResponse(
data=PaginatedList(
items=[AlarmRecordResponse.model_validate(a) for a in alarms],
total=total,
page=page,
page_size=page_size,
total_pages=math.ceil(total / page_size) if total else 0,
)
)
@router.get(
"/stats",
response_model=APIResponse[dict],
summary="获取报警统计 / Get alarm statistics",
)
async def alarm_stats(db: AsyncSession = Depends(get_db)):
"""
获取报警统计:总数、未确认数、按类型分组统计。
Get alarm statistics: total, unacknowledged count, and breakdown by type.
"""
# Total alarms
total_result = await db.execute(select(func.count(AlarmRecord.id)))
total = total_result.scalar() or 0
# Unacknowledged alarms
unack_result = await db.execute(
select(func.count(AlarmRecord.id)).where(AlarmRecord.acknowledged == False) # noqa: E712
)
unacknowledged = unack_result.scalar() or 0
# By type
type_result = await db.execute(
select(AlarmRecord.alarm_type, func.count(AlarmRecord.id))
.group_by(AlarmRecord.alarm_type)
.order_by(func.count(AlarmRecord.id).desc())
)
by_type = {row[0]: row[1] for row in type_result.all()}
return APIResponse(
data={
"total": total,
"unacknowledged": unacknowledged,
"acknowledged": total - unacknowledged,
"by_type": by_type,
}
)
@router.get(
"/{alarm_id}",
response_model=APIResponse[AlarmRecordResponse],
summary="获取报警详情 / Get alarm details",
)
async def get_alarm(alarm_id: int, db: AsyncSession = Depends(get_db)):
"""
按ID获取报警记录详情。
Get alarm record details by ID.
"""
result = await db.execute(
select(AlarmRecord).where(AlarmRecord.id == alarm_id)
)
alarm = result.scalar_one_or_none()
if alarm is None:
raise HTTPException(status_code=404, detail=f"Alarm {alarm_id} not found / 未找到报警记录{alarm_id}")
return APIResponse(data=AlarmRecordResponse.model_validate(alarm))
@router.put(
"/{alarm_id}/acknowledge",
response_model=APIResponse[AlarmRecordResponse],
summary="确认报警 / Acknowledge alarm",
)
async def acknowledge_alarm(
alarm_id: int,
body: AlarmAcknowledge | None = None,
db: AsyncSession = Depends(get_db),
):
"""
确认(或取消确认)报警记录。
Acknowledge (or un-acknowledge) an alarm record.
"""
result = await db.execute(
select(AlarmRecord).where(AlarmRecord.id == alarm_id)
)
alarm = result.scalar_one_or_none()
if alarm is None:
raise HTTPException(status_code=404, detail=f"Alarm {alarm_id} not found / 未找到报警记录{alarm_id}")
acknowledged = body.acknowledged if body else True
alarm.acknowledged = acknowledged
await db.flush()
await db.refresh(alarm)
return APIResponse(
message="Alarm acknowledged / 报警已确认" if acknowledged else "Alarm un-acknowledged / 已取消确认",
data=AlarmRecordResponse.model_validate(alarm),
)

187
app/routers/attendance.py Normal file
View File

@@ -0,0 +1,187 @@
"""
Attendance Router - 考勤管理接口
API endpoints for attendance record queries and statistics.
"""
import math
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import AttendanceRecord
from app.schemas import (
APIResponse,
AttendanceRecordResponse,
PaginatedList,
)
from app.services import device_service
router = APIRouter(prefix="/api/attendance", tags=["Attendance / 考勤管理"])
@router.get(
"",
response_model=APIResponse[PaginatedList[AttendanceRecordResponse]],
summary="获取考勤记录列表 / List attendance records",
)
async def list_attendance(
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
attendance_type: str | None = Query(default=None, description="考勤类型 / Attendance type"),
start_time: datetime | None = Query(default=None, description="开始时间 / Start time (ISO 8601)"),
end_time: datetime | None = Query(default=None, description="结束时间 / End time (ISO 8601)"),
page: int = Query(default=1, ge=1, description="页码 / Page number"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
db: AsyncSession = Depends(get_db),
):
"""
获取考勤记录列表,支持按设备、考勤类型、时间范围过滤。
List attendance records with filters for device, type, and time range.
"""
query = select(AttendanceRecord)
count_query = select(func.count(AttendanceRecord.id))
if device_id is not None:
query = query.where(AttendanceRecord.device_id == device_id)
count_query = count_query.where(AttendanceRecord.device_id == device_id)
if attendance_type:
query = query.where(AttendanceRecord.attendance_type == attendance_type)
count_query = count_query.where(AttendanceRecord.attendance_type == attendance_type)
if start_time:
query = query.where(AttendanceRecord.recorded_at >= start_time)
count_query = count_query.where(AttendanceRecord.recorded_at >= start_time)
if end_time:
query = query.where(AttendanceRecord.recorded_at <= end_time)
count_query = count_query.where(AttendanceRecord.recorded_at <= end_time)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
offset = (page - 1) * page_size
query = query.order_by(AttendanceRecord.recorded_at.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
records = list(result.scalars().all())
return APIResponse(
data=PaginatedList(
items=[AttendanceRecordResponse.model_validate(r) for r in records],
total=total,
page=page,
page_size=page_size,
total_pages=math.ceil(total / page_size) if total else 0,
)
)
@router.get(
"/stats",
response_model=APIResponse[dict],
summary="获取考勤统计 / Get attendance statistics",
)
async def attendance_stats(
device_id: int | None = Query(default=None, description="设备ID / Device ID (optional)"),
start_time: datetime | None = Query(default=None, description="开始时间 / Start time"),
end_time: datetime | None = Query(default=None, description="结束时间 / End time"),
db: AsyncSession = Depends(get_db),
):
"""
获取考勤统计:总记录数、按类型分组统计、按设备分组统计。
Get attendance statistics: total records, breakdown by type and by device.
"""
base_filter = []
if device_id is not None:
base_filter.append(AttendanceRecord.device_id == device_id)
if start_time:
base_filter.append(AttendanceRecord.recorded_at >= start_time)
if end_time:
base_filter.append(AttendanceRecord.recorded_at <= end_time)
# Total count
total_q = select(func.count(AttendanceRecord.id)).where(*base_filter) if base_filter else select(func.count(AttendanceRecord.id))
total_result = await db.execute(total_q)
total = total_result.scalar() or 0
# By type
type_q = select(
AttendanceRecord.attendance_type, func.count(AttendanceRecord.id)
).group_by(AttendanceRecord.attendance_type)
if base_filter:
type_q = type_q.where(*base_filter)
type_result = await db.execute(type_q)
by_type = {row[0]: row[1] for row in type_result.all()}
# By device (top 20)
device_q = select(
AttendanceRecord.device_id, func.count(AttendanceRecord.id)
).group_by(AttendanceRecord.device_id).order_by(
func.count(AttendanceRecord.id).desc()
).limit(20)
if base_filter:
device_q = device_q.where(*base_filter)
device_result = await db.execute(device_q)
by_device = {str(row[0]): row[1] for row in device_result.all()}
return APIResponse(
data={
"total": total,
"by_type": by_type,
"by_device": by_device,
}
)
@router.get(
"/device/{device_id}",
response_model=APIResponse[PaginatedList[AttendanceRecordResponse]],
summary="获取设备考勤记录 / Get device attendance records",
)
async def device_attendance(
device_id: int,
start_time: datetime | None = Query(default=None, description="开始时间 / Start time"),
end_time: datetime | None = Query(default=None, description="结束时间 / End time"),
page: int = Query(default=1, ge=1, description="页码 / Page number"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
db: AsyncSession = Depends(get_db),
):
"""
获取指定设备的考勤记录。
Get attendance records for a specific device.
"""
# Verify device exists
device = await device_service.get_device(db, device_id)
if device is None:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
query = select(AttendanceRecord).where(AttendanceRecord.device_id == device_id)
count_query = select(func.count(AttendanceRecord.id)).where(AttendanceRecord.device_id == device_id)
if start_time:
query = query.where(AttendanceRecord.recorded_at >= start_time)
count_query = count_query.where(AttendanceRecord.recorded_at >= start_time)
if end_time:
query = query.where(AttendanceRecord.recorded_at <= end_time)
count_query = count_query.where(AttendanceRecord.recorded_at <= end_time)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
offset = (page - 1) * page_size
query = query.order_by(AttendanceRecord.recorded_at.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
records = list(result.scalars().all())
return APIResponse(
data=PaginatedList(
items=[AttendanceRecordResponse.model_validate(r) for r in records],
total=total,
page=page,
page_size=page_size,
total_pages=math.ceil(total / page_size) if total else 0,
)
)

102
app/routers/beacons.py Normal file
View File

@@ -0,0 +1,102 @@
"""
Beacons Router - 蓝牙信标管理接口
API endpoints for managing Bluetooth beacon configuration.
"""
import math
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas import (
APIResponse,
BeaconConfigCreate,
BeaconConfigResponse,
BeaconConfigUpdate,
PaginatedList,
)
from app.services import beacon_service
router = APIRouter(prefix="/api/beacons", tags=["Beacons / 蓝牙信标"])
@router.get(
"",
response_model=APIResponse[PaginatedList[BeaconConfigResponse]],
summary="获取信标列表 / List beacons",
)
async def list_beacons(
status: str | None = Query(default=None, description="状态筛选 (active/inactive)"),
search: str | None = Query(default=None, description="搜索 MAC/名称/区域"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
records, total = await beacon_service.get_beacons(
db, page=page, page_size=page_size, status_filter=status, search=search
)
return APIResponse(
data=PaginatedList(
items=[BeaconConfigResponse.model_validate(r) for r in records],
total=total,
page=page,
page_size=page_size,
total_pages=math.ceil(total / page_size) if total else 0,
)
)
@router.get(
"/{beacon_id}",
response_model=APIResponse[BeaconConfigResponse],
summary="获取信标详情 / Get beacon",
)
async def get_beacon(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")
return APIResponse(data=BeaconConfigResponse.model_validate(beacon))
@router.post(
"",
response_model=APIResponse[BeaconConfigResponse],
status_code=201,
summary="添加信标 / Create beacon",
)
async def create_beacon(body: BeaconConfigCreate, db: AsyncSession = Depends(get_db)):
existing = await beacon_service.get_beacon_by_mac(db, body.beacon_mac)
if existing:
raise HTTPException(status_code=400, detail=f"Beacon MAC {body.beacon_mac} already exists")
beacon = await beacon_service.create_beacon(db, body)
await db.commit()
return APIResponse(message="Beacon created", data=BeaconConfigResponse.model_validate(beacon))
@router.put(
"/{beacon_id}",
response_model=APIResponse[BeaconConfigResponse],
summary="更新信标 / Update beacon",
)
async def update_beacon(
beacon_id: int, body: BeaconConfigUpdate, db: AsyncSession = Depends(get_db)
):
beacon = await beacon_service.update_beacon(db, beacon_id, body)
if beacon is None:
raise HTTPException(status_code=404, detail="Beacon not found")
await db.commit()
return APIResponse(message="Beacon updated", data=BeaconConfigResponse.model_validate(beacon))
@router.delete(
"/{beacon_id}",
response_model=APIResponse,
summary="删除信标 / Delete beacon",
)
async def delete_beacon(beacon_id: int, db: AsyncSession = Depends(get_db)):
success = await beacon_service.delete_beacon(db, beacon_id)
if not success:
raise HTTPException(status_code=404, detail="Beacon not found")
await db.commit()
return APIResponse(message="Beacon deleted")

135
app/routers/bluetooth.py Normal file
View File

@@ -0,0 +1,135 @@
"""
Bluetooth Router - 蓝牙数据接口
API endpoints for querying Bluetooth punch and location records.
"""
import math
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import BluetoothRecord
from app.schemas import (
APIResponse,
BluetoothRecordResponse,
PaginatedList,
)
from app.services import device_service
router = APIRouter(prefix="/api/bluetooth", tags=["Bluetooth / 蓝牙数据"])
@router.get(
"",
response_model=APIResponse[PaginatedList[BluetoothRecordResponse]],
summary="获取蓝牙记录列表 / List bluetooth records",
)
async def list_bluetooth_records(
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
record_type: str | None = Query(default=None, description="记录类型 / Record type (punch/location)"),
start_time: datetime | None = Query(default=None, description="开始时间 / Start time (ISO 8601)"),
end_time: datetime | None = Query(default=None, description="结束时间 / End time (ISO 8601)"),
page: int = Query(default=1, ge=1, description="页码 / Page number"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
db: AsyncSession = Depends(get_db),
):
"""
获取蓝牙数据记录列表,支持按设备、记录类型、时间范围过滤。
List Bluetooth records with filters for device, record type, and time range.
"""
query = select(BluetoothRecord)
count_query = select(func.count(BluetoothRecord.id))
if device_id is not None:
query = query.where(BluetoothRecord.device_id == device_id)
count_query = count_query.where(BluetoothRecord.device_id == device_id)
if record_type:
query = query.where(BluetoothRecord.record_type == record_type)
count_query = count_query.where(BluetoothRecord.record_type == record_type)
if start_time:
query = query.where(BluetoothRecord.recorded_at >= start_time)
count_query = count_query.where(BluetoothRecord.recorded_at >= start_time)
if end_time:
query = query.where(BluetoothRecord.recorded_at <= end_time)
count_query = count_query.where(BluetoothRecord.recorded_at <= end_time)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
offset = (page - 1) * page_size
query = query.order_by(BluetoothRecord.recorded_at.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
records = list(result.scalars().all())
return APIResponse(
data=PaginatedList(
items=[BluetoothRecordResponse.model_validate(r) for r in records],
total=total,
page=page,
page_size=page_size,
total_pages=math.ceil(total / page_size) if total else 0,
)
)
@router.get(
"/device/{device_id}",
response_model=APIResponse[PaginatedList[BluetoothRecordResponse]],
summary="获取设备蓝牙记录 / Get bluetooth records for device",
)
async def device_bluetooth_records(
device_id: int,
record_type: str | None = Query(default=None, description="记录类型 / Record type (punch/location)"),
start_time: datetime | None = Query(default=None, description="开始时间 / Start time"),
end_time: datetime | None = Query(default=None, description="结束时间 / End time"),
page: int = Query(default=1, ge=1, description="页码 / Page number"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
db: AsyncSession = Depends(get_db),
):
"""
获取指定设备的蓝牙数据记录。
Get Bluetooth records for a specific device.
"""
# Verify device exists
device = await device_service.get_device(db, device_id)
if device is None:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
query = select(BluetoothRecord).where(BluetoothRecord.device_id == device_id)
count_query = select(func.count(BluetoothRecord.id)).where(BluetoothRecord.device_id == device_id)
if record_type:
query = query.where(BluetoothRecord.record_type == record_type)
count_query = count_query.where(BluetoothRecord.record_type == record_type)
if start_time:
query = query.where(BluetoothRecord.recorded_at >= start_time)
count_query = count_query.where(BluetoothRecord.recorded_at >= start_time)
if end_time:
query = query.where(BluetoothRecord.recorded_at <= end_time)
count_query = count_query.where(BluetoothRecord.recorded_at <= end_time)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
offset = (page - 1) * page_size
query = query.order_by(BluetoothRecord.recorded_at.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
records = list(result.scalars().all())
return APIResponse(
data=PaginatedList(
items=[BluetoothRecordResponse.model_validate(r) for r in records],
total=total,
page=page,
page_size=page_size,
total_pages=math.ceil(total / page_size) if total else 0,
)
)

288
app/routers/commands.py Normal file
View File

@@ -0,0 +1,288 @@
"""
Commands Router - 指令管理接口
API endpoints for sending commands / messages to devices and viewing command history.
"""
import math
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas import (
APIResponse,
CommandResponse,
PaginatedList,
)
from app.services import command_service, device_service
router = APIRouter(prefix="/api/commands", tags=["Commands / 指令管理"])
# ---------------------------------------------------------------------------
# Request schemas specific to this router
# ---------------------------------------------------------------------------
class SendCommandRequest(BaseModel):
"""Request body for sending a command to a device."""
device_id: int | None = Field(None, description="设备ID / Device ID (provide device_id or imei)")
imei: str | None = Field(None, description="IMEI号 / IMEI number (provide device_id or imei)")
command_type: str = Field(..., max_length=30, description="指令类型 / Command type")
command_content: str = Field(..., description="指令内容 / Command content")
class SendMessageRequest(BaseModel):
"""Request body for sending a message (0x82) to a device."""
device_id: int | None = Field(None, description="设备ID / Device ID (provide device_id or imei)")
imei: str | None = Field(None, description="IMEI号 / IMEI number (provide device_id or imei)")
message: str = Field(..., max_length=500, description="消息内容 / Message content")
class SendTTSRequest(BaseModel):
"""Request body for sending a TTS voice broadcast to a device."""
device_id: int | None = Field(None, description="设备ID / Device ID (provide device_id or imei)")
imei: str | None = Field(None, description="IMEI号 / IMEI number (provide device_id or imei)")
text: str = Field(..., min_length=1, max_length=200, description="语音播报文本 / TTS text content")
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
async def _resolve_device(
db: AsyncSession,
device_id: int | None,
imei: str | None,
):
"""Resolve a device from either device_id or imei. Returns the Device ORM instance."""
if device_id is None and imei is None:
raise HTTPException(
status_code=400,
detail="Either device_id or imei must be provided / 必须提供 device_id 或 imei",
)
if device_id is not None:
device = await device_service.get_device(db, device_id)
else:
device = await device_service.get_device_by_imei(db, imei)
if device is None:
identifier = f"ID={device_id}" if device_id else f"IMEI={imei}"
raise HTTPException(
status_code=404,
detail=f"Device {identifier} not found / 未找到设备 {identifier}",
)
return device
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get(
"",
response_model=APIResponse[PaginatedList[CommandResponse]],
summary="获取指令历史 / List command history",
)
async def list_commands(
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
status: str | None = Query(default=None, description="指令状态 / Command status (pending/sent/success/failed)"),
page: int = Query(default=1, ge=1, description="页码 / Page number"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
db: AsyncSession = Depends(get_db),
):
"""
获取指令历史记录,支持按设备和状态过滤。
List command history with optional device and status filters.
"""
commands, total = await command_service.get_commands(
db, device_id=device_id, status=status, page=page, page_size=page_size
)
return APIResponse(
data=PaginatedList(
items=[CommandResponse.model_validate(c) for c in commands],
total=total,
page=page,
page_size=page_size,
total_pages=math.ceil(total / page_size) if total else 0,
)
)
@router.post(
"/send",
response_model=APIResponse[CommandResponse],
status_code=201,
summary="发送指令 / Send command to device",
)
async def send_command(body: SendCommandRequest, db: AsyncSession = Depends(get_db)):
"""
向设备发送指令通过TCP连接下发
Send a command to a device via the TCP connection.
Requires the device to be online.
"""
device = await _resolve_device(db, body.device_id, body.imei)
# Import tcp_manager lazily to avoid circular imports
from app.tcp_server import tcp_manager
# Check if device is connected
if device.imei not in tcp_manager.connections:
raise HTTPException(
status_code=400,
detail=f"Device {device.imei} is not online / 设备 {device.imei} 不在线",
)
# Create command log entry
command_log = await command_service.create_command(
db,
device_id=device.id,
command_type=body.command_type,
command_content=body.command_content,
)
# Send command via TCP
try:
await tcp_manager.send_command(
device.imei, body.command_type, body.command_content
)
except Exception as e:
command_log.status = "failed"
await db.flush()
await db.refresh(command_log)
raise HTTPException(
status_code=500,
detail=f"Failed to send command / 指令发送失败: {str(e)}",
)
command_log.status = "sent"
await db.flush()
await db.refresh(command_log)
return APIResponse(
message="Command sent successfully / 指令发送成功",
data=CommandResponse.model_validate(command_log),
)
@router.post(
"/message",
response_model=APIResponse[CommandResponse],
status_code=201,
summary="发送留言 / Send message to device (0x82)",
)
async def send_message(body: SendMessageRequest, db: AsyncSession = Depends(get_db)):
"""
向设备发送留言消息(协议号 0x82
Send a text message to a device using protocol 0x82.
"""
device = await _resolve_device(db, body.device_id, body.imei)
from app.tcp_server import tcp_manager
if device.imei not in tcp_manager.connections:
raise HTTPException(
status_code=400,
detail=f"Device {device.imei} is not online / 设备 {device.imei} 不在线",
)
# Create command log for the message
command_log = await command_service.create_command(
db,
device_id=device.id,
command_type="message",
command_content=body.message,
)
try:
await tcp_manager.send_message(device.imei, body.message)
except Exception as e:
command_log.status = "failed"
await db.flush()
await db.refresh(command_log)
raise HTTPException(
status_code=500,
detail=f"Failed to send message / 留言发送失败: {str(e)}",
)
command_log.status = "sent"
await db.flush()
await db.refresh(command_log)
return APIResponse(
message="Message sent successfully / 留言发送成功",
data=CommandResponse.model_validate(command_log),
)
@router.post(
"/tts",
response_model=APIResponse[CommandResponse],
status_code=201,
summary="语音下发 / Send TTS voice broadcast to device",
)
async def send_tts(body: SendTTSRequest, db: AsyncSession = Depends(get_db)):
"""
向设备发送 TTS 语音播报(通过 0x80 在线指令TTS 命令格式)。
Send a TTS voice broadcast to a device via online command (0x80).
The device will use its built-in TTS engine to speak the text aloud.
"""
device = await _resolve_device(db, body.device_id, body.imei)
from app.tcp_server import tcp_manager
if device.imei not in tcp_manager.connections:
raise HTTPException(
status_code=400,
detail=f"Device {device.imei} is not online / 设备 {device.imei} 不在线",
)
tts_command = f"TTS,{body.text}"
# Create command log entry
command_log = await command_service.create_command(
db,
device_id=device.id,
command_type="tts",
command_content=tts_command,
)
try:
await tcp_manager.send_command(device.imei, "tts", tts_command)
except Exception as e:
command_log.status = "failed"
await db.flush()
await db.refresh(command_log)
raise HTTPException(
status_code=500,
detail=f"Failed to send TTS / 语音下发失败: {str(e)}",
)
command_log.status = "sent"
await db.flush()
await db.refresh(command_log)
return APIResponse(
message="TTS sent successfully / 语音下发成功",
data=CommandResponse.model_validate(command_log),
)
@router.get(
"/{command_id}",
response_model=APIResponse[CommandResponse],
summary="获取指令详情 / Get command details",
)
async def get_command(command_id: int, db: AsyncSession = Depends(get_db)):
"""
按ID获取指令详情。
Get command log details by ID.
"""
command = await command_service.get_command(db, command_id)
if command is None:
raise HTTPException(status_code=404, detail=f"Command {command_id} not found / 未找到指令{command_id}")
return APIResponse(data=CommandResponse.model_validate(command))

154
app/routers/devices.py Normal file
View File

@@ -0,0 +1,154 @@
"""
Devices Router - 设备管理接口
API endpoints for device CRUD operations and statistics.
"""
import math
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas import (
APIResponse,
DeviceCreate,
DeviceResponse,
DeviceUpdate,
PaginatedList,
)
from app.services import device_service
router = APIRouter(prefix="/api/devices", tags=["Devices / 设备管理"])
@router.get(
"",
response_model=APIResponse[PaginatedList[DeviceResponse]],
summary="获取设备列表 / List devices",
)
async def list_devices(
page: int = Query(default=1, ge=1, description="页码 / Page number"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
status: str | None = Query(default=None, description="状态过滤 / Status filter (online/offline)"),
search: str | None = Query(default=None, description="搜索IMEI或名称 / Search by IMEI or name"),
db: AsyncSession = Depends(get_db),
):
"""
获取设备列表,支持分页、状态过滤和搜索。
List devices with pagination, optional status filter, and search.
"""
devices, total = await device_service.get_devices(
db, page=page, page_size=page_size, status_filter=status, search=search
)
return APIResponse(
data=PaginatedList(
items=[DeviceResponse.model_validate(d) for d in devices],
total=total,
page=page,
page_size=page_size,
total_pages=math.ceil(total / page_size) if total else 0,
)
)
@router.get(
"/stats",
response_model=APIResponse[dict],
summary="获取设备统计 / Get device statistics",
)
async def device_stats(db: AsyncSession = Depends(get_db)):
"""
获取设备统计信息:总数、在线、离线。
Get device statistics: total, online, offline counts.
"""
stats = await device_service.get_device_stats(db)
return APIResponse(data=stats)
@router.get(
"/imei/{imei}",
response_model=APIResponse[DeviceResponse],
summary="按IMEI查询设备 / Get device by IMEI",
)
async def get_device_by_imei(imei: str, db: AsyncSession = Depends(get_db)):
"""
按IMEI号查询设备信息。
Get device details by IMEI number.
"""
device = await device_service.get_device_by_imei(db, imei)
if device is None:
raise HTTPException(status_code=404, detail=f"Device with IMEI {imei} not found / 未找到IMEI为{imei}的设备")
return APIResponse(data=DeviceResponse.model_validate(device))
@router.get(
"/{device_id}",
response_model=APIResponse[DeviceResponse],
summary="获取设备详情 / Get device details",
)
async def get_device(device_id: int, db: AsyncSession = Depends(get_db)):
"""
按ID获取设备详细信息。
Get device details by ID.
"""
device = await device_service.get_device(db, device_id)
if device is None:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
return APIResponse(data=DeviceResponse.model_validate(device))
@router.post(
"",
response_model=APIResponse[DeviceResponse],
status_code=201,
summary="创建设备 / Create device",
)
async def create_device(device_data: DeviceCreate, db: AsyncSession = Depends(get_db)):
"""
手动注册新设备。
Manually register a new device.
"""
# Check for duplicate IMEI
existing = await device_service.get_device_by_imei(db, device_data.imei)
if existing is not None:
raise HTTPException(
status_code=400,
detail=f"Device with IMEI {device_data.imei} already exists / IMEI {device_data.imei} 已存在",
)
device = await device_service.create_device(db, device_data)
return APIResponse(data=DeviceResponse.model_validate(device))
@router.put(
"/{device_id}",
response_model=APIResponse[DeviceResponse],
summary="更新设备信息 / Update device",
)
async def update_device(
device_id: int, device_data: DeviceUpdate, db: AsyncSession = Depends(get_db)
):
"""
更新设备信息(名称、状态等)。
Update device information (name, status, etc.).
"""
device = await device_service.update_device(db, device_id, device_data)
if device is None:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
return APIResponse(data=DeviceResponse.model_validate(device))
@router.delete(
"/{device_id}",
response_model=APIResponse,
summary="删除设备 / Delete device",
)
async def delete_device(device_id: int, db: AsyncSession = Depends(get_db)):
"""
删除设备及其关联数据。
Delete a device and all associated records.
"""
deleted = await device_service.delete_device(db, device_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
return APIResponse(message="Device deleted successfully / 设备删除成功")

115
app/routers/locations.py Normal file
View File

@@ -0,0 +1,115 @@
"""
Locations Router - 位置数据接口
API endpoints for querying location records and device tracks.
"""
import math
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas import (
APIResponse,
LocationRecordResponse,
PaginatedList,
)
from app.services import device_service, location_service
router = APIRouter(prefix="/api/locations", tags=["Locations / 位置数据"])
@router.get(
"",
response_model=APIResponse[PaginatedList[LocationRecordResponse]],
summary="获取位置记录列表 / List location records",
)
async def list_locations(
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
location_type: str | None = Query(default=None, description="定位类型 / Location type (gps/lbs/wifi)"),
start_time: datetime | None = Query(default=None, description="开始时间 / Start time (ISO 8601)"),
end_time: datetime | None = Query(default=None, description="结束时间 / End time (ISO 8601)"),
page: int = Query(default=1, ge=1, description="页码 / Page number"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
db: AsyncSession = Depends(get_db),
):
"""
获取位置记录列表,支持按设备、定位类型、时间范围过滤。
List location records with filters for device, location type, and time range.
"""
records, total = await location_service.get_locations(
db,
device_id=device_id,
location_type=location_type,
start_time=start_time,
end_time=end_time,
page=page,
page_size=page_size,
)
return APIResponse(
data=PaginatedList(
items=[LocationRecordResponse.model_validate(r) for r in records],
total=total,
page=page,
page_size=page_size,
total_pages=math.ceil(total / page_size) if total else 0,
)
)
@router.get(
"/latest/{device_id}",
response_model=APIResponse[LocationRecordResponse | None],
summary="获取设备最新位置 / Get latest location",
)
async def latest_location(device_id: int, db: AsyncSession = Depends(get_db)):
"""
获取指定设备的最新位置信息。
Get the most recent location record for a device.
"""
# Verify device exists
device = await device_service.get_device(db, device_id)
if device is None:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
record = await location_service.get_latest_location(db, device_id)
if record is None:
return APIResponse(
code=0,
message="No location data available / 暂无位置数据",
data=None,
)
return APIResponse(data=LocationRecordResponse.model_validate(record))
@router.get(
"/track/{device_id}",
response_model=APIResponse[list[LocationRecordResponse]],
summary="获取设备轨迹 / Get device track",
)
async def device_track(
device_id: int,
start_time: datetime = Query(..., description="开始时间 / Start time (ISO 8601)"),
end_time: datetime = Query(..., description="结束时间 / End time (ISO 8601)"),
db: AsyncSession = Depends(get_db),
):
"""
获取设备在指定时间范围内的运动轨迹(按时间正序排列)。
Get device movement track within a time range (ordered chronologically).
"""
# Verify device exists
device = await device_service.get_device(db, device_id)
if device is None:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
if start_time >= end_time:
raise HTTPException(
status_code=400,
detail="start_time must be before end_time / 开始时间必须早于结束时间",
)
records = await location_service.get_device_track(db, device_id, start_time, end_time)
return APIResponse(
data=[LocationRecordResponse.model_validate(r) for r in records]
)