Compare commits
2 Commits
ced836179c
...
cde5146bfe
| Author | SHA1 | Date | |
|---|---|---|---|
| cde5146bfe | |||
| d54e53e0b7 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ __pycache__/
|
||||
nohup.out
|
||||
.env
|
||||
.claude/
|
||||
.idea/
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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})>"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user