feat: 位置追踪优化、考勤去重、围栏考勤补充设备信息

- 地图轨迹点按定位类型区分颜色 (GPS蓝/WiFi青/LBS橙/蓝牙紫)
- LBS/WiFi定位点显示精度圈 (虚线圆, LBS~1km/WiFi~80m)
- 地图图例显示各定位类型颜色和精度范围
- 精度圈添加 bubble:true 防止遮挡轨迹点点击
- 点击列表记录直接在地图显示Marker+弹窗 (无需先加载轨迹)
- 修复3D地图setZoomAndCenter坐标偏移, 改用setCenter+setZoom
- 最新位置轮询超时从15s延长至30s (适配LBS慢响应)
- 考勤每日去重: 同设备同类型每天只记录一条 (fence/device/bluetooth通用)
- 围栏自动考勤补充设备电量/信号/基站信息 (从Device表和位置包获取)
- 考勤来源字段 attendance_source 区分 device/bluetooth/fence

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
2026-03-30 04:26:29 +00:00
parent 1d06cc5415
commit 891344bfa0
8 changed files with 598 additions and 100 deletions

View File

@@ -145,32 +145,35 @@ async def geocode_location(
location_type: "lbs"/"wifi" for 2G(GSM), "lbs_4g"/"wifi_4g" for 4G(LTE).
"""
# Check cache first
# Build cache key (include neighbor cells hash for accuracy)
nb_hash = tuple(sorted((nc.get("lac", 0), nc.get("cell_id", 0)) for nc in neighbor_cells)) if neighbor_cells else ()
if wifi_list:
wifi_cache_key = tuple(sorted((ap.get("mac", "") for ap in wifi_list)))
cached = _wifi_cache.get_cached(wifi_cache_key)
if cached is not None:
return cached
elif mcc is not None and lac is not None and cell_id is not None:
cache_key = (mcc, mnc or 0, lac, cell_id)
cache_key = (mcc, mnc or 0, lac, cell_id, nb_hash)
cached = _cell_cache.get_cached(cache_key)
if cached is not None:
return cached
api_key = AMAP_KEY
if not api_key:
return (None, None)
# Map location_type to v5 network parameter
# Valid: GSM, GPRS, EDGE, HSUPA, HSDPA, WCDMA, NR (LTE is NOT valid!)
_NETWORK_MAP = {"lbs_4g": "WCDMA", "wifi_4g": "WCDMA", "gps_4g": "WCDMA"}
network = _NETWORK_MAP.get(location_type or "", "GSM")
# Determine network type from location_type
is_4g = location_type in ("lbs_4g", "wifi_4g", "gps_4g")
result: tuple[Optional[float], Optional[float]] = (None, None)
# Try v5 API first (POST restapi.amap.com/v5/position/IoT)
result = await _geocode_amap_v5(
mcc, mnc, lac, cell_id, wifi_list, neighbor_cells,
imei=imei, api_key=api_key, is_4g=is_4g,
)
# Try v5 API first (requires bts with cage field + network param)
if AMAP_KEY:
result = await _geocode_amap_v5(
mcc, mnc, lac, cell_id, wifi_list, neighbor_cells,
imei=imei, api_key=AMAP_KEY, network=network,
)
# Fallback to legacy API if v5 fails and hardware key is available
# Fallback to legacy API if v5 fails
if result[0] is None and AMAP_HARDWARE_KEY:
result = await _geocode_amap_legacy(
mcc, mnc, lac, cell_id, wifi_list, neighbor_cells,
@@ -181,20 +184,25 @@ async def geocode_location(
if wifi_list:
_wifi_cache.put(wifi_cache_key, result)
elif mcc is not None and lac is not None and cell_id is not None:
_cell_cache.put((mcc, mnc or 0, lac, cell_id), result)
_cell_cache.put(cache_key, result)
return result
def _build_bts(mcc: Optional[int], mnc: Optional[int], lac: Optional[int], cell_id: Optional[int]) -> str:
"""Build bts (base station) parameter: mcc,mnc,lac,cellid,signal,cage"""
def _build_bts(
mcc: Optional[int], mnc: Optional[int], lac: Optional[int], cell_id: Optional[int],
*, include_cage: bool = False,
) -> str:
"""Build bts parameter. v5 API uses cage field, legacy does not."""
if mcc is not None and lac is not None and cell_id is not None:
return f"{mcc},{mnc or 0},{lac},{cell_id},-65,0"
base = f"{mcc},{mnc or 0},{lac},{cell_id},-65"
return f"{base},0" if include_cage else base
return ""
def _build_nearbts(
neighbor_cells: Optional[list[dict]], mcc: Optional[int], mnc: Optional[int]
neighbor_cells: Optional[list[dict]], mcc: Optional[int], mnc: Optional[int],
*, include_cage: bool = False,
) -> list[str]:
"""Build nearbts (neighbor cell) parts."""
parts = []
@@ -203,7 +211,8 @@ def _build_nearbts(
nc_lac = nc.get("lac", 0)
nc_cid = nc.get("cell_id", 0)
nc_signal = -(nc.get("rssi", 0)) if nc.get("rssi") else -80
parts.append(f"{mcc or 460},{mnc or 0},{nc_lac},{nc_cid},{nc_signal},0")
base = f"{mcc or 460},{mnc or 0},{nc_lac},{nc_cid},{nc_signal}"
parts.append(f"{base},0" if include_cage else base)
return parts
@@ -259,22 +268,20 @@ def _select_mmac(wifi_parts: list[str]) -> tuple[str, list[str]]:
async def _geocode_amap_v5(
mcc: Optional[int], mnc: Optional[int], lac: Optional[int], cell_id: Optional[int],
wifi_list: Optional[list[dict]], neighbor_cells: Optional[list[dict]],
*, imei: Optional[str] = None, api_key: str, is_4g: bool = False,
*, imei: Optional[str] = None, api_key: str, network: str = "GSM",
) -> tuple[Optional[float], Optional[float]]:
"""
Use 高德 IoT 定位 v5 API (POST restapi.amap.com/v5/position/IoT).
Key differences from legacy:
- POST method, key in URL params, data in body
- accesstype: 0=未知, 1=移动网络, 2=WiFi
- WiFi requires mmac (connected WiFi) + macs (nearby, 2-30)
- network: GSM(default)/LTE/WCDMA/NR — critical for 4G accuracy
- diu replaces imei
- No digital signature needed
- show_fields can return address directly
Key requirements:
- POST method, key in URL params, data in form body
- bts MUST have 6 fields: mcc,mnc,lac,cellid,signal,cage
- network MUST be valid: GSM/GPRS/EDGE/HSUPA/HSDPA/WCDMA/NR (LTE is NOT valid!)
- For 4G LTE, use WCDMA as network value
- accesstype: 1=移动网络, 2=WiFi (requires mmac + 2+ macs)
"""
bts = _build_bts(mcc, mnc, lac, cell_id)
nearbts_parts = _build_nearbts(neighbor_cells, mcc, mnc)
bts = _build_bts(mcc, mnc, lac, cell_id, include_cage=True)
nearbts_parts = _build_nearbts(neighbor_cells, mcc, mnc, include_cage=True)
wifi_parts = _build_wifi_parts(wifi_list)
if not bts and not wifi_parts:
@@ -288,7 +295,7 @@ async def _geocode_amap_v5(
body: dict[str, str] = {
"accesstype": accesstype,
"cdma": "0",
"network": "LTE" if is_4g else "GSM",
"network": network,
"diu": imei or _settings.GEOCODING_DEFAULT_IMEI,
"show_fields": "formatted_address",
}
@@ -309,6 +316,8 @@ async def _geocode_amap_v5(
url = f"https://restapi.amap.com/v5/position/IoT?key={api_key}"
logger.info("Amap v5 request body: %s", body)
try:
async with aiohttp.ClientSession() as session:
async with session.post(
@@ -316,6 +325,7 @@ async def _geocode_amap_v5(
) as resp:
if resp.status == 200:
data = await resp.json(content_type=None)
logger.info("Amap v5 response: %s", data)
if data.get("status") == "1":
position = data.get("position", {})
location = position.get("location", "") if isinstance(position, dict) else ""
@@ -333,8 +343,8 @@ async def _geocode_amap_v5(
else:
infocode = data.get("infocode", "")
logger.warning(
"Amap v5 geocode error: %s (code=%s)",
data.get("info", ""), infocode,
"Amap v5 geocode error: %s (code=%s) body=%s",
data.get("info", ""), infocode, body,
)
else:
logger.warning("Amap v5 geocode HTTP %d", resp.status)
@@ -390,6 +400,8 @@ async def _geocode_amap_legacy(
url = "https://apilocate.amap.com/position"
logger.info("Amap legacy request params: %s", {k: v for k, v in params.items() if k != 'key'})
try:
async with aiohttp.ClientSession() as session:
async with session.get(
@@ -397,6 +409,7 @@ async def _geocode_amap_legacy(
) as resp:
if resp.status == 200:
data = await resp.json(content_type=None)
logger.info("Amap legacy response: %s", data)
if data.get("status") == "1" and data.get("result"):
result = data["result"]
location = result.get("location", "")