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:
@@ -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", "")
|
||||
|
||||
Reference in New Issue
Block a user