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:
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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"<ApiKey(id={self.id}, name={self.name}, permissions={self.permissions})>"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user