From ced836179c09341f025bef3507c38fa643abe40a Mon Sep 17 00:00:00 2001 From: default <1075071661@qq.com> Date: Tue, 24 Mar 2026 05:25:31 +0000 Subject: [PATCH] Unify all timestamps to Beijing time (UTC+8) - Add BEIJING_TZ constant in config.py - Replace all timezone.utc references across 11 files - Device-reported times, DB defaults, protocol sync all use Beijing time via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- app/config.py | 4 ++++ app/dependencies.py | 6 ++++-- app/main.py | 5 +++-- app/models.py | 30 ++++++++++++++++-------------- app/protocol/builder.py | 8 +++++--- app/protocol/parser.py | 6 ++++-- app/routers/commands.py | 8 +++++--- app/services/beacon_service.py | 6 ++++-- app/services/device_service.py | 8 +++++--- app/tcp_server.py | 30 ++++++++++++++++-------------- app/websocket_manager.py | 6 ++++-- 11 files changed, 70 insertions(+), 47 deletions(-) diff --git a/app/config.py b/app/config.py index d8798f4..e805642 100644 --- a/app/config.py +++ b/app/config.py @@ -1,9 +1,13 @@ +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" diff --git a/app/dependencies.py b/app/dependencies.py index 0cc66cd..3ba677f 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -5,7 +5,9 @@ Supports master API key (env) and database-managed API keys with permission leve import hashlib import secrets -from datetime import datetime, timezone +from datetime import datetime + +from app.config import BEIJING_TZ from fastapi import Depends, HTTPException, Security from fastapi.security import APIKeyHeader @@ -57,7 +59,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(timezone.utc) + db_key.last_used_at = datetime.now(BEIJING_TZ) await db.flush() return {"permissions": db_key.permissions, "key_id": db_key.id, "name": db_key.name} diff --git a/app/main.py b/app/main.py index cd878e7..8b9bf76 100644 --- a/app/main.py +++ b/app/main.py @@ -28,11 +28,12 @@ from app.extensions import limiter async def run_data_cleanup(): """Delete records older than DATA_RETENTION_DAYS.""" - from datetime import datetime, timezone, timedelta + from datetime import datetime, timedelta + from app.config import BEIJING_TZ from sqlalchemy import delete from app.models import LocationRecord, HeartbeatRecord, AlarmRecord, AttendanceRecord, BluetoothRecord - cutoff = datetime.now(timezone.utc) - timedelta(days=settings.DATA_RETENTION_DAYS) + cutoff = datetime.now(BEIJING_TZ) - timedelta(days=settings.DATA_RETENTION_DAYS) total_deleted = 0 async with async_session() as session: async with session.begin(): diff --git a/app/models.py b/app/models.py index 9a9bf2f..08ec782 100644 --- a/app/models.py +++ b/app/models.py @@ -1,4 +1,6 @@ -from datetime import datetime, timezone +from datetime import datetime + +from app.config import BEIJING_TZ from sqlalchemy import ( BigInteger, @@ -17,8 +19,8 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database import Base -def _utcnow() -> datetime: - return datetime.now(timezone.utc) +def _now_beijing() -> datetime: + return datetime.now(BEIJING_TZ) class Device(Base): @@ -39,9 +41,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=_utcnow, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_beijing, nullable=False) updated_at: Mapped[datetime | None] = mapped_column( - DateTime, default=_utcnow, onupdate=_utcnow, nullable=True + DateTime, default=_now_beijing, onupdate=_now_beijing, nullable=True ) # Relationships @@ -102,7 +104,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=_utcnow, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_beijing, nullable=False) device: Mapped["Device"] = relationship(back_populates="locations") @@ -147,7 +149,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=_utcnow, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_beijing, nullable=False) device: Mapped["Device"] = relationship(back_populates="alarms") @@ -175,7 +177,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=_utcnow, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_beijing, nullable=False) device: Mapped["Device"] = relationship(back_populates="heartbeats") @@ -215,7 +217,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=_utcnow, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_beijing, nullable=False) device: Mapped["Device"] = relationship(back_populates="attendance_records") @@ -254,7 +256,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=_utcnow, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_beijing, nullable=False) device: Mapped["Device"] = relationship(back_populates="bluetooth_records") @@ -282,9 +284,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=_utcnow, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_beijing, nullable=False) updated_at: Mapped[datetime | None] = mapped_column( - DateTime, default=_utcnow, onupdate=_utcnow, nullable=True + DateTime, default=_now_beijing, onupdate=_now_beijing, nullable=True ) def __repr__(self) -> str: @@ -312,7 +314,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=_utcnow, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_beijing, nullable=False) device: Mapped["Device"] = relationship(back_populates="command_logs") @@ -336,7 +338,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=_utcnow, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_beijing, nullable=False) def __repr__(self) -> str: return f"" diff --git a/app/protocol/builder.py b/app/protocol/builder.py index 4254f0c..97790c8 100644 --- a/app/protocol/builder.py +++ b/app/protocol/builder.py @@ -8,7 +8,9 @@ from __future__ import annotations import struct import time -from datetime import datetime, timezone +from datetime import datetime + +from app.config import BEIJING_TZ from typing import Optional from .constants import ( @@ -141,9 +143,9 @@ class PacketBuilder: """ Build a Time Sync 2 response (0x8A). - Returns the current UTC time as YY MM DD HH MM SS (6 bytes). + Returns the current Beijing time as YY MM DD HH MM SS (6 bytes). """ - now = datetime.now(timezone.utc) + now = datetime.now(BEIJING_TZ) info = struct.pack( "BBBBBB", now.year - 2000, diff --git a/app/protocol/parser.py b/app/protocol/parser.py index 0b7151f..a239073 100644 --- a/app/protocol/parser.py +++ b/app/protocol/parser.py @@ -9,7 +9,9 @@ start markers. from __future__ import annotations import struct -from datetime import datetime, timezone +from datetime import datetime + +from app.config import BEIJING_TZ from typing import Any, Dict, List, Tuple from .constants import ( @@ -226,7 +228,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=timezone.utc) + dt = datetime(year, mo, dd, hh, mi, ss, tzinfo=BEIJING_TZ) except ValueError: dt = None return { diff --git a/app/routers/commands.py b/app/routers/commands.py index c61df1f..7d14bae 100644 --- a/app/routers/commands.py +++ b/app/routers/commands.py @@ -5,7 +5,9 @@ API endpoints for sending commands / messages to devices and viewing command his import logging import math -from datetime import datetime, timezone +from datetime import datetime + +from app.config import BEIJING_TZ from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel, Field @@ -129,7 +131,7 @@ async def _send_to_device( ) command_log.status = "sent" - command_log.sent_at = datetime.now(timezone.utc) + command_log.sent_at = datetime.now(BEIJING_TZ) await db.flush() await db.refresh(command_log) @@ -290,7 +292,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(timezone.utc) + cmd_log.sent_at = datetime.now(BEIJING_TZ) await db.flush() await db.refresh(cmd_log) results.append(BatchCommandResult( diff --git a/app/services/beacon_service.py b/app/services/beacon_service.py index afd3502..36b267f 100644 --- a/app/services/beacon_service.py +++ b/app/services/beacon_service.py @@ -3,7 +3,9 @@ Beacon Service - 蓝牙信标管理服务 Provides CRUD operations for Bluetooth beacon configuration. """ -from datetime import datetime, timezone +from datetime import datetime + +from app.config import BEIJING_TZ from sqlalchemy import func, select, or_ from sqlalchemy.ext.asyncio import AsyncSession @@ -78,7 +80,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(timezone.utc) + beacon.updated_at = datetime.now(BEIJING_TZ) await db.flush() await db.refresh(beacon) diff --git a/app/services/device_service.py b/app/services/device_service.py index e42e98b..6c2500b 100644 --- a/app/services/device_service.py +++ b/app/services/device_service.py @@ -3,7 +3,9 @@ Device Service - 设备管理服务 Provides CRUD operations and statistics for badge devices. """ -from datetime import datetime, timezone +from datetime import datetime + +from app.config import BEIJING_TZ from sqlalchemy import func, select, or_ from sqlalchemy.ext.asyncio import AsyncSession @@ -158,7 +160,7 @@ async def update_device( for field, value in update_fields.items(): setattr(device, field, value) - device.updated_at = datetime.now(timezone.utc) + device.updated_at = datetime.now(BEIJING_TZ) await db.flush() await db.refresh(device) return device @@ -245,7 +247,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(timezone.utc) + now = datetime.now(BEIJING_TZ) results = [] for device_id in device_ids: diff --git a/app/tcp_server.py b/app/tcp_server.py index a669330..3448921 100644 --- a/app/tcp_server.py +++ b/app/tcp_server.py @@ -14,6 +14,8 @@ 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 @@ -240,7 +242,7 @@ class ConnectionInfo: def __init__(self, addr: Tuple[str, int]) -> None: self.imei: Optional[str] = None self.addr = addr - self.connected_at = datetime.now(timezone.utc) + self.connected_at = datetime.now(BEIJING_TZ) self.last_activity = self.connected_at self.serial_counter: int = 1 @@ -331,7 +333,7 @@ class TCPManager: break recv_buffer += data - conn_info.last_activity = datetime.now(timezone.utc) + conn_info.last_activity = datetime.now(BEIJING_TZ) logger.info("Received %d bytes from %s:%d (IMEI=%s): %s", len(data), addr[0], addr[1], conn_info.imei, data[:50].hex()) @@ -555,12 +557,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 UTC datetime.""" + """Parse a 6-byte datetime field at *offset* and return a Beijing-time 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=timezone.utc) + return datetime(2000 + yy, mo, dd, hh, mi, ss, tzinfo=BEIJING_TZ) except ValueError: return None @@ -634,7 +636,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(timezone.utc) + now = datetime.now(BEIJING_TZ) try: async with async_session() as session: async with session.begin(): @@ -731,7 +733,7 @@ class TCPManager: if ext_info: extension_data = ext_info - now = datetime.now(timezone.utc) + now = datetime.now(BEIJING_TZ) try: async with async_session() as session: @@ -854,7 +856,7 @@ class TCPManager: content = pkt["content"] proto = pkt["protocol"] - now = datetime.now(timezone.utc) + now = datetime.now(BEIJING_TZ) # Parse recorded_at from the 6-byte datetime at offset 0 recorded_at = self._parse_datetime(content, 0) or now @@ -1247,7 +1249,7 @@ class TCPManager: if len(content) >= 8: language = struct.unpack("!H", content[6:8])[0] - now = datetime.now(timezone.utc) + now = datetime.now(BEIJING_TZ) if language == 0x0001: # Chinese: use GMT+8 timestamp ts = int(now.timestamp()) + 8 * 3600 @@ -1269,7 +1271,7 @@ class TCPManager: conn_info: ConnectionInfo, ) -> None: """Handle time sync 2 request (0x8A). Respond with YY MM DD HH MM SS.""" - now = datetime.now(timezone.utc) + now = datetime.now(BEIJING_TZ) payload = bytes( [ now.year % 100, @@ -1361,7 +1363,7 @@ class TCPManager: content = pkt["content"] proto = pkt["protocol"] - now = datetime.now(timezone.utc) + now = datetime.now(BEIJING_TZ) recorded_at = self._parse_datetime(content, 0) or now @@ -1660,7 +1662,7 @@ class TCPManager: imei = conn_info.imei content = pkt["content"] proto = pkt["protocol"] - now = datetime.now(timezone.utc) + now = datetime.now(BEIJING_TZ) # -- Parse fields -- pos = 0 @@ -1886,7 +1888,7 @@ class TCPManager: """ content = pkt["content"] imei = conn_info.imei - now = datetime.now(timezone.utc) + now = datetime.now(BEIJING_TZ) # -- Parse 0xB2 fields -- pos = 0 @@ -2036,7 +2038,7 @@ class TCPManager: """ content = pkt["content"] imei = conn_info.imei - now = datetime.now(timezone.utc) + now = datetime.now(BEIJING_TZ) pos = 0 recorded_at = self._parse_datetime(content, pos) or now @@ -2293,7 +2295,7 @@ class TCPManager: except Exception: response_text = content[5:].hex() - now = datetime.now(timezone.utc) + now = datetime.now(BEIJING_TZ) try: async with async_session() as session: diff --git a/app/websocket_manager.py b/app/websocket_manager.py index bef2706..f455a3e 100644 --- a/app/websocket_manager.py +++ b/app/websocket_manager.py @@ -6,7 +6,9 @@ Manages client connections, topic subscriptions, and broadcasting. import asyncio import json import logging -from datetime import datetime, timezone +from datetime import datetime + +from app.config import BEIJING_TZ from fastapi import WebSocket @@ -57,7 +59,7 @@ class WebSocketManager: return message = json.dumps( - {"topic": topic, "data": data, "timestamp": datetime.now(timezone.utc).isoformat()}, + {"topic": topic, "data": data, "timestamp": datetime.now(BEIJING_TZ).isoformat()}, default=str, ensure_ascii=False, )