feat: Add real-time visitor map with IP geolocation

- Add latitude/longitude columns to visitor_logs model
- Update visitor_service to fetch and store coordinates from ip-api.com
- Add /admin/map-data API endpoint for map visualization
- Create VisitorMap component using Leaflet/OpenStreetMap
- Integrate map into visitor-stats admin page
- 30-second auto-refresh with animation for new visitors
- Color-coded markers (red: active, blue: recent)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
AutonetSellCar Deploy
2026-01-05 18:31:22 +09:00
parent 4858965087
commit b8f0ae4d28
7 changed files with 469 additions and 3 deletions

View File

@@ -332,3 +332,102 @@ def trigger_aggregation(
if result:
return {"status": "success", "date": date_str}
return {"status": "no_data", "date": date_str}
@router.get("/admin/map-data")
def get_visitor_map_data(
minutes: int = 30,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user),
):
"""
Get visitor locations for map display
Returns recent visitors with lat/lng coordinates
"""
start_time = datetime.utcnow() - timedelta(minutes=minutes)
# Get recent visitors with coordinates
visitors = db.query(
VisitorLog.latitude,
VisitorLog.longitude,
VisitorLog.country,
VisitorLog.country_code,
VisitorLog.city,
VisitorLog.visited_at,
VisitorLog.page_path,
VisitorLog.device_type,
).filter(
and_(
VisitorLog.visited_at >= start_time,
VisitorLog.latitude.isnot(None),
VisitorLog.longitude.isnot(None),
)
).order_by(
desc(VisitorLog.visited_at)
).limit(100).all()
# Group by unique location for clustering
locations = []
location_set = set()
for v in visitors:
# Round coordinates for grouping (roughly city-level)
lat_key = round(v.latitude, 2) if v.latitude else 0
lng_key = round(v.longitude, 2) if v.longitude else 0
location_key = f"{lat_key}:{lng_key}"
if location_key not in location_set:
location_set.add(location_key)
locations.append({
"lat": v.latitude,
"lng": v.longitude,
"country": v.country,
"country_code": v.country_code,
"city": v.city,
"last_visit": v.visited_at.isoformat() if v.visited_at else None,
"page": v.page_path,
"device": v.device_type,
"count": 1, # Will be aggregated
})
else:
# Increment count for existing location
for loc in locations:
if round(loc["lat"], 2) == lat_key and round(loc["lng"], 2) == lng_key:
loc["count"] += 1
break
# Get recent activity for animation (last 5 minutes)
recent_start = datetime.utcnow() - timedelta(minutes=5)
recent_visitors = db.query(
VisitorLog.latitude,
VisitorLog.longitude,
VisitorLog.country,
VisitorLog.city,
VisitorLog.visited_at,
).filter(
and_(
VisitorLog.visited_at >= recent_start,
VisitorLog.latitude.isnot(None),
VisitorLog.longitude.isnot(None),
)
).order_by(
desc(VisitorLog.visited_at)
).limit(20).all()
recent = [
{
"lat": v.latitude,
"lng": v.longitude,
"country": v.country,
"city": v.city,
"time": v.visited_at.isoformat() if v.visited_at else None,
}
for v in recent_visitors
]
return {
"locations": locations,
"recent": recent,
"total_with_coords": len(visitors),
"minutes": minutes,
}

View File

@@ -1,7 +1,7 @@
"""
Visitor tracking models for analytics
"""
from sqlalchemy import Column, Integer, String, DateTime, Text, Index
from sqlalchemy import Column, Integer, String, DateTime, Text, Index, Float
from sqlalchemy.sql import func
from ..database import Base
@@ -41,6 +41,8 @@ class VisitorLog(Base):
country_code = Column(String(5), nullable=True)
city = Column(String(100), nullable=True)
region = Column(String(100), nullable=True)
latitude = Column(Float, nullable=True)
longitude = Column(Float, nullable=True)
# UTM parameters
utm_source = Column(String(100), nullable=True)

View File

@@ -15,7 +15,7 @@ from sqlalchemy import func
from ..models.visitor import VisitorLog, VisitorDailyStats, VisitorSession
# IP Geolocation service (free, 45 req/min limit)
IP_API_URL = "http://ip-api.com/json/{ip}?fields=status,country,countryCode,regionName,city"
IP_API_URL = "http://ip-api.com/json/{ip}?fields=status,country,countryCode,regionName,city,lat,lon"
# Cache for IP geolocation results (in-memory, simple)
_geo_cache: Dict[str, Dict] = {}
@@ -77,7 +77,7 @@ async def get_geo_info(ip: str) -> Optional[Dict]:
if ip.startswith(('127.', '192.168.', '10.', '172.16.', '172.17.', '172.18.', '172.19.',
'172.20.', '172.21.', '172.22.', '172.23.', '172.24.', '172.25.',
'172.26.', '172.27.', '172.28.', '172.29.', '172.30.', '172.31.', 'localhost', '::1')):
return {"country": "Local", "country_code": "LO", "region": "", "city": ""}
return {"country": "Local", "country_code": "LO", "region": "", "city": "", "latitude": None, "longitude": None}
try:
async with httpx.AsyncClient() as client:
@@ -94,6 +94,8 @@ async def get_geo_info(ip: str) -> Optional[Dict]:
"country_code": data.get("countryCode", ""),
"region": data.get("regionName", ""),
"city": data.get("city", ""),
"latitude": data.get("lat"),
"longitude": data.get("lon"),
}
# Cache the result
_geo_cache[ip] = result
@@ -165,6 +167,8 @@ async def log_visit(
country_code=geo_info.get("country_code"),
city=geo_info.get("city"),
region=geo_info.get("region"),
latitude=geo_info.get("latitude"),
longitude=geo_info.get("longitude"),
utm_source=utm_source,
utm_medium=utm_medium,
utm_campaign=utm_campaign,