From 6d1c98bcaecf44e9df987979a6b7c2fecdcc51c5 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Tue, 26 May 2026 14:54:40 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=88=E5=B9=B6=E7=9B=B8=E5=90=8C=E5=B1=9E?= =?UTF-8?q?=E5=9C=B0=E7=9A=84IP=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/permission_monitoring.py | 9 +- .../tests/test_permission_monitoring_api.py | 163 +++++++++++++++++- 2 files changed, 165 insertions(+), 7 deletions(-) diff --git a/backend/app/api/v1/permission_monitoring.py b/backend/app/api/v1/permission_monitoring.py index a86ef777..b31c0025 100644 --- a/backend/app/api/v1/permission_monitoring.py +++ b/backend/app/api/v1/permission_monitoring.py @@ -632,7 +632,7 @@ async def get_ip_locations( ) ) result = await db.execute(_apply_monitoring_scope_to_log_query(query, scope)) - buckets: dict[tuple[str, str, str, str], dict] = {} + buckets: dict[tuple[str, str, str], dict] = {} all_ip_addresses: set[str] = set() all_user_ids: set[uuid.UUID] = set() total_count = 0 @@ -641,15 +641,16 @@ async def get_ip_locations( for ip_address, user_id, allowed in result.all(): ip_info = resolve_ip_location(ip_address) - key = (ip_info.country, ip_info.province, ip_info.city, ip_info.isp) + key = (ip_info.country, ip_info.province, ip_info.city) + location = " / ".join(part for part in [ip_info.country, ip_info.province, ip_info.city] if part) or ip_info.location or "未知" bucket = buckets.setdefault( key, { "country": ip_info.country, "province": ip_info.province, "city": ip_info.city, - "isp": ip_info.isp, - "location": ip_info.location, + "isp": "", + "location": location, "total_count": 0, "allowed_count": 0, "denied_count": 0, diff --git a/backend/tests/test_permission_monitoring_api.py b/backend/tests/test_permission_monitoring_api.py index 45073f27..404c1a31 100644 --- a/backend/tests/test_permission_monitoring_api.py +++ b/backend/tests/test_permission_monitoring_api.py @@ -10,12 +10,12 @@ from app.api.v1 import permission_monitoring class FakeIpInfo: - def __init__(self, province: str, city: str) -> None: + def __init__(self, province: str, city: str, isp: str = "电信") -> None: self.country = "中国" self.province = province self.city = city - self.isp = "电信" - self.location = f"中国 / {province} / {city} / 电信" + self.isp = isp + self.location = f"中国 / {province} / {city} / {isp}" async def _seed_permission_log(db_session, study_id: uuid.UUID, user_id: uuid.UUID, *, allowed: bool, elapsed_ms: float) -> None: @@ -334,6 +334,163 @@ async def test_ip_locations_counts_unique_users_per_location(db_session, monkeyp assert result["summary"]["unique_user_count"] == 2 +@pytest.mark.asyncio +async def test_ip_locations_merges_same_region_with_different_isp(db_session, monkeypatch): + """同一省市的 IP 属地统计不应因运营商不同拆分。""" + await db_session.execute(text("DELETE FROM permission_access_logs")) + await db_session.commit() + + study_id = "00000000-0000-0000-0000-000000000401" + user_id = "00000000-0000-0000-0000-000000000501" + + await db_session.execute( + text( + """ + INSERT INTO studies (id, code, name, status, is_locked, visit_schedule, active_roles) + VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule, :active_roles) + """ + ), + { + "id": study_id, + "code": "IP-LOCATION-MERGE-STUDY", + "name": "IP Location Merge Study", + "status": "ACTIVE", + "is_locked": False, + "visit_schedule": "[]", + "active_roles": "[]", + }, + ) + await db_session.execute( + text( + """ + INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status) + VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status) + """ + ), + { + "id": user_id, + "email": "region-merge@example.com", + "password_hash": "hash", + "full_name": "属地合并用户", + "role": "PM", + "clinical_department": "临床运营", + "status": "ACTIVE", + }, + ) + for ip_address in ["10.2.1.1", "10.2.1.2"]: + await db_session.execute( + text( + """ + INSERT INTO permission_access_logs + (id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, created_at) + VALUES + (:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, CURRENT_TIMESTAMP) + """ + ), + { + "id": str(uuid.uuid4()), + "study_id": study_id, + "user_id": user_id, + "endpoint_key": "admin.permissions.read", + "role": "PM", + "allowed": True, + "elapsed_ms": 3.2, + "ip_address": ip_address, + }, + ) + await db_session.commit() + + ip_info_by_address = { + "10.2.1.1": FakeIpInfo("重庆", "重庆市", "电信"), + "10.2.1.2": FakeIpInfo("重庆", "重庆市", "联通"), + } + monkeypatch.setattr( + permission_monitoring, + "resolve_ip_location", + lambda ip: ip_info_by_address[ip], + ) + + result = await permission_monitoring.get_ip_locations(db=db_session, _=object(), days=7, limit=10) + + assert len(result["items"]) == 1 + assert result["items"][0]["province"] == "重庆" + assert result["items"][0]["city"] == "重庆市" + assert result["items"][0]["total_count"] == 2 + assert result["items"][0]["unique_ip_count"] == 2 + + +@pytest.mark.asyncio +async def test_ip_locations_keeps_private_network_location_label(db_session, monkeypatch): + """局域网 IP 无省市信息时,属地统计仍应保留可展示标签。""" + await db_session.execute(text("DELETE FROM permission_access_logs")) + await db_session.commit() + + study_id = "00000000-0000-0000-0000-000000000601" + user_id = "00000000-0000-0000-0000-000000000701" + + await db_session.execute( + text( + """ + INSERT INTO studies (id, code, name, status, is_locked, visit_schedule, active_roles) + VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule, :active_roles) + """ + ), + { + "id": study_id, + "code": "IP-LOCATION-PRIVATE-STUDY", + "name": "IP Location Private Study", + "status": "ACTIVE", + "is_locked": False, + "visit_schedule": "[]", + "active_roles": "[]", + }, + ) + await db_session.execute( + text( + """ + INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status) + VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status) + """ + ), + { + "id": user_id, + "email": "private-network@example.com", + "password_hash": "hash", + "full_name": "局域网用户", + "role": "PM", + "clinical_department": "临床运营", + "status": "ACTIVE", + }, + ) + await db_session.execute( + text( + """ + INSERT INTO permission_access_logs + (id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, created_at) + VALUES + (:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, CURRENT_TIMESTAMP) + """ + ), + { + "id": str(uuid.uuid4()), + "study_id": study_id, + "user_id": user_id, + "endpoint_key": "admin.permissions.read", + "role": "PM", + "allowed": True, + "elapsed_ms": 3.2, + "ip_address": "192.168.1.10", + }, + ) + await db_session.commit() + + result = await permission_monitoring.get_ip_locations(db=db_session, _=object(), days=7, limit=10) + + assert result["items"][0]["location"] == "局域网" + assert result["items"][0]["province"] == "" + assert result["items"][0]["city"] == "" + + @pytest.mark.asyncio async def test_access_logs_include_user_behavior_summary(db_session): """访问日志应返回用户行为审计汇总和用户排行。"""