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 <noreply@hapi.run>
This commit is contained in:
2026-03-24 05:25:31 +00:00
parent 11281e5be2
commit ced836179c
11 changed files with 70 additions and 47 deletions

View File

@@ -1,9 +1,13 @@
from datetime import timezone, timedelta
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
from pydantic import Field from pydantic import Field
from pydantic_settings import BaseSettings 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 directory (where config.py lives → parent = app/ → parent = project root)
_PROJECT_ROOT = Path(__file__).resolve().parent.parent _PROJECT_ROOT = Path(__file__).resolve().parent.parent
_DEFAULT_DB_PATH = _PROJECT_ROOT / "badge_admin.db" _DEFAULT_DB_PATH = _PROJECT_ROOT / "badge_admin.db"

View File

@@ -5,7 +5,9 @@ Supports master API key (env) and database-managed API keys with permission leve
import hashlib import hashlib
import secrets 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 import Depends, HTTPException, Security
from fastapi.security import APIKeyHeader 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") raise HTTPException(status_code=401, detail="Invalid API key / 无效的 API Key")
# Update last_used_at # 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() await db.flush()
return {"permissions": db_key.permissions, "key_id": db_key.id, "name": db_key.name} return {"permissions": db_key.permissions, "key_id": db_key.id, "name": db_key.name}

View File

@@ -28,11 +28,12 @@ from app.extensions import limiter
async def run_data_cleanup(): async def run_data_cleanup():
"""Delete records older than DATA_RETENTION_DAYS.""" """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 sqlalchemy import delete
from app.models import LocationRecord, HeartbeatRecord, AlarmRecord, AttendanceRecord, BluetoothRecord 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 total_deleted = 0
async with async_session() as session: async with async_session() as session:
async with session.begin(): async with session.begin():

View File

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

View File

@@ -8,7 +8,9 @@ from __future__ import annotations
import struct import struct
import time import time
from datetime import datetime, timezone from datetime import datetime
from app.config import BEIJING_TZ
from typing import Optional from typing import Optional
from .constants import ( from .constants import (
@@ -141,9 +143,9 @@ class PacketBuilder:
""" """
Build a Time Sync 2 response (0x8A). 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( info = struct.pack(
"BBBBBB", "BBBBBB",
now.year - 2000, now.year - 2000,

View File

@@ -9,7 +9,9 @@ start markers.
from __future__ import annotations from __future__ import annotations
import struct 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 typing import Any, Dict, List, Tuple
from .constants import ( from .constants import (
@@ -226,7 +228,7 @@ class PacketParser:
yy, mo, dd, hh, mi, ss = struct.unpack_from("BBBBBB", data, offset) yy, mo, dd, hh, mi, ss = struct.unpack_from("BBBBBB", data, offset)
year = 2000 + yy year = 2000 + yy
try: 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: except ValueError:
dt = None dt = None
return { return {

View File

@@ -5,7 +5,9 @@ API endpoints for sending commands / messages to devices and viewing command his
import logging import logging
import math 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 fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -129,7 +131,7 @@ async def _send_to_device(
) )
command_log.status = "sent" 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.flush()
await db.refresh(command_log) 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 device.imei, body.command_type, body.command_content
) )
cmd_log.status = "sent" 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.flush()
await db.refresh(cmd_log) await db.refresh(cmd_log)
results.append(BatchCommandResult( results.append(BatchCommandResult(

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,9 @@ Manages client connections, topic subscriptions, and broadcasting.
import asyncio import asyncio
import json import json
import logging import logging
from datetime import datetime, timezone from datetime import datetime
from app.config import BEIJING_TZ
from fastapi import WebSocket from fastapi import WebSocket
@@ -57,7 +59,7 @@ class WebSocketManager:
return return
message = json.dumps( 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, default=str,
ensure_ascii=False, ensure_ascii=False,
) )