From ab59476d10b6d8c856b0cfcb9e65e9a5cb530944 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Mon, 13 Jul 2026 16:03:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=A1=8C=E9=9D=A2=E4=B8=8E=E7=9B=91?= =?UTF-8?q?=E6=8E=A7):=20=E5=AE=8C=E5=96=84=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E5=AF=BC=E8=88=AA=E5=92=8C=E7=99=BB=E5=BD=95=E6=B4=BB=E5=8A=A8?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验 - 补充登录会话 IP 采集、地理位置回退、管理端展示及数据库迁移 - 更新桌面发布检查、运维文档和前后端测试覆盖 --- .../20260713_01_add_login_session_ip.py | 25 + backend/app/api/v1/permission_monitoring.py | 33 +- backend/app/api/v1/users.py | 9 +- backend/app/core/config.py | 5 + backend/app/models/user_login_session.py | 3 +- backend/app/schemas/user.py | 4 + .../app/services/ip_geolocation_fallback.py | 174 ++++++ .../services/monitoring_server_location.py | 6 + .../services/source_location_aggregator.py | 24 +- backend/app/services/user_login_sessions.py | 33 + backend/tests/test_ip_geolocation_fallback.py | 58 ++ .../tests/test_monitoring_server_location.py | 43 ++ .../tests/test_permission_monitoring_api.py | 52 ++ backend/tests/test_user_login_sessions.py | 68 ++- docker-compose.yaml | 6 + ...desktop-release-stabilization-checklist.md | 26 +- docs/guides/client-release.md | 7 + docs/guides/system-monitoring-operations.md | 16 +- frontend/scripts/verify-desktop-release.mjs | 65 +- frontend/src-tauri/src/lib.rs | 321 +++++++++- frontend/src/App.vue | 5 +- frontend/src/api/users.ts | 2 +- .../AccountConnectionStatus.test.ts | 6 + .../components/AccountConnectionStatus.vue | 24 +- frontend/src/components/DesktopLayout.vue | 569 +++++++++++++++--- .../src/components/Layout.desktop.test.ts | 120 +++- .../components/PermissionAccessLogs.test.ts | 1 + .../src/components/PermissionAccessLogs.vue | 2 +- .../components/PermissionIpLocations.test.ts | 3 + .../src/components/PermissionIpLocations.vue | 5 +- frontend/src/components/WebLayout.vue | 2 +- .../layout/workspaceTabTitle.test.ts | 81 +++ .../components/layout/workspaceTabTitle.ts | 53 ++ frontend/src/runtime/desktopMenu.test.ts | 32 + frontend/src/runtime/desktopMenu.ts | 31 +- .../src/runtime/desktopUiPreferences.test.ts | 55 +- frontend/src/runtime/desktopUiPreferences.ts | 67 ++- frontend/src/runtime/index.ts | 7 + frontend/src/store/study.ts | 10 +- frontend/src/types/api.ts | 5 + frontend/src/views/DesktopPreferences.vue | 8 +- .../src/views/DesktopProjectEntry.test.ts | 4 + frontend/src/views/DesktopProjectEntry.vue | 7 +- frontend/src/views/ProfileSettings.vue | 21 +- frontend/src/views/WebWorkbenchEntry.test.ts | 4 + frontend/src/views/WebWorkbenchEntry.vue | 5 +- .../views/admin/UserLoginActivitiesDrawer.vue | 151 ++++- .../src/views/admin/UsersLoginStatus.test.ts | 17 +- .../views/documents/DocumentDetail.test.ts | 1 + .../src/views/documents/DocumentDetail.vue | 1 + 50 files changed, 2086 insertions(+), 191 deletions(-) create mode 100644 backend/alembic/versions/20260713_01_add_login_session_ip.py create mode 100644 backend/app/services/ip_geolocation_fallback.py create mode 100644 backend/tests/test_ip_geolocation_fallback.py create mode 100644 frontend/src/components/layout/workspaceTabTitle.test.ts create mode 100644 frontend/src/components/layout/workspaceTabTitle.ts create mode 100644 frontend/src/runtime/desktopMenu.test.ts diff --git a/backend/alembic/versions/20260713_01_add_login_session_ip.py b/backend/alembic/versions/20260713_01_add_login_session_ip.py new file mode 100644 index 00000000..d4f2087f --- /dev/null +++ b/backend/alembic/versions/20260713_01_add_login_session_ip.py @@ -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") diff --git a/backend/app/api/v1/permission_monitoring.py b/backend/app/api/v1/permission_monitoring.py index 102dced3..51e7aed8 100644 --- a/backend/app/api/v1/permission_monitoring.py +++ b/backend/app/api/v1/permission_monitoring.py @@ -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), }, diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index bbeee149..06553e0d 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -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) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 76f3b8e4..7ee125fe 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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) diff --git a/backend/app/models/user_login_session.py b/backend/app/models/user_login_session.py index f0d4b588..832bdeed 100644 --- a/backend/app/models/user_login_session.py +++ b/backend/app/models/user_login_session.py @@ -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) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 9a4c9ef7..435aa6eb 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -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) diff --git a/backend/app/services/ip_geolocation_fallback.py b/backend/app/services/ip_geolocation_fallback.py new file mode 100644 index 00000000..ca7f0f93 --- /dev/null +++ b/backend/app/services/ip_geolocation_fallback.py @@ -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() diff --git a/backend/app/services/monitoring_server_location.py b/backend/app/services/monitoring_server_location.py index 5ffb60b7..3a84eca0 100644 --- a/backend/app/services/monitoring_server_location.py +++ b/backend/app/services/monitoring_server_location.py @@ -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( diff --git a/backend/app/services/source_location_aggregator.py b/backend/app/services/source_location_aggregator.py index 188581b1..0613c4c0 100644 --- a/backend/app/services/source_location_aggregator.py +++ b/backend/app/services/source_location_aggregator.py @@ -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 diff --git a/backend/app/services/user_login_sessions.py b/backend/app/services/user_login_sessions.py index 924b6d98..e523217a 100644 --- a/backend/app/services/user_login_sessions.py +++ b/backend/app/services/user_login_sessions.py @@ -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, ) diff --git a/backend/tests/test_ip_geolocation_fallback.py b/backend/tests/test_ip_geolocation_fallback.py new file mode 100644 index 00000000..1b209317 --- /dev/null +++ b/backend/tests/test_ip_geolocation_fallback.py @@ -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() diff --git a/backend/tests/test_monitoring_server_location.py b/backend/tests/test_monitoring_server_location.py index df78c80d..cea407eb 100644 --- a/backend/tests/test_monitoring_server_location.py +++ b/backend/tests/test_monitoring_server_location.py @@ -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() diff --git a/backend/tests/test_permission_monitoring_api.py b/backend/tests/test_permission_monitoring_api.py index 60f7033b..dd33847b 100644 --- a/backend/tests/test_permission_monitoring_api.py +++ b/backend/tests/test_permission_monitoring_api.py @@ -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 属地统计不应因运营商不同拆分。""" diff --git a/backend/tests/test_user_login_sessions.py b/backend/tests/test_user_login_sessions.py index e1420621..2e950b6d 100644 --- a/backend/tests/test_user_login_sessions.py +++ b/backend/tests/test_user_login_sessions.py @@ -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) diff --git a/docker-compose.yaml b/docker-compose.yaml index 49da1b89..38dd88d7 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -38,8 +38,14 @@ services: MONITORING_RETENTION_INTERVAL_SECONDS: ${MONITORING_RETENTION_INTERVAL_SECONDS:-86400} USER_LOGIN_ACTIVITY_RETENTION_DAYS: ${USER_LOGIN_ACTIVITY_RETENTION_DAYS:-180} USER_SESSION_ONLINE_SECONDS: ${USER_SESSION_ONLINE_SECONDS:-300} + TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.1/32,::1/128,172.16.0.0/12,192.168.0.0/16} MONITORING_SERVER_PUBLIC_IP: ${MONITORING_SERVER_PUBLIC_IP:-} MONITORING_PUBLIC_IP_DISCOVERY_URLS: ${MONITORING_PUBLIC_IP_DISCOVERY_URLS:-https://api64.ipify.org,https://icanhazip.com} + MONITORING_IP_GEO_FALLBACK_ENABLED: ${MONITORING_IP_GEO_FALLBACK_ENABLED:-true} + MONITORING_IP_GEO_FALLBACK_API_KEY: ${MONITORING_IP_GEO_FALLBACK_API_KEY:-} + MONITORING_IP_GEO_FALLBACK_TIMEOUT_SECONDS: ${MONITORING_IP_GEO_FALLBACK_TIMEOUT_SECONDS:-2.5} + MONITORING_IP_GEO_FALLBACK_CACHE_SECONDS: ${MONITORING_IP_GEO_FALLBACK_CACHE_SECONDS:-604800} + MONITORING_IP_GEO_FALLBACK_MAX_LOOKUPS: ${MONITORING_IP_GEO_FALLBACK_MAX_LOOKUPS:-10} depends_on: db: condition: service_healthy diff --git a/docs/audits/desktop-release-stabilization-checklist.md b/docs/audits/desktop-release-stabilization-checklist.md index 27b54afa..96f00e7f 100644 --- a/docs/audits/desktop-release-stabilization-checklist.md +++ b/docs/audits/desktop-release-stabilization-checklist.md @@ -47,7 +47,11 @@ npm run desktop:build:app - [ ] Tauri capability 不包含 shell 权限、持久文件系统 scope 或宽泛目录读写。 - [ ] 文件系统与 opener scope 只允许 `$TEMP/ctms-desktop/**`。 - [ ] 单实例插件先于其他桌面插件注册。 -- [ ] Tauri command 白名单仅包含凭据和更新命令。 +- [ ] macOS 首个顶层 submenu 为应用菜单,包含关于、设置、服务、隐藏和退出;文件菜单保持独立。 +- [ ] macOS 红色按钮保持真正关闭窗口的系统语义,Dock/Finder reopen 事件在需要时重建、显示并聚焦主窗口。 +- [ ] 桌面快捷键只通过受控 command 同步到原生菜单,不向 WebView 开放原始窗口或菜单控制权限。 +- [ ] 桌面主题支持跟随系统、明亮和暗黑,并通过受控 command 同步窗口外观。 +- [ ] Tauri command 白名单仅包含凭据、更新和已审计的桌面 UI 窄命令。 - [ ] 前端源码不通过 query string 传递 token。 - [ ] `ctms_token` 只允许由 `secureSessionStorage` 处理。 - [ ] 登录表单密码不写入 `localStorage` 或 `sessionStorage`;Web 端只使用浏览器凭据管理能力,Desktop 端只使用系统凭据库。 @@ -91,6 +95,10 @@ npm run desktop:build:app | 系统通知拒绝 | 不适用 | 必测 | 开关回退,提示系统权限未开启 | | 通知领取与 ack | 不适用 | 必测 | 显示成功后 ack;失败等待租约重试 | | 单实例重复启动 | 不适用 | 必测 | 恢复、显示并聚焦主窗口 | +| 关闭窗口后 Dock 重开 | 不适用 | 必测 | 红色关闭按钮真正关闭主窗口;点击 Dock 后重建、显示并聚焦 | +| 原生应用菜单 | 不适用 | 必测 | CTMS、文件、编辑、显示、导航、窗口和帮助菜单顺序及职责符合 macOS 习惯 | +| 自定义快捷键同步 | 不适用 | 必测 | 设置修改后原生菜单立即显示并响应新的后退、前进和刷新快捷键 | +| 跟随系统主题 | 不适用 | 必测 | 系统明暗模式变化时 WebView、标题栏和系统控件同步更新 | | 自动更新检查 | 不适用 | 必测 | release 通道按当前 CTMS origin 派生清单 | | 更新稍后提醒 | 不适用 | 必测 | 同版本 24 小时内不重复提示 | | 更新安装失败 | 不适用 | 必测 | 不打断业务录入,显示可排障错误 | @@ -107,6 +115,8 @@ npm run desktop:build:app - [ ] 通知开关显示 OS 权限状态。 - [ ] 手动检查更新能反馈“已是最新版本”、未启用更新或检查失败。 - [ ] 关键弹窗、表单、按钮在最小窗口尺寸 `1180x760` 下不重叠、不溢出。 +- [ ] “设置…”位于 macOS CTMS 应用菜单并使用 `⌘,`;退出位于应用菜单而不是文件菜单。 +- [ ] “显示”和“导航”菜单中的快捷键与桌面偏好保存值一致。 - [ ] 更新弹窗只显示版本、发布日期和通用 release notes,不展示 token、下载链接或业务详情。 ## 5. 不允许项 @@ -231,3 +241,17 @@ npm run desktop:build:app - 在真实 macOS `.app` 中以 `1180x760` 检查登录页、服务器设置、个人中心、系统偏好和更新弹窗无重叠、无横向溢出。 - 在真实系统通知权限拒绝流程中确认权限状态提示、开关回退和错误提示与 OS 状态一致。 + +## 11. 2026-07-13 macOS 原生体验适配记录 + +本轮继续保留 Tauri 和共享 Vue 前端,不引入 Swift 业务 UI,也不改变 Windows 内测边界: + +- macOS 新增标准 CTMS 应用菜单,将关于、设置、服务、隐藏和退出等项目收敛到首个应用 submenu;文件、编辑、显示、导航、窗口和帮助菜单保持独立。 +- macOS 红色关闭按钮保持真正关闭主窗口的系统语义;Tauri `RunEvent::Reopen` 在 Dock/Finder 再激活时从受控配置重建、显示并聚焦主窗口,重复启动继续复用同一恢复 helper。 +- 新增受控 `desktop_menu_set_shortcuts` command,只接受后退、前进和刷新三项经过双层校验的快捷键,并同步原生菜单 accelerator。 +- 桌面主题新增“跟随系统”,WebView 监听 `prefers-color-scheme`,受控 `desktop_window_set_theme` command 同步 Tauri 窗口外观;未向 WebView 增加原始窗口权限。 +- `desktop:release:check` 已增加应用菜单、Dock reopen、关闭后重建、快捷键同步、系统主题和新增 command 白名单约束。 + +真实 `.app` 仍需人工验证:红色按钮真正关闭主窗口后 Dock 重建、菜单顺序、个人中心自身关闭按钮、编辑菜单文本输入行为、自定义快捷键即时同步,以及系统明暗模式切换时的标题栏一致性。 + +本轮未引入以下条件性 Swift 能力:Quick Look 需先确认附件审阅频率和 Windows 降级语义;Touch ID 会改变现有 30 天会话恢复体验,需先确定凭据访问控制策略;通知点击回跳需先定义固定、无敏感信息的动作和目标页面。三项均不是当前发布前置条件。 diff --git a/docs/guides/client-release.md b/docs/guides/client-release.md index 0af11e45..095929c9 100644 --- a/docs/guides/client-release.md +++ b/docs/guides/client-release.md @@ -30,12 +30,19 @@ The runtime contract currently provides: - file picker/save/open adapters - desktop system notification adapters - desktop updater adapters +- native desktop application menu and shortcut synchronization +- desktop window lifecycle and system appearance synchronization Business modules must use this public runtime entry point. They must not inspect Tauri globals or import Tauri packages directly. Native files, notifications, secure session storage, and automatic updates must remain behind `frontend/src/runtime/` and explicit capability flags. +Desktop menu accelerators and window appearance are synchronized through narrow +Tauri commands owned by `frontend/src/runtime/`. macOS close/reopen behavior is +handled by the Tauri event loop so a genuinely closed main window can be rebuilt from the Dock; +these concerns must not move into Vue business modules. + ## Version Change Update every client manifest with one command: diff --git a/docs/guides/system-monitoring-operations.md b/docs/guides/system-monitoring-operations.md index 9471be86..731018f7 100644 --- a/docs/guides/system-monitoring-operations.md +++ b/docs/guides/system-monitoring-operations.md @@ -33,13 +33,19 @@ docker compose ps MONITORING_ACCESS_LOG_RETENTION_DAYS=90 MONITORING_METRIC_RETENTION_DAYS=400 MONITORING_RETENTION_INTERVAL_SECONDS=86400 +MONITORING_IP_GEO_FALLBACK_ENABLED=true +MONITORING_IP_GEO_FALLBACK_TIMEOUT_SECONDS=2.5 +MONITORING_IP_GEO_FALLBACK_CACHE_SECONDS=604800 +MONITORING_IP_GEO_FALLBACK_MAX_LOOKUPS=10 USER_LOGIN_ACTIVITY_RETENTION_DAYS=180 USER_SESSION_ONLINE_SECONDS=300 ``` 访问日志留存范围为 7–3650 天,指标留存范围为 30–3650 天,清理周期范围为 60–604800 秒。修改后需重启后端。正式环境部署新版本前必须先执行 Alembic migration。 -账号管理页的“在线”状态由服务端会话心跳计算:最近 `USER_SESSION_ONLINE_SECONDS` 秒内成功心跳且未退出的会话视为在线。登录记录仅保存客户端类型、版本、登录/最近活动/退出时间;不保存 Token、密码或原始 IP。 +账号管理页的“在线”状态由服务端会话心跳计算:最近 `USER_SESSION_ONLINE_SECONDS` 秒内成功心跳且未退出的会话视为在线。登录记录保存客户端类型、版本、服务端观测到的登录 IP、登录/最近活动/退出时间;IP 属地由本地离线数据库按需解析,不发送到外部服务。登录记录接口仅限系统管理员访问并禁止 HTTP 缓存,不保存 Token 或密码,并与登录活动一起按 `USER_LOGIN_ACTIVITY_RETENTION_DAYS` 清理。 + +登录 IP 只接受可信反向代理提供的转发地址。Docker Compose 默认信任容器常用的 `172.16.0.0/12` 和 `192.168.0.0/16` 内部网段;其他部署必须通过 `TRUSTED_PROXY_CIDRS` 明确配置实际代理网段,不得直接信任任意来源的 `X-Forwarded-For`。 ## 来源地图服务器位置 @@ -49,7 +55,13 @@ USER_SESSION_ONLINE_SECONDS=300 2. `FRONTEND_PUBLIC_URL` 主机名解析出的公网 IP。 3. `MONITORING_PUBLIC_IP_DISCOVERY_URLS` 配置的公网出口 IP 查询服务。 -默认公网查询服务为 `https://api64.ipify.org,https://icanhazip.com`,单次超时 2.5 秒。解析成功后通过本地 ip2region 数据库确定属地,再由后端转换为地图坐标;解析失败时 API 返回 `server_location: null`,地图不会使用任意默认城市代替。 +默认公网查询服务为 `https://api64.ipify.org,https://icanhazip.com`,单次超时 2.5 秒。解析成功后通过本地 ip2region 数据库确定属地并转换为地图坐标;本地库没有坐标时复用下述 IP2Location.io 受控兜底。两条链路均失败时 API 返回 `server_location: null`,地图不会使用任意默认城市代替。默认打开全球视图,以便服务器与访问来源分属不同国家时仍能完整显示飞线。 + +## 访问来源坐标兜底 + +访问来源始终先使用本地 ip2region 和内置行政区质心解析。只有公网 IP 已识别、但本地链路无法得到地图坐标时,后端才调用 IPAddress.my 使用的 IP2Location.io 官方 JSON API `https://api.ip2location.io/` 补充经纬度;私网、回环、链路本地和无效地址不会发送给第三方。 + +兜底默认启用,单次请求最多查询 10 个尚未缓存的公网 IP,最多并发 5 个请求,超时 2.5 秒。成功结果缓存 7 天,失败结果缓存 1 小时;第三方不可用时继续返回本地解析结果,不影响访问来源接口。可设置 `MONITORING_IP_GEO_FALLBACK_ENABLED=false` 完全禁用外部查询。无密钥模式受服务方每日额度限制;如需配置 API Key,使用 `MONITORING_IP_GEO_FALLBACK_API_KEY`,后端通过 `Authorization: Bearer` 发送,禁止把密钥写入 URL 或日志。 受限网络建议显式配置: diff --git a/frontend/scripts/verify-desktop-release.mjs b/frontend/scripts/verify-desktop-release.mjs index f8e86541..3800f194 100644 --- a/frontend/scripts/verify-desktop-release.mjs +++ b/frontend/scripts/verify-desktop-release.mjs @@ -174,18 +174,56 @@ const verifyRustBoundary = async () => { singleInstanceIndex, dialogIndex > singleInstanceIndex ? dialogIndex : singleInstanceIndex + 600, ); + const restoreWindowStart = libSource.indexOf("fn restore_main_window"); + const restoreWindowEnd = libSource.indexOf("fn is_allowed_shortcut_key", restoreWindowStart); + const restoreWindowSource = libSource.slice(restoreWindowStart, restoreWindowEnd); const restoreWindowTokens = ['get_webview_window("main")', "window.unminimize()", "window.show()", "window.set_focus()"]; + assert(restoreWindowStart >= 0, "Desktop runtime must define a shared main-window restore helper."); + assert( + singleInstanceSource.includes("restore_main_window(app)"), + "Single-instance duplicate launch handler must use the shared main-window restore helper.", + ); for (const token of restoreWindowTokens) { - assert(singleInstanceSource.includes(token), `Single-instance duplicate launch handler must call ${token}.`); + assert(restoreWindowSource.includes(token), `Main-window restore helper must call ${token}.`); } assert( - singleInstanceSource.indexOf("window.unminimize()") < singleInstanceSource.indexOf("window.show()") && - singleInstanceSource.indexOf("window.show()") < singleInstanceSource.indexOf("window.set_focus()"), - "Single-instance duplicate launch handler must restore, show, then focus the main window.", + restoreWindowSource.indexOf("window.unminimize()") < restoreWindowSource.indexOf("window.show()") && + restoreWindowSource.indexOf("window.show()") < restoreWindowSource.indexOf("window.set_focus()"), + "Main-window restore helper must restore, show, then focus the main window.", ); + assert(libSource.includes("RunEvent::Reopen"), "macOS Dock reopen events must restore the main window."); + assert(libSource.includes("WebviewWindowBuilder::from_config"), "Dock reopen must rebuild a main window that was actually closed."); + assert(libSource.includes('find(|config| config.label == "main")'), "Main-window rebuild must use only the configured main window."); + assert(!libSource.includes("WindowEvent::CloseRequested"), "The macOS red close button must keep its native close-window semantics."); + assert(!libSource.includes("api.prevent_close()"), "The macOS red close button must not be converted into a hide action."); + assert(!libSource.includes("window.hide()"), "The macOS red close button must not hide the main window."); + + const appMenuIndex = libSource.indexOf('package.name.clone()'); + const fileMenuIndex = libSource.indexOf('"文件"'); + assert(appMenuIndex >= 0 && appMenuIndex < fileMenuIndex, "macOS application menu must precede the File menu."); + for (const token of [ + 'Some("关于 CTMS")', + "short_version: Some(String::new())", + "icon: handle.default_window_icon().cloned()", + '"ctms.desktop.preferences"', + "PredefinedMenuItem::services", + "PredefinedMenuItem::hide", + "PredefinedMenuItem::hide_others", + "PredefinedMenuItem::show_all", + "PredefinedMenuItem::quit", + "WINDOW_SUBMENU_ID", + "HELP_SUBMENU_ID", + "TOGGLE_SIDEBAR_COMMAND_ID", + '"显示/隐藏导航栏"', + ]) { + assert(libSource.includes(token), `Desktop menu must include ${token}.`); + } const handlerSource = libSource.match(/generate_handler!\s*\\?\[([\s\S]*?)\]/)?.[1] || ""; - const commands = handlerSource.match(/[a-z_]+::[a-z_]+/g) || []; + const commands = handlerSource + .split(",") + .map((command) => command.trim()) + .filter(Boolean); const allowedCommands = [ "credentials::credential_get", "credentials::credential_set", @@ -195,6 +233,8 @@ const verifyRustBoundary = async () => { "credentials::login_credential_delete", "updates::desktop_update_check", "updates::desktop_update_install", + "desktop_menu_set_shortcuts", + "desktop_window_set_theme", ]; const unexpected = commands.filter((command) => !allowedCommands.includes(command)); const missing = allowedCommands.filter((command) => !commands.includes(command)); @@ -202,6 +242,20 @@ const verifyRustBoundary = async () => { assert(missing.length === 0, `Missing expected Tauri commands: ${missing.join(", ") || ""}.`); }; +const verifyDesktopUiNativeSync = async () => { + const menuSource = await readFile(resolve(sourceDir, "runtime/desktopMenu.ts"), "utf8"); + const preferencesSource = await readFile(resolve(sourceDir, "runtime/desktopUiPreferences.ts"), "utf8"); + const appSource = await readFile(resolve(sourceDir, "App.vue"), "utf8"); + + assert(menuSource.includes('invoke("desktop_menu_set_shortcuts"'), "Desktop shortcuts must sync through the controlled Tauri command."); + assert(menuSource.includes("DESKTOP_SHORTCUTS_CHANGED_EVENT"), "Native menu shortcuts must track preference changes."); + assert(preferencesSource.includes('"system" | "light" | "dark"'), "Desktop theme must support following the system appearance."); + assert(preferencesSource.includes('invoke("desktop_window_set_theme"'), "Desktop window theme must sync through the controlled Tauri command."); + assert(preferencesSource.includes('matchMedia("(prefers-color-scheme: dark)")'), "System theme changes must update the WebView appearance."); + assert(appSource.includes("initializeDesktopThemePreference()"), "Desktop theme synchronization must initialize at app startup."); + assert(appSource.includes("initializeDesktopMenuShortcutSync()"), "Native menu shortcut synchronization must initialize at app startup."); +}; + const verifySourceSafety = async () => { const sourceExtensions = new Set([".ts", ".tsx", ".vue", ".js", ".jsx", ".rs"]); const files = [ @@ -358,6 +412,7 @@ const verifyWorkflowGates = async () => { await verifyTauriConfig(); await verifyCapabilities(); await verifyRustBoundary(); +await verifyDesktopUiNativeSync(); await verifySourceSafety(); await verifyNotificationBoundary(); await verifySessionBoundary(); diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index b073da28..23e14c7a 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -1,15 +1,90 @@ mod credentials; mod updates; -use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu}; -use tauri::{Emitter, Manager, Runtime}; +use std::collections::HashSet; + +use serde::Deserialize; +use tauri::menu::{ + AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID, +}; +#[cfg(target_os = "macos")] +use tauri::RunEvent; +use tauri::{Emitter, Manager, Runtime, Theme, WebviewWindow, WebviewWindowBuilder}; const DESKTOP_MENU_COMMAND_EVENT: &str = "ctms:desktop-menu-command"; +const VIEW_MENU_ID: &str = "ctms.menu.view"; +const NAVIGATION_MENU_ID: &str = "ctms.menu.navigation"; +const REFRESH_COMMAND_ID: &str = "ctms.desktop.refresh"; +const TOGGLE_SIDEBAR_COMMAND_ID: &str = "ctms.desktop.toggleSidebar"; +const BACK_COMMAND_ID: &str = "ctms.desktop.back"; +const FORWARD_COMMAND_ID: &str = "ctms.desktop.forward"; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DesktopMenuShortcuts { + back: String, + forward: String, + refresh: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "lowercase")] +enum DesktopThemePreference { + System, + Light, + Dark, +} fn desktop_menu(handle: &tauri::AppHandle) -> tauri::Result> { + let package = handle.package_info(); + let config = handle.config(); + let about = AboutMetadata { + name: Some(package.name.clone()), + version: Some(package.version.to_string()), + // macOS falls back to CFBundleVersion when this value is omitted, which + // renders duplicated text such as `Version 0.1.0 (0.1.0)`. + #[cfg(target_os = "macos")] + short_version: Some(String::new()), + copyright: config.bundle.copyright.clone(), + authors: config + .bundle + .publisher + .clone() + .map(|publisher| vec![publisher]), + // Supplying the icon explicitly keeps the native About panel from + // falling back to the generic macOS application/folder icon. + icon: handle.default_window_icon().cloned(), + ..Default::default() + }; + Menu::with_items( handle, &[ + #[cfg(target_os = "macos")] + &Submenu::with_items( + handle, + package.name.clone(), + true, + &[ + &PredefinedMenuItem::about(handle, Some("关于 CTMS"), Some(about.clone()))?, + &PredefinedMenuItem::separator(handle)?, + &MenuItem::with_id( + handle, + "ctms.desktop.preferences", + "设置…", + true, + Some("CmdOrCtrl+,"), + )?, + &PredefinedMenuItem::separator(handle)?, + &PredefinedMenuItem::services(handle, None)?, + &PredefinedMenuItem::separator(handle)?, + &PredefinedMenuItem::hide(handle, None)?, + &PredefinedMenuItem::hide_others(handle, None)?, + &PredefinedMenuItem::show_all(handle, None)?, + &PredefinedMenuItem::separator(handle)?, + &PredefinedMenuItem::quit(handle, None)?, + ], + )?, &Submenu::with_items( handle, "文件", @@ -22,15 +97,17 @@ fn desktop_menu(handle: &tauri::AppHandle) -> tauri::Result, + Some("CmdOrCtrl+,"), )?, &PredefinedMenuItem::separator(handle)?, &PredefinedMenuItem::close_window(handle, None)?, + #[cfg(not(target_os = "macos"))] &PredefinedMenuItem::quit(handle, None)?, ], )?, @@ -48,44 +125,49 @@ fn desktop_menu(handle: &tauri::AppHandle) -> tauri::Result, )?, + &PredefinedMenuItem::separator(handle)?, &PredefinedMenuItem::fullscreen(handle, None)?, ], )?, - &Submenu::with_items( + &Submenu::with_id_and_items( handle, + NAVIGATION_MENU_ID, "导航", true, &[ + &MenuItem::with_id(handle, BACK_COMMAND_ID, "后退", true, Some("CmdOrCtrl+["))?, &MenuItem::with_id( handle, - "ctms.desktop.back", - "返回", - true, - None::<&str>, - )?, - &MenuItem::with_id( - handle, - "ctms.desktop.forward", + FORWARD_COMMAND_ID, "前进", true, - None::<&str>, + Some("CmdOrCtrl+]"), )?, ], )?, - &Submenu::with_items( + &Submenu::with_id_and_items( handle, + WINDOW_SUBMENU_ID, "窗口", true, &[ @@ -94,17 +176,20 @@ fn desktop_menu(handle: &tauri::AppHandle) -> tauri::Result, )?, ], )?, @@ -118,8 +203,148 @@ fn emit_menu_command(app: &tauri::AppHandle, command: &str) { } } +fn restore_main_window(app: &tauri::AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + return Ok(()); + } + + let config = app + .config() + .app + .windows + .iter() + .find(|config| config.label == "main") + .cloned() + .ok_or_else(|| "未找到主窗口配置".to_string())?; + let window = WebviewWindowBuilder::from_config(app, &config) + .map_err(|error| format!("读取主窗口配置失败:{error}"))? + .build() + .map_err(|error| format!("重建主窗口失败:{error}"))?; + let _ = window.show(); + let _ = window.set_focus(); + Ok(()) +} + +fn is_allowed_shortcut_key(value: &str) -> bool { + matches!( + value, + "[" | "]" + | "," + | "." + | "/" + | ";" + | "=" + | "-" + | "ArrowLeft" + | "ArrowRight" + | "ArrowUp" + | "ArrowDown" + | "Home" + | "End" + | "PageUp" + | "PageDown" + ) || (value.len() == 1 + && value + .as_bytes() + .first() + .is_some_and(|value| value.is_ascii_uppercase() || value.is_ascii_digit())) +} + +fn normalize_menu_shortcut(value: &str) -> Result { + const RESERVED: &[&str] = &[ + "Mod+K", + "Mod+,", + "Mod+W", + "Mod+Q", + "Mod+C", + "Mod+V", + "Mod+X", + "Mod+A", + "Mod+Z", + "Mod+Shift+Z", + ]; + + if RESERVED.contains(&value) { + return Err("快捷键与系统或应用保留快捷键冲突".to_string()); + } + + let parts = value.split('+').collect::>(); + if parts.len() < 2 || parts[0] != "Mod" { + return Err("快捷键必须以 Mod 开头".to_string()); + } + + let key = parts.last().copied().unwrap_or_default(); + if !is_allowed_shortcut_key(key) { + return Err("快捷键按键不受支持".to_string()); + } + + let mut modifiers = HashSet::new(); + for modifier in &parts[1..parts.len() - 1] { + if !matches!(*modifier, "Shift" | "Alt") || !modifiers.insert(*modifier) { + return Err("快捷键修饰键不受支持或重复".to_string()); + } + } + + Ok(format!("CmdOrCtrl+{}", parts[1..].join("+"))) +} + +fn set_menu_accelerator( + app: &tauri::AppHandle, + submenu_id: &str, + item_id: &str, + accelerator: &str, +) -> Result<(), String> { + let menu = app.menu().ok_or_else(|| "桌面菜单尚未初始化".to_string())?; + let submenu = menu + .get(submenu_id) + .and_then(|item| item.as_submenu().cloned()) + .ok_or_else(|| format!("未找到桌面菜单:{submenu_id}"))?; + let item = submenu + .get(item_id) + .and_then(|item| item.as_menuitem().cloned()) + .ok_or_else(|| format!("未找到桌面菜单项:{item_id}"))?; + item.set_accelerator(Some(accelerator)) + .map_err(|error| format!("更新桌面菜单快捷键失败:{error}")) +} + +#[tauri::command] +fn desktop_menu_set_shortcuts( + app: tauri::AppHandle, + shortcuts: DesktopMenuShortcuts, +) -> Result<(), String> { + let back = normalize_menu_shortcut(&shortcuts.back)?; + let forward = normalize_menu_shortcut(&shortcuts.forward)?; + let refresh = normalize_menu_shortcut(&shortcuts.refresh)?; + if HashSet::from([back.as_str(), forward.as_str(), refresh.as_str()]).len() != 3 { + return Err("桌面快捷键不能重复".to_string()); + } + + set_menu_accelerator(&app, NAVIGATION_MENU_ID, BACK_COMMAND_ID, &back)?; + set_menu_accelerator(&app, NAVIGATION_MENU_ID, FORWARD_COMMAND_ID, &forward)?; + set_menu_accelerator(&app, VIEW_MENU_ID, REFRESH_COMMAND_ID, &refresh)?; + Ok(()) +} + +#[tauri::command] +fn desktop_window_set_theme( + window: WebviewWindow, + theme: DesktopThemePreference, +) -> Result<(), String> { + let theme = match theme { + DesktopThemePreference::System => None, + DesktopThemePreference::Light => Some(Theme::Light), + DesktopThemePreference::Dark => Some(Theme::Dark), + }; + window + .set_theme(theme) + .map_err(|error| format!("更新桌面窗口主题失败:{error}")) +} + pub fn run() { - tauri::Builder::default() + let app = tauri::Builder::default() .menu(desktop_menu) .on_menu_event(|app, event| { let command = event.id().as_ref(); @@ -128,11 +353,7 @@ pub fn run() { } }) .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { - if let Some(window) = app.get_webview_window("main") { - let _ = window.unminimize(); - let _ = window.show(); - let _ = window.set_focus(); - } + let _ = restore_main_window(app); })) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) @@ -149,7 +370,45 @@ pub fn run() { credentials::login_credential_delete, updates::desktop_update_check, updates::desktop_update_install, + desktop_menu_set_shortcuts, + desktop_window_set_theme, ]) - .run(tauri::generate_context!()) - .expect("error while running CTMS desktop application"); + .build(tauri::generate_context!()) + .expect("error while building CTMS desktop application"); + + app.run(|app, event| { + #[cfg(target_os = "macos")] + if let RunEvent::Reopen { .. } = event { + if let Err(error) = restore_main_window(app) { + eprintln!("{error}"); + } + } + #[cfg(not(target_os = "macos"))] + let _ = (app, event); + }); +} + +#[cfg(test)] +mod tests { + use super::normalize_menu_shortcut; + + #[test] + fn converts_runtime_shortcuts_to_native_accelerators() { + assert_eq!( + normalize_menu_shortcut("Mod+Shift+[").unwrap(), + "CmdOrCtrl+Shift+[" + ); + assert_eq!( + normalize_menu_shortcut("Mod+Alt+ArrowLeft").unwrap(), + "CmdOrCtrl+Alt+ArrowLeft" + ); + } + + #[test] + fn rejects_reserved_or_malformed_shortcuts() { + assert!(normalize_menu_shortcut("Mod+Q").is_err()); + assert!(normalize_menu_shortcut("Ctrl+R").is_err()); + assert!(normalize_menu_shortcut("Mod+Shift+Shift+R").is_err()); + assert!(normalize_menu_shortcut("Mod+F12").is_err()); + } } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 9720b6e7..73f89354 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -10,13 +10,14 @@ import { initSessionManager } from "./session/sessionManager"; import { initDesktopNotificationManager } from "./session/desktopNotificationManager"; import { initDesktopUpdateManager } from "./session/desktopUpdateManager"; -import { applyDesktopThemePreference, isTauriRuntime } from "./runtime"; +import { initializeDesktopMenuShortcutSync, initializeDesktopThemePreference, isTauriRuntime } from "./runtime"; import SessionTimeoutPrompt from "./components/SessionTimeoutPrompt.vue"; const isDesktopRuntime = isTauriRuntime(); if (isDesktopRuntime) { - applyDesktopThemePreference(); + initializeDesktopThemePreference(); + initializeDesktopMenuShortcutSync(); document.body.classList.add("is-desktop-runtime"); } else { document.body.classList.remove("is-desktop-runtime"); diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index ed08c339..68523987 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -22,7 +22,7 @@ export const updateUser = ( export const deleteUser = (userId: string) => apiDelete(`/api/v1/users/${userId}`, { suppressErrorMessage: true }); -export const fetchUserLoginActivities = (userId: string, limit = 30) => +export const fetchUserLoginActivities = (userId: string, limit = 100) => apiGet(`/api/v1/users/${userId}/login-activities`, { params: { limit }, }); diff --git a/frontend/src/components/AccountConnectionStatus.test.ts b/frontend/src/components/AccountConnectionStatus.test.ts index d2d59402..2aa8941b 100644 --- a/frontend/src/components/AccountConnectionStatus.test.ts +++ b/frontend/src/components/AccountConnectionStatus.test.ts @@ -14,6 +14,12 @@ describe("account connection status", () => { expect(component).toContain("当前账号与连接状态"); expect(component).toContain("API 延迟"); expect(component).toContain("会话状态"); + expect(component).toContain("useStudyStore"); + expect(component).toContain("getProjectRole(study.currentStudy, study.currentStudyRole)"); + expect(component).toContain('isSystemAdmin(auth.user) ? "系统角色" : "当前项目角色"'); + expect(component).toContain("projectRole.value ? displayRoleLabel(projectRole.value) : \"未选择项目\""); + expect(component).toContain("`${study.currentStudy.name} · ${roleLabel.value}`"); + expect(component).not.toContain('auth.user?.is_admin ? "系统管理员" : "普通账号"'); expect(component).toContain("mode === 'network'"); expect(component).toContain("当前 IP"); expect(component).toContain("clientIpLabel"); diff --git a/frontend/src/components/AccountConnectionStatus.vue b/frontend/src/components/AccountConnectionStatus.vue index add7dab4..49df76ef 100644 --- a/frontend/src/components/AccountConnectionStatus.vue +++ b/frontend/src/components/AccountConnectionStatus.vue @@ -59,8 +59,8 @@
-
角色
-
{{ roleLabel }}
+
{{ roleFieldLabel }}
+
{{ roleLabel }}
客户端
@@ -91,11 +91,14 @@ import { computed, onBeforeUnmount, onMounted, ref } from "vue"; import { useAuthStore } from "../store/auth"; import { useSessionStore } from "../store/session"; +import { useStudyStore } from "../store/study"; import { clientRuntime, getAppMetadata, isTauriRuntime } from "../runtime"; import { IDLE_TIMEOUT_MINUTES } from "../session/sessionManager"; import { parseJwtExp } from "../session/jwt"; import { useConnectionStatus } from "../composables/useConnectionStatus"; +import { useRoleTemplateMeta } from "../composables/useRoleTemplateMeta"; import { evaluateConnectionSecurity } from "../utils/connectionSecurity"; +import { getProjectRole, isSystemAdmin } from "../utils/roles"; withDefaults(defineProps<{ mode?: "indicator" | "details" | "network" }>(), { mode: "indicator", @@ -103,8 +106,10 @@ withDefaults(defineProps<{ mode?: "indicator" | "details" | "network" }>(), { const auth = useAuthStore(); const session = useSessionStore(); +const study = useStudyStore(); const isDesktop = isTauriRuntime(); const appMetadata = getAppMetadata(); +const { roleLabel: displayRoleLabel, loadRoleTemplates } = useRoleTemplateMeta(); const { status, statusLabel, @@ -125,7 +130,17 @@ const securityCheckExpanded = ref(true); const userDisplayName = computed( () => auth.user?.full_name || auth.user?.username || auth.user?.email || "当前用户", ); -const roleLabel = computed(() => (auth.user?.is_admin ? "系统管理员" : "普通账号")); +const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole)); +const roleFieldLabel = computed(() => (isSystemAdmin(auth.user) ? "系统角色" : "当前项目角色")); +const roleLabel = computed(() => { + if (isSystemAdmin(auth.user)) return "系统管理员"; + return projectRole.value ? displayRoleLabel(projectRole.value) : "未选择项目"; +}); +const roleContextTitle = computed(() => { + if (isSystemAdmin(auth.user)) return roleLabel.value; + if (!study.currentStudy) return "选择项目后显示该项目内的具体角色"; + return `${study.currentStudy.name} · ${roleLabel.value}`; +}); const clientLabel = computed(() => { if (!isDesktop) return "网页端"; const platform = appMetadata.platform === "macos" ? "macOS" : appMetadata.platform || "桌面系统"; @@ -195,6 +210,9 @@ const runSecurityCheck = async () => { }; onMounted(() => { + if (!isSystemAdmin(auth.user)) { + void loadRoleTemplates().catch(() => {}); + } stopConnectionMonitor = startConnectionMonitor(); nowTs.value = Date.now(); sessionClockTimer = window.setInterval(() => { diff --git a/frontend/src/components/DesktopLayout.vue b/frontend/src/components/DesktopLayout.vue index 926b6aff..75b18315 100644 --- a/frontend/src/components/DesktopLayout.vue +++ b/frontend/src/components/DesktopLayout.vue @@ -1,5 +1,12 @@