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

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