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
+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)