Compare commits

...

2 Commits

Author SHA1 Message Date
cde5146bfe Merge branch 'master' of https://code.desunweb3.com/xufuhan/desungongpai
# Conflicts:
#	.gitignore
#	app/config.py
#	app/dependencies.py
#	app/main.py
#	app/models.py
#	app/protocol/builder.py
#	app/protocol/parser.py
#	app/routers/commands.py
#	app/services/beacon_service.py
#	app/services/device_service.py
#	app/tcp_server.py
#	app/websocket_manager.py
2026-03-27 10:22:16 +00:00
d54e53e0b7 feat: KKS P240/P241 蓝牙工牌管理系统初始提交
FastAPI + SQLAlchemy + asyncio TCP 服务器,支持设备管理、实时定位、
告警、考勤打卡、蓝牙记录、指令下发、TTS语音播报等功能。
2026-03-27 10:19:34 +00:00
13 changed files with 1734 additions and 70 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ __pycache__/
nohup.out
.env
.claude/
.idea/

View File

@@ -1,13 +1,9 @@
from datetime import timezone, timedelta
from pathlib import Path
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings
# Beijing time (UTC+8)
BEIJING_TZ = timezone(timedelta(hours=8))
# Project root directory (where config.py lives → parent = app/ → parent = project root)
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
_DEFAULT_DB_PATH = _PROJECT_ROOT / "badge_admin.db"

View File

@@ -5,9 +5,7 @@ Supports master API key (env) and database-managed API keys with permission leve
import hashlib
import secrets
from datetime import datetime
from app.config import BEIJING_TZ
from datetime import datetime, timezone
from fastapi import Depends, HTTPException, Security
from fastapi.security import APIKeyHeader
@@ -59,7 +57,7 @@ async def verify_api_key(
raise HTTPException(status_code=401, detail="Invalid API key / 无效的 API Key")
# Update last_used_at
db_key.last_used_at = datetime.now(BEIJING_TZ)
db_key.last_used_at = datetime.now(timezone.utc)
await db.flush()
return {"permissions": db_key.permissions, "key_id": db_key.id, "name": db_key.name}

View File

@@ -28,12 +28,11 @@ from app.extensions import limiter
async def run_data_cleanup():
"""Delete records older than DATA_RETENTION_DAYS."""
from datetime import datetime, timedelta
from app.config import BEIJING_TZ
from datetime import datetime, timezone, timedelta
from sqlalchemy import delete
from app.models import LocationRecord, HeartbeatRecord, AlarmRecord, AttendanceRecord, BluetoothRecord
cutoff = datetime.now(BEIJING_TZ) - timedelta(days=settings.DATA_RETENTION_DAYS)
cutoff = datetime.now(timezone.utc) - timedelta(days=settings.DATA_RETENTION_DAYS)
total_deleted = 0
async with async_session() as session:
async with session.begin():

View File

@@ -1,6 +1,4 @@
from datetime import datetime
from app.config import BEIJING_TZ
from datetime import datetime, timezone
from sqlalchemy import (
BigInteger,
@@ -19,8 +17,8 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def _now_beijing() -> datetime:
return datetime.now(BEIJING_TZ)
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class Device(Base):
@@ -41,9 +39,9 @@ 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)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_beijing, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime, default=_now_beijing, onupdate=_now_beijing, nullable=True
DateTime, default=_utcnow, onupdate=_utcnow, nullable=True
)
# Relationships
@@ -104,7 +102,7 @@ class LocationRecord(Base):
address: Mapped[str | None] = mapped_column(Text, nullable=True)
raw_data: Mapped[str | None] = mapped_column(Text, nullable=True)
recorded_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_beijing, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
device: Mapped["Device"] = relationship(back_populates="locations")
@@ -149,7 +147,7 @@ class AlarmRecord(Base):
address: Mapped[str | None] = mapped_column(Text, nullable=True)
acknowledged: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
recorded_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_beijing, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
device: Mapped["Device"] = relationship(back_populates="alarms")
@@ -177,7 +175,7 @@ class HeartbeatRecord(Base):
battery_level: Mapped[int] = mapped_column(Integer, nullable=False)
gsm_signal: Mapped[int] = mapped_column(Integer, nullable=False)
extension_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_beijing, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
device: Mapped["Device"] = relationship(back_populates="heartbeats")
@@ -217,7 +215,7 @@ class AttendanceRecord(Base):
lbs_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
address: Mapped[str | None] = mapped_column(Text, nullable=True)
recorded_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_beijing, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
device: Mapped["Device"] = relationship(back_populates="attendance_records")
@@ -256,7 +254,7 @@ class BluetoothRecord(Base):
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
recorded_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_beijing, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
device: Mapped["Device"] = relationship(back_populates="bluetooth_records")
@@ -284,9 +282,9 @@ class BeaconConfig(Base):
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
address: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), default="active", nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_beijing, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime, default=_now_beijing, onupdate=_now_beijing, nullable=True
DateTime, default=_utcnow, onupdate=_utcnow, nullable=True
)
def __repr__(self) -> str:
@@ -314,7 +312,7 @@ class CommandLog(Base):
) # pending, sent, success, failed
sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
response_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_beijing, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
device: Mapped["Device"] = relationship(back_populates="command_logs")
@@ -338,7 +336,7 @@ class ApiKey(Base):
) # read, write, admin
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_beijing, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
def __repr__(self) -> str:
return f"<ApiKey(id={self.id}, name={self.name}, permissions={self.permissions})>"

View File

@@ -8,9 +8,7 @@ from __future__ import annotations
import struct
import time
from datetime import datetime
from app.config import BEIJING_TZ
from datetime import datetime, timezone
from typing import Optional
from .constants import (
@@ -143,9 +141,9 @@ class PacketBuilder:
"""
Build a Time Sync 2 response (0x8A).
Returns the current Beijing time as YY MM DD HH MM SS (6 bytes).
Returns the current UTC time as YY MM DD HH MM SS (6 bytes).
"""
now = datetime.now(BEIJING_TZ)
now = datetime.now(timezone.utc)
info = struct.pack(
"BBBBBB",
now.year - 2000,

View File

@@ -9,9 +9,7 @@ start markers.
from __future__ import annotations
import struct
from datetime import datetime
from app.config import BEIJING_TZ
from datetime import datetime, timezone
from typing import Any, Dict, List, Tuple
from .constants import (
@@ -228,7 +226,7 @@ class PacketParser:
yy, mo, dd, hh, mi, ss = struct.unpack_from("BBBBBB", data, offset)
year = 2000 + yy
try:
dt = datetime(year, mo, dd, hh, mi, ss, tzinfo=BEIJING_TZ)
dt = datetime(year, mo, dd, hh, mi, ss, tzinfo=timezone.utc)
except ValueError:
dt = None
return {

View File

@@ -5,9 +5,7 @@ API endpoints for sending commands / messages to devices and viewing command his
import logging
import math
from datetime import datetime
from app.config import BEIJING_TZ
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
@@ -131,7 +129,7 @@ async def _send_to_device(
)
command_log.status = "sent"
command_log.sent_at = datetime.now(BEIJING_TZ)
command_log.sent_at = datetime.now(timezone.utc)
await db.flush()
await db.refresh(command_log)
@@ -292,7 +290,7 @@ async def batch_send_command(request: Request, body: BatchCommandRequest, db: As
device.imei, body.command_type, body.command_content
)
cmd_log.status = "sent"
cmd_log.sent_at = datetime.now(BEIJING_TZ)
cmd_log.sent_at = datetime.now(timezone.utc)
await db.flush()
await db.refresh(cmd_log)
results.append(BatchCommandResult(

View File

@@ -3,9 +3,7 @@ Beacon Service - 蓝牙信标管理服务
Provides CRUD operations for Bluetooth beacon configuration.
"""
from datetime import datetime
from app.config import BEIJING_TZ
from datetime import datetime, timezone
from sqlalchemy import func, select, or_
from sqlalchemy.ext.asyncio import AsyncSession
@@ -80,7 +78,7 @@ async def update_beacon(
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(beacon, key, value)
beacon.updated_at = datetime.now(BEIJING_TZ)
beacon.updated_at = datetime.now(timezone.utc)
await db.flush()
await db.refresh(beacon)

View File

@@ -3,9 +3,7 @@ Device Service - 设备管理服务
Provides CRUD operations and statistics for badge devices.
"""
from datetime import datetime
from app.config import BEIJING_TZ
from datetime import datetime, timezone
from sqlalchemy import func, select, or_
from sqlalchemy.ext.asyncio import AsyncSession
@@ -160,7 +158,7 @@ async def update_device(
for field, value in update_fields.items():
setattr(device, field, value)
device.updated_at = datetime.now(BEIJING_TZ)
device.updated_at = datetime.now(timezone.utc)
await db.flush()
await db.refresh(device)
return device
@@ -247,7 +245,7 @@ async def batch_update_devices(
devices = await get_devices_by_ids(db, device_ids)
found_map = {d.id: d for d in devices}
update_fields = update_data.model_dump(exclude_unset=True)
now = datetime.now(BEIJING_TZ)
now = datetime.now(timezone.utc)
results = []
for device_id in device_ids:

View File

@@ -14,8 +14,6 @@ import asyncio
import logging
import struct
from datetime import datetime, timezone
from app.config import BEIJING_TZ
from typing import Any, Dict, Optional, Tuple
from sqlalchemy import select, update
@@ -242,7 +240,7 @@ class ConnectionInfo:
def __init__(self, addr: Tuple[str, int]) -> None:
self.imei: Optional[str] = None
self.addr = addr
self.connected_at = datetime.now(BEIJING_TZ)
self.connected_at = datetime.now(timezone.utc)
self.last_activity = self.connected_at
self.serial_counter: int = 1
@@ -333,7 +331,7 @@ class TCPManager:
break
recv_buffer += data
conn_info.last_activity = datetime.now(BEIJING_TZ)
conn_info.last_activity = datetime.now(timezone.utc)
logger.info("Received %d bytes from %s:%d (IMEI=%s): %s",
len(data), addr[0], addr[1], conn_info.imei, data[:50].hex())
@@ -557,12 +555,12 @@ class TCPManager:
@staticmethod
def _parse_datetime(content: bytes, offset: int = 0) -> Optional[datetime]:
"""Parse a 6-byte datetime field at *offset* and return a Beijing-time datetime."""
"""Parse a 6-byte datetime field at *offset* and return a UTC datetime."""
if len(content) < offset + 6:
return None
yy, mo, dd, hh, mi, ss = struct.unpack_from("BBBBBB", content, offset)
try:
return datetime(2000 + yy, mo, dd, hh, mi, ss, tzinfo=BEIJING_TZ)
return datetime(2000 + yy, mo, dd, hh, mi, ss, tzinfo=timezone.utc)
except ValueError:
return None
@@ -636,7 +634,7 @@ class TCPManager:
lang_str = "zh" if lang_code == 1 else "en" if lang_code == 2 else str(lang_code)
# Persist device record
now = datetime.now(BEIJING_TZ)
now = datetime.now(timezone.utc)
try:
async with async_session() as session:
async with session.begin():
@@ -733,7 +731,7 @@ class TCPManager:
if ext_info:
extension_data = ext_info
now = datetime.now(BEIJING_TZ)
now = datetime.now(timezone.utc)
try:
async with async_session() as session:
@@ -856,7 +854,7 @@ class TCPManager:
content = pkt["content"]
proto = pkt["protocol"]
now = datetime.now(BEIJING_TZ)
now = datetime.now(timezone.utc)
# Parse recorded_at from the 6-byte datetime at offset 0
recorded_at = self._parse_datetime(content, 0) or now
@@ -1249,7 +1247,7 @@ class TCPManager:
if len(content) >= 8:
language = struct.unpack("!H", content[6:8])[0]
now = datetime.now(BEIJING_TZ)
now = datetime.now(timezone.utc)
if language == 0x0001:
# Chinese: use GMT+8 timestamp
ts = int(now.timestamp()) + 8 * 3600
@@ -1271,7 +1269,7 @@ class TCPManager:
conn_info: ConnectionInfo,
) -> None:
"""Handle time sync 2 request (0x8A). Respond with YY MM DD HH MM SS."""
now = datetime.now(BEIJING_TZ)
now = datetime.now(timezone.utc)
payload = bytes(
[
now.year % 100,
@@ -1363,7 +1361,7 @@ class TCPManager:
content = pkt["content"]
proto = pkt["protocol"]
now = datetime.now(BEIJING_TZ)
now = datetime.now(timezone.utc)
recorded_at = self._parse_datetime(content, 0) or now
@@ -1662,7 +1660,7 @@ class TCPManager:
imei = conn_info.imei
content = pkt["content"]
proto = pkt["protocol"]
now = datetime.now(BEIJING_TZ)
now = datetime.now(timezone.utc)
# -- Parse fields --
pos = 0
@@ -1888,7 +1886,7 @@ class TCPManager:
"""
content = pkt["content"]
imei = conn_info.imei
now = datetime.now(BEIJING_TZ)
now = datetime.now(timezone.utc)
# -- Parse 0xB2 fields --
pos = 0
@@ -2038,7 +2036,7 @@ class TCPManager:
"""
content = pkt["content"]
imei = conn_info.imei
now = datetime.now(BEIJING_TZ)
now = datetime.now(timezone.utc)
pos = 0
recorded_at = self._parse_datetime(content, pos) or now
@@ -2295,7 +2293,7 @@ class TCPManager:
except Exception:
response_text = content[5:].hex()
now = datetime.now(BEIJING_TZ)
now = datetime.now(timezone.utc)
try:
async with async_session() as session:

View File

@@ -6,9 +6,7 @@ Manages client connections, topic subscriptions, and broadcasting.
import asyncio
import json
import logging
from datetime import datetime
from app.config import BEIJING_TZ
from datetime import datetime, timezone
from fastapi import WebSocket
@@ -59,7 +57,7 @@ class WebSocketManager:
return
message = json.dumps(
{"topic": topic, "data": data, "timestamp": datetime.now(BEIJING_TZ).isoformat()},
{"topic": topic, "data": data, "timestamp": datetime.now(timezone.utc).isoformat()},
default=str,
ensure_ascii=False,
)

1686
通讯协议.md Normal file

File diff suppressed because it is too large Load Diff