feat(桌面与监控): 完善工作台导航和登录活动定位
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

- 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验

- 补充登录会话 IP 采集、地理位置回退、管理端展示及数据库迁移

- 更新桌面发布检查、运维文档和前后端测试覆盖
This commit is contained in:
Cheng Zhou
2026-07-13 16:03:20 +08:00
parent b26ebdda02
commit ab59476d10
50 changed files with 2086 additions and 191 deletions
@@ -0,0 +1,25 @@
"""Add the server-observed login IP to user login sessions.
Revision ID: 20260713_01
Revises: 20260710_04
"""
import sqlalchemy as sa
from alembic import op
revision = "20260713_01"
down_revision = "20260710_04"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"user_login_sessions",
sa.Column("login_ip", sa.String(length=45), nullable=True),
)
def downgrade() -> None:
op.drop_column("user_login_sessions", "login_ip")
+30 -3
View File
@@ -28,6 +28,7 @@ from app.models.permission_metric_snapshot import PermissionMetricSnapshot
from app.models.security_access_log import SecurityAccessLog
from app.models.user import User
from app.services.geo_location_metadata import GeoLocationMetadata, resolve_geo_location_metadata
from app.services.ip_geolocation_fallback import resolve_external_ip_locations
from app.services.ip_location import IpLocation, resolve_ip_location
from app.services.monitoring_retention import get_monitoring_retention_status
from app.services.permission_log_writer import get_log_writer
@@ -1604,12 +1605,15 @@ async def get_ip_locations(
SecurityAccessLog.category,
)
)
permission_rows = permission_result.all()
security_rows = security_result.all()
buckets: dict[tuple[str, str, str], dict] = {}
all_ip_addresses: set[str] = set()
all_user_ids: set[str] = set()
located_ip_addresses: set[str] = set()
private_ip_addresses: set[str] = set()
unknown_ip_addresses: set[str] = set()
external_fallback_ip_addresses: set[str] = set()
total_count = 0
allowed_count = 0
denied_count = 0
@@ -1619,6 +1623,28 @@ async def get_ip_locations(
resolved_locations: dict[str, IpLocation] = {}
resolved_metadata: dict[str, GeoLocationMetadata] = {}
source_ip_addresses = list(
dict.fromkeys(
[str(row[0]) for row in permission_rows if row[0]]
+ [str(row[0]) for row in security_rows if row[0]]
)
)
for ip_address in source_ip_addresses:
ip_info = resolve_ip_location(ip_address)
resolved_locations[ip_address] = ip_info
resolved_metadata[ip_address] = resolve_geo_location_metadata(ip_info)
missing_coordinate_ips = [
ip_address
for ip_address, metadata in resolved_metadata.items()
if metadata.accuracy_level != "private"
and (metadata.longitude is None or metadata.latitude is None)
]
external_locations = await resolve_external_ip_locations(missing_coordinate_ips)
for ip_address, external_location in external_locations.items():
resolved_locations[ip_address] = external_location.merge_ip_location(resolved_locations[ip_address])
resolved_metadata[ip_address] = external_location.to_metadata()
external_fallback_ip_addresses.add(ip_address)
def normalize_observed_at(value: datetime | None) -> datetime | None:
if value is None:
return None
@@ -1725,7 +1751,7 @@ async def get_ip_locations(
if user_id:
all_user_ids.add(str(user_id))
for ip_address, user_id, allowed, event_count, first_seen_at, last_seen_at in permission_result.all():
for ip_address, user_id, allowed, event_count, first_seen_at, last_seen_at in permission_rows:
add_ip_location_row(
ip_address,
user_id,
@@ -1736,7 +1762,7 @@ async def get_ip_locations(
source_kind="permission",
)
for client_ip, user_identifier, auth_status, allowed, severity, category, event_count, first_seen_at, last_seen_at in security_result.all():
for client_ip, user_identifier, auth_status, allowed, severity, category, event_count, first_seen_at, last_seen_at in security_rows:
user_id = None
if auth_status == "AUTHENTICATED" and user_identifier:
try:
@@ -1837,10 +1863,11 @@ async def get_ip_locations(
},
"server_location": server_location.to_public_dict() if server_location else None,
"data_quality": {
"resolver": "ip2region",
"resolver": "ip2region+ip2location.io" if external_fallback_ip_addresses else "ip2region",
"located_ip_count": located_count,
"private_ip_count": private_count,
"unknown_ip_count": unknown_count,
"external_fallback_ip_count": len(external_fallback_ip_addresses),
"location_coverage_rate": round((located_count / public_ip_count) * 100, 1) if public_ip_count else 100.0,
"returned_region_count": len(items),
},
+6 -3
View File
@@ -1,6 +1,6 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -10,7 +10,7 @@ from app.crud import user as user_crud
from app.crud import member as member_crud
from app.utils.pagination import paginate
from app.schemas.user import LoginStatus, UserCreate, UserLoginActivityRead, UserRead, UserStatus, UserUpdate
from app.services.user_login_sessions import get_login_summaries, list_login_activities
from app.services.user_login_sessions import get_login_summaries, list_login_activities, login_activity_payload
router = APIRouter()
@@ -53,13 +53,16 @@ async def list_users(
@router.get("/{user_id}/login-activities", response_model=list[UserLoginActivityRead])
async def read_user_login_activities(
user_id: uuid.UUID,
response: Response,
limit: int = Query(default=30, ge=1, le=100),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
) -> list[UserLoginActivityRead]:
response.headers["Cache-Control"] = "no-store"
if not await user_crud.get_by_id(db, user_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
return await list_login_activities(db, user_id=user_id, limit=limit)
activities = await list_login_activities(db, user_id=user_id, limit=limit)
return [login_activity_payload(activity) for activity in activities]
@router.post("/", response_model=UserRead, status_code=status.HTTP_201_CREATED)
+5
View File
@@ -40,6 +40,11 @@ class Settings(BaseSettings):
)
MONITORING_PUBLIC_IP_DISCOVERY_TIMEOUT_SECONDS: float = Field(default=2.5, ge=0.5, le=10)
MONITORING_SERVER_LOCATION_CACHE_SECONDS: int = Field(default=86400, ge=300, le=604800)
MONITORING_IP_GEO_FALLBACK_ENABLED: bool = True
MONITORING_IP_GEO_FALLBACK_API_KEY: Optional[str] = None
MONITORING_IP_GEO_FALLBACK_TIMEOUT_SECONDS: float = Field(default=2.5, ge=0.5, le=10)
MONITORING_IP_GEO_FALLBACK_CACHE_SECONDS: int = Field(default=604800, ge=3600, le=2592000)
MONITORING_IP_GEO_FALLBACK_MAX_LOOKUPS: int = Field(default=10, ge=1, le=100)
MONITORING_ACCESS_LOG_RETENTION_DAYS: int = Field(default=90, ge=7, le=3650)
MONITORING_METRIC_RETENTION_DAYS: int = Field(default=400, ge=30, le=3650)
MONITORING_RETENTION_INTERVAL_SECONDS: int = Field(default=86400, ge=60, le=604800)
+2 -1
View File
@@ -11,7 +11,7 @@ from app.db.base_class import Base
class UserLoginSession(Base):
"""Server-side login activity record without storing credentials or raw IPs."""
"""Server-side login activity record without storing credentials."""
__tablename__ = "user_login_sessions"
@@ -26,6 +26,7 @@ class UserLoginSession(Base):
client_platform: Mapped[str | None] = mapped_column(String(32), nullable=True)
client_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
client_source: Mapped[str | None] = mapped_column(String(32), nullable=True)
login_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)
login_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+4
View File
@@ -89,10 +89,14 @@ class UserLoginActivityRead(BaseModel):
client_type: Literal["web", "desktop"]
client_platform: Optional[str] = None
client_version: Optional[str] = None
client_source: Optional[str] = None
login_ip: Optional[str] = None
ip_location: Optional[str] = None
login_at: datetime
last_seen_at: datetime
ended_at: Optional[datetime] = None
end_reason: Optional[str] = None
activity_status: Literal["ONLINE", "OFFLINE", "ENDED"]
model_config = ConfigDict(from_attributes=True)
@@ -0,0 +1,174 @@
"""Bounded third-party coordinate fallback for public source IPs.
IPAddress.my identifies IP2Location.io as its data provider. We use the
provider's documented JSON API instead of scraping the public HTML page.
Only globally routable addresses are eligible, and results are cached so the
monitoring UI does not turn into a per-refresh third-party lookup fan-out.
"""
from __future__ import annotations
import asyncio
import ipaddress
import logging
import math
import time
from dataclasses import dataclass
from typing import Any, Iterable
import httpx
from app.core.config import settings
from app.services.geo_location_metadata import GeoLocationMetadata
from app.services.ip_location import IpLocation
logger = logging.getLogger("ctms.ip_geolocation_fallback")
_API_URL = "https://api.ip2location.io/"
_MAX_RESPONSE_BYTES = 64 * 1024
_MAX_CACHE_ENTRIES = 4096
_NEGATIVE_CACHE_SECONDS = 3600
_cache: dict[str, tuple[float, "ExternalIpLocation | None"]] = {}
_cache_lock = asyncio.Lock()
def _text(value: Any, limit: int = 160) -> str:
candidate = str(value or "").strip()
return "" if candidate in {"", "-", "0"} else candidate[:limit]
def _coordinate(value: Any, *, minimum: float, maximum: float) -> float | None:
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if math.isfinite(number) and minimum <= number <= maximum else None
def _global_ip(value: str | None) -> str | None:
try:
address = ipaddress.ip_address((value or "").strip())
except ValueError:
return None
return str(address) if address.is_global else None
@dataclass(frozen=True)
class ExternalIpLocation:
ip_address: str
country: str
country_code: str
region: str
city: str
isp: str
longitude: float
latitude: float
def merge_ip_location(self, local: IpLocation) -> IpLocation:
country = self.country or local.country
province = self.region or local.province
city = self.city or local.city
isp = self.isp or local.isp
location = " / ".join(part for part in [country, province, city, isp] if part) or local.location
return IpLocation(location=location, country=country, province=province, city=city, isp=isp)
def to_metadata(self) -> GeoLocationMetadata:
return GeoLocationMetadata(
country=self.country,
country_code=self.country_code,
region_code="",
longitude=self.longitude,
latitude=self.latitude,
accuracy_level="city" if self.city else "country",
)
def _parse_response(expected_ip: str, payload: Any) -> ExternalIpLocation | None:
if not isinstance(payload, dict):
return None
response_ip = _global_ip(_text(payload.get("ip"), 45))
if response_ip != expected_ip:
return None
latitude = _coordinate(payload.get("latitude"), minimum=-90, maximum=90)
longitude = _coordinate(payload.get("longitude"), minimum=-180, maximum=180)
if latitude is None or longitude is None:
return None
country_code = _text(payload.get("country_code"), 2).upper()
if len(country_code) != 2:
country_code = ""
return ExternalIpLocation(
ip_address=expected_ip,
country=_text(payload.get("country_name"), 100),
country_code=country_code,
region=_text(payload.get("region_name"), 100),
city=_text(payload.get("city_name"), 100),
isp=_text(payload.get("isp"), 160),
longitude=longitude,
latitude=latitude,
)
async def _fetch_one(
client: httpx.AsyncClient,
semaphore: asyncio.Semaphore,
ip_address: str,
) -> ExternalIpLocation | None:
async with semaphore:
try:
response = await client.get(_API_URL, params={"ip": ip_address, "format": "json"})
response.raise_for_status()
if len(response.content) > _MAX_RESPONSE_BYTES:
return None
return _parse_response(ip_address, response.json())
except (httpx.HTTPError, ValueError):
logger.warning("External IP coordinate fallback unavailable")
return None
async def resolve_external_ip_locations(ip_addresses: Iterable[str]) -> dict[str, ExternalIpLocation]:
if not settings.MONITORING_IP_GEO_FALLBACK_ENABLED or settings.ENV == "test":
return {}
candidates = list(dict.fromkeys(filter(None, (_global_ip(value) for value in ip_addresses))))
if not candidates:
return {}
now = time.monotonic()
resolved: dict[str, ExternalIpLocation] = {}
pending: list[str] = []
async with _cache_lock:
for ip_address in candidates:
cached = _cache.get(ip_address)
if cached and cached[0] > now:
if cached[1] is not None:
resolved[ip_address] = cached[1]
continue
pending.append(ip_address)
pending = pending[: settings.MONITORING_IP_GEO_FALLBACK_MAX_LOOKUPS]
if not pending:
return resolved
headers = {"Accept": "application/json", "User-Agent": "CTMS-IP-Coordinate-Fallback/1.0"}
api_key = (settings.MONITORING_IP_GEO_FALLBACK_API_KEY or "").strip()
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
timeout = httpx.Timeout(settings.MONITORING_IP_GEO_FALLBACK_TIMEOUT_SECONDS)
semaphore = asyncio.Semaphore(min(5, len(pending)))
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False, headers=headers) as client:
fetched = await asyncio.gather(*(_fetch_one(client, semaphore, item) for item in pending))
now = time.monotonic()
async with _cache_lock:
for ip_address, item in zip(pending, fetched):
ttl = settings.MONITORING_IP_GEO_FALLBACK_CACHE_SECONDS if item else _NEGATIVE_CACHE_SECONDS
_cache[ip_address] = (now + ttl, item)
if item is not None:
resolved[ip_address] = item
while len(_cache) > _MAX_CACHE_ENTRIES:
_cache.pop(next(iter(_cache)))
return resolved
def reset_ip_geolocation_fallback_cache() -> None:
_cache.clear()
@@ -21,6 +21,7 @@ import httpx
from app.core.config import settings
from app.services.geo_location_metadata import resolve_geo_location_metadata
from app.services.ip_geolocation_fallback import resolve_external_ip_locations
from app.services.ip_location import resolve_ip_location
logger = logging.getLogger("ctms.monitoring_server_location")
@@ -128,6 +129,11 @@ async def resolve_monitoring_server_location(*, force: bool = False) -> Monitori
if public_ip:
ip_location = resolve_ip_location(public_ip)
metadata = resolve_geo_location_metadata(ip_location)
if metadata.longitude is None or metadata.latitude is None:
external_location = (await resolve_external_ip_locations([public_ip])).get(public_ip)
if external_location is not None:
ip_location = external_location.merge_ip_location(ip_location)
metadata = external_location.to_metadata()
if metadata.longitude is not None and metadata.latitude is not None:
country = metadata.country or ip_location.country
name = " / ".join(
@@ -20,6 +20,7 @@ from app.models.permission_access_log import PermissionAccessLog
from app.models.security_access_log import SecurityAccessLog
from app.models.source_location_snapshot import SourceLocationSnapshot
from app.services.geo_location_metadata import resolve_geo_location_metadata
from app.services.ip_geolocation_fallback import resolve_external_ip_locations
from app.services.ip_location import resolve_ip_location
logger = logging.getLogger("ctms.source_location_aggregator")
@@ -149,12 +150,31 @@ async def aggregate_source_location_hour(bucket_start: datetime, bucket_end: dat
auth_failure_count=auth_failures,
)
resolved_locations = {
ip_address: resolve_ip_location(ip_address)
for ip_address, _user_identity in aggregates
}
resolved_metadata = {
ip_address: resolve_geo_location_metadata(ip_info)
for ip_address, ip_info in resolved_locations.items()
}
missing_coordinate_ips = [
ip_address
for ip_address, metadata in resolved_metadata.items()
if metadata.accuracy_level != "private"
and (metadata.longitude is None or metadata.latitude is None)
]
external_locations = await resolve_external_ip_locations(missing_coordinate_ips)
for ip_address, external_location in external_locations.items():
resolved_locations[ip_address] = external_location.merge_ip_location(resolved_locations[ip_address])
resolved_metadata[ip_address] = external_location.to_metadata()
await session.execute(
delete(SourceLocationSnapshot).where(SourceLocationSnapshot.bucket_time == bucket_start)
)
for row in aggregates.values():
ip_info = resolve_ip_location(row["ip_address"])
metadata = resolve_geo_location_metadata(ip_info)
ip_info = resolved_locations[row["ip_address"]]
metadata = resolved_metadata[row["ip_address"]]
longitude = metadata.longitude
latitude = metadata.latitude
country = metadata.country or ip_info.country
@@ -11,7 +11,9 @@ from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.request_context import resolve_client_ip
from app.models.user_login_session import UserLoginSession
from app.services.ip_location import resolve_ip_location
@dataclass(frozen=True)
@@ -59,6 +61,35 @@ def session_client_type(payload: dict[str, Any], headers: Any) -> str:
return "desktop" if "desktop" in {from_payload, from_header} else "web"
def login_activity_status(session: UserLoginSession, *, reference_time: datetime | None = None) -> str:
if session.ended_at is not None:
return "ENDED"
cutoff = (reference_time or _now()) - timedelta(seconds=settings.USER_SESSION_ONLINE_SECONDS)
return "ONLINE" if _as_utc(session.last_seen_at) >= cutoff else "OFFLINE"
def login_activity_payload(
session: UserLoginSession,
*,
reference_time: datetime | None = None,
) -> dict[str, Any]:
ip_location = resolve_ip_location(session.login_ip) if session.login_ip else None
return {
"id": session.id,
"client_type": session.client_type,
"client_platform": session.client_platform,
"client_version": session.client_version,
"client_source": session.client_source,
"login_ip": session.login_ip,
"ip_location": ip_location.location if ip_location else None,
"login_at": session.login_at,
"last_seen_at": session.last_seen_at,
"ended_at": session.ended_at,
"end_reason": session.end_reason,
"activity_status": login_activity_status(session, reference_time=reference_time),
}
async def create_login_session(
db: AsyncSession,
*,
@@ -75,6 +106,7 @@ async def create_login_session(
client_platform=_text_header(request.headers, "x-ctms-client-platform", 32),
client_version=_text_header(request.headers, "x-ctms-client-version", 64),
client_source=_text_header(request.headers, "x-ctms-client-source", 32),
login_ip=resolve_client_ip(request),
login_at=occurred_at,
last_seen_at=occurred_at,
)
@@ -102,6 +134,7 @@ async def touch_login_session(
client_platform=_text_header(request.headers, "x-ctms-client-platform", 32),
client_version=_text_header(request.headers, "x-ctms-client-version", 64),
client_source=_text_header(request.headers, "x-ctms-client-source", 32),
login_ip=resolve_client_ip(request),
login_at=now,
last_seen_at=now,
)
@@ -0,0 +1,58 @@
import pytest
from app.core.config import settings
from app.services import ip_geolocation_fallback
from app.services.ip_geolocation_fallback import ExternalIpLocation
def test_ip2location_response_parser_requires_matching_public_ip_and_coordinates():
result = ip_geolocation_fallback._parse_response(
"8.8.8.8",
{
"ip": "8.8.8.8",
"country_code": "US",
"country_name": "United States of America",
"region_name": "California",
"city_name": "Mountain View",
"latitude": 37.38605,
"longitude": -122.08385,
"isp": "Google LLC",
},
)
assert result is not None
assert result.country_code == "US"
assert result.longitude == -122.08385
assert ip_geolocation_fallback._parse_response("8.8.8.8", {"ip": "1.1.1.1"}) is None
assert ip_geolocation_fallback._global_ip("192.168.1.8") is None
@pytest.mark.asyncio
async def test_external_fallback_caches_public_ip_results(monkeypatch):
calls = []
async def fake_fetch(_client, _semaphore, ip_address):
calls.append(ip_address)
return ExternalIpLocation(
ip_address=ip_address,
country="United States of America",
country_code="US",
region="California",
city="Mountain View",
isp="Google LLC",
longitude=-122.08385,
latitude=37.38605,
)
monkeypatch.setattr(settings, "ENV", "development")
monkeypatch.setattr(settings, "MONITORING_IP_GEO_FALLBACK_ENABLED", True)
monkeypatch.setattr(ip_geolocation_fallback, "_fetch_one", fake_fetch)
ip_geolocation_fallback.reset_ip_geolocation_fallback_cache()
first = await ip_geolocation_fallback.resolve_external_ip_locations(["8.8.8.8", "192.168.1.8"])
second = await ip_geolocation_fallback.resolve_external_ip_locations(["8.8.8.8"])
assert first["8.8.8.8"].city == "Mountain View"
assert second["8.8.8.8"].latitude == 37.38605
assert calls == ["8.8.8.8"]
ip_geolocation_fallback.reset_ip_geolocation_fallback_cache()
@@ -4,6 +4,7 @@ import pytest
from app.core.config import settings
from app.services import monitoring_server_location
from app.services.ip_geolocation_fallback import ExternalIpLocation
def test_public_ip_normalization_rejects_private_addresses():
@@ -51,3 +52,45 @@ async def test_server_location_returns_none_instead_of_a_hardcoded_fallback(monk
assert location is None
monitoring_server_location.reset_monitoring_server_location_cache()
@pytest.mark.asyncio
async def test_server_location_uses_external_coordinates_when_local_coordinates_are_missing(monkeypatch):
monkeypatch.setattr(settings, "MONITORING_SERVER_PUBLIC_IP", "5.34.216.210")
monkeypatch.setattr(
monitoring_server_location,
"resolve_ip_location",
lambda _ip: SimpleNamespace(
location="公网",
country="",
province="",
city="",
isp="",
),
)
async def fake_external_lookup(ip_addresses):
assert ip_addresses == ["5.34.216.210"]
return {
"5.34.216.210": ExternalIpLocation(
ip_address="5.34.216.210",
country="United States of America",
country_code="US",
region="California",
city="Los Angeles",
isp="Example ISP",
longitude=-118.2439,
latitude=34.05257,
)
}
monkeypatch.setattr(monitoring_server_location, "resolve_external_ip_locations", fake_external_lookup)
monitoring_server_location.reset_monitoring_server_location_cache()
location = await monitoring_server_location.resolve_monitoring_server_location()
assert location is not None
assert location.name == "United States of America / California / Los Angeles"
assert location.longitude == -118.2439
assert location.latitude == 34.05257
monitoring_server_location.reset_monitoring_server_location_cache()
@@ -13,6 +13,7 @@ from app.core.permission_monitor import set_permission_monitor, PermissionMonito
from app.api.v1 import permission_monitoring
from app.api.v1.system_permissions import list_system_permissions
from app.services.monitoring_server_location import MonitoringServerLocation
from app.services.ip_geolocation_fallback import ExternalIpLocation
class FakeIpInfo:
@@ -583,6 +584,57 @@ async def test_ip_locations_counts_unique_users_per_location(db_session, monkeyp
assert sorted(resolved_ips) == ["10.1.1.1", "10.1.1.2", "10.1.1.3"]
@pytest.mark.asyncio
async def test_ip_locations_uses_external_coordinates_only_when_local_coordinates_are_missing(db_session, monkeypatch):
await db_session.execute(text("DELETE FROM permission_access_logs"))
await db_session.execute(text("DELETE FROM security_access_logs"))
await db_session.commit()
study_id = uuid.uuid4()
user_id = uuid.uuid4()
await _seed_permission_log(db_session, study_id, user_id, allowed=True, elapsed_ms=3.2)
await db_session.execute(
text("UPDATE permission_access_logs SET ip_address = :ip_address"),
{"ip_address": "8.8.8.8"},
)
await db_session.commit()
monkeypatch.setattr(
permission_monitoring,
"resolve_ip_location",
lambda _ip: FakeIpInfo("", "", isp="", country="未知国家"),
)
async def fake_external_lookup(ip_addresses):
assert ip_addresses == ["8.8.8.8"]
return {
"8.8.8.8": ExternalIpLocation(
ip_address="8.8.8.8",
country="United States of America",
country_code="US",
region="California",
city="Mountain View",
isp="Google LLC",
longitude=-122.08385,
latitude=37.38605,
)
}
monkeypatch.setattr(permission_monitoring, "resolve_external_ip_locations", fake_external_lookup)
result = await permission_monitoring.get_ip_locations(
db=db_session,
_=AdminUserStub(),
days=7,
limit=10,
)
assert result["items"][0]["longitude"] == -122.08385
assert result["items"][0]["latitude"] == 37.38605
assert result["items"][0]["city"] == "Mountain View"
assert result["data_quality"]["external_fallback_ip_count"] == 1
assert result["data_quality"]["resolver"] == "ip2region+ip2location.io"
@pytest.mark.asyncio
async def test_ip_locations_merges_same_region_with_different_isp(db_session, monkeypatch):
"""同一省市的 IP 属地统计不应因运营商不同拆分。"""
+66 -2
View File
@@ -1,6 +1,7 @@
from datetime import datetime, timedelta, timezone
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
import pytest
@@ -8,7 +9,13 @@ from app.core.config import settings
from app.crud import user as user_crud
from app.models.user import User, UserStatus
from app.models.user_login_session import UserLoginSession
from app.services.user_login_sessions import get_login_summaries, session_id_from_payload
from app.schemas.user import UserLoginActivityRead
from app.services.user_login_sessions import (
create_login_session,
get_login_summaries,
login_activity_payload,
session_id_from_payload,
)
def test_legacy_session_id_is_stable_without_storing_a_token():
@@ -28,6 +35,63 @@ def test_session_heartbeat_returns_server_observed_client_ip():
assert '"client_ip": resolve_client_ip(request)' in auth_source
@pytest.mark.asyncio
async def test_login_session_stores_server_observed_login_ip(db_session):
user = User(
id=uuid.uuid4(),
email="login-ip@example.com",
password_hash="hash",
full_name="Login IP",
clinical_department="IT",
status=UserStatus.ACTIVE,
)
db_session.add(user)
await db_session.commit()
request = SimpleNamespace(
headers={
"x-ctms-client-type": "desktop",
"x-ctms-client-platform": "macos",
"x-ctms-client-version": "0.1.0",
},
client=SimpleNamespace(host="203.0.113.18"),
)
session = await create_login_session(
db_session,
session_id=uuid.uuid4(),
user_id=user.id,
request=request,
)
assert session.login_ip == "203.0.113.18"
assert session.client_type == "desktop"
def test_login_activity_payload_uses_server_authoritative_status_and_local_ip_location(monkeypatch):
now = datetime.now(timezone.utc)
monkeypatch.setattr(settings, "USER_SESSION_ONLINE_SECONDS", 300)
session = UserLoginSession(
id=uuid.uuid4(),
user_id=uuid.uuid4(),
client_type="web",
login_ip="127.0.0.1",
login_at=now - timedelta(minutes=10),
last_seen_at=now - timedelta(seconds=30),
)
online = login_activity_payload(session, reference_time=now)
assert online["activity_status"] == "ONLINE"
assert online["login_ip"] == "127.0.0.1"
assert online["ip_location"] == "本机"
assert UserLoginActivityRead.model_validate(online).activity_status == "ONLINE"
session.last_seen_at = now - timedelta(minutes=6)
assert login_activity_payload(session, reference_time=now)["activity_status"] == "OFFLINE"
session.ended_at = now - timedelta(minutes=1)
assert login_activity_payload(session, reference_time=now)["activity_status"] == "ENDED"
@pytest.mark.asyncio
async def test_login_summary_marks_recent_unended_sessions_online(db_session, monkeypatch):
now = datetime.now(timezone.utc)