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,
}