小程序后端接入 talkingq 共享库并新增消息与定位能力

This commit is contained in:
stu2not
2026-04-20 20:07:35 +08:00
parent e7fe92b863
commit 24b1d1025b
22 changed files with 3469 additions and 762 deletions

View File

@@ -0,0 +1,327 @@
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import text
from sqlalchemy.orm import Session
try:
from app.db_compat import current_timestamp_sql, select_for_update_clause
from app.schemas.location import DeviceLocationReportRequest
from app.service.im import DeviceIdentity, authenticate_device_identity
except ModuleNotFoundError:
from db_compat import current_timestamp_sql, select_for_update_clause
from schemas.location import DeviceLocationReportRequest
from service.im import DeviceIdentity, authenticate_device_identity
@dataclass(frozen=True)
class ParentDeviceAccess:
device_id: str
child_id: int
child_name: str | None
def assert_parent_device_access(db: Session, *, device_id: str, user_id: int) -> ParentDeviceAccess:
row = (
db.execute(
text(
"""
SELECT
db.device_id,
db.child_id,
c.child_name
FROM device_bindings AS db
LEFT JOIN children AS c
ON c.child_id = db.child_id
AND c.status = 1
WHERE db.device_id = :device_id
AND db.owner_user_id = :user_id
AND db.status = 1
LIMIT 1
"""
),
{"device_id": device_id, "user_id": user_id},
)
.mappings()
.first()
)
if not row:
raise HTTPException(status_code=404, detail="device not found")
if row["child_id"] is None:
raise HTTPException(status_code=404, detail="device not bound to a child")
return ParentDeviceAccess(
device_id=str(row["device_id"]),
child_id=int(row["child_id"]),
child_name=row["child_name"],
)
def report_device_location(
db: Session,
*,
device_id: str,
serial_number: str,
payload: DeviceLocationReportRequest,
) -> tuple[DeviceIdentity, Mapping[str, Any]]:
device_identity = authenticate_device_identity(
db=db,
device_id=device_id,
serial_number=serial_number,
)
now_sql = current_timestamp_sql(db)
child_id = device_identity.child_id
try:
current_row = _get_current_location_row(db, child_id=child_id, lock=True)
params = {
"child_id": child_id,
"device_id": device_identity.device_id,
"coord_type": payload.coord_type,
"lat": payload.lat,
"lng": payload.lng,
"accuracy_m": payload.accuracy_m,
"altitude_m": payload.altitude_m,
"speed_mps": payload.speed_mps,
"heading_deg": payload.heading_deg,
"source": payload.source,
"battery_pct": payload.battery_pct,
"device_time": payload.device_time,
}
if current_row:
db.execute(
text(
f"""
UPDATE child_location_current
SET device_id = :device_id,
coord_type = :coord_type,
lat = :lat,
lng = :lng,
accuracy_m = :accuracy_m,
altitude_m = :altitude_m,
speed_mps = :speed_mps,
heading_deg = :heading_deg,
source = :source,
battery_pct = :battery_pct,
device_time = :device_time,
server_time = {now_sql},
updated_at = {now_sql}
WHERE child_id = :child_id
"""
),
params,
)
else:
db.execute(
text(
f"""
INSERT INTO child_location_current (
child_id,
device_id,
coord_type,
lat,
lng,
accuracy_m,
altitude_m,
speed_mps,
heading_deg,
source,
battery_pct,
device_time,
server_time,
updated_at
) VALUES (
:child_id,
:device_id,
:coord_type,
:lat,
:lng,
:accuracy_m,
:altitude_m,
:speed_mps,
:heading_deg,
:source,
:battery_pct,
:device_time,
{now_sql},
{now_sql}
)
"""
),
params,
)
history_id = _next_primary_key(db, "child_location_history")
insert_history_sql = f"""
INSERT INTO child_location_history (
{'id,' if history_id is not None else ''}
child_id,
device_id,
coord_type,
lat,
lng,
accuracy_m,
altitude_m,
speed_mps,
heading_deg,
source,
battery_pct,
device_time,
server_time,
created_at
) VALUES (
{':id,' if history_id is not None else ''}
:child_id,
:device_id,
:coord_type,
:lat,
:lng,
:accuracy_m,
:altitude_m,
:speed_mps,
:heading_deg,
:source,
:battery_pct,
:device_time,
{now_sql},
{now_sql}
)
"""
history_params = dict(params)
if history_id is not None:
history_params["id"] = history_id
db.execute(text(insert_history_sql), history_params)
db.commit()
except Exception:
db.rollback()
raise
current_row = _get_current_location_row(db, child_id=child_id, lock=False)
if not current_row:
raise RuntimeError("current location not found after report")
return device_identity, current_row
def get_device_current_location(
db: Session,
*,
device_id: str,
user_id: int,
) -> Mapping[str, Any]:
access = assert_parent_device_access(db, device_id=device_id, user_id=user_id)
row = _get_current_location_row(db, child_id=access.child_id, lock=False)
if not row or str(row["device_id"]) != device_id:
raise HTTPException(status_code=404, detail="location not found")
return {**row, "child_name": access.child_name}
def get_device_trajectory(
db: Session,
*,
device_id: str,
user_id: int,
start_at: datetime | None,
end_at: datetime | None,
limit: int,
) -> tuple[ParentDeviceAccess, list[Mapping[str, Any]]]:
access = assert_parent_device_access(db, device_id=device_id, user_id=user_id)
params: dict[str, Any] = {
"device_id": device_id,
"child_id": access.child_id,
"fetch_limit": limit,
}
where = """
device_id = :device_id
AND child_id = :child_id
"""
if start_at is not None:
where += " AND device_time >= :start_at"
params["start_at"] = start_at
if end_at is not None:
where += " AND device_time <= :end_at"
params["end_at"] = end_at
rows = (
db.execute(
text(
f"""
SELECT
id,
child_id,
device_id,
coord_type,
lat,
lng,
accuracy_m,
altitude_m,
speed_mps,
heading_deg,
source,
battery_pct,
device_time,
server_time,
created_at
FROM child_location_history
WHERE {where}
ORDER BY device_time DESC, id DESC
LIMIT :fetch_limit
"""
),
params,
)
.mappings()
.all()
)
rows = list(rows)
rows.reverse()
return access, rows
def _get_current_location_row(
db: Session,
*,
child_id: int,
lock: bool,
) -> Mapping[str, Any] | None:
lock_clause = select_for_update_clause(db) if lock else ""
return (
db.execute(
text(
f"""
SELECT
child_id,
device_id,
coord_type,
lat,
lng,
accuracy_m,
altitude_m,
speed_mps,
heading_deg,
source,
battery_pct,
device_time,
server_time,
updated_at
FROM child_location_current
WHERE child_id = :child_id
LIMIT 1{lock_clause}
"""
),
{"child_id": child_id},
)
.mappings()
.first()
)
def _next_primary_key(db: Session, table_name: str) -> int | None:
bind = db.get_bind()
if bind is None or bind.dialect.name != "sqlite":
return None
return int(
db.execute(text(f"SELECT COALESCE(MAX(id), 0) + 1 FROM {table_name}")).scalar_one()
)