feat(监控): 完善系统监控、登录状态与访问审计能力
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

This commit is contained in:
Cheng Zhou
2026-07-10 17:11:24 +08:00
parent 400c9be3a7
commit f11a5c84d9
82 changed files with 10283 additions and 1781 deletions
+3 -1
View File
@@ -20,6 +20,7 @@
- `docker compose config` - `docker compose config`
- `curl -i http://127.0.0.1:8888/` - `curl -i http://127.0.0.1:8888/`
- `curl -i http://127.0.0.1:8888/health` - `curl -i http://127.0.0.1:8888/health`
- `curl -i http://127.0.0.1:8888/readyz`
## 账号与注册 ## 账号与注册
- 初始化管理员:`admin@huapont.cn / admin123`(通过生产初始化命令显式创建) - 初始化管理员:`admin@huapont.cn / admin123`(通过生产初始化命令显式创建)
@@ -38,7 +39,7 @@
## 访问方式 ## 访问方式
- 前端:`http://localhost:8888` - 前端:`http://localhost:8888`
- 后端 API:同域 `/api/v1/*` - 后端 API:同域 `/api/v1/*`
- `nginx` 负责托管前端静态资源,并将 `/api``/health` 转发到 `backend` - `nginx` 负责托管前端静态资源,并将 `/api``/health``/readyz` 转发到 `backend`
## macOS 桌面端开发 ## macOS 桌面端开发
- 桌面端约束集中在 `AGENTS.md`:Tauri 在线客户端,不内嵌后端、不保存本地业务权威数据、不做离线同步;在线辅助缓存必须遵守 `docs/desktop-local-cache-plan.md` - 桌面端约束集中在 `AGENTS.md`:Tauri 在线客户端,不内嵌后端、不保存本地业务权威数据、不做离线同步;在线辅助缓存必须遵守 `docs/desktop-local-cache-plan.md`
@@ -54,6 +55,7 @@
- 分支维护中文 SOP`docs/guides/branch-maintenance-sop-zh.md` - 分支维护中文 SOP`docs/guides/branch-maintenance-sop-zh.md`
- 分支环境安装配置:`docs/guides/branch-environment-installation.md` - 分支环境安装配置:`docs/guides/branch-environment-installation.md`
- 发布检查清单:`docs/guides/release-checklist.md` - 发布检查清单:`docs/guides/release-checklist.md`
- 系统监测简易运维:`docs/guides/system-monitoring-operations.md`
## 本地配置 ## 本地配置
- 本地编辑器配置(如 `.vscode/`)不纳入版本库。 - 本地编辑器配置(如 `.vscode/`)不纳入版本库。
@@ -0,0 +1,42 @@
"""add access context to permission access logs
Revision ID: 20260709_02
Revises: 20260709_01
Create Date: 2026-07-09 15:45:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260709_02"
down_revision: Union[str, None] = "20260709_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("permission_access_logs", sa.Column("user_agent", sa.String(500), nullable=True))
op.add_column("permission_access_logs", sa.Column("client_type", sa.String(16), nullable=True))
op.add_column("permission_access_logs", sa.Column("client_version", sa.String(32), nullable=True))
op.add_column("permission_access_logs", sa.Column("client_platform", sa.String(16), nullable=True))
op.add_column("permission_access_logs", sa.Column("build_channel", sa.String(16), nullable=True))
op.add_column("permission_access_logs", sa.Column("build_commit", sa.String(64), nullable=True))
op.create_index("ix_perm_log_ip_created", "permission_access_logs", ["ip_address", "created_at"])
op.create_index("ix_perm_log_client_source_created", "permission_access_logs", ["client_type", "created_at"])
def downgrade() -> None:
op.drop_index("ix_perm_log_client_source_created", table_name="permission_access_logs")
op.drop_index("ix_perm_log_ip_created", table_name="permission_access_logs")
for column in (
"build_commit",
"build_channel",
"client_platform",
"client_version",
"client_type",
"user_agent",
):
op.drop_column("permission_access_logs", column)
@@ -0,0 +1,35 @@
"""add request headers to permission access logs
Revision ID: 20260709_03
Revises: 20260709_02
Create Date: 2026-07-09 16:58:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "20260709_03"
down_revision: Union[str, None] = "20260709_02"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
REQUEST_HEADERS_TYPE = sa.JSON().with_variant(
postgresql.JSONB(astext_type=sa.Text()),
"postgresql",
)
def upgrade() -> None:
op.add_column(
"permission_access_logs",
sa.Column("request_headers", REQUEST_HEADERS_TYPE, nullable=True),
)
def downgrade() -> None:
op.drop_column("permission_access_logs", "request_headers")
@@ -0,0 +1,35 @@
"""add request headers to security access logs
Revision ID: 20260709_04
Revises: 20260709_03
Create Date: 2026-07-09 17:30:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "20260709_04"
down_revision: Union[str, None] = "20260709_03"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
REQUEST_HEADERS_TYPE = sa.JSON().with_variant(
postgresql.JSONB(astext_type=sa.Text()),
"postgresql",
)
def upgrade() -> None:
op.add_column(
"security_access_logs",
sa.Column("request_headers", REQUEST_HEADERS_TYPE, nullable=True),
)
def downgrade() -> None:
op.drop_column("security_access_logs", "request_headers")
@@ -0,0 +1,40 @@
"""add request snapshots to access logs
Revision ID: 20260709_05
Revises: 20260709_04
Create Date: 2026-07-09 18:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "20260709_05"
down_revision: Union[str, None] = "20260709_04"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
REQUEST_SNAPSHOT_TYPE = sa.JSON().with_variant(
postgresql.JSONB(astext_type=sa.Text()),
"postgresql",
)
def upgrade() -> None:
op.add_column(
"permission_access_logs",
sa.Column("request_snapshot", REQUEST_SNAPSHOT_TYPE, nullable=True),
)
op.add_column(
"security_access_logs",
sa.Column("request_snapshot", REQUEST_SNAPSHOT_TYPE, nullable=True),
)
def downgrade() -> None:
op.drop_column("security_access_logs", "request_snapshot")
op.drop_column("permission_access_logs", "request_snapshot")
@@ -0,0 +1,67 @@
"""add monitoring event identity and persisted security classification
Revision ID: 20260710_01
Revises: 20260709_05
Create Date: 2026-07-10 09:30:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260710_01"
down_revision: Union[str, None] = "20260709_05"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("permission_access_logs", sa.Column("request_id", sa.String(36), nullable=True))
op.add_column("security_access_logs", sa.Column("request_id", sa.String(36), nullable=True))
op.add_column("security_access_logs", sa.Column("category", sa.String(30), nullable=True))
op.add_column("security_access_logs", sa.Column("severity", sa.String(16), nullable=True))
op.create_index("ix_perm_log_request_id", "permission_access_logs", ["request_id"])
op.create_index("ix_security_log_request_id", "security_access_logs", ["request_id"])
op.create_index("ix_security_log_category_created", "security_access_logs", ["category", "created_at"])
op.create_index("ix_security_log_severity_created", "security_access_logs", ["severity", "created_at"])
op.execute(
"""
UPDATE security_access_logs
SET category = CASE
WHEN lower(path) LIKE '%/.env%' OR lower(path) LIKE '%.git%'
OR lower(path) LIKE '%backup%' OR lower(path) LIKE '%config.php%'
OR lower(path) LIKE '%wp-config%' OR lower(path) LIKE '%database.yml%'
THEN 'PROBE'
WHEN status_code >= 500 THEN 'SERVER_ERROR'
WHEN auth_status = 'INVALID_TOKEN' THEN 'INVALID_TOKEN'
WHEN auth_status = 'ANONYMOUS' AND status_code IN (401, 403) THEN 'ANONYMOUS_API'
WHEN status_code = 404 THEN 'NOT_FOUND_NOISE'
ELSE 'OTHER'
END,
severity = CASE
WHEN lower(path) LIKE '%/.env%' OR lower(path) LIKE '%.git%'
OR lower(path) LIKE '%backup%' OR lower(path) LIKE '%config.php%'
OR lower(path) LIKE '%wp-config%' OR lower(path) LIKE '%database.yml%'
THEN 'CRITICAL'
WHEN status_code >= 500 THEN 'HIGH'
WHEN auth_status = 'INVALID_TOKEN' THEN 'MEDIUM'
WHEN auth_status = 'ANONYMOUS' AND status_code IN (401, 403) THEN 'MEDIUM'
ELSE 'LOW'
END
"""
)
def downgrade() -> None:
op.drop_index("ix_security_log_severity_created", table_name="security_access_logs")
op.drop_index("ix_security_log_category_created", table_name="security_access_logs")
op.drop_index("ix_security_log_request_id", table_name="security_access_logs")
op.drop_index("ix_perm_log_request_id", table_name="permission_access_logs")
op.drop_column("security_access_logs", "severity")
op.drop_column("security_access_logs", "category")
op.drop_column("security_access_logs", "request_id")
op.drop_column("permission_access_logs", "request_id")
@@ -0,0 +1,93 @@
"""add source location hourly snapshots
Revision ID: 20260710_02
Revises: 20260710_01
Create Date: 2026-07-10 15:30:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260710_02"
down_revision: Union[str, None] = "20260710_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"source_location_snapshots",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("bucket_time", sa.DateTime(timezone=True), nullable=False),
sa.Column("ip_hash", sa.String(length=64), nullable=False),
sa.Column("user_hash", sa.String(length=64), nullable=False, server_default=""),
sa.Column("country", sa.String(length=100), nullable=False, server_default=""),
sa.Column("country_code", sa.String(length=16), nullable=False, server_default=""),
sa.Column("province", sa.String(length=100), nullable=False, server_default=""),
sa.Column("region_code", sa.String(length=24), nullable=False, server_default=""),
sa.Column("city", sa.String(length=100), nullable=False, server_default=""),
sa.Column("isp", sa.String(length=160), nullable=False, server_default=""),
sa.Column("location", sa.String(length=320), nullable=False, server_default=""),
sa.Column("longitude", sa.Float(), nullable=True),
sa.Column("latitude", sa.Float(), nullable=True),
sa.Column("accuracy_level", sa.String(length=16), nullable=False, server_default="unknown"),
sa.Column("allowed_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("denied_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("security_event_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("high_risk_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("auth_failure_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("bucket_time", "ip_hash", "user_hash", name="uq_source_location_bucket_identity"),
)
op.create_index("ix_source_location_bucket", "source_location_snapshots", ["bucket_time"])
op.create_index(
"ix_source_location_country_bucket",
"source_location_snapshots",
["country_code", "bucket_time"],
)
op.create_index(
"ix_source_location_risk_bucket",
"source_location_snapshots",
["high_risk_count", "bucket_time"],
)
op.execute(
"""
UPDATE security_access_logs
SET category = CASE
WHEN lower(path) LIKE '%/.env%' OR lower(path) LIKE '%.git%'
OR lower(path) LIKE '%backup%' OR lower(path) LIKE '%config.php%'
OR lower(path) LIKE '%wp-config%' OR lower(path) LIKE '%database.yml%'
THEN 'PROBE'
WHEN status_code >= 500 THEN 'SERVER_ERROR'
WHEN auth_status = 'INVALID_TOKEN' THEN 'INVALID_TOKEN'
WHEN auth_status = 'ANONYMOUS' AND status_code IN (401, 403) THEN 'ANONYMOUS_API'
WHEN status_code = 404 THEN 'NOT_FOUND_NOISE'
ELSE 'OTHER'
END,
severity = CASE
WHEN lower(path) LIKE '%/.env%' OR lower(path) LIKE '%.git%'
OR lower(path) LIKE '%backup%' OR lower(path) LIKE '%config.php%'
OR lower(path) LIKE '%wp-config%' OR lower(path) LIKE '%database.yml%'
THEN 'CRITICAL'
WHEN status_code >= 500 THEN 'HIGH'
WHEN auth_status = 'INVALID_TOKEN' THEN 'MEDIUM'
WHEN auth_status = 'ANONYMOUS' AND status_code IN (401, 403) THEN 'MEDIUM'
ELSE 'LOW'
END
WHERE category = 'ABNORMAL_IP'
"""
)
def downgrade() -> None:
op.drop_index("ix_source_location_risk_bucket", table_name="source_location_snapshots")
op.drop_index("ix_source_location_country_bucket", table_name="source_location_snapshots")
op.drop_index("ix_source_location_bucket", table_name="source_location_snapshots")
op.drop_table("source_location_snapshots")
@@ -0,0 +1,30 @@
"""Remove deployment-specific coordinates from private source snapshots.
Revision ID: 20260710_03
Revises: 20260710_02
"""
from alembic import op
revision = "20260710_03"
down_revision = "20260710_02"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"""
UPDATE source_location_snapshots
SET longitude = NULL,
latitude = NULL
WHERE accuracy_level = 'private'
"""
)
def downgrade() -> None:
# The previous coordinates were a hardcoded deployment assumption and
# cannot be restored without reintroducing incorrect data.
pass
@@ -0,0 +1,40 @@
"""Add user login session activity records.
Revision ID: 20260710_04
Revises: 20260710_03
"""
import sqlalchemy as sa
from alembic import op
revision = "20260710_04"
down_revision = "20260710_03"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"user_login_sessions",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("client_type", sa.String(length=16), nullable=False, server_default="web"),
sa.Column("client_platform", sa.String(length=32), nullable=True),
sa.Column("client_version", sa.String(length=64), nullable=True),
sa.Column("client_source", sa.String(length=32), nullable=True),
sa.Column("login_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("end_reason", sa.String(length=32), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_user_login_sessions_user_login", "user_login_sessions", ["user_id", "login_at"])
op.create_index("ix_user_login_sessions_last_seen", "user_login_sessions", ["last_seen_at"])
def downgrade() -> None:
op.drop_index("ix_user_login_sessions_last_seen", table_name="user_login_sessions")
op.drop_index("ix_user_login_sessions_user_login", table_name="user_login_sessions")
op.drop_table("user_login_sessions")
+57 -5
View File
@@ -9,7 +9,8 @@ import uuid
from app.core.config import settings from app.core.config import settings
from app.core.login_crypto import create_login_challenge, decrypt_login_payload, get_public_key_pem from app.core.login_crypto import create_login_challenge, decrypt_login_payload, get_public_key_pem
from app.core.security import create_access_token, decode_token_allow_expired, oauth2_scheme, verify_password from app.core.request_context import resolve_ctms_client_type
from app.core.security import create_access_token, decode_token, decode_token_allow_expired, oauth2_scheme, verify_password
from app.core.deps import get_current_user, get_db_session from app.core.deps import get_current_user, get_db_session
from app.crud import user as user_crud from app.crud import user as user_crud
from app.models.user import UserStatus from app.models.user import UserStatus
@@ -26,6 +27,12 @@ from app.schemas.email_settings import (
) )
from app.schemas.user import Token, UserRead, UserRegisterRequest, UserSelfUpdate, UserUpdate from app.schemas.user import Token, UserRead, UserRegisterRequest, UserSelfUpdate, UserUpdate
from app.services import email_service from app.services import email_service
from app.services.user_login_sessions import (
create_login_session,
end_login_session,
session_id_from_payload,
touch_login_session,
)
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
@@ -95,7 +102,7 @@ def get_session_policy_for_client_type(client_type: str) -> SessionPolicy:
def get_request_session_client_type(request: Request) -> str: def get_request_session_client_type(request: Request) -> str:
return normalize_session_client_type(request.headers.get("x-ctms-client-type")) return normalize_session_client_type(resolve_ctms_client_type(request.headers))
def policy_expires_at(issued_at: datetime, session_start: datetime, policy: SessionPolicy) -> datetime: def policy_expires_at(issued_at: datetime, session_start: datetime, policy: SessionPolicy) -> datetime:
@@ -104,8 +111,9 @@ def policy_expires_at(issued_at: datetime, session_start: datetime, policy: Sess
return min(access_expires_at, session_expires_at) return min(access_expires_at, session_expires_at)
def issue_user_token(db_user, request: Request) -> Token: async def issue_user_token(db_user, request: Request, db: AsyncSession) -> Token:
session_start = datetime.now(timezone.utc) session_start = datetime.now(timezone.utc)
session_id = uuid.uuid4()
client_type = get_request_session_client_type(request) client_type = get_request_session_client_type(request)
policy = get_session_policy_for_client_type(client_type) policy = get_session_policy_for_client_type(client_type)
access_token = create_access_token( access_token = create_access_token(
@@ -115,6 +123,14 @@ def issue_user_token(db_user, request: Request) -> Token:
max_age_seconds=policy.absolute_max_seconds, max_age_seconds=policy.absolute_max_seconds,
issued_at=session_start, issued_at=session_start,
client_type=client_type, client_type=client_type,
session_id=str(session_id),
)
await create_login_session(
db,
session_id=session_id,
user_id=db_user.id,
request=request,
login_at=session_start,
) )
return Token(access_token=access_token, token_type="bearer") return Token(access_token=access_token, token_type="bearer")
@@ -284,7 +300,7 @@ async def login_for_access_token(
db_user = await authenticate_encrypted_password(payload, db) db_user = await authenticate_encrypted_password(payload, db)
ensure_user_active(db_user) ensure_user_active(db_user)
return issue_user_token(db_user, request) return await issue_user_token(db_user, request, db)
@router.post("/dev-login", response_model=Token) @router.post("/dev-login", response_model=Token)
@@ -295,7 +311,7 @@ async def dev_login_for_access_token(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
db_user = await authenticate_plain_password(payload, db) db_user = await authenticate_plain_password(payload, db)
ensure_user_active(db_user) ensure_user_active(db_user)
return issue_user_token(db_user, request) return await issue_user_token(db_user, request, db)
@router.get("/me", response_model=UserRead) @router.get("/me", response_model=UserRead)
@@ -339,11 +355,47 @@ async def extend_access_token(
max_age_seconds=policy.absolute_max_seconds, max_age_seconds=policy.absolute_max_seconds,
issued_at=issued_at, issued_at=issued_at,
client_type=normalize_session_client_type(payload.get("client_type")), client_type=normalize_session_client_type(payload.get("client_type")),
session_id=str(session_id_from_payload(payload)),
) )
expires_at = policy_expires_at(issued_at, session_start, policy) expires_at = policy_expires_at(issued_at, session_start, policy)
return ExtendResponse(accessToken=new_token, expiresAt=expires_at) return ExtendResponse(accessToken=new_token, expiresAt=expires_at)
@router.post("/session/heartbeat")
async def heartbeat_login_session(
request: Request,
token: str = Depends(oauth2_scheme),
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
) -> dict:
session = await touch_login_session(
db,
user_id=current_user.id,
payload=decode_token(token),
request=request,
)
if session is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录会话已结束")
return {
"status": "online",
"last_seen_at": session.last_seen_at.isoformat(),
}
@router.post("/session/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout_login_session(
token: str = Depends(oauth2_scheme),
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
) -> Response:
await end_login_session(
db,
user_id=current_user.id,
payload=decode_token(token),
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.patch("/me", response_model=UserRead) @router.patch("/me", response_model=UserRead)
async def update_me( async def update_me(
payload: UserSelfUpdate, payload: UserSelfUpdate,
File diff suppressed because it is too large Load Diff
+31 -2
View File
@@ -9,7 +9,8 @@ from app.schemas.common import PaginatedResponse
from app.crud import user as user_crud from app.crud import user as user_crud
from app.crud import member as member_crud from app.crud import member as member_crud
from app.utils.pagination import paginate from app.utils.pagination import paginate
from app.schemas.user import UserCreate, UserRead, UserStatus, UserUpdate from app.schemas.user import UserCreate, UserLoginActivityRead, UserRead, UserStatus, UserUpdate
from app.services.user_login_sessions import get_login_summaries, list_login_activities
router = APIRouter() router = APIRouter()
@@ -25,7 +26,35 @@ async def list_users(
) -> PaginatedResponse[UserRead]: ) -> PaginatedResponse[UserRead]:
users = await user_crud.list_users(db, skip=skip, limit=limit, keyword=keyword, status=user_status) users = await user_crud.list_users(db, skip=skip, limit=limit, keyword=keyword, status=user_status)
total_users = await user_crud.count_users(db, keyword=keyword, status=user_status) total_users = await user_crud.count_users(db, keyword=keyword, status=user_status)
return paginate(list(users), total=total_users) summaries = await get_login_summaries(db, [user.id for user in users])
items = []
for user in users:
summary = summaries.get(user.id)
item = UserRead.model_validate(user).model_dump()
if summary:
item.update(
{
"login_status": summary.status,
"last_login_at": summary.last_login_at,
"last_seen_at": summary.last_seen_at,
"last_client_type": summary.client_type,
"active_session_count": summary.active_session_count,
}
)
items.append(item)
return paginate(items, total=total_users)
@router.get("/{user_id}/login-activities", response_model=list[UserLoginActivityRead])
async def read_user_login_activities(
user_id: uuid.UUID,
limit: int = Query(default=30, ge=1, le=100),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
) -> list[UserLoginActivityRead]:
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)
@router.post("/", response_model=UserRead, status_code=status.HTTP_201_CREATED) @router.post("/", response_model=UserRead, status_code=status.HTTP_201_CREATED)
+12
View File
@@ -33,6 +33,18 @@ class Settings(BaseSettings):
) )
IP2REGION_XDB_PATH: Optional[str] = None IP2REGION_XDB_PATH: Optional[str] = None
IP2REGION_IPV6_XDB_PATH: Optional[str] = None IP2REGION_IPV6_XDB_PATH: Optional[str] = None
TRUSTED_PROXY_CIDRS: str = "127.0.0.1/32,::1/128,172.16.0.0/12"
MONITORING_SERVER_PUBLIC_IP: Optional[str] = None
MONITORING_PUBLIC_IP_DISCOVERY_URLS: str = (
"https://api64.ipify.org,https://icanhazip.com"
)
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_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)
USER_LOGIN_ACTIVITY_RETENTION_DAYS: int = Field(default=180, ge=30, le=3650)
USER_SESSION_ONLINE_SECONDS: int = Field(default=300, ge=60, le=3600)
@lru_cache @lru_cache
+13 -3
View File
@@ -286,8 +286,9 @@ def _enqueue_permission_log(
writer = get_log_writer() writer = get_log_writer()
if writer: if writer:
forwarded = request.headers.get("x-forwarded-for") from app.core.request_context import build_request_audit_context, get_request_audit_context
ip = forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else None)
context = get_request_audit_context() or build_request_audit_context(request)
writer.enqueue({ writer.enqueue({
"study_id": study_id, "study_id": study_id,
"user_id": user_id, "user_id": user_id,
@@ -295,7 +296,16 @@ def _enqueue_permission_log(
"role": role, "role": role,
"allowed": allowed, "allowed": allowed,
"elapsed_ms": elapsed_ms, "elapsed_ms": elapsed_ms,
"ip_address": ip, "ip_address": context.client_ip,
"user_agent": context.user_agent,
"client_type": context.client_type,
"client_version": context.client_version,
"client_platform": context.client_platform,
"build_channel": context.build_channel,
"build_commit": context.build_commit,
"request_headers": context.request_headers,
"request_snapshot": context.request_snapshot,
"request_id": context.request_id,
}) })
+187 -6
View File
@@ -2,14 +2,19 @@
from __future__ import annotations from __future__ import annotations
import ipaddress
import re import re
import uuid
from contextvars import ContextVar, Token from contextvars import ContextVar, Token
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from app.core.config import settings
@dataclass(frozen=True) @dataclass(frozen=True)
class RequestAuditContext: class RequestAuditContext:
request_id: str | None = None
client_ip: str | None = None client_ip: str | None = None
user_agent: str | None = None user_agent: str | None = None
client_type: str | None = None client_type: str | None = None
@@ -17,6 +22,8 @@ class RequestAuditContext:
client_platform: str | None = None client_platform: str | None = None
build_channel: str | None = None build_channel: str | None = None
build_commit: str | None = None build_commit: str | None = None
request_headers: dict[str, str] | None = None
request_snapshot: dict[str, Any] | None = None
_request_audit_context: ContextVar[RequestAuditContext | None] = ContextVar( _request_audit_context: ContextVar[RequestAuditContext | None] = ContextVar(
@@ -25,11 +32,37 @@ _request_audit_context: ContextVar[RequestAuditContext | None] = ContextVar(
) )
_SENSITIVE_TEXT_PATTERN = re.compile( _SENSITIVE_TEXT_PATTERN = re.compile(
r"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]+|((?:access_)?token|authorization)=([^&\s]+)" r"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]+|((?:access_)?token|authorization|password|passwd|secret|credential|api[-_]?key|session)=([^&\s]+)"
) )
_SENSITIVE_HEADER_NAME_PATTERN = re.compile(
r"(?i)(authorization|cookie|set-cookie|token|password|passwd|secret|credential|api[-_]?key|session)"
)
_SAFE_REQUEST_HEADER_NAMES = frozenset(
{
"accept",
"accept-language",
"content-type",
"host",
"origin",
"user-agent",
"x-request-id",
"x-correlation-id",
}
)
_SAFE_REQUEST_HEADER_PREFIXES = ("x-ctms-",)
_SENSITIVE_PARAMETER_NAME_PATTERN = re.compile(
r"(?i)(token|authorization|password|passwd|secret|credential|api[-_]?key|session|"
r"subject|participant|patient|user_?name|full_?name|email|phone|mobile|id_?card|"
r"identity|certificate|contact|address)"
)
_KNOWN_CLIENT_TYPES = frozenset({"web", "desktop"})
_CTMS_CLIENT_SOURCE_TO_TYPE = {
"ctms-web": "web",
"ctms-desktop": "desktop",
}
def _clean_header_value(value: str | None, max_length: int) -> str | None: def _clean_audit_value(value: str | None, max_length: int) -> str | None:
if value is None: if value is None:
return None return None
cleaned = _SENSITIVE_TEXT_PATTERN.sub(lambda m: f"{m.group(1) or m.group(2) + '='}[redacted]", value.strip()) cleaned = _SENSITIVE_TEXT_PATTERN.sub(lambda m: f"{m.group(1) or m.group(2) + '='}[redacted]", value.strip())
@@ -38,26 +71,174 @@ def _clean_header_value(value: str | None, max_length: int) -> str | None:
return cleaned[:max_length] return cleaned[:max_length]
def _clean_header_value(value: str | None, max_length: int) -> str | None:
return _clean_audit_value(value, max_length)
def resolve_ctms_client_type(headers: Any) -> str | None:
source = _clean_header_value(headers.get("x-ctms-client-source"), 32)
if source:
mapped_type = _CTMS_CLIENT_SOURCE_TO_TYPE.get(source.strip().lower())
if mapped_type:
return mapped_type
client_type = _clean_header_value(headers.get("x-ctms-client-type"), 16)
if not client_type:
return None
normalized = client_type.strip().lower()
return normalized if normalized in _KNOWN_CLIENT_TYPES else normalized[:16]
def build_sanitized_request_headers(request: Any) -> dict[str, str] | None:
captured: dict[str, str] = {}
for raw_name, raw_value in request.headers.items():
name = str(raw_name).strip().lower()
if not name:
continue
if _SENSITIVE_HEADER_NAME_PATTERN.search(name):
captured[name] = "[redacted]"
continue
if name not in _SAFE_REQUEST_HEADER_NAMES and not name.startswith(_SAFE_REQUEST_HEADER_PREFIXES):
continue
value = _clean_header_value(str(raw_value), 500)
if value:
captured[name] = value
return captured or None
def _sanitize_named_value(raw_name: Any, raw_value: Any, max_length: int = 500) -> dict[str, str] | None:
name = _clean_audit_value(str(raw_name), 120)
if not name:
return None
if _SENSITIVE_PARAMETER_NAME_PATTERN.search(name):
return {"name": name, "value": "[redacted]"}
value = _clean_audit_value(str(raw_value), max_length)
if value is None:
return {"name": name, "value": ""}
return {"name": name, "value": value}
def _query_param_items(request: Any) -> list[dict[str, str]] | None:
query_params = getattr(request, "query_params", None)
if not query_params:
return None
if hasattr(query_params, "multi_items"):
raw_items = query_params.multi_items()
else:
raw_items = query_params.items()
items = [
item
for item in (_sanitize_named_value(name, value) for name, value in raw_items)
if item is not None
][:50]
return items or None
def _sanitized_query_string(items: list[dict[str, str]] | None) -> str | None:
if not items:
return None
value = "&".join(f"{item['name']}={item['value']}" for item in items)
return value[:2000] or None
def _request_client_snapshot(request: Any) -> dict[str, str | int | None] | None:
client = getattr(request, "client", None)
if not client:
return None
host = _clean_audit_value(getattr(client, "host", None), 120)
port = getattr(client, "port", None)
if host is None and port is None:
return None
return {"host": host, "port": port}
def build_request_snapshot(request: Any, *, request_id: str | None = None) -> dict[str, Any]:
headers = getattr(request, "headers", {})
scope = getattr(request, "scope", {}) or {}
url = getattr(request, "url", None)
query_params = _query_param_items(request)
path = getattr(url, "path", None) or scope.get("path")
method = getattr(request, "method", None) or scope.get("method")
snapshot: dict[str, Any] = {
"request_id": request_id,
"method": _clean_audit_value(str(method).upper() if method else None, 12),
"path": _clean_audit_value(path, 500),
"query_string": _sanitized_query_string(query_params),
"query_params": query_params,
"headers": build_sanitized_request_headers(request),
"http_version": _clean_audit_value(scope.get("http_version"), 16),
"scheme": _clean_audit_value(getattr(url, "scheme", None) or scope.get("scheme"), 16),
"client": _request_client_snapshot(request),
"content": {
"type": _clean_audit_value(headers.get("content-type"), 200),
"length": _clean_audit_value(headers.get("content-length"), 32),
},
"body": {
"captured": False,
"reason": "body_not_captured_by_audit_policy",
},
}
return {key: value for key, value in snapshot.items() if value not in (None, {}, [])}
def _parse_ip(value: str | None):
try:
return ipaddress.ip_address((value or "").strip())
except ValueError:
return None
def _trusted_proxy_networks():
networks = []
for value in settings.TRUSTED_PROXY_CIDRS.split(","):
candidate = value.strip()
if not candidate:
continue
try:
networks.append(ipaddress.ip_network(candidate, strict=False))
except ValueError:
continue
return tuple(networks)
def _is_trusted_proxy(value: str | None) -> bool:
address = _parse_ip(value)
return bool(address and any(address in network for network in _trusted_proxy_networks()))
def resolve_client_ip(request: Any) -> str | None: def resolve_client_ip(request: Any) -> str | None:
peer_ip = request.client.host if request.client else None
if not _is_trusted_proxy(peer_ip):
return _clean_header_value(peer_ip, 45)
forwarded = request.headers.get("x-forwarded-for") forwarded = request.headers.get("x-forwarded-for")
if forwarded: if forwarded:
return _clean_header_value(forwarded.split(",")[0].strip(), 45) chain = [part.strip() for part in forwarded.split(",") if _parse_ip(part)]
chain.append(str(peer_ip))
for candidate in reversed(chain):
if not _is_trusted_proxy(candidate):
return _clean_header_value(candidate, 45)
real_ip = request.headers.get("x-real-ip") real_ip = request.headers.get("x-real-ip")
if real_ip: if _parse_ip(real_ip):
return _clean_header_value(real_ip.strip(), 45) return _clean_header_value(real_ip.strip(), 45)
return _clean_header_value(request.client.host if request.client else None, 45) return _clean_header_value(peer_ip, 45)
def build_request_audit_context(request: Any) -> RequestAuditContext: def build_request_audit_context(request: Any) -> RequestAuditContext:
headers = request.headers headers = request.headers
request_id = str(uuid.uuid4())
return RequestAuditContext( return RequestAuditContext(
request_id=request_id,
client_ip=resolve_client_ip(request), client_ip=resolve_client_ip(request),
user_agent=_clean_header_value(headers.get("user-agent"), 500), user_agent=_clean_header_value(headers.get("user-agent"), 500),
client_type=_clean_header_value(headers.get("x-ctms-client-type"), 16), client_type=resolve_ctms_client_type(headers),
client_version=_clean_header_value(headers.get("x-ctms-client-version"), 32), client_version=_clean_header_value(headers.get("x-ctms-client-version"), 32),
client_platform=_clean_header_value(headers.get("x-ctms-client-platform"), 16), client_platform=_clean_header_value(headers.get("x-ctms-client-platform"), 16),
build_channel=_clean_header_value(headers.get("x-ctms-build-channel"), 16), build_channel=_clean_header_value(headers.get("x-ctms-build-channel"), 16),
build_commit=_clean_header_value(headers.get("x-ctms-build-commit"), 64), build_commit=_clean_header_value(headers.get("x-ctms-build-commit"), 64),
request_headers=build_sanitized_request_headers(request),
request_snapshot=build_request_snapshot(request, request_id=request_id),
) )
+3
View File
@@ -22,6 +22,7 @@ def create_access_token(
max_age_seconds: Optional[int] = None, max_age_seconds: Optional[int] = None,
issued_at: Optional[datetime] = None, issued_at: Optional[datetime] = None,
client_type: Optional[str] = None, client_type: Optional[str] = None,
session_id: Optional[str] = None,
) -> str: ) -> str:
now = issued_at or datetime.now(timezone.utc) now = issued_at or datetime.now(timezone.utc)
if now.tzinfo is None: if now.tzinfo is None:
@@ -42,6 +43,8 @@ def create_access_token(
} }
if client_type: if client_type:
to_encode["client_type"] = client_type to_encode["client_type"] = client_type
if session_id:
to_encode["sid"] = session_id
return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=ALGORITHM) return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=ALGORITHM)
+2
View File
@@ -42,6 +42,8 @@ from app.models.permission_access_log import PermissionAccessLog # noqa: F401
from app.models.permission_metric_snapshot import PermissionMetricSnapshot # noqa: F401 from app.models.permission_metric_snapshot import PermissionMetricSnapshot # noqa: F401
from app.models.permission_template import PermissionTemplate, PermissionTemplateVersion # noqa: F401 from app.models.permission_template import PermissionTemplate, PermissionTemplateVersion # noqa: F401
from app.models.security_access_log import SecurityAccessLog # noqa: F401 from app.models.security_access_log import SecurityAccessLog # noqa: F401
from app.models.source_location_snapshot import SourceLocationSnapshot # noqa: F401
from app.models.user_login_session import UserLoginSession # noqa: F401
from app.models.desktop_notification import ( # noqa: F401 from app.models.desktop_notification import ( # noqa: F401
DesktopNotificationDelivery, DesktopNotificationDelivery,
DesktopNotificationSubscription, DesktopNotificationSubscription,
+59 -3
View File
@@ -5,8 +5,9 @@ import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from collections import defaultdict from collections import defaultdict
from fastapi import FastAPI from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy import text from sqlalchemy import text
from app.api.v1.router import api_router from app.api.v1.router import api_router
@@ -19,15 +20,23 @@ from app.db.session import SessionLocal, engine
from app.services.visit_scheduler import run_daily_lost_visit_job from app.services.visit_scheduler import run_daily_lost_visit_job
from app.services.permission_log_writer import start_log_writer, stop_log_writer from app.services.permission_log_writer import start_log_writer, stop_log_writer
from app.services.permission_metric_aggregator import run_hourly_metric_aggregation from app.services.permission_metric_aggregator import run_hourly_metric_aggregation
from app.services.source_location_aggregator import run_hourly_source_location_aggregation
from app.services.monitoring_server_location import resolve_monitoring_server_location
from app.services.monitoring_retention import run_monitoring_retention
from app.services.security_access_log_writer import ( from app.services.security_access_log_writer import (
get_security_log_writer, get_security_log_writer,
start_security_log_writer, start_security_log_writer,
stop_security_log_writer, stop_security_log_writer,
) )
from app.core.security import decode_token from app.core.security import decode_token
from app.core.deps import get_db_session
from app.core.request_context import ( from app.core.request_context import (
build_request_audit_context, build_request_audit_context,
build_request_snapshot,
build_sanitized_request_headers,
get_request_audit_context,
reset_request_audit_context, reset_request_audit_context,
resolve_ctms_client_type,
resolve_client_ip, resolve_client_ip,
set_request_audit_context, set_request_audit_context,
) )
@@ -42,8 +51,12 @@ setup_config_stats: dict[str, int] = defaultdict(int)
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI): async def lifespan(_: FastAPI):
stop_event = asyncio.Event() stop_event = asyncio.Event()
await resolve_monitoring_server_location()
scheduler_task = asyncio.create_task(run_daily_lost_visit_job(stop_event)) scheduler_task = asyncio.create_task(run_daily_lost_visit_job(stop_event))
aggregator_task = asyncio.create_task(run_hourly_metric_aggregation(stop_event)) aggregator_task = asyncio.create_task(run_hourly_metric_aggregation(stop_event))
source_location_aggregator_task = asyncio.create_task(
run_hourly_source_location_aggregation(stop_event)
)
await start_log_writer() await start_log_writer()
await start_security_log_writer() await start_security_log_writer()
if settings.ENV == "development": if settings.ENV == "development":
@@ -52,12 +65,15 @@ async def lifespan(_: FastAPI):
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
async with SessionLocal() as session: async with SessionLocal() as session:
await ensure_admin_exists(session) await ensure_admin_exists(session)
retention_task = asyncio.create_task(run_monitoring_retention(stop_event))
yield yield
stop_event.set() stop_event.set()
await stop_log_writer() await stop_log_writer()
await stop_security_log_writer() await stop_security_log_writer()
await scheduler_task await scheduler_task
await aggregator_task await aggregator_task
await source_location_aggregator_task
await retention_task
async def _ensure_legacy_primary_keys(conn) -> None: async def _ensure_legacy_primary_keys(conn) -> None:
@@ -129,19 +145,23 @@ def create_app() -> FastAPI:
"Accept", "Accept",
"Authorization", "Authorization",
"Content-Type", "Content-Type",
"X-CTMS-Client-Source",
"X-CTMS-Client-Type", "X-CTMS-Client-Type",
"X-CTMS-Client-Version", "X-CTMS-Client-Version",
"X-CTMS-Client-Platform", "X-CTMS-Client-Platform",
"X-CTMS-Build-Channel", "X-CTMS-Build-Channel",
"X-CTMS-Build-Commit", "X-CTMS-Build-Commit",
"X-Request-ID",
"X-Correlation-ID",
], ],
expose_headers=["X-Request-ID"],
) )
@app.middleware("http") @app.middleware("http")
async def setup_config_monitoring_middleware(request, call_next): async def setup_config_monitoring_middleware(request, call_next):
path = request.url.path path = request.url.path
is_setup_config_path = "/api/v1/studies/" in path and "/setup-config" in path is_setup_config_path = "/api/v1/studies/" in path and "/setup-config" in path
should_security_log = path.startswith("/api/") should_security_log = path.startswith("/api/") and path != "/api/v1/auth/session/heartbeat"
started_at = time.perf_counter() started_at = time.perf_counter()
audit_context = build_request_audit_context(request) audit_context = build_request_audit_context(request)
audit_context_token = set_request_audit_context(audit_context) audit_context_token = set_request_audit_context(audit_context)
@@ -167,6 +187,8 @@ def create_app() -> FastAPI:
raise raise
status_code = int(response.status_code) status_code = int(response.status_code)
if audit_context.request_id:
response.headers["X-Request-ID"] = audit_context.request_id
if is_setup_config_path: if is_setup_config_path:
normalized_path = UUID_RE.sub("{study_id}", path) normalized_path = UUID_RE.sub("{study_id}", path)
duration_ms = int((time.perf_counter() - started_at) * 1000) duration_ms = int((time.perf_counter() - started_at) * 1000)
@@ -229,6 +251,36 @@ def create_app() -> FastAPI:
async def health() -> dict[str, str]: async def health() -> dict[str, str]:
return {"status": "ok"} return {"status": "ok"}
@app.get(
"/readyz",
tags=["health"],
summary="服务就绪检查",
description="验证应用可访问数据库;失败时返回 503。",
)
async def readiness(db=Depends(get_db_session)):
started_at = time.perf_counter()
try:
await db.execute(text("SELECT 1"))
except Exception as exc:
logger.exception("Readiness database check failed")
return JSONResponse(
status_code=503,
content={
"status": "not_ready",
"database": {
"status": "unhealthy",
"error_type": type(exc).__name__,
},
},
)
return {
"status": "ready",
"database": {
"status": "healthy",
"latency_ms": round((time.perf_counter() - started_at) * 1000, 2),
},
}
@app.get( @app.get(
"/health/setup-config-stats", "/health/setup-config-stats",
tags=["health"], tags=["health"],
@@ -271,6 +323,7 @@ def _enqueue_security_access_log(request, path: str, status_code: int, started_a
if not writer: if not writer:
return return
auth_status, user_identifier = _resolve_auth_context(request) auth_status, user_identifier = _resolve_auth_context(request)
context = get_request_audit_context()
writer.enqueue( writer.enqueue(
{ {
"method": request.method, "method": request.method,
@@ -279,11 +332,14 @@ def _enqueue_security_access_log(request, path: str, status_code: int, started_a
"elapsed_ms": round((time.perf_counter() - started_at) * 1000, 2), "elapsed_ms": round((time.perf_counter() - started_at) * 1000, 2),
"client_ip": resolve_client_ip(request), "client_ip": resolve_client_ip(request),
"user_agent": request.headers.get("user-agent"), "user_agent": request.headers.get("user-agent"),
"client_type": request.headers.get("x-ctms-client-type"), "client_type": resolve_ctms_client_type(request.headers),
"client_version": request.headers.get("x-ctms-client-version"), "client_version": request.headers.get("x-ctms-client-version"),
"client_platform": request.headers.get("x-ctms-client-platform"), "client_platform": request.headers.get("x-ctms-client-platform"),
"build_channel": request.headers.get("x-ctms-build-channel"), "build_channel": request.headers.get("x-ctms-build-channel"),
"build_commit": request.headers.get("x-ctms-build-commit"), "build_commit": request.headers.get("x-ctms-build-commit"),
"request_headers": context.request_headers if context else build_sanitized_request_headers(request),
"request_snapshot": context.request_snapshot if context else build_request_snapshot(request),
"request_id": context.request_id if context else None,
"auth_status": auth_status, "auth_status": auth_status,
"user_identifier": user_identifier, "user_identifier": user_identifier,
} }
+16 -2
View File
@@ -6,13 +6,15 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Index, String from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Index, String
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.db.base_class import Base from app.db.base_class import Base
JSONB_TYPE = JSON().with_variant(JSONB, "postgresql")
class PermissionAccessLog(Base): class PermissionAccessLog(Base):
__tablename__ = "permission_access_logs" __tablename__ = "permission_access_logs"
@@ -22,6 +24,9 @@ class PermissionAccessLog(Base):
Index("ix_perm_log_endpoint_created", "endpoint_key", "created_at"), Index("ix_perm_log_endpoint_created", "endpoint_key", "created_at"),
Index("ix_perm_log_created_at", "created_at"), Index("ix_perm_log_created_at", "created_at"),
Index("ix_perm_log_allowed", "allowed", "created_at"), Index("ix_perm_log_allowed", "allowed", "created_at"),
Index("ix_perm_log_ip_created", "ip_address", "created_at"),
Index("ix_perm_log_client_source_created", "client_type", "created_at"),
Index("ix_perm_log_request_id", "request_id"),
) )
id: Mapped[uuid.UUID] = mapped_column( id: Mapped[uuid.UUID] = mapped_column(
@@ -38,6 +43,15 @@ class PermissionAccessLog(Base):
allowed: Mapped[bool] = mapped_column(Boolean, nullable=False) allowed: Mapped[bool] = mapped_column(Boolean, nullable=False)
elapsed_ms: Mapped[float] = mapped_column(Float, nullable=False) elapsed_ms: Mapped[float] = mapped_column(Float, nullable=False)
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True) ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
client_type: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
client_version: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
client_platform: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
build_channel: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
build_commit: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
request_headers: Mapped[Optional[dict]] = mapped_column(JSONB_TYPE, nullable=True)
request_snapshot: Mapped[Optional[dict]] = mapped_column(JSONB_TYPE, nullable=True)
request_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )
+12 -2
View File
@@ -6,13 +6,15 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy import DateTime, Float, Index, Integer, String from sqlalchemy import JSON, DateTime, Float, Index, Integer, String
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.db.base_class import Base from app.db.base_class import Base
JSONB_TYPE = JSON().with_variant(JSONB, "postgresql")
class SecurityAccessLog(Base): class SecurityAccessLog(Base):
__tablename__ = "security_access_logs" __tablename__ = "security_access_logs"
@@ -21,6 +23,9 @@ class SecurityAccessLog(Base):
Index("ix_security_log_ip_created", "client_ip", "created_at"), Index("ix_security_log_ip_created", "client_ip", "created_at"),
Index("ix_security_log_status_created", "status_code", "created_at"), Index("ix_security_log_status_created", "status_code", "created_at"),
Index("ix_security_log_auth_created", "auth_status", "created_at"), Index("ix_security_log_auth_created", "auth_status", "created_at"),
Index("ix_security_log_request_id", "request_id"),
Index("ix_security_log_category_created", "category", "created_at"),
Index("ix_security_log_severity_created", "severity", "created_at"),
) )
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
@@ -35,8 +40,13 @@ class SecurityAccessLog(Base):
client_platform: Mapped[Optional[str]] = mapped_column(String(16), nullable=True) client_platform: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
build_channel: Mapped[Optional[str]] = mapped_column(String(16), nullable=True) build_channel: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
build_commit: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) build_commit: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
request_headers: Mapped[Optional[dict]] = mapped_column(JSONB_TYPE, nullable=True)
request_snapshot: Mapped[Optional[dict]] = mapped_column(JSONB_TYPE, nullable=True)
auth_status: Mapped[str] = mapped_column(String(30), nullable=False) auth_status: Mapped[str] = mapped_column(String(30), nullable=False)
user_identifier: Mapped[Optional[str]] = mapped_column(String(80), nullable=True) user_identifier: Mapped[Optional[str]] = mapped_column(String(80), nullable=True)
request_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
category: Mapped[Optional[str]] = mapped_column(String(30), nullable=True)
severity: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )
@@ -0,0 +1,48 @@
"""Hourly source-location rollup for monitoring analytics."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Float, Index, Integer, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from app.db.base_class import Base
class SourceLocationSnapshot(Base):
__tablename__ = "source_location_snapshots"
__table_args__ = (
UniqueConstraint("bucket_time", "ip_hash", "user_hash", name="uq_source_location_bucket_identity"),
Index("ix_source_location_bucket", "bucket_time"),
Index("ix_source_location_country_bucket", "country_code", "bucket_time"),
Index("ix_source_location_risk_bucket", "high_risk_count", "bucket_time"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
bucket_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
ip_hash: Mapped[str] = mapped_column(String(64), nullable=False)
user_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
country: Mapped[str] = mapped_column(String(100), nullable=False, default="")
country_code: Mapped[str] = mapped_column(String(16), nullable=False, default="")
province: Mapped[str] = mapped_column(String(100), nullable=False, default="")
region_code: Mapped[str] = mapped_column(String(24), nullable=False, default="")
city: Mapped[str] = mapped_column(String(100), nullable=False, default="")
isp: Mapped[str] = mapped_column(String(160), nullable=False, default="")
location: Mapped[str] = mapped_column(String(320), nullable=False, default="")
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
accuracy_level: Mapped[str] = mapped_column(String(16), nullable=False, default="unknown")
allowed_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
denied_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
security_event_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
high_risk_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
auth_failure_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class UserLoginSession(Base):
"""Server-side login activity record without storing credentials or raw IPs."""
__tablename__ = "user_login_sessions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
client_type: Mapped[str] = mapped_column(String(16), nullable=False, default="web")
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_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)
end_reason: Mapped[str | None] = mapped_column(String(32), nullable=True)
+19
View File
@@ -6,6 +6,7 @@ from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
UserStatus = Literal["PENDING", "ACTIVE", "REJECTED", "DISABLED"] UserStatus = Literal["PENDING", "ACTIVE", "REJECTED", "DISABLED"]
LoginStatus = Literal["ONLINE", "OFFLINE"]
PASSWORD_REGEX = re.compile(r"^(?=.*[A-Za-z])(?=.*\d).{8,}$") PASSWORD_REGEX = re.compile(r"^(?=.*[A-Za-z])(?=.*\d).{8,}$")
@@ -60,6 +61,11 @@ class UserRead(BaseModel):
approved_at: Optional[datetime] = None approved_at: Optional[datetime] = None
approved_by: Optional[uuid.UUID] = None approved_by: Optional[uuid.UUID] = None
avatar_url: Optional[str] = None avatar_url: Optional[str] = None
login_status: LoginStatus = "OFFLINE"
last_login_at: Optional[datetime] = None
last_seen_at: Optional[datetime] = None
last_client_type: Optional[Literal["web", "desktop"]] = None
active_session_count: int = 0
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -78,6 +84,19 @@ class UserResponse(UserRead):
pass pass
class UserLoginActivityRead(BaseModel):
id: uuid.UUID
client_type: Literal["web", "desktop"]
client_platform: Optional[str] = None
client_version: Optional[str] = None
login_at: datetime
last_seen_at: datetime
ended_at: Optional[datetime] = None
end_reason: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class UserSelfUpdate(_PasswordValidator): class UserSelfUpdate(_PasswordValidator):
full_name: Optional[str] = None full_name: Optional[str] = None
clinical_department: Optional[str] = None clinical_department: Optional[str] = None
@@ -0,0 +1,175 @@
"""Canonical map metadata for monitoring source locations.
ip2region provides administrative names but no coordinates. This module keeps
the normalization and centroid contract on the server so web and desktop
clients render the same location semantics.
"""
from __future__ import annotations
from dataclasses import dataclass
from app.services.ip_location import IpLocation
@dataclass(frozen=True)
class GeoLocationMetadata:
country: str
country_code: str
region_code: str
longitude: float | None
latitude: float | None
accuracy_level: str
COUNTRY_ALIASES = {
"中国": "China",
"China": "China",
"Mainland China": "China",
"中国香港": "China",
"中国澳门": "China",
"中国台湾": "China",
"美国": "United States",
"United States": "United States",
"United States of America": "United States",
"USA": "United States",
"土耳其": "Türkiye",
"Turkey": "Türkiye",
"Türkiye": "Türkiye",
}
COUNTRY_CODES = {
"China": "CN",
"United States": "US",
"Netherlands": "NL",
"Türkiye": "TR",
"Australia": "AU",
"Japan": "JP",
"Singapore": "SG",
"Germany": "DE",
"France": "FR",
"United Kingdom": "GB",
"Canada": "CA",
"India": "IN",
"Russia": "RU",
}
COUNTRY_CENTROIDS = {
"China": (104.1954, 35.8617),
"United States": (-95.7129, 37.0902),
"Netherlands": (5.2913, 52.1326),
"Türkiye": (35.2433, 38.9637),
"Australia": (133.7751, -25.2744),
"Japan": (138.2529, 36.2048),
"Singapore": (103.8198, 1.3521),
"Germany": (10.4515, 51.1657),
"France": (2.2137, 46.2276),
"United Kingdom": (-3.436, 55.3781),
"Canada": (-106.3468, 56.1304),
"India": (78.9629, 20.5937),
"Russia": (105.3188, 61.524),
}
CHINA_REGION_METADATA = {
"北京市": ("CN-BJ", 116.4074, 39.9042),
"天津市": ("CN-TJ", 117.2008, 39.0842),
"河北省": ("CN-HE", 114.5025, 38.0455),
"山西省": ("CN-SX", 112.5492, 37.857),
"内蒙古自治区": ("CN-NM", 111.6708, 40.8183),
"辽宁省": ("CN-LN", 123.4315, 41.8057),
"吉林省": ("CN-JL", 125.3245, 43.8868),
"黑龙江省": ("CN-HL", 126.6424, 45.7567),
"上海市": ("CN-SH", 121.4737, 31.2304),
"江苏省": ("CN-JS", 118.7633, 32.0617),
"浙江省": ("CN-ZJ", 120.1551, 30.2741),
"安徽省": ("CN-AH", 117.2272, 31.8206),
"福建省": ("CN-FJ", 119.2965, 26.0745),
"江西省": ("CN-JX", 115.8582, 28.682),
"山东省": ("CN-SD", 117.1201, 36.6512),
"河南省": ("CN-HA", 113.6254, 34.7466),
"湖北省": ("CN-HB", 114.3055, 30.5928),
"湖南省": ("CN-HN", 112.9388, 28.2282),
"广东省": ("CN-GD", 113.2644, 23.1291),
"广西壮族自治区": ("CN-GX", 108.3669, 22.817),
"海南省": ("CN-HI", 110.3312, 20.0311),
"重庆": ("CN-CQ", 106.5516, 29.563),
"重庆市": ("CN-CQ", 106.5516, 29.563),
"四川省": ("CN-SC", 104.0665, 30.5723),
"贵州省": ("CN-GZ", 106.6302, 26.647),
"云南省": ("CN-YN", 102.8329, 24.8801),
"西藏自治区": ("CN-XZ", 91.1322, 29.6604),
"陕西省": ("CN-SN", 108.9398, 34.3416),
"甘肃省": ("CN-GS", 103.8343, 36.0611),
"青海省": ("CN-QH", 101.7782, 36.6171),
"宁夏回族自治区": ("CN-NX", 106.2309, 38.4872),
"新疆维吾尔自治区": ("CN-XJ", 87.6168, 43.8256),
"台湾省": ("CN-TW", 121.5654, 25.033),
"香港特别行政区": ("CN-HK", 114.1694, 22.3193),
"澳门特别行政区": ("CN-MO", 113.5439, 22.1987),
}
CITY_CENTROIDS = {
"南京": (118.7969, 32.0603),
"南京市": (118.7969, 32.0603),
"San Jose": (-121.8863, 37.3382),
"South Holland": (4.493, 52.0208),
"Istanbul": (28.9784, 41.0082),
}
def resolve_geo_location_metadata(location: IpLocation) -> GeoLocationMetadata:
if location.location in {"局域网", "本机"}:
return GeoLocationMetadata(
country="",
country_code="PRIVATE",
region_code="PRIVATE",
longitude=None,
latitude=None,
accuracy_level="private",
)
country = COUNTRY_ALIASES.get(location.country, location.country)
country_code = COUNTRY_CODES.get(country, "")
city_coordinate = CITY_CENTROIDS.get(location.city)
if city_coordinate:
return GeoLocationMetadata(
country=country,
country_code=country_code,
region_code="",
longitude=city_coordinate[0],
latitude=city_coordinate[1],
accuracy_level="city",
)
region_metadata = CHINA_REGION_METADATA.get(location.province)
if country in {"China", "中国"} and region_metadata:
region_code, longitude, latitude = region_metadata
return GeoLocationMetadata(
country="China",
country_code="CN",
region_code=region_code,
longitude=longitude,
latitude=latitude,
accuracy_level="region",
)
country_coordinate = COUNTRY_CENTROIDS.get(country)
if country_coordinate:
return GeoLocationMetadata(
country=country,
country_code=country_code,
region_code="",
longitude=country_coordinate[0],
latitude=country_coordinate[1],
accuracy_level="country",
)
return GeoLocationMetadata(
country=country,
country_code=country_code,
region_code="",
longitude=None,
latitude=None,
accuracy_level="unknown",
)
+2
View File
@@ -9,6 +9,7 @@ import ipaddress
import logging import logging
import sys import sys
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -125,5 +126,6 @@ class Ip2RegionResolver:
_resolver = Ip2RegionResolver() _resolver = Ip2RegionResolver()
@lru_cache(maxsize=8192)
def resolve_ip_location(ip: str | None) -> IpLocation: def resolve_ip_location(ip: str | None) -> IpLocation:
return _resolver.lookup(ip) return _resolver.lookup(ip)
@@ -0,0 +1,151 @@
"""监测数据留存清理任务。"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import delete
from app.core.config import settings
from app.db.session import SessionLocal
from app.models.permission_access_log import PermissionAccessLog
from app.models.permission_metric_snapshot import PermissionMetricSnapshot
from app.models.security_access_log import SecurityAccessLog
from app.models.source_location_snapshot import SourceLocationSnapshot
from app.models.user_login_session import UserLoginSession
logger = logging.getLogger("ctms.monitoring_retention")
@dataclass
class MonitoringRetentionState:
running: bool = False
last_run_started_at: datetime | None = None
last_success_at: datetime | None = None
last_error_at: datetime | None = None
last_error_type: str | None = None
error_count: int = 0
last_deleted: dict[str, int] = field(default_factory=dict)
total_deleted: dict[str, int] = field(default_factory=dict)
def snapshot(self) -> dict[str, Any]:
return {
"running": self.running,
"access_log_retention_days": settings.MONITORING_ACCESS_LOG_RETENTION_DAYS,
"metric_retention_days": settings.MONITORING_METRIC_RETENTION_DAYS,
"login_activity_retention_days": settings.USER_LOGIN_ACTIVITY_RETENTION_DAYS,
"interval_seconds": settings.MONITORING_RETENTION_INTERVAL_SECONDS,
"last_run_started_at": (
self.last_run_started_at.isoformat() if self.last_run_started_at else None
),
"last_success_at": self.last_success_at.isoformat() if self.last_success_at else None,
"last_error_at": self.last_error_at.isoformat() if self.last_error_at else None,
"last_error_type": self.last_error_type,
"error_count": self.error_count,
"last_deleted": dict(self.last_deleted),
"total_deleted": dict(self.total_deleted),
}
_state = MonitoringRetentionState()
def get_monitoring_retention_status() -> dict[str, Any]:
return _state.snapshot()
def _deleted_count(result: Any) -> int:
rowcount = getattr(result, "rowcount", 0)
return max(0, int(rowcount or 0))
async def purge_expired_monitoring_data(
*, now: datetime | None = None
) -> dict[str, int]:
reference_time = now or datetime.now(timezone.utc)
access_cutoff = reference_time - timedelta(
days=settings.MONITORING_ACCESS_LOG_RETENTION_DAYS
)
metric_cutoff = reference_time - timedelta(
days=settings.MONITORING_METRIC_RETENTION_DAYS
)
login_activity_cutoff = reference_time - timedelta(
days=settings.USER_LOGIN_ACTIVITY_RETENTION_DAYS
)
async with SessionLocal() as session:
permission_result = await session.execute(
delete(PermissionAccessLog).where(PermissionAccessLog.created_at < access_cutoff)
)
security_result = await session.execute(
delete(SecurityAccessLog).where(SecurityAccessLog.created_at < access_cutoff)
)
metric_result = await session.execute(
delete(PermissionMetricSnapshot).where(
PermissionMetricSnapshot.bucket_time < metric_cutoff
)
)
source_location_result = await session.execute(
delete(SourceLocationSnapshot).where(
SourceLocationSnapshot.bucket_time < access_cutoff
)
)
login_session_result = await session.execute(
delete(UserLoginSession).where(UserLoginSession.last_seen_at < login_activity_cutoff)
)
await session.commit()
return {
"permission_access_logs": _deleted_count(permission_result),
"security_access_logs": _deleted_count(security_result),
"permission_metric_snapshots": _deleted_count(metric_result),
"source_location_snapshots": _deleted_count(source_location_result),
"user_login_sessions": _deleted_count(login_session_result),
}
async def run_monitoring_retention_once(
*, now: datetime | None = None
) -> dict[str, int]:
_state.last_run_started_at = now or datetime.now(timezone.utc)
try:
deleted = await purge_expired_monitoring_data(now=now)
except Exception as exc:
_state.last_error_at = datetime.now(timezone.utc)
_state.last_error_type = type(exc).__name__
_state.error_count += 1
raise
_state.last_success_at = datetime.now(timezone.utc)
_state.last_deleted = deleted
for key, count in deleted.items():
_state.total_deleted[key] = _state.total_deleted.get(key, 0) + count
return deleted
async def run_monitoring_retention(stop_event: asyncio.Event) -> None:
logger.info("Monitoring retention task started")
_state.running = True
try:
while not stop_event.is_set():
try:
deleted = await run_monitoring_retention_once()
if any(deleted.values()):
logger.info("Purged expired monitoring data: %s", deleted)
except Exception:
logger.exception("Failed to purge expired monitoring data")
try:
await asyncio.wait_for(
stop_event.wait(),
timeout=settings.MONITORING_RETENTION_INTERVAL_SECONDS,
)
except asyncio.TimeoutError:
continue
finally:
_state.running = False
logger.info("Monitoring retention task stopped")
@@ -0,0 +1,164 @@
"""Resolve the monitoring deployment's public network location.
The map server marker is derived from the deployment's public IP instead of a
frontend or configuration coordinate. An explicit public IP is accepted for
restricted networks; otherwise the public frontend hostname and, finally, a
small set of public-IP discovery endpoints are used. Results are cached so the
monitoring API never performs per-request network discovery.
"""
from __future__ import annotations
import asyncio
import ipaddress
import logging
import socket
import time
from dataclasses import dataclass
from urllib.parse import urlparse
import httpx
from app.core.config import settings
from app.services.geo_location_metadata import resolve_geo_location_metadata
from app.services.ip_location import resolve_ip_location
logger = logging.getLogger("ctms.monitoring_server_location")
@dataclass(frozen=True)
class MonitoringServerLocation:
name: str
longitude: float
latitude: float
accuracy_level: str
resolution_source: str
def to_public_dict(self) -> dict:
return {
"name": self.name,
"longitude": self.longitude,
"latitude": self.latitude,
"accuracy_level": self.accuracy_level,
"resolution_source": self.resolution_source,
}
_cached_location: MonitoringServerLocation | None = None
_cache_initialized = False
_cache_expires_at = 0.0
_cache_lock = asyncio.Lock()
def _normalize_public_ip(value: str | None) -> str | None:
candidate = (value or "").strip().splitlines()[0] if (value or "").strip() else ""
try:
address = ipaddress.ip_address(candidate)
except ValueError:
return None
return str(address) if address.is_global else None
async def _resolve_frontend_public_ip() -> str | None:
hostname = urlparse(settings.FRONTEND_PUBLIC_URL).hostname
if not hostname:
return None
literal_ip = _normalize_public_ip(hostname)
if literal_ip:
return literal_ip
try:
loop = asyncio.get_running_loop()
records = await loop.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
except (OSError, socket.gaierror):
return None
candidates = []
for _, _, _, _, socket_address in records:
candidate = _normalize_public_ip(str(socket_address[0]))
if candidate and candidate not in candidates:
candidates.append(candidate)
return next((item for item in candidates if ":" not in item), candidates[0] if candidates else None)
async def _discover_public_ip() -> tuple[str | None, str]:
configured_ip = _normalize_public_ip(settings.MONITORING_SERVER_PUBLIC_IP)
if configured_ip:
return configured_ip, "configured_public_ip"
frontend_ip = await _resolve_frontend_public_ip()
if frontend_ip:
return frontend_ip, "frontend_dns"
if settings.ENV == "test":
return None, "unavailable"
urls = [
item.strip()
for item in settings.MONITORING_PUBLIC_IP_DISCOVERY_URLS.split(",")
if item.strip()
]
timeout = httpx.Timeout(settings.MONITORING_PUBLIC_IP_DISCOVERY_TIMEOUT_SECONDS)
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
for url in urls:
try:
response = await client.get(url, headers={"Accept": "text/plain"})
response.raise_for_status()
except httpx.HTTPError:
logger.warning("Public IP discovery endpoint unavailable", extra={"endpoint": url})
continue
discovered_ip = _normalize_public_ip(response.text[:128])
if discovered_ip:
return discovered_ip, "public_ip_discovery"
return None, "unavailable"
async def resolve_monitoring_server_location(*, force: bool = False) -> MonitoringServerLocation | None:
global _cached_location, _cache_initialized, _cache_expires_at
now = time.monotonic()
if not force and _cache_initialized and now < _cache_expires_at:
return _cached_location
async with _cache_lock:
now = time.monotonic()
if not force and _cache_initialized and now < _cache_expires_at:
return _cached_location
public_ip, resolution_source = await _discover_public_ip()
location = None
if public_ip:
ip_location = resolve_ip_location(public_ip)
metadata = resolve_geo_location_metadata(ip_location)
if metadata.longitude is not None and metadata.latitude is not None:
country = metadata.country or ip_location.country
name = " / ".join(
part for part in [country, ip_location.province, ip_location.city] if part
) or "公网服务器"
location = MonitoringServerLocation(
name=name,
longitude=metadata.longitude,
latitude=metadata.latitude,
accuracy_level=metadata.accuracy_level,
resolution_source=resolution_source,
)
logger.info(
"Monitoring server location resolved",
extra={"resolution_source": resolution_source, "accuracy_level": metadata.accuracy_level},
)
else:
logger.warning("Deployment public IP has no usable geo coordinates")
else:
logger.warning("Unable to discover the deployment public IP")
_cached_location = location
_cache_initialized = True
cache_seconds = settings.MONITORING_SERVER_LOCATION_CACHE_SECONDS if location else 300
_cache_expires_at = now + cache_seconds
return location
def reset_monitoring_server_location_cache() -> None:
"""Reset process-local state for configuration reloads and tests."""
global _cached_location, _cache_initialized, _cache_expires_at
_cached_location = None
_cache_initialized = False
_cache_expires_at = 0.0
+106 -40
View File
@@ -13,6 +13,7 @@ import asyncio
import logging import logging
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any
from app.db.session import SessionLocal from app.db.session import SessionLocal
from app.models.permission_access_log import PermissionAccessLog from app.models.permission_access_log import PermissionAccessLog
@@ -22,85 +23,150 @@ logger = logging.getLogger("ctms.permission_log_writer")
BATCH_SIZE = 50 BATCH_SIZE = 50
FLUSH_INTERVAL = 5.0 FLUSH_INTERVAL = 5.0
QUEUE_MAX_SIZE = 10000 QUEUE_MAX_SIZE = 10000
WRITE_ATTEMPTS = 2
_QUEUE_STOP = object()
class PermissionLogWriter: class PermissionLogWriter:
def __init__(self): def __init__(self):
self._queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=QUEUE_MAX_SIZE) self._queue: asyncio.Queue[dict | object] = asyncio.Queue(maxsize=QUEUE_MAX_SIZE)
self._task: asyncio.Task | None = None self._task: asyncio.Task | None = None
self._stopping = False
self._started_at: datetime | None = None
self._last_success_at: datetime | None = None
self._last_error_at: datetime | None = None
self._last_error_type: str | None = None
self._accepted_entries = 0
self._written_entries = 0
self._dropped_entries = 0
self._failed_batches = 0
self._failed_entries = 0
self._retry_count = 0
def enqueue(self, entry: dict) -> None: def enqueue(self, entry: dict) -> None:
if self._stopping:
self._dropped_entries += 1
logger.warning("Permission log writer is stopping, dropping entry")
return
try: try:
self._queue.put_nowait(entry) self._queue.put_nowait(entry)
self._accepted_entries += 1
except asyncio.QueueFull: except asyncio.QueueFull:
self._dropped_entries += 1
logger.warning("Permission log queue full, dropping entry") logger.warning("Permission log queue full, dropping entry")
async def start(self) -> None: async def start(self) -> None:
if self._task and not self._task.done():
return
self._stopping = False
self._started_at = datetime.now(timezone.utc)
self._task = asyncio.create_task(self._flush_loop()) self._task = asyncio.create_task(self._flush_loop())
logger.info("PermissionLogWriter started") logger.info("PermissionLogWriter started")
async def stop(self) -> None: async def stop(self) -> None:
if self._task: if self._task and not self._task.done():
self._task.cancel() self._stopping = True
try: await self._queue.put(_QUEUE_STOP)
await self._task await self._task
except asyncio.CancelledError: self._task = None
pass
await self._drain()
logger.info("PermissionLogWriter stopped") logger.info("PermissionLogWriter stopped")
async def _flush_loop(self) -> None: async def _flush_loop(self) -> None:
while True: while True:
batch = await self._collect_batch() batch, should_stop = await self._collect_batch()
if batch: if batch:
await self._write_batch(batch) await self._write_batch(batch)
if should_stop:
break
async def _collect_batch(self) -> list[dict]: async def _collect_batch(self) -> tuple[list[dict], bool]:
batch: list[dict] = [] batch: list[dict] = []
try: try:
first = await asyncio.wait_for(self._queue.get(), timeout=FLUSH_INTERVAL) first = await asyncio.wait_for(self._queue.get(), timeout=FLUSH_INTERVAL)
if first is _QUEUE_STOP:
return batch, True
batch.append(first) batch.append(first)
except asyncio.TimeoutError: except asyncio.TimeoutError:
return batch return batch, False
while len(batch) < BATCH_SIZE: while len(batch) < BATCH_SIZE:
try: try:
item = self._queue.get_nowait() item = self._queue.get_nowait()
if item is _QUEUE_STOP:
return batch, True
batch.append(item) batch.append(item)
except asyncio.QueueEmpty: except asyncio.QueueEmpty:
break break
return batch return batch, False
async def _write_batch(self, batch: list[dict]) -> None: async def _write_batch(self, batch: list[dict]) -> bool:
try: for attempt in range(WRITE_ATTEMPTS):
async with SessionLocal() as session:
for entry in batch:
log = PermissionAccessLog(
id=uuid.uuid4(),
study_id=entry["study_id"],
user_id=entry["user_id"],
endpoint_key=entry["endpoint_key"],
role=entry["role"],
allowed=entry["allowed"],
elapsed_ms=entry["elapsed_ms"],
ip_address=entry.get("ip_address"),
created_at=entry.get("created_at", datetime.now(timezone.utc)),
)
session.add(log)
await session.commit()
except Exception:
logger.exception("Failed to write permission access log batch (%d entries)", len(batch))
async def _drain(self) -> None:
batch: list[dict] = []
while not self._queue.empty():
try: try:
batch.append(self._queue.get_nowait()) async with SessionLocal() as session:
except asyncio.QueueEmpty: for entry in batch:
break log = PermissionAccessLog(
if batch: id=uuid.uuid4(),
await self._write_batch(batch) study_id=entry["study_id"],
user_id=entry["user_id"],
endpoint_key=entry["endpoint_key"],
role=entry["role"],
allowed=entry["allowed"],
elapsed_ms=entry["elapsed_ms"],
ip_address=entry.get("ip_address"),
user_agent=entry.get("user_agent"),
client_type=entry.get("client_type"),
client_version=entry.get("client_version"),
client_platform=entry.get("client_platform"),
build_channel=entry.get("build_channel"),
build_commit=entry.get("build_commit"),
request_headers=entry.get("request_headers"),
request_snapshot=entry.get("request_snapshot"),
request_id=entry.get("request_id"),
created_at=entry.get("created_at", datetime.now(timezone.utc)),
)
session.add(log)
await session.commit()
except Exception as exc:
self._last_error_at = datetime.now(timezone.utc)
self._last_error_type = type(exc).__name__
if attempt + 1 < WRITE_ATTEMPTS:
self._retry_count += 1
logger.warning(
"Permission access log batch write failed; retrying (%d entries)",
len(batch),
)
await asyncio.sleep(0)
continue
self._failed_batches += 1
self._failed_entries += len(batch)
logger.exception(
"Failed to write permission access log batch after retries (%d entries)",
len(batch),
)
return False
self._written_entries += len(batch)
self._last_success_at = datetime.now(timezone.utc)
return True
return False
def stats(self) -> dict[str, Any]:
return {
"running": bool(self._task and not self._task.done()),
"stopping": self._stopping,
"queue_size": self._queue.qsize(),
"queue_capacity": self._queue.maxsize,
"accepted_entries": self._accepted_entries,
"written_entries": self._written_entries,
"dropped_entries": self._dropped_entries,
"failed_batches": self._failed_batches,
"failed_entries": self._failed_entries,
"retry_count": self._retry_count,
"started_at": self._started_at.isoformat() if self._started_at else None,
"last_success_at": self._last_success_at.isoformat() if self._last_success_at else None,
"last_error_at": self._last_error_at.isoformat() if self._last_error_at else None,
"last_error_type": self._last_error_type,
}
_writer: PermissionLogWriter | None = None _writer: PermissionLogWriter | None = None
@@ -6,99 +6,170 @@ import asyncio
import logging import logging
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any
from app.db.session import SessionLocal from app.db.session import SessionLocal
from app.models.security_access_log import SecurityAccessLog from app.models.security_access_log import SecurityAccessLog
from app.services.ip_location import resolve_ip_location
from app.services.security_events import classify_security_event
logger = logging.getLogger("ctms.security_access_log_writer") logger = logging.getLogger("ctms.security_access_log_writer")
BATCH_SIZE = 100 BATCH_SIZE = 100
FLUSH_INTERVAL = 3.0 FLUSH_INTERVAL = 3.0
QUEUE_MAX_SIZE = 20000 QUEUE_MAX_SIZE = 20000
WRITE_ATTEMPTS = 2
_QUEUE_STOP = object()
class SecurityAccessLogWriter: class SecurityAccessLogWriter:
def __init__(self) -> None: def __init__(self) -> None:
self._queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=QUEUE_MAX_SIZE) self._queue: asyncio.Queue[dict | object] = asyncio.Queue(maxsize=QUEUE_MAX_SIZE)
self._task: asyncio.Task | None = None self._task: asyncio.Task | None = None
self._stopping = False
self._started_at: datetime | None = None
self._last_success_at: datetime | None = None
self._last_error_at: datetime | None = None
self._last_error_type: str | None = None
self._accepted_entries = 0
self._written_entries = 0
self._dropped_entries = 0
self._failed_batches = 0
self._failed_entries = 0
self._retry_count = 0
def enqueue(self, entry: dict) -> None: def enqueue(self, entry: dict) -> None:
if self._stopping:
self._dropped_entries += 1
logger.warning("Security access log writer is stopping, dropping entry")
return
try: try:
self._queue.put_nowait(entry) self._queue.put_nowait(entry)
self._accepted_entries += 1
except asyncio.QueueFull: except asyncio.QueueFull:
self._dropped_entries += 1
logger.warning("Security access log queue full, dropping entry") logger.warning("Security access log queue full, dropping entry")
async def start(self) -> None: async def start(self) -> None:
if self._task and not self._task.done():
return
self._stopping = False
self._started_at = datetime.now(timezone.utc)
self._task = asyncio.create_task(self._flush_loop()) self._task = asyncio.create_task(self._flush_loop())
logger.info("SecurityAccessLogWriter started") logger.info("SecurityAccessLogWriter started")
async def stop(self) -> None: async def stop(self) -> None:
if self._task: if self._task and not self._task.done():
self._task.cancel() self._stopping = True
try: await self._queue.put(_QUEUE_STOP)
await self._task await self._task
except asyncio.CancelledError: self._task = None
pass
await self._drain()
logger.info("SecurityAccessLogWriter stopped") logger.info("SecurityAccessLogWriter stopped")
async def _flush_loop(self) -> None: async def _flush_loop(self) -> None:
while True: while True:
batch = await self._collect_batch() batch, should_stop = await self._collect_batch()
if batch: if batch:
await self._write_batch(batch) await self._write_batch(batch)
if should_stop:
break
async def _collect_batch(self) -> list[dict]: async def _collect_batch(self) -> tuple[list[dict], bool]:
batch: list[dict] = [] batch: list[dict] = []
try: try:
first = await asyncio.wait_for(self._queue.get(), timeout=FLUSH_INTERVAL) first = await asyncio.wait_for(self._queue.get(), timeout=FLUSH_INTERVAL)
if first is _QUEUE_STOP:
return batch, True
batch.append(first) batch.append(first)
except asyncio.TimeoutError: except asyncio.TimeoutError:
return batch return batch, False
while len(batch) < BATCH_SIZE: while len(batch) < BATCH_SIZE:
try: try:
batch.append(self._queue.get_nowait()) item = self._queue.get_nowait()
if item is _QUEUE_STOP:
return batch, True
batch.append(item)
except asyncio.QueueEmpty: except asyncio.QueueEmpty:
break break
return batch return batch, False
async def _write_batch(self, batch: list[dict]) -> None: async def _write_batch(self, batch: list[dict]) -> bool:
try: for attempt in range(WRITE_ATTEMPTS):
async with SessionLocal() as session: try:
for entry in batch: async with SessionLocal() as session:
session.add( for entry in batch:
SecurityAccessLog( classification = classify_security_event(
id=uuid.uuid4(),
method=entry["method"],
path=entry["path"], path=entry["path"],
status_code=entry["status_code"], status_code=entry["status_code"],
elapsed_ms=entry["elapsed_ms"],
client_ip=entry.get("client_ip"),
user_agent=entry.get("user_agent"),
client_type=entry.get("client_type"),
client_version=entry.get("client_version"),
client_platform=entry.get("client_platform"),
build_channel=entry.get("build_channel"),
build_commit=entry.get("build_commit"),
auth_status=entry["auth_status"], auth_status=entry["auth_status"],
user_identifier=entry.get("user_identifier"), ip_location=resolve_ip_location(entry.get("client_ip")),
created_at=entry.get("created_at", datetime.now(timezone.utc)),
) )
session.add(
SecurityAccessLog(
id=uuid.uuid4(),
method=entry["method"],
path=entry["path"],
status_code=entry["status_code"],
elapsed_ms=entry["elapsed_ms"],
client_ip=entry.get("client_ip"),
user_agent=entry.get("user_agent"),
client_type=entry.get("client_type"),
client_version=entry.get("client_version"),
client_platform=entry.get("client_platform"),
build_channel=entry.get("build_channel"),
build_commit=entry.get("build_commit"),
request_headers=entry.get("request_headers"),
request_snapshot=entry.get("request_snapshot"),
request_id=entry.get("request_id"),
category=classification["category"],
severity=classification["severity"],
auth_status=entry["auth_status"],
user_identifier=entry.get("user_identifier"),
created_at=entry.get("created_at", datetime.now(timezone.utc)),
)
)
await session.commit()
except Exception as exc:
self._last_error_at = datetime.now(timezone.utc)
self._last_error_type = type(exc).__name__
if attempt + 1 < WRITE_ATTEMPTS:
self._retry_count += 1
logger.warning(
"Security access log batch write failed; retrying (%d entries)",
len(batch),
) )
await session.commit() await asyncio.sleep(0)
except Exception: continue
logger.exception("Failed to write security access log batch (%d entries)", len(batch)) self._failed_batches += 1
self._failed_entries += len(batch)
logger.exception(
"Failed to write security access log batch after retries (%d entries)",
len(batch),
)
return False
self._written_entries += len(batch)
self._last_success_at = datetime.now(timezone.utc)
return True
return False
async def _drain(self) -> None: def stats(self) -> dict[str, Any]:
batch: list[dict] = [] return {
while not self._queue.empty(): "running": bool(self._task and not self._task.done()),
try: "stopping": self._stopping,
batch.append(self._queue.get_nowait()) "queue_size": self._queue.qsize(),
except asyncio.QueueEmpty: "queue_capacity": self._queue.maxsize,
break "accepted_entries": self._accepted_entries,
if batch: "written_entries": self._written_entries,
await self._write_batch(batch) "dropped_entries": self._dropped_entries,
"failed_batches": self._failed_batches,
"failed_entries": self._failed_entries,
"retry_count": self._retry_count,
"started_at": self._started_at.isoformat() if self._started_at else None,
"last_success_at": self._last_success_at.isoformat() if self._last_success_at else None,
"last_error_at": self._last_error_at.isoformat() if self._last_error_at else None,
"last_error_type": self._last_error_type,
}
_writer: SecurityAccessLogWriter | None = None _writer: SecurityAccessLogWriter | None = None
+37
View File
@@ -0,0 +1,37 @@
"""Security event classification shared by writers and monitoring APIs."""
from __future__ import annotations
from app.services.ip_location import IpLocation
SENSITIVE_PROBE_MARKERS = (
"/.env",
".env",
"/.git",
".git/config",
"backup",
"config.php",
"wp-config",
"database.yml",
)
def classify_security_event(
*,
path: str,
status_code: int,
auth_status: str,
ip_location: IpLocation | None = None,
) -> dict[str, str]:
normalized_path = (path or "").lower()
if any(marker in normalized_path for marker in SENSITIVE_PROBE_MARKERS):
return {"category": "PROBE", "severity": "CRITICAL"}
if status_code >= 500:
return {"category": "SERVER_ERROR", "severity": "HIGH"}
if auth_status == "INVALID_TOKEN":
return {"category": "INVALID_TOKEN", "severity": "MEDIUM"}
if auth_status == "ANONYMOUS" and status_code in {401, 403}:
return {"category": "ANONYMOUS_API", "severity": "MEDIUM"}
if status_code == 404:
return {"category": "NOT_FOUND_NOISE", "severity": "LOW"}
return {"category": "OTHER", "severity": "LOW"}
@@ -0,0 +1,272 @@
"""Hourly source-location aggregation and timeline reads."""
from __future__ import annotations
import asyncio
import hashlib
import hmac
import logging
import uuid
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import Literal
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.db.session import SessionLocal
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_location import resolve_ip_location
logger = logging.getLogger("ctms.source_location_aggregator")
def _normalize_datetime(value: datetime) -> datetime:
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc)
def _identity_hash(kind: str, value: str | None) -> str:
if not value:
return ""
payload = f"source-location:{kind}:{value}".encode("utf-8")
return hmac.new(settings.JWT_SECRET_KEY.encode("utf-8"), payload, hashlib.sha256).hexdigest()
async def aggregate_source_location_hour(bucket_start: datetime, bucket_end: datetime) -> int:
bucket_start = _normalize_datetime(bucket_start)
bucket_end = _normalize_datetime(bucket_end)
async with SessionLocal() as session:
matching_security_request = select(SecurityAccessLog.id).where(
PermissionAccessLog.request_id.is_not(None),
SecurityAccessLog.request_id == PermissionAccessLog.request_id,
).exists()
permission_rows = (
await session.execute(
select(
PermissionAccessLog.ip_address,
PermissionAccessLog.user_id,
func.count().filter(PermissionAccessLog.allowed.is_(True)).label("allowed_count"),
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied_count"),
func.min(PermissionAccessLog.created_at).label("first_seen_at"),
func.max(PermissionAccessLog.created_at).label("last_seen_at"),
)
.where(
PermissionAccessLog.created_at >= bucket_start,
PermissionAccessLog.created_at < bucket_end,
PermissionAccessLog.ip_address.is_not(None),
~matching_security_request,
)
.group_by(PermissionAccessLog.ip_address, PermissionAccessLog.user_id)
)
).all()
security_rows = (
await session.execute(
select(
SecurityAccessLog.client_ip,
SecurityAccessLog.user_identifier,
func.count().filter(SecurityAccessLog.status_code < 400).label("allowed_count"),
func.count().filter(SecurityAccessLog.status_code >= 400).label("denied_count"),
func.count()
.filter(
SecurityAccessLog.category.is_not(None),
~SecurityAccessLog.category.in_(("OTHER", "NOT_FOUND_NOISE")),
)
.label("security_event_count"),
func.count()
.filter(SecurityAccessLog.severity.in_(("HIGH", "CRITICAL")))
.label("high_risk_count"),
func.count()
.filter(
SecurityAccessLog.status_code >= 400,
SecurityAccessLog.auth_status.in_(("INVALID_TOKEN", "ANONYMOUS")),
)
.label("auth_failure_count"),
func.min(SecurityAccessLog.created_at).label("first_seen_at"),
func.max(SecurityAccessLog.created_at).label("last_seen_at"),
)
.where(
SecurityAccessLog.created_at >= bucket_start,
SecurityAccessLog.created_at < bucket_end,
SecurityAccessLog.client_ip.is_not(None),
)
.group_by(SecurityAccessLog.client_ip, SecurityAccessLog.user_identifier)
)
).all()
aggregates: dict[tuple[str, str], dict] = {}
def merge_row(
ip_address: str,
user_identity: str,
allowed_count: int,
denied_count: int,
first_seen_at: datetime,
last_seen_at: datetime,
*,
security_event_count: int = 0,
high_risk_count: int = 0,
auth_failure_count: int = 0,
) -> None:
key = (ip_address, user_identity)
row = aggregates.setdefault(
key,
{
"ip_address": ip_address,
"user_identity": user_identity,
"allowed_count": 0,
"denied_count": 0,
"security_event_count": 0,
"high_risk_count": 0,
"auth_failure_count": 0,
"first_seen_at": _normalize_datetime(first_seen_at),
"last_seen_at": _normalize_datetime(last_seen_at),
},
)
row["allowed_count"] += int(allowed_count or 0)
row["denied_count"] += int(denied_count or 0)
row["security_event_count"] += int(security_event_count or 0)
row["high_risk_count"] += int(high_risk_count or 0)
row["auth_failure_count"] += int(auth_failure_count or 0)
row["first_seen_at"] = min(row["first_seen_at"], _normalize_datetime(first_seen_at))
row["last_seen_at"] = max(row["last_seen_at"], _normalize_datetime(last_seen_at))
for ip_address, user_id, allowed, denied, first_seen, last_seen in permission_rows:
merge_row(str(ip_address), str(user_id or ""), allowed, denied, first_seen, last_seen)
for ip_address, user_identifier, allowed, denied, security_events, high_risk, auth_failures, first_seen, last_seen in security_rows:
merge_row(
str(ip_address),
str(user_identifier or ""),
allowed,
denied,
first_seen,
last_seen,
security_event_count=security_events,
high_risk_count=high_risk,
auth_failure_count=auth_failures,
)
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)
longitude = metadata.longitude
latitude = metadata.latitude
country = metadata.country or ip_info.country
location = " / ".join(
part for part in [country, ip_info.province, ip_info.city] if part
) or ip_info.location or "未知"
session.add(
SourceLocationSnapshot(
id=uuid.uuid4(),
bucket_time=bucket_start,
ip_hash=_identity_hash("ip", row["ip_address"]),
user_hash=_identity_hash("user", row["user_identity"]),
country=country,
country_code=metadata.country_code,
province=ip_info.province,
region_code=metadata.region_code,
city=ip_info.city,
isp=ip_info.isp,
location=location,
longitude=longitude,
latitude=latitude,
accuracy_level=metadata.accuracy_level,
allowed_count=row["allowed_count"],
denied_count=row["denied_count"],
security_event_count=row["security_event_count"],
high_risk_count=row["high_risk_count"],
auth_failure_count=row["auth_failure_count"],
first_seen_at=row["first_seen_at"],
last_seen_at=row["last_seen_at"],
)
)
await session.commit()
return len(aggregates)
async def get_source_location_timeline(
db: AsyncSession,
*,
start_at: datetime,
end_at: datetime,
granularity: Literal["hour", "day"],
) -> list[dict]:
rows = (
await db.execute(
select(SourceLocationSnapshot)
.where(
SourceLocationSnapshot.bucket_time >= start_at,
SourceLocationSnapshot.bucket_time < end_at,
)
.order_by(SourceLocationSnapshot.bucket_time)
)
).scalars().all()
buckets: dict[datetime, dict] = defaultdict(
lambda: {
"allowed_count": 0,
"denied_count": 0,
"security_event_count": 0,
"high_risk_count": 0,
"ip_hashes": set(),
"user_hashes": set(),
}
)
for row in rows:
bucket = _normalize_datetime(row.bucket_time)
if granularity == "day":
bucket = bucket.replace(hour=0, minute=0, second=0, microsecond=0)
else:
bucket = bucket.replace(minute=0, second=0, microsecond=0)
item = buckets[bucket]
item["allowed_count"] += row.allowed_count
item["denied_count"] += row.denied_count
item["security_event_count"] += row.security_event_count
item["high_risk_count"] += row.high_risk_count
item["ip_hashes"].add(row.ip_hash)
if row.user_hash:
item["user_hashes"].add(row.user_hash)
return [
{
"bucket_time": bucket.isoformat(),
"total_count": item["allowed_count"] + item["denied_count"],
"allowed_count": item["allowed_count"],
"denied_count": item["denied_count"],
"security_event_count": item["security_event_count"],
"high_risk_count": item["high_risk_count"],
"unique_ip_count": len(item["ip_hashes"]),
"unique_user_count": len(item["user_hashes"]),
}
for bucket, item in sorted(buckets.items())
]
async def run_hourly_source_location_aggregation(stop_event: asyncio.Event) -> None:
logger.info("Source location aggregator started")
now = datetime.now(timezone.utc)
completed_hour = now.replace(minute=0, second=0, microsecond=0)
try:
await aggregate_source_location_hour(completed_hour - timedelta(hours=1), completed_hour)
except Exception:
logger.exception("Failed to backfill source location snapshot for %s", completed_hour)
while not stop_event.is_set():
now = datetime.now(timezone.utc)
next_hour = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
try:
await asyncio.wait_for(stop_event.wait(), timeout=(next_hour - now).total_seconds())
break
except asyncio.TimeoutError:
pass
try:
await aggregate_source_location_hour(next_hour - timedelta(hours=1), next_hour)
except Exception:
logger.exception("Failed to aggregate source locations for %s", next_hour)
logger.info("Source location aggregator stopped")
+198
View File
@@ -0,0 +1,198 @@
"""Server-authoritative login session activity for the admin account list."""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Iterable
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.user_login_session import UserLoginSession
@dataclass(frozen=True)
class UserLoginSummary:
status: str = "OFFLINE"
last_login_at: datetime | None = None
last_seen_at: datetime | None = None
client_type: str | None = None
active_session_count: int = 0
def _now() -> datetime:
return datetime.now(timezone.utc)
def _as_utc(value: datetime) -> datetime:
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc)
def _text_header(headers: Any, name: str, limit: int) -> str | None:
value = (headers.get(name) or "").strip()
return value[:limit] or None
def session_id_from_payload(payload: dict[str, Any]) -> uuid.UUID:
raw_session_id = payload.get("sid")
try:
return uuid.UUID(str(raw_session_id))
except (TypeError, ValueError):
# Tokens issued before session tracking are mapped to a deterministic
# legacy session without persisting the token or a token-derived value.
legacy_key = ":".join(
[
str(payload.get("sub") or ""),
str(payload.get("orig_iat") or payload.get("iat") or ""),
str(payload.get("client_type") or "web"),
]
)
return uuid.uuid5(uuid.NAMESPACE_URL, f"ctms:legacy-session:{legacy_key}")
def session_client_type(payload: dict[str, Any], headers: Any) -> str:
from_payload = (payload.get("client_type") or "").strip().lower()
from_header = (headers.get("x-ctms-client-type") or "").strip().lower()
return "desktop" if "desktop" in {from_payload, from_header} else "web"
async def create_login_session(
db: AsyncSession,
*,
session_id: uuid.UUID,
user_id: uuid.UUID,
request: Any,
login_at: datetime | None = None,
) -> UserLoginSession:
occurred_at = login_at or _now()
session = UserLoginSession(
id=session_id,
user_id=user_id,
client_type=session_client_type({}, request.headers),
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_at=occurred_at,
last_seen_at=occurred_at,
)
db.add(session)
await db.commit()
await db.refresh(session)
return session
async def touch_login_session(
db: AsyncSession,
*,
user_id: uuid.UUID,
payload: dict[str, Any],
request: Any,
) -> UserLoginSession | None:
session_id = session_id_from_payload(payload)
session = await db.get(UserLoginSession, session_id)
now = _now()
if session is None:
session = UserLoginSession(
id=session_id,
user_id=user_id,
client_type=session_client_type(payload, request.headers),
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_at=now,
last_seen_at=now,
)
db.add(session)
elif session.user_id != user_id or session.ended_at is not None:
return None
else:
session.last_seen_at = now
session.client_platform = _text_header(request.headers, "x-ctms-client-platform", 32) or session.client_platform
session.client_version = _text_header(request.headers, "x-ctms-client-version", 64) or session.client_version
await db.commit()
await db.refresh(session)
return session
async def end_login_session(
db: AsyncSession,
*,
user_id: uuid.UUID,
payload: dict[str, Any],
reason: str = "logout",
) -> bool:
session_id = session_id_from_payload(payload)
result = await db.execute(
update(UserLoginSession)
.where(
UserLoginSession.id == session_id,
UserLoginSession.user_id == user_id,
UserLoginSession.ended_at.is_(None),
)
.values(ended_at=_now(), end_reason=reason, last_seen_at=_now())
)
await db.commit()
return bool(result.rowcount)
async def get_login_summaries(
db: AsyncSession,
user_ids: Iterable[uuid.UUID],
) -> dict[uuid.UUID, UserLoginSummary]:
ids = list(user_ids)
if not ids:
return {}
rows = (
await db.execute(
select(UserLoginSession)
.where(UserLoginSession.user_id.in_(ids))
.order_by(UserLoginSession.user_id, UserLoginSession.login_at.desc())
)
).scalars().all()
cutoff = _now() - timedelta(seconds=settings.USER_SESSION_ONLINE_SECONDS)
summaries: dict[uuid.UUID, UserLoginSummary] = {}
mutable: dict[uuid.UUID, dict[str, Any]] = {}
for row in rows:
current = mutable.setdefault(
row.user_id,
{
"last_login_at": row.login_at,
"last_seen_at": row.last_seen_at,
"client_type": row.client_type,
"active_session_count": 0,
},
)
row_last_seen_at = _as_utc(row.last_seen_at)
if row_last_seen_at and (
current["last_seen_at"] is None or row_last_seen_at > _as_utc(current["last_seen_at"])
):
current["last_seen_at"] = row_last_seen_at
if row.ended_at is None and row_last_seen_at >= cutoff:
current["active_session_count"] += 1
for user_id, item in mutable.items():
summaries[user_id] = UserLoginSummary(
status="ONLINE" if item["active_session_count"] else "OFFLINE",
last_login_at=item["last_login_at"],
last_seen_at=item["last_seen_at"],
client_type=item["client_type"],
active_session_count=item["active_session_count"],
)
return summaries
async def list_login_activities(
db: AsyncSession,
*,
user_id: uuid.UUID,
limit: int,
) -> list[UserLoginSession]:
result = await db.execute(
select(UserLoginSession)
.where(UserLoginSession.user_id == user_id)
.order_by(UserLoginSession.login_at.desc())
.limit(limit)
)
return list(result.scalars().all())
+4 -6
View File
@@ -246,7 +246,7 @@ async def test_project_member_can_read_own_effective_permissions(db_session: Asy
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_project_pm_monitoring_scope_is_limited_to_own_projects(db_session: AsyncSession): async def test_project_pm_cannot_access_system_monitoring(db_session: AsyncSession):
pm_id = uuid.uuid4() pm_id = uuid.uuid4()
own_study_id = uuid.uuid4() own_study_id = uuid.uuid4()
other_study_id = uuid.uuid4() other_study_id = uuid.uuid4()
@@ -256,12 +256,10 @@ async def test_project_pm_monitoring_scope_is_limited_to_own_projects(db_session
await _seed_member(db_session, own_study_id, pm_id, "PM") await _seed_member(db_session, own_study_id, pm_id, "PM")
await db_session.commit() await db_session.commit()
scope = await resolve_monitoring_scope(db_session, UserStub(id=pm_id)) with pytest.raises(HTTPException) as exc_info:
await resolve_monitoring_scope(db_session, UserStub(id=pm_id))
assert scope.is_admin is False assert exc_info.value.status_code == 403
assert scope.study_ids == {own_study_id}
assert scope.can_access_study(own_study_id)
assert not scope.can_access_study(other_study_id)
@pytest.mark.asyncio @pytest.mark.asyncio
+4
View File
@@ -54,6 +54,9 @@ class _DummyTask:
def cancel(self): def cancel(self):
self.cancelled = True self.cancelled = True
def done(self):
return False
def __await__(self): def __await__(self):
async def _wait(): async def _wait():
self.awaited = True self.awaited = True
@@ -106,6 +109,7 @@ async def test_development_startup_does_not_seed_business_data(monkeypatch):
monkeypatch.setattr(main.settings, "ENV", "development") monkeypatch.setattr(main.settings, "ENV", "development")
monkeypatch.setattr(main, "ensure_admin_exists", ensure_admin_exists) monkeypatch.setattr(main, "ensure_admin_exists", ensure_admin_exists)
monkeypatch.setattr(main, "run_daily_lost_visit_job", fake_scheduler) monkeypatch.setattr(main, "run_daily_lost_visit_job", fake_scheduler)
monkeypatch.setattr(main, "resolve_monitoring_server_location", AsyncMock(return_value=None))
monkeypatch.setattr(main.asyncio, "create_task", fake_create_task) monkeypatch.setattr(main.asyncio, "create_task", fake_create_task)
monkeypatch.setattr(main, "engine", _DummyEngine()) monkeypatch.setattr(main, "engine", _DummyEngine())
monkeypatch.setattr(main, "SessionLocal", fake_session_local) monkeypatch.setattr(main, "SessionLocal", fake_session_local)
+145
View File
@@ -4,6 +4,7 @@ import pytest
from app.core.request_context import ( from app.core.request_context import (
RequestAuditContext, RequestAuditContext,
build_request_audit_context,
reset_request_audit_context, reset_request_audit_context,
set_request_audit_context, set_request_audit_context,
) )
@@ -22,6 +23,48 @@ class FakeAsyncSession:
self.flushed = True self.flushed = True
class FakeClient:
def __init__(self, host: str = "10.0.0.8") -> None:
self.host = host
self.port = 54321
class FakeUrl:
scheme = "https"
path = "/api/v1/audit"
query = "token=secret-token&view=all&subject_name=Alice"
class FakeQueryParams:
def __init__(self) -> None:
self._items = [
("token", "secret-token"),
("view", "all"),
("subject_name", "Alice"),
]
def __bool__(self) -> bool:
return True
def multi_items(self):
return list(self._items)
class FakeRequest:
def __init__(self, headers: dict[str, str], *, client_host: str = "10.0.0.8") -> None:
self.headers = headers
self.client = FakeClient(client_host)
self.method = "GET"
self.url = FakeUrl()
self.query_params = FakeQueryParams()
self.scope = {
"http_version": "1.1",
"scheme": "https",
"path": "/api/v1/audit",
"method": "GET",
}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_audit_log_action_captures_request_access_context(): async def test_audit_log_action_captures_request_access_context():
context_token = set_request_audit_context( context_token = set_request_audit_context(
@@ -60,3 +103,105 @@ async def test_audit_log_action_captures_request_access_context():
assert log.client_platform == "macos" assert log.client_platform == "macos"
assert log.build_channel == "release" assert log.build_channel == "release"
assert log.build_commit == "abcdef1" assert log.build_commit == "abcdef1"
def test_request_audit_context_captures_sanitized_headers():
context = build_request_audit_context(
FakeRequest(
{
"user-agent": "Chrome",
"referer": "https://ctms.local/audit?token=secret-token",
"authorization": "Bearer secret-token",
"cookie": "sid=secret",
"x-ctms-client-source": "ctms-web",
"x-ctms-client-type": "web",
"x-ctms-client-version": "0.1.0",
"x-request-id": "req-123",
"x-custom-secret": "hidden",
"x-random-debug": "skip-me",
}
)
)
assert context.user_agent == "Chrome"
assert context.client_type == "web"
assert context.client_ip == "10.0.0.8"
assert uuid.UUID(context.request_id)
assert context.request_headers == {
"authorization": "[redacted]",
"cookie": "[redacted]",
"user-agent": "Chrome",
"x-ctms-client-source": "ctms-web",
"x-ctms-client-type": "web",
"x-ctms-client-version": "0.1.0",
"x-custom-secret": "[redacted]",
"x-request-id": "req-123",
}
assert context.request_snapshot is not None
assert context.request_snapshot["method"] == "GET"
assert context.request_snapshot["request_id"] == context.request_id
assert context.request_snapshot["path"] == "/api/v1/audit"
assert context.request_snapshot["query_string"] == (
"token=[redacted]&view=all&subject_name=[redacted]"
)
assert context.request_snapshot["query_params"] == [
{"name": "token", "value": "[redacted]"},
{"name": "view", "value": "all"},
{"name": "subject_name", "value": "[redacted]"},
]
assert context.request_snapshot["headers"]["authorization"] == "[redacted]"
assert context.request_snapshot["headers"]["cookie"] == "[redacted]"
assert context.request_snapshot["headers"]["x-ctms-client-source"] == "ctms-web"
assert "referer" not in context.request_snapshot["headers"]
assert "x-random-debug" not in context.request_snapshot["headers"]
assert context.request_snapshot["client"] == {"host": "10.0.0.8", "port": 54321}
assert context.request_snapshot["body"] == {
"captured": False,
"reason": "body_not_captured_by_audit_policy",
}
def test_request_audit_context_prefers_explicit_ctms_client_source():
context = build_request_audit_context(
FakeRequest(
{
"x-ctms-client-source": "ctms-desktop",
"x-ctms-client-type": "web",
}
)
)
assert context.client_type == "desktop"
assert context.request_headers == {
"x-ctms-client-source": "ctms-desktop",
"x-ctms-client-type": "web",
}
def test_request_audit_context_uses_proxy_observed_ip_instead_of_spoofed_forwarded_prefix(monkeypatch):
monkeypatch.setattr("app.core.request_context.settings.TRUSTED_PROXY_CIDRS", "10.0.0.0/8")
context = build_request_audit_context(
FakeRequest(
{
"x-real-ip": "203.0.113.20",
"x-forwarded-for": "1.2.3.4, 203.0.113.20",
}
)
)
assert context.client_ip == "203.0.113.20"
def test_request_audit_context_ignores_forwarded_headers_from_untrusted_peer(monkeypatch):
monkeypatch.setattr("app.core.request_context.settings.TRUSTED_PROXY_CIDRS", "10.0.0.0/8")
context = build_request_audit_context(
FakeRequest(
{
"x-real-ip": "203.0.113.20",
"x-forwarded-for": "1.2.3.4",
},
client_host="198.51.100.8",
)
)
assert context.client_ip == "198.51.100.8"
+210
View File
@@ -0,0 +1,210 @@
from __future__ import annotations
import asyncio
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.models.permission_access_log import PermissionAccessLog
from app.models.permission_metric_snapshot import PermissionMetricSnapshot
from app.models.security_access_log import SecurityAccessLog
from app.models.study import Study
from app.models.user import User, UserStatus
from app.services import monitoring_retention
from app.services import permission_log_writer, security_access_log_writer
class _FakeSession:
def __init__(self) -> None:
self.added: list[object] = []
self.committed = False
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
def add(self, value: object) -> None:
self.added.append(value)
async def commit(self) -> None:
self.committed = True
@pytest.mark.asyncio
async def test_permission_writer_gracefully_flushes_and_reports_stats(monkeypatch):
session = _FakeSession()
monkeypatch.setattr(permission_log_writer, "SessionLocal", lambda: session)
writer = permission_log_writer.PermissionLogWriter()
await writer.start()
writer.enqueue(
{
"study_id": uuid.uuid4(),
"user_id": uuid.uuid4(),
"endpoint_key": "subjects.read",
"role": "PM",
"allowed": True,
"elapsed_ms": 2.5,
}
)
await writer.stop()
stats = writer.stats()
assert session.committed is True
assert len(session.added) == 1
assert stats["running"] is False
assert stats["accepted_entries"] == 1
assert stats["written_entries"] == 1
assert stats["dropped_entries"] == 0
assert stats["queue_size"] == 0
assert stats["last_success_at"] is not None
@pytest.mark.asyncio
async def test_permission_writer_retries_and_exposes_permanent_failure(monkeypatch):
class _FailingSession:
async def __aenter__(self):
raise RuntimeError("database unavailable")
async def __aexit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr(permission_log_writer, "SessionLocal", _FailingSession)
writer = permission_log_writer.PermissionLogWriter()
batch = [
{
"study_id": uuid.uuid4(),
"user_id": uuid.uuid4(),
"endpoint_key": "subjects.read",
"role": "PM",
"allowed": True,
"elapsed_ms": 2.5,
}
]
assert await writer._write_batch(batch) is False
stats = writer.stats()
assert stats["retry_count"] == 1
assert stats["failed_batches"] == 1
assert stats["failed_entries"] == 1
assert stats["last_error_type"] == "RuntimeError"
def test_writer_queue_overflow_is_counted():
writer = security_access_log_writer.SecurityAccessLogWriter()
writer._queue = asyncio.Queue(maxsize=1)
writer.enqueue({"path": "/first"})
writer.enqueue({"path": "/dropped"})
stats = writer.stats()
assert stats["accepted_entries"] == 1
assert stats["dropped_entries"] == 1
assert stats["queue_size"] == 1
@pytest.mark.asyncio
async def test_retention_deletes_expired_rows_and_preserves_recent_rows(
db_session, test_engine, monkeypatch
):
now = datetime(2026, 7, 10, tzinfo=timezone.utc)
study_id = (await db_session.execute(select(Study.id).limit(1))).scalar_one()
user = User(
email=f"retention-{uuid.uuid4()}@example.com",
password_hash="hash",
full_name="Retention Test",
clinical_department="QA",
status=UserStatus.ACTIVE,
)
db_session.add(user)
await db_session.flush()
old_permission = PermissionAccessLog(
study_id=study_id,
user_id=user.id,
endpoint_key="old",
role="PM",
allowed=True,
elapsed_ms=1,
created_at=now - timedelta(days=91),
)
recent_permission = PermissionAccessLog(
study_id=study_id,
user_id=user.id,
endpoint_key="recent",
role="PM",
allowed=True,
elapsed_ms=1,
created_at=now - timedelta(days=89),
)
old_security = SecurityAccessLog(
method="GET",
path="/old",
status_code=404,
elapsed_ms=1,
auth_status="ANONYMOUS",
created_at=now - timedelta(days=91),
)
recent_security = SecurityAccessLog(
method="GET",
path="/recent",
status_code=200,
elapsed_ms=1,
auth_status="AUTHENTICATED",
created_at=now - timedelta(days=89),
)
old_metric = PermissionMetricSnapshot(
bucket_time=now - timedelta(days=401)
)
recent_metric = PermissionMetricSnapshot(
bucket_time=now - timedelta(days=399)
)
db_session.add_all(
[
old_permission,
recent_permission,
old_security,
recent_security,
old_metric,
recent_metric,
]
)
await db_session.commit()
old_permission_id = old_permission.id
recent_permission_id = recent_permission.id
old_security_id = old_security.id
recent_security_id = recent_security.id
old_metric_id = old_metric.id
recent_metric_id = recent_metric.id
session_factory = async_sessionmaker(
bind=test_engine,
class_=AsyncSession,
expire_on_commit=False,
)
monkeypatch.setattr(monitoring_retention, "SessionLocal", session_factory)
monkeypatch.setattr(
monitoring_retention.settings, "MONITORING_ACCESS_LOG_RETENTION_DAYS", 90
)
monkeypatch.setattr(
monitoring_retention.settings, "MONITORING_METRIC_RETENTION_DAYS", 400
)
deleted = await monitoring_retention.purge_expired_monitoring_data(now=now)
db_session.expire_all()
assert deleted["permission_access_logs"] >= 1
assert deleted["security_access_logs"] >= 1
assert deleted["permission_metric_snapshots"] >= 1
assert await db_session.get(PermissionAccessLog, old_permission_id) is None
assert await db_session.get(SecurityAccessLog, old_security_id) is None
assert await db_session.get(PermissionMetricSnapshot, old_metric_id) is None
assert await db_session.get(PermissionAccessLog, recent_permission_id) is not None
assert await db_session.get(SecurityAccessLog, recent_security_id) is not None
assert await db_session.get(PermissionMetricSnapshot, recent_metric_id) is not None
@@ -0,0 +1,53 @@
from types import SimpleNamespace
import pytest
from app.core.config import settings
from app.services import monitoring_server_location
def test_public_ip_normalization_rejects_private_addresses():
assert monitoring_server_location._normalize_public_ip("8.8.8.8\n") == "8.8.8.8"
assert monitoring_server_location._normalize_public_ip("10.0.0.8") is None
assert monitoring_server_location._normalize_public_ip("127.0.0.1") is None
assert monitoring_server_location._normalize_public_ip("not-an-ip") is None
@pytest.mark.asyncio
async def test_server_location_is_derived_from_configured_public_ip(monkeypatch):
monkeypatch.setattr(settings, "MONITORING_SERVER_PUBLIC_IP", "8.8.8.8")
monkeypatch.setattr(
monitoring_server_location,
"resolve_ip_location",
lambda ip: SimpleNamespace(
location="United States / California / San Jose",
country="United States",
province="California",
city="San Jose",
isp="Google",
),
)
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 / California / San Jose"
assert location.longitude == -121.8863
assert location.latitude == 37.3382
assert location.resolution_source == "configured_public_ip"
monitoring_server_location.reset_monitoring_server_location_cache()
@pytest.mark.asyncio
async def test_server_location_returns_none_instead_of_a_hardcoded_fallback(monkeypatch):
async def no_public_ip():
return None, "unavailable"
monkeypatch.setattr(monitoring_server_location, "_discover_public_ip", no_public_ip)
monitoring_server_location.reset_monitoring_server_location_cache()
location = await monitoring_server_location.resolve_monitoring_server_location()
assert location is None
monitoring_server_location.reset_monitoring_server_location_cache()
+554 -22
View File
@@ -1,7 +1,9 @@
"""监控API测试:权限系统监控API端点验证。""" """监控API测试:权限系统监控API端点验证。"""
import inspect import inspect
import json
import uuid import uuid
from types import SimpleNamespace
import pytest import pytest
from fastapi import HTTPException from fastapi import HTTPException
@@ -10,6 +12,7 @@ from sqlalchemy import text
from app.core.permission_monitor import set_permission_monitor, PermissionMonitor from app.core.permission_monitor import set_permission_monitor, PermissionMonitor
from app.api.v1 import permission_monitoring from app.api.v1 import permission_monitoring
from app.api.v1.system_permissions import list_system_permissions from app.api.v1.system_permissions import list_system_permissions
from app.services.monitoring_server_location import MonitoringServerLocation
class FakeIpInfo: class FakeIpInfo:
@@ -30,6 +33,18 @@ class ProjectPmUserStub:
is_admin = False is_admin = False
@pytest.fixture(autouse=True)
def disable_server_public_ip_discovery(monkeypatch):
async def no_server_location():
return None
monkeypatch.setattr(
permission_monitoring,
"resolve_monitoring_server_location",
no_server_location,
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_resolve_monitoring_scope_rejects_project_pm(db_session): async def test_resolve_monitoring_scope_rejects_project_pm(db_session):
"""系统监测模块仅允许系统管理员访问,项目 PM 不再具备监测范围。""" """系统监测模块仅允许系统管理员访问,项目 PM 不再具备监测范围。"""
@@ -103,9 +118,11 @@ async def _seed_permission_log(db_session, study_id: uuid.UUID, user_id: uuid.UU
text( text(
""" """
INSERT INTO permission_access_logs INSERT INTO permission_access_logs
(id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, created_at) (id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address,
request_snapshot, created_at)
VALUES VALUES
(:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, CURRENT_TIMESTAMP) (:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address,
:request_snapshot, CURRENT_TIMESTAMP)
""" """
), ),
{ {
@@ -117,6 +134,7 @@ async def _seed_permission_log(db_session, study_id: uuid.UUID, user_id: uuid.UU
"allowed": allowed, "allowed": allowed,
"elapsed_ms": elapsed_ms, "elapsed_ms": elapsed_ms,
"ip_address": "127.0.0.1", "ip_address": "127.0.0.1",
"request_snapshot": None,
}, },
) )
await db_session.commit() await db_session.commit()
@@ -262,8 +280,8 @@ async def test_permission_system_health_degraded(db_session):
assert any("权限拒绝率偏高" in issue for issue in data["issues"]) assert any("权限拒绝率偏高" in issue for issue in data["issues"])
assert any("缓存命中率偏低" in issue for issue in data["issues"]) assert any("缓存命中率偏低" in issue for issue in data["issues"])
assert data["status"] == "unhealthy" assert data["status"] == "unhealthy"
assert data["health_score"] == 30 assert data["health_score"] == 35
assert data["score_breakdown"]["performance"]["deduction"] == 25 assert data["score_breakdown"]["performance"]["deduction"] == 20
assert data["score_breakdown"]["deny_rate"]["deduction"] == 20 assert data["score_breakdown"]["deny_rate"]["deduction"] == 20
assert data["score_breakdown"]["cache"]["deduction"] == 25 assert data["score_breakdown"]["cache"]["deduction"] == 25
@@ -279,6 +297,10 @@ async def test_permission_system_health_includes_metrics(db_session):
assert "cache_stats" in data assert "cache_stats" in data
assert "score_breakdown" in data assert "score_breakdown" in data
assert "sample_sufficient" in data assert "sample_sufficient" in data
assert "components" in data
assert "storage" in data
assert "runtime" in data["score_breakdown"]
assert data["components"]["database"]["status"] == "healthy"
assert "total_checks" in data["last_hour"] assert "total_checks" in data["last_hour"]
assert "cache_metrics" in data["cache_stats"] assert "cache_metrics" in data["cache_stats"]
@@ -304,6 +326,41 @@ async def test_permission_system_health_ignores_small_cache_sample(db_session):
assert data["health_score"] == 100 - non_cache_deductions assert data["health_score"] == 100 - non_cache_deductions
@pytest.mark.asyncio
async def test_permission_system_health_reports_writer_degradation(db_session, monkeypatch):
monitor = PermissionMonitor()
set_permission_monitor(monitor)
writer = SimpleNamespace(
stats=lambda: {
"running": True,
"queue_size": 3,
"queue_capacity": 100,
"failed_batches": 0,
"dropped_entries": 2,
}
)
monkeypatch.setattr(permission_monitoring, "get_log_writer", lambda: writer)
monkeypatch.setattr(permission_monitoring, "get_security_log_writer", lambda: None)
monkeypatch.setattr(
permission_monitoring,
"get_monitoring_retention_status",
lambda: {
"running": False,
"last_run_started_at": None,
"last_success_at": None,
"last_error_at": None,
},
)
data = await permission_monitoring.permission_system_health(
db=db_session, _=AdminUserStub()
)
assert data["components"]["permission_log_writer"]["status"] == "degraded"
assert data["score_breakdown"]["runtime"]["deduction"] == 10
assert any("权限日志写入器" in issue for issue in data["issues"])
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_ip_locations_counts_unique_users_per_location(db_session, monkeypatch): async def test_ip_locations_counts_unique_users_per_location(db_session, monkeypatch):
"""IP 属地统计应按省市聚合访问次数、来源 IP 数和访问用户数。""" """IP 属地统计应按省市聚合访问次数、来源 IP 数和访问用户数。"""
@@ -381,10 +438,26 @@ async def test_ip_locations_counts_unique_users_per_location(db_session, monkeyp
) )
await db_session.commit() await db_session.commit()
resolved_ips = []
def fake_resolve_ip_location(ip):
resolved_ips.append(ip)
return FakeIpInfo("广东省", "深圳市")
async def fake_resolve_monitoring_server_location():
return MonitoringServerLocation(
name="China / 重庆 / 重庆市",
longitude=106.5516,
latitude=29.563,
accuracy_level="city",
resolution_source="public_ip_discovery",
)
monkeypatch.setattr(permission_monitoring, "resolve_ip_location", fake_resolve_ip_location)
monkeypatch.setattr( monkeypatch.setattr(
permission_monitoring, permission_monitoring,
"resolve_ip_location", "resolve_monitoring_server_location",
lambda ip: FakeIpInfo("广东省", "深圳市"), fake_resolve_monitoring_server_location,
) )
result = await permission_monitoring.get_ip_locations(db=db_session, _=AdminUserStub(), days=7, limit=10) result = await permission_monitoring.get_ip_locations(db=db_session, _=AdminUserStub(), days=7, limit=10)
@@ -396,6 +469,56 @@ async def test_ip_locations_counts_unique_users_per_location(db_session, monkeyp
assert result["summary"]["total_count"] == 3 assert result["summary"]["total_count"] == 3
assert result["summary"]["unique_ip_count"] == 2 assert result["summary"]["unique_ip_count"] == 2
assert result["summary"]["unique_user_count"] == 2 assert result["summary"]["unique_user_count"] == 2
assert result["items"][0]["country_code"] == "CN"
assert result["items"][0]["region_code"] == "CN-GD"
assert result["items"][0]["longitude"] is not None
assert result["items"][0]["risk_level"] == "attention"
assert result["period"]["mode"] == "rolling"
assert result["period"]["retention_days"] >= 7
assert result["data_quality"]["located_ip_count"] == 2
assert result["server_location"]["longitude"] == 106.5516
assert result["server_location"]["resolution_source"] == "public_ip_discovery"
assert sorted(resolved_ips) == ["10.1.1.1", "10.1.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
(lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' ||
substr(lower(hex(randomblob(2))),2) || '-' ||
substr('89ab', abs(random()) % 4 + 1, 1) ||
substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6))),
:study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address,
datetime('now', '-120 days'))
"""
),
{
"study_id": study_id,
"user_id": user_a,
"endpoint_key": "admin.permissions.read",
"role": "PM",
"allowed": True,
"elapsed_ms": 2.4,
"ip_address": "10.1.1.3",
},
)
await db_session.commit()
resolved_ips.clear()
all_result = await permission_monitoring.get_ip_locations(
db=db_session,
_=AdminUserStub(),
days=7,
limit=10,
all_time=True,
)
assert all_result["days"] is None
assert all_result["summary"]["total_count"] == 4
assert all_result["summary"]["unique_ip_count"] == 3
assert sorted(resolved_ips) == ["10.1.1.1", "10.1.1.2", "10.1.1.3"]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -530,9 +653,11 @@ async def test_ip_locations_keeps_private_network_location_label(db_session, mon
text( text(
""" """
INSERT INTO permission_access_logs INSERT INTO permission_access_logs
(id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, created_at) (id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address,
request_snapshot, created_at)
VALUES VALUES
(:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, CURRENT_TIMESTAMP) (:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address,
:request_snapshot, CURRENT_TIMESTAMP)
""" """
), ),
{ {
@@ -544,6 +669,7 @@ async def test_ip_locations_keeps_private_network_location_label(db_session, mon
"allowed": True, "allowed": True,
"elapsed_ms": 3.2, "elapsed_ms": 3.2,
"ip_address": "192.168.1.10", "ip_address": "192.168.1.10",
"request_snapshot": None,
}, },
) )
await db_session.commit() await db_session.commit()
@@ -556,8 +682,8 @@ async def test_ip_locations_keeps_private_network_location_label(db_session, mon
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_ip_locations_includes_abnormal_security_ips(db_session, monkeypatch): async def test_ip_locations_scores_security_sources_by_behavior(db_session, monkeypatch):
"""来源分析应同时统计安全事件中的异常来源 IP""" """来源分析应按安全行为而非地域评估来源风险"""
await db_session.execute(text("DELETE FROM permission_access_logs")) await db_session.execute(text("DELETE FROM permission_access_logs"))
await db_session.execute(text("DELETE FROM security_access_logs")) await db_session.execute(text("DELETE FROM security_access_logs"))
await db_session.commit() await db_session.commit()
@@ -603,9 +729,11 @@ async def test_ip_locations_includes_abnormal_security_ips(db_session, monkeypat
text( text(
""" """
INSERT INTO permission_access_logs INSERT INTO permission_access_logs
(id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, created_at) (id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address,
request_snapshot, created_at)
VALUES VALUES
(:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, CURRENT_TIMESTAMP) (:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address,
:request_snapshot, CURRENT_TIMESTAMP)
""" """
), ),
{ {
@@ -617,9 +745,10 @@ async def test_ip_locations_includes_abnormal_security_ips(db_session, monkeypat
"allowed": True, "allowed": True,
"elapsed_ms": 3.2, "elapsed_ms": 3.2,
"ip_address": "10.3.1.1", "ip_address": "10.3.1.1",
"request_snapshot": None,
}, },
) )
for ip_address in ["8.8.8.8", "1.1.1.1"]: for ip_address, status_code in [("8.8.8.8", 403), ("1.1.1.1", 200)]:
await db_session.execute( await db_session.execute(
text( text(
""" """
@@ -633,7 +762,7 @@ async def test_ip_locations_includes_abnormal_security_ips(db_session, monkeypat
"id": str(uuid.uuid4()), "id": str(uuid.uuid4()),
"method": "GET", "method": "GET",
"path": "/api/v1/admin", "path": "/api/v1/admin",
"status_code": 403, "status_code": status_code,
"elapsed_ms": 8.5, "elapsed_ms": 8.5,
"client_ip": ip_address, "client_ip": ip_address,
"user_agent": "pytest", "user_agent": "pytest",
@@ -658,18 +787,23 @@ async def test_ip_locations_includes_abnormal_security_ips(db_session, monkeypat
locations = {item["country"]: item for item in result["items"]} locations = {item["country"]: item for item in result["items"]}
assert result["summary"]["total_count"] == 3 assert result["summary"]["total_count"] == 3
assert result["summary"]["denied_count"] == 2 assert result["summary"]["denied_count"] == 1
assert result["summary"]["unique_ip_count"] == 3 assert result["summary"]["unique_ip_count"] == 3
assert result["summary"]["unique_user_count"] == 1 assert result["summary"]["unique_user_count"] == 1
assert locations["United States"]["total_count"] == 1 assert locations["United States"]["total_count"] == 1
assert locations["United States"]["denied_count"] == 1 assert locations["United States"]["denied_count"] == 1
assert locations["United States"]["risk_level"] == "high"
assert locations["United States"]["country_code"] == "US"
assert locations["Australia"]["total_count"] == 1 assert locations["Australia"]["total_count"] == 1
assert locations["Australia"]["denied_count"] == 0
assert locations["Australia"]["risk_level"] == "normal"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_access_logs_include_user_behavior_summary(db_session): async def test_access_logs_include_user_behavior_summary(db_session):
"""访问日志应返回用户行为审计汇总和用户排行。""" """访问日志应返回用户行为审计汇总和用户排行。"""
await db_session.execute(text("DELETE FROM permission_access_logs")) await db_session.execute(text("DELETE FROM permission_access_logs"))
await db_session.execute(text("DELETE FROM security_access_logs"))
await db_session.commit() await db_session.commit()
study_id = "00000000-0000-0000-0000-000000000201" study_id = "00000000-0000-0000-0000-000000000201"
@@ -784,10 +918,300 @@ def test_access_logs_user_stats_query_avoids_postgresql_uuid_max():
assert "func.max(PermissionAccessLog.user_id)" not in source assert "func.max(PermissionAccessLog.user_id)" not in source
@pytest.mark.asyncio
async def test_access_logs_filter_by_account_location_and_client_source(db_session, monkeypatch):
"""访问审计应支持按账号、IP 属地和客户端来源排查。"""
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 = "00000000-0000-0000-0000-000000001401"
user_a = "00000000-0000-0000-0000-000000001501"
user_b = "00000000-0000-0000-0000-000000001502"
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": "ACCESS-CONTEXT-STUDY",
"name": "Access Context Study",
"status": "ACTIVE",
"is_locked": False,
"visit_schedule": "[]",
"active_roles": "[]",
},
)
for user_id, email, name in [
(user_a, "audit-shanghai@example.com", "上海审计用户"),
(user_b, "audit-beijing@example.com", "北京审计用户"),
]:
await db_session.execute(
text(
"""
INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status)
VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status)
"""
),
{
"id": user_id,
"email": email,
"password_hash": "hash",
"full_name": name,
"clinical_department": "临床运营",
"is_admin": False,
"status": "ACTIVE",
},
)
rows = [
(user_a, "203.0.113.10", "web", "Chrome", "macos", {"user-agent": "Chrome", "x-request-id": "req-web"}),
(user_b, "198.51.100.20", "desktop", "CTMS Desktop", "windows", {"user-agent": "CTMS Desktop"}),
(user_a, "10.10.10.10", None, "curl/8.0", None, {"user-agent": "curl/8.0"}),
]
for user, ip_address, client_type, user_agent, platform, request_headers in rows:
await db_session.execute(
text(
"""
INSERT INTO permission_access_logs
(id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address,
user_agent, client_type, client_version, client_platform, build_channel, build_commit, request_headers, created_at)
VALUES
(:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address,
:user_agent, :client_type, :client_version, :client_platform, :build_channel, :build_commit, :request_headers, CURRENT_TIMESTAMP)
"""
),
{
"id": str(uuid.uuid4()),
"study_id": study_id,
"user_id": user,
"endpoint_key": "admin.permissions.read",
"role": "PM",
"allowed": True,
"elapsed_ms": 4.0,
"ip_address": ip_address,
"user_agent": user_agent,
"client_type": client_type,
"client_version": "0.1.0" if client_type else None,
"client_platform": platform,
"build_channel": "local" if client_type else None,
"build_commit": "abcdef1" if client_type else None,
"request_headers": json.dumps(request_headers),
},
)
await db_session.commit()
ip_info_by_address = {
"203.0.113.10": FakeIpInfo("上海市", "上海市", "电信"),
"198.51.100.20": FakeIpInfo("北京市", "北京市", "联通"),
"10.10.10.10": FakeIpInfo("", "", "", "局域网"),
}
monkeypatch.setattr(
permission_monitoring,
"resolve_ip_location",
lambda ip: ip_info_by_address[ip],
)
result = await permission_monitoring.get_access_logs(
db=db_session,
_=AdminUserStub(),
study_id=None,
user_id=None,
endpoint_key=None,
role=None,
allowed=None,
start_time=None,
end_time=None,
client_ip=None,
client_type="web",
keyword="上海",
page=1,
page_size=50,
)
assert result["total"] == 1
assert result["items"][0]["user_email"] == "audit-shanghai@example.com"
assert result["items"][0]["client_type"] == "web"
assert result["items"][0]["client_platform"] == "macos"
assert result["items"][0]["user_agent"] == "Chrome"
assert result["items"][0]["request_headers"]["x-request-id"] == "req-web"
assert result["items"][0]["ip_city"] == "上海市"
unknown_result = await permission_monitoring.get_access_logs(
db=db_session,
_=AdminUserStub(),
study_id=None,
user_id=None,
endpoint_key=None,
role=None,
allowed=None,
start_time=None,
end_time=None,
client_ip=None,
client_type="unknown",
keyword="curl",
page=1,
page_size=50,
)
assert unknown_result["total"] == 1
assert unknown_result["items"][0]["ip_address"] == "10.10.10.10"
@pytest.mark.asyncio
async def test_access_logs_include_security_events_for_admin(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 = "00000000-0000-0000-0000-000000001601"
user_id = "00000000-0000-0000-0000-000000001701"
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": "ACCESS-SECURITY-FEED",
"name": "Access Security Feed",
"status": "ACTIVE",
"is_locked": False,
"visit_schedule": "[]",
"active_roles": "[]",
},
)
await db_session.execute(
text(
"""
INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status)
VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status)
"""
),
{
"id": user_id,
"email": "access-feed@example.com",
"password_hash": "hash",
"full_name": "统一访问用户",
"clinical_department": "临床运营",
"is_admin": False,
"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,
request_snapshot, created_at)
VALUES
(:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address,
:request_snapshot, CURRENT_TIMESTAMP)
"""
),
{
"id": str(uuid.uuid4()),
"study_id": study_id,
"user_id": user_id,
"endpoint_key": "project_overview:read",
"role": "PM",
"allowed": True,
"elapsed_ms": 3.0,
"ip_address": "192.168.10.2",
"request_snapshot": json.dumps(
{
"method": "GET",
"path": "/api/v1/projects/overview",
"query_string": "tab=summary",
"headers": {"user-agent": "Chrome", "x-request-id": "perm-1"},
"body": {"captured": False, "reason": "body_not_captured_by_audit_policy"},
}
),
},
)
await db_session.execute(
text(
"""
INSERT INTO security_access_logs
(id, method, path, status_code, elapsed_ms, client_ip, user_agent, auth_status,
user_identifier, request_headers, request_snapshot, created_at)
VALUES
(:id, :method, :path, :status_code, :elapsed_ms, :client_ip, :user_agent, :auth_status,
:user_identifier, :request_headers, :request_snapshot, CURRENT_TIMESTAMP)
"""
),
{
"id": str(uuid.uuid4()),
"method": "GET",
"path": "/api/.git/config",
"status_code": 404,
"elapsed_ms": 1.5,
"client_ip": "45.148.10.95",
"user_agent": "probe-bot/1.0",
"auth_status": "ANONYMOUS",
"user_identifier": None,
"request_headers": json.dumps({"user-agent": "probe-bot/1.0", "x-request-id": "sec-1"}),
"request_snapshot": json.dumps(
{
"method": "GET",
"path": "/api/.git/config",
"query_string": "token=[redacted]",
"headers": {"user-agent": "probe-bot/1.0", "authorization": "[redacted]"},
"body": {"captured": False, "reason": "body_not_captured_by_audit_policy"},
}
),
},
)
await db_session.commit()
ip_info_by_address = {
"192.168.10.2": FakeIpInfo("", "", "", "局域网"),
"45.148.10.95": FakeIpInfo("South Holland", "", "", "Netherlands"),
}
monkeypatch.setattr(permission_monitoring, "resolve_ip_location", lambda ip: ip_info_by_address[ip])
result = await permission_monitoring.get_access_logs(
db=db_session,
_=AdminUserStub(),
study_id=None,
user_id=None,
endpoint_key=None,
role=None,
allowed=None,
start_time=None,
end_time=None,
client_ip=None,
client_type=None,
keyword=None,
page=1,
page_size=50,
)
assert result["total"] == 2
assert result["summary"]["total_count"] == 2
assert result["summary"]["denied_count"] == 1
items_by_type = {item["event_type"]: item for item in result["items"]}
assert items_by_type["permission"]["endpoint_key"] == "project_overview:read"
assert items_by_type["security"]["status_code"] == 404
assert items_by_type["security"]["allowed"] is False
assert items_by_type["security"]["category"] == "PROBE"
assert items_by_type["security"]["severity"] == "CRITICAL"
assert items_by_type["security"]["request_headers"]["x-request-id"] == "sec-1"
assert items_by_type["permission"]["request_snapshot"]["path"] == "/api/v1/projects/overview"
assert items_by_type["security"]["request_snapshot"]["headers"]["authorization"] == "[redacted]"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_access_logs_ip_ranking_aggregates_by_ip_total(db_session): async def test_access_logs_ip_ranking_aggregates_by_ip_total(db_session):
"""IP 访问排行应按同一 IP 的整体访问量聚合排序,而不是按用户/IP/角色拆分。""" """IP 访问排行应按同一 IP 的整体访问量聚合排序,而不是按用户/IP/角色拆分。"""
await db_session.execute(text("DELETE FROM permission_access_logs")) await db_session.execute(text("DELETE FROM permission_access_logs"))
await db_session.execute(text("DELETE FROM security_access_logs"))
await db_session.commit() await db_session.commit()
study_id = "00000000-0000-0000-0000-000000001201" study_id = "00000000-0000-0000-0000-000000001201"
@@ -896,10 +1320,13 @@ async def test_security_access_logs_include_anonymous_ip_attempts(db_session, mo
text( text(
""" """
INSERT INTO security_access_logs INSERT INTO security_access_logs
(id, method, path, status_code, elapsed_ms, client_ip, user_agent, auth_status, user_identifier, created_at) (id, method, path, status_code, elapsed_ms, client_ip, user_agent, auth_status,
user_identifier, request_snapshot, created_at)
VALUES VALUES
('00000000-0000-4000-8000-000000000501', 'POST', '/api/v1/auth/login', 401, 12.5, ('00000000-0000-4000-8000-000000000501', 'POST', '/api/v1/auth/login', 401, 12.5,
'203.0.113.10', 'attack-bot/1.0', 'ANONYMOUS', NULL, CURRENT_TIMESTAMP) '203.0.113.10', 'attack-bot/1.0', 'ANONYMOUS', NULL,
'{"method":"POST","path":"/api/v1/auth/login","headers":{"authorization":"[redacted]"}}',
CURRENT_TIMESTAMP)
""" """
) )
) )
@@ -925,6 +1352,7 @@ async def test_security_access_logs_include_anonymous_ip_attempts(db_session, mo
assert result["items"][0]["ip_isp"] == "联通" assert result["items"][0]["ip_isp"] == "联通"
assert result["items"][0]["account_label"] == "未知账号" assert result["items"][0]["account_label"] == "未知账号"
assert result["items"][0]["status_code"] == 401 assert result["items"][0]["status_code"] == 401
assert result["items"][0]["request_snapshot"]["headers"]["authorization"] == "[redacted]"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -993,8 +1421,8 @@ async def test_security_access_logs_prioritize_sensitive_probe_over_foreign_ip(d
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_security_access_logs_classify_non_china_ip_as_abnormal(db_session, monkeypatch): async def test_security_access_logs_classify_foreign_invalid_token_by_behavior(db_session, monkeypatch):
"""安全事件明细应把非中国公网 IP 归类为异常 IP""" """海外来源不应单凭地域升级风险,仍按认证行为分类"""
await db_session.execute(text("DELETE FROM security_access_logs")) await db_session.execute(text("DELETE FROM security_access_logs"))
monkeypatch.setattr( monkeypatch.setattr(
permission_monitoring, permission_monitoring,
@@ -1024,8 +1452,8 @@ async def test_security_access_logs_classify_non_china_ip_as_abnormal(db_session
) )
item = result["items"][0] item = result["items"][0]
assert item["category"] == "ABNORMAL_IP" assert item["category"] == "INVALID_TOKEN"
assert item["severity"] == "HIGH" assert item["severity"] == "MEDIUM"
assert item["ip_location"] == "United States / California / San Jose / Google" assert item["ip_location"] == "United States / California / San Jose / Google"
assert item["ip_country"] == "United States" assert item["ip_country"] == "United States"
@@ -1062,4 +1490,108 @@ async def test_security_access_logs_resolve_public_ip_with_packaged_ip2region(db
assert item["ip_country"] == "United States" assert item["ip_country"] == "United States"
assert item["ip_province"] == "California" assert item["ip_province"] == "California"
assert item["ip_city"] == "San Jose" assert item["ip_city"] == "San Jose"
assert item["category"] == "ABNORMAL_IP" assert item["category"] == "NOT_FOUND_NOISE"
@pytest.mark.asyncio
async def test_access_logs_deduplicate_permission_and_security_rows_by_request_id(db_session):
await db_session.execute(text("DELETE FROM permission_access_logs"))
await db_session.execute(text("DELETE FROM security_access_logs"))
request_id = str(uuid.uuid4())
study_id = str(uuid.uuid4())
user_id = str(uuid.uuid4())
await _seed_permission_log(db_session, uuid.UUID(study_id), uuid.UUID(user_id), allowed=False, elapsed_ms=4.0)
await db_session.execute(
text("UPDATE permission_access_logs SET request_id = :request_id"),
{"request_id": request_id},
)
await db_session.execute(
text(
"""
INSERT INTO security_access_logs
(id, method, path, status_code, elapsed_ms, client_ip, auth_status, user_identifier,
request_id, category, severity, created_at)
VALUES
(:id, 'GET', '/api/v1/studies/denied', 403, 5.0, '203.0.113.20', 'AUTHENTICATED', :user_id,
:request_id, 'OTHER', 'LOW', CURRENT_TIMESTAMP)
"""
),
{"id": str(uuid.uuid4()), "user_id": user_id, "request_id": request_id},
)
await db_session.commit()
result = await permission_monitoring.get_access_logs(
db=db_session,
_=AdminUserStub(),
study_id=None,
user_id=None,
endpoint_key=None,
role=None,
allowed=None,
start_time=None,
end_time=None,
client_ip=None,
client_type=None,
keyword=None,
page=1,
page_size=50,
)
assert result["total"] == 1
assert result["items"][0]["event_type"] == "security"
assert result["items"][0]["request_id"] == request_id
@pytest.mark.asyncio
async def test_security_events_only_includes_persisted_probe(db_session):
await db_session.execute(text("DELETE FROM security_access_logs"))
for category, ip in [("PROBE", "52.53.218.145"), ("OTHER", "203.0.113.20")]:
await db_session.execute(
text(
"""
INSERT INTO security_access_logs
(id, method, path, status_code, elapsed_ms, client_ip, auth_status,
category, severity, created_at)
VALUES
(:id, 'GET', '/api/v1/studies/', 200, 3.0, :ip, 'AUTHENTICATED',
:category, :severity, CURRENT_TIMESTAMP)
"""
),
{
"id": str(uuid.uuid4()),
"ip": ip,
"category": category,
"severity": "CRITICAL" if category == "PROBE" else "LOW",
},
)
await db_session.commit()
result = await permission_monitoring.get_security_access_logs(
db=db_session,
_=AdminUserStub(),
status_min=None,
auth_status=None,
events_only=True,
page=1,
page_size=20,
)
assert result["total"] == 1
assert result["items"][0]["category"] == "PROBE"
assert result["summary"]["high_risk_count"] == 1
def test_server_error_classification_takes_priority_over_foreign_ip():
log = SimpleNamespace(
path="/api/v1/studies/",
status_code=503,
auth_status="AUTHENTICATED",
category=None,
severity=None,
)
location = FakeIpInfo("California", "San Jose", "Google", "United States")
assert permission_monitoring._classify_security_access_log(log, location) == {
"category": "SERVER_ERROR",
"severity": "HIGH",
}
+13
View File
@@ -12,6 +12,7 @@ from datetime import datetime, timedelta, timezone
import base64 import base64
import json import json
import os import os
import uuid
from app.main import create_app from app.main import create_app
from app.core.config import settings from app.core.config import settings
@@ -397,6 +398,7 @@ async def test_root_returns_service_metadata(client_and_db):
client, _ = client_and_db client, _ = client_and_db
resp = await client.get("/") resp = await client.get("/")
assert resp.status_code == 200 assert resp.status_code == 200
assert uuid.UUID(resp.headers["x-request-id"])
assert resp.json() == { assert resp.json() == {
"service": "ctms-backend", "service": "ctms-backend",
"status": "ok", "status": "ok",
@@ -406,6 +408,17 @@ async def test_root_returns_service_metadata(client_and_db):
} }
@pytest.mark.asyncio
async def test_readiness_checks_database(client_and_db):
client, _ = client_and_db
resp = await client.get("/readyz")
assert resp.status_code == 200
payload = resp.json()
assert payload["status"] == "ready"
assert payload["database"]["status"] == "healthy"
assert payload["database"]["latency_ms"] >= 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_registered_user_can_login_after_email_verification(client_and_db): async def test_registered_user_can_login_after_email_verification(client_and_db):
client, SessionLocal = client_and_db client, SessionLocal = client_and_db
@@ -0,0 +1,68 @@
from datetime import datetime, timedelta, timezone
import pytest
from app.models.source_location_snapshot import SourceLocationSnapshot
from app.services.source_location_aggregator import _identity_hash, get_source_location_timeline
def _snapshot(bucket_time: datetime, *, allowed: int, denied: int) -> SourceLocationSnapshot:
return SourceLocationSnapshot(
bucket_time=bucket_time,
ip_hash=_identity_hash("ip", "203.0.113.10"),
user_hash=_identity_hash("user", "user-1"),
country="United States",
country_code="US",
province="California",
region_code="",
city="San Jose",
isp="Example ISP",
location="United States / California / San Jose",
longitude=-121.8863,
latitude=37.3382,
accuracy_level="city",
allowed_count=allowed,
denied_count=denied,
security_event_count=denied,
high_risk_count=0,
auth_failure_count=denied,
first_seen_at=bucket_time,
last_seen_at=bucket_time + timedelta(minutes=30),
)
def test_source_location_identity_hash_is_stable_and_does_not_expose_ip():
first = _identity_hash("ip", "203.0.113.10")
second = _identity_hash("ip", "203.0.113.10")
assert first == second
assert len(first) == 64
assert "203.0.113.10" not in first
@pytest.mark.asyncio
async def test_source_location_timeline_supports_hour_and_day_rollups(db_session):
start = datetime(2026, 7, 10, 8, tzinfo=timezone.utc)
db_session.add(_snapshot(start, allowed=4, denied=1))
db_session.add(_snapshot(start + timedelta(hours=1), allowed=3, denied=2))
await db_session.commit()
hourly = await get_source_location_timeline(
db_session,
start_at=start,
end_at=start + timedelta(hours=2),
granularity="hour",
)
daily = await get_source_location_timeline(
db_session,
start_at=start,
end_at=start + timedelta(hours=2),
granularity="day",
)
assert [item["total_count"] for item in hourly] == [5, 5]
assert len(daily) == 1
assert daily[0]["total_count"] == 10
assert daily[0]["denied_count"] == 3
assert daily[0]["unique_ip_count"] == 1
assert daily[0]["unique_user_count"] == 1
+61
View File
@@ -0,0 +1,61 @@
from datetime import datetime, timedelta, timezone
import uuid
import pytest
from app.core.config import settings
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
def test_legacy_session_id_is_stable_without_storing_a_token():
payload = {"sub": "00000000-0000-0000-0000-000000000101", "orig_iat": 1_700_000_000, "client_type": "web"}
first = session_id_from_payload(payload)
second = session_id_from_payload(payload)
assert first == second
assert isinstance(first, uuid.UUID)
@pytest.mark.asyncio
async def test_login_summary_marks_recent_unended_sessions_online(db_session, monkeypatch):
now = datetime.now(timezone.utc)
monkeypatch.setattr(settings, "USER_SESSION_ONLINE_SECONDS", 300)
user = User(
id=uuid.uuid4(),
email="login-summary@example.com",
password_hash="hash",
full_name="Login Summary",
clinical_department="IT",
status=UserStatus.ACTIVE,
)
db_session.add(user)
db_session.add_all(
[
UserLoginSession(
id=uuid.uuid4(),
user_id=user.id,
client_type="desktop",
login_at=now - timedelta(minutes=10),
last_seen_at=now - timedelta(seconds=20),
),
UserLoginSession(
id=uuid.uuid4(),
user_id=user.id,
client_type="web",
login_at=now - timedelta(days=1),
last_seen_at=now - timedelta(days=1),
ended_at=now - timedelta(days=1),
end_reason="logout",
),
]
)
await db_session.commit()
summaries = await get_login_summaries(db_session, [user.id])
assert summaries[user.id].status == "ONLINE"
assert summaries[user.id].active_session_count == 1
assert summaries[user.id].client_type == "desktop"
+1 -1
View File
@@ -68,7 +68,7 @@ services:
- ./frontend/public/favicon.ico:/usr/share/nginx/html/favicon.ico:ro - ./frontend/public/favicon.ico:/usr/share/nginx/html/favicon.ico:ro
depends_on: depends_on:
backend: backend:
condition: service_started condition: service_healthy
frontend-dev: frontend-dev:
condition: service_healthy condition: service_healthy
networks: networks:
+19 -1
View File
@@ -33,9 +33,26 @@ services:
FRONTEND_PUBLIC_URL: ${FRONTEND_PUBLIC_URL:-http://localhost:8888} FRONTEND_PUBLIC_URL: ${FRONTEND_PUBLIC_URL:-http://localhost:8888}
LOGIN_RSA_PRIVATE_KEY: ${LOGIN_RSA_PRIVATE_KEY:-} LOGIN_RSA_PRIVATE_KEY: ${LOGIN_RSA_PRIVATE_KEY:-}
LOGIN_RSA_KEY_ID: ${LOGIN_RSA_KEY_ID:-default} LOGIN_RSA_KEY_ID: ${LOGIN_RSA_KEY_ID:-default}
MONITORING_ACCESS_LOG_RETENTION_DAYS: ${MONITORING_ACCESS_LOG_RETENTION_DAYS:-90}
MONITORING_METRIC_RETENTION_DAYS: ${MONITORING_METRIC_RETENTION_DAYS:-400}
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}
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}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
healthcheck:
test:
- CMD
- python
- -c
- import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/readyz', timeout=2).read()
interval: 10s
timeout: 3s
retries: 5
start_period: 20s
networks: networks:
- ctms_net - ctms_net
@@ -76,7 +93,8 @@ services:
- ./nginx/certs:/etc/nginx/certs:ro - ./nginx/certs:/etc/nginx/certs:ro
- ./frontend/public/favicon.ico:/usr/share/nginx/html/favicon.ico:ro - ./frontend/public/favicon.ico:/usr/share/nginx/html/favicon.ico:ro
depends_on: depends_on:
- backend backend:
condition: service_healthy
networks: networks:
- ctms_net - ctms_net
@@ -0,0 +1,82 @@
# 系统监测简易运维指南
## 定位与访问边界
系统监测用于单实例或小规模 CTMS 部署的日常巡检、访问追踪和初步故障定位,不替代集中式指标、日志和告警平台。监测页面及 `/api/v1/permission-monitoring/*` 仅允许系统管理员访问,项目 PM 不具备系统级监测权限。
## 健康检查
- `GET /health`:进程存活探针,不访问数据库,适合判断 HTTP 服务是否仍在响应。
- `GET /readyz`:服务就绪探针,会执行数据库查询;数据库不可用时返回 `503`。Nginx、安装脚本和后端容器健康检查均使用或暴露此探针。
- `GET /api/v1/permission-monitoring/health`:管理员健康详情,包含权限检查质量、缓存、进程内告警、数据库延迟、权限/安全日志写入队列、留存任务和监测表数据量。
部署后的最小检查:
```bash
curl -fsS http://127.0.0.1:8888/health
curl -fsS http://127.0.0.1:8888/readyz
docker compose ps
```
## 数据留存
后台任务在应用启动后立即清理一次,之后按固定间隔执行。默认策略:
- 权限访问日志和安全访问日志:90 天。
- 权限小时指标:400 天。
- 账号登录活动:180 天。
- 清理周期:86400 秒(每天)。
可通过后端环境变量调整:
```text
MONITORING_ACCESS_LOG_RETENTION_DAYS=90
MONITORING_METRIC_RETENTION_DAYS=400
MONITORING_RETENTION_INTERVAL_SECONDS=86400
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。
## 来源地图服务器位置
来源地图中的服务器标记不使用固定城市或前端坐标。后端启动时按以下顺序确定部署服务器的公网地址,并缓存定位结果:
1. `MONITORING_SERVER_PUBLIC_IP` 明确指定的公网 IP,适用于禁止主动访问公网探测服务的生产网络。
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`,地图不会使用任意默认城市代替。
受限网络建议显式配置:
```text
MONITORING_SERVER_PUBLIC_IP=<部署服务器公网IP>
MONITORING_PUBLIC_IP_DISCOVERY_URLS=
```
公网 IP 仅用于服务端定位,不写入来源分析 API 响应或浏览器界面。
## 日志完整性与隐私
- 每个请求由服务端生成 UUID 请求标识,并通过响应头 `X-Request-ID` 返回;权限日志和安全日志以该标识消除同一请求的重复统计。
- 写入队列保持非阻塞;队列满或数据库批次写入最终失败时不阻塞业务请求,但会累计丢弃量、失败批次、队列占用和最近错误时间,并在管理员健康页显示降级。
- 请求正文不采集。请求头仅保留运维白名单字段,认证、Cookie、密钥类字段统一脱敏;查询参数中的令牌、账号、受试者/患者、姓名、邮箱、电话、证件和地址类值会替换为 `[redacted]`
- IP、User-Agent 和请求路径仍属于运维审计数据,应按管理员最小授权和上述留存策略管理,不应复制到公开工单或通知正文。
## 常见异常处理
| 现象 | 首要检查 | 建议处理 |
| --- | --- | --- |
| `/health` 正常、`/readyz` 返回 503 | 数据库容器、连接串、迁移状态 | 检查 `docker compose ps`、数据库日志和 `alembic current` |
| 日志写入器降级 | 队列占用、丢弃量、失败批次、最近错误 | 检查数据库连接和容量;恢复后确认队列回落并重启以清零进程累计计数 |
| 留存任务异常 | 最近成功/错误时间 | 检查数据库权限、表结构和后端日志,确认 migration 已升级 |
| 页面显示“可能是上次成功结果” | 对应 API 或网络请求 | 使用刷新按钮重试,并结合 `/readyz` 与浏览器网络面板定位 |
| 安全事件突然增多 | 分类、来源 IP、路径和状态码 | 优先核对敏感路径探测、5xx 和无效令牌,不要仅按 4xx 总量或地理位置判断 |
## 当前边界与后续升级
权限缓存指标、告警列表和写入器累计计数仍为单进程内存状态,重启后会重置;当前也不负责短信、邮件、Slack 等外部通知。需要多实例部署、跨重启趋势、值班通知或长期容量分析时,应接入 Prometheus/OpenTelemetry、集中日志和 Alertmanager 类告警链路,并保留本模块作为管理员快速诊断入口。
+19
View File
@@ -33,6 +33,11 @@ export type LoginKeyResponse = {
expires_at: string; expires_at: string;
}; };
export type SessionHeartbeatResponse = {
status: "online";
last_seen_at: string;
};
export type EncryptedPasswordRequest = { export type EncryptedPasswordRequest = {
key_id: string; key_id: string;
challenge: string; challenge: string;
@@ -53,4 +58,18 @@ export const extendToken = (token: string): Promise<AxiosResponse<ExtendResponse
export const getLoginKey = (): Promise<AxiosResponse<LoginKeyResponse>> => export const getLoginKey = (): Promise<AxiosResponse<LoginKeyResponse>> =>
authClient.get<LoginKeyResponse>("/api/v1/auth/login-key"); authClient.get<LoginKeyResponse>("/api/v1/auth/login-key");
export const heartbeatSession = (token: string): Promise<AxiosResponse<SessionHeartbeatResponse>> =>
authClient.post<SessionHeartbeatResponse>(
"/api/v1/auth/session/heartbeat",
{},
{ headers: { Authorization: `Bearer ${token}` }, timeout: 5000 },
);
export const logoutSession = (token: string): Promise<AxiosResponse<void>> =>
authClient.post<void>(
"/api/v1/auth/session/logout",
{},
{ headers: { Authorization: `Bearer ${token}` } },
);
export default authClient; export default authClient;
+11 -1
View File
@@ -78,6 +78,9 @@ export const fetchAccessLogs = (params: {
allowed?: boolean; allowed?: boolean;
start_time?: string; start_time?: string;
end_time?: string; end_time?: string;
client_ip?: string;
client_type?: string;
keyword?: string;
page?: number; page?: number;
page_size?: number; page_size?: number;
}) => apiGet<AccessLogsResponse>(`/api/v1/permission-monitoring/access-logs`, { params }); }) => apiGet<AccessLogsResponse>(`/api/v1/permission-monitoring/access-logs`, { params });
@@ -87,6 +90,13 @@ export const fetchSecurityAccessLogs = (params?: {
auth_status?: string; auth_status?: string;
client_type?: string; client_type?: string;
client_version?: string; client_version?: string;
client_ip?: string;
category?: string;
severity?: string;
keyword?: string;
events_only?: boolean;
start_time?: string;
end_time?: string;
page?: number; page?: number;
page_size?: number; page_size?: number;
}) => apiGet<SecurityAccessLogsResponse>(`/api/v1/permission-monitoring/security-logs`, { params }); }) => apiGet<SecurityAccessLogsResponse>(`/api/v1/permission-monitoring/security-logs`, { params });
@@ -97,7 +107,7 @@ export const fetchPermissionTrends = (period: "24h" | "7d" | "30d" = "24h") =>
export const fetchTopDenied = (params?: { days?: number; limit?: number }) => export const fetchTopDenied = (params?: { days?: number; limit?: number }) =>
apiGet<TopDeniedResponse>(`/api/v1/permission-monitoring/top-denied`, { params }); apiGet<TopDeniedResponse>(`/api/v1/permission-monitoring/top-denied`, { params });
export const fetchIpLocations = (params?: { days?: number; limit?: number }) => export const fetchIpLocations = (params?: { days?: number; all_time?: boolean; limit?: number }) =>
apiGet<IpLocationsResponse>(`/api/v1/permission-monitoring/ip-locations`, { params }); apiGet<IpLocationsResponse>(`/api/v1/permission-monitoring/ip-locations`, { params });
export const fetchStatsSummary = () => export const fetchStatsSummary = () =>
+6 -1
View File
@@ -1,5 +1,5 @@
import { apiGet, apiPatch, apiPost } from "./axios"; import { apiGet, apiPatch, apiPost } from "./axios";
import type { ApiListResponse, UserInfo } from "../types/api"; import type { ApiListResponse, UserInfo, UserLoginActivity } from "../types/api";
import { apiDelete } from "./axios"; import { apiDelete } from "./axios";
export const fetchUsers = (params?: Record<string, any>) => export const fetchUsers = (params?: Record<string, any>) =>
@@ -21,3 +21,8 @@ export const updateUser = (
export const deleteUser = (userId: string) => export const deleteUser = (userId: string) =>
apiDelete<void>(`/api/v1/users/${userId}`, { suppressErrorMessage: true }); apiDelete<void>(`/api/v1/users/${userId}`, { suppressErrorMessage: true });
export const fetchUserLoginActivities = (userId: string, limit = 30) =>
apiGet<UserLoginActivity[]>(`/api/v1/users/${userId}/login-activities`, {
params: { limit },
});
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = (path: string) => readFileSync(resolve(__dirname, path), "utf8");
describe("account connection status", () => {
it("keeps the compact indicator and detailed account diagnostics in both shells", () => {
const component = readSource("./AccountConnectionStatus.vue");
const webLayout = readSource("./WebLayout.vue");
const desktopLayout = readSource("./DesktopLayout.vue");
expect(component).toContain('mode === \'indicator\'');
expect(component).toContain("当前账号与连接状态");
expect(component).toContain("API 延迟");
expect(component).toContain("会话状态");
expect(component).toContain("不代表系统健康评分");
expect(component).not.toContain("ip_address");
expect(component).not.toContain("access_token");
expect(webLayout).toContain('<AccountConnectionStatus mode="indicator" />');
expect(webLayout).toContain('<AccountConnectionStatus mode="details" />');
expect(desktopLayout).toContain('<AccountConnectionStatus mode="indicator" />');
expect(desktopLayout).toContain('<AccountConnectionStatus mode="details" />');
});
});
@@ -0,0 +1,267 @@
<template>
<span
v-if="mode === 'indicator'"
class="account-connection-indicator"
:class="`is-${status}`"
:title="`账号会话:${statusLabel}`"
>
<i class="account-connection-dot"></i>
<span>{{ statusLabel }}</span>
</span>
<div v-else class="account-connection-details" aria-label="当前账号与连接状态" @click.stop>
<div class="connection-details-head">
<span class="connection-details-title">当前账号</span>
<span class="connection-state-badge" :class="`is-${status}`">
<i class="account-connection-dot"></i>
{{ statusLabel }}
</span>
</div>
<strong class="connection-account-name">{{ userDisplayName }}</strong>
<span class="connection-account-email">{{ auth.user?.email || "未提供邮箱" }}</span>
<dl class="connection-details-grid">
<div>
<dt>角色</dt>
<dd>{{ roleLabel }}</dd>
</div>
<div>
<dt>客户端</dt>
<dd>{{ clientLabel }}</dd>
</div>
<div>
<dt>运行环境</dt>
<dd>{{ environmentLabel }}</dd>
</div>
<div>
<dt>会话状态</dt>
<dd>{{ sessionRemainingLabel }}</dd>
</div>
<div>
<dt>API 延迟</dt>
<dd>{{ latencyLabel }}</dd>
</div>
<div>
<dt>最近通信</dt>
<dd>{{ lastCommunicationLabel }}</dd>
</div>
</dl>
<p class="connection-details-note">连接状态仅表示会话与后端通信不代表系统健康评分</p>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted } from "vue";
import { useAuthStore } from "../store/auth";
import { useSessionStore } from "../store/session";
import { getAppMetadata, isTauriRuntime } from "../runtime";
import { IDLE_TIMEOUT_MINUTES } from "../session/sessionManager";
import { parseJwtExp } from "../session/jwt";
import { useConnectionStatus } from "../composables/useConnectionStatus";
withDefaults(defineProps<{ mode?: "indicator" | "details" }>(), {
mode: "indicator",
});
const auth = useAuthStore();
const session = useSessionStore();
const isDesktop = isTauriRuntime();
const appMetadata = getAppMetadata();
const {
status,
statusLabel,
latencyMs,
lastSuccessAt,
lastCheckedAt,
startConnectionMonitor,
} = useConnectionStatus();
let stopConnectionMonitor: (() => void) | undefined;
const userDisplayName = computed(
() => auth.user?.full_name || auth.user?.username || auth.user?.email || "当前用户",
);
const roleLabel = computed(() => (auth.user?.is_admin ? "系统管理员" : "普通账号"));
const clientLabel = computed(() => {
if (!isDesktop) return "网页端";
const platform = appMetadata.platform === "macos" ? "macOS" : appMetadata.platform || "桌面系统";
return `桌面端 · ${platform}`;
});
const environmentLabel = computed(() =>
import.meta.env.VITE_RUNTIME_ENV === "production" ? "生产环境" : "开发环境",
);
const effectiveNow = computed(() => Math.max(lastCheckedAt.value || 0, Date.now()));
const sessionDeadlineAt = computed(() => {
const tokenExpiryAt = auth.token ? parseJwtExp(auth.token) : null;
if (!tokenExpiryAt) return null;
if (isDesktop) return tokenExpiryAt;
return Math.min(tokenExpiryAt, session.lastUserActiveAt + IDLE_TIMEOUT_MINUTES * 60 * 1000);
});
const sessionRemainingLabel = computed(() => {
if (!auth.token) return "未登录";
if (!sessionDeadlineAt.value) return "有效";
const remainingMs = sessionDeadlineAt.value - effectiveNow.value;
if (remainingMs <= 0) return "即将失效";
const totalMinutes = Math.max(1, Math.ceil(remainingMs / 60_000));
if (totalMinutes < 60) return `剩余 ${totalMinutes} 分钟`;
const totalHours = Math.floor(totalMinutes / 60);
if (totalHours < 24) return `剩余 ${totalHours} 小时`;
return `剩余 ${Math.floor(totalHours / 24)}`;
});
const latencyLabel = computed(() => (latencyMs.value === null ? "--" : `${latencyMs.value} ms`));
const lastCommunicationLabel = computed(() => {
if (!lastSuccessAt.value) return status.value === "checking" ? "检测中" : "暂无成功记录";
return new Date(lastSuccessAt.value).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
});
onMounted(() => {
stopConnectionMonitor = startConnectionMonitor();
});
onBeforeUnmount(() => {
stopConnectionMonitor?.();
});
</script>
<style scoped>
.account-connection-indicator,
.connection-state-badge {
display: inline-flex;
align-items: center;
gap: 5px;
color: #64748b;
font-size: 11px;
font-weight: 650;
white-space: nowrap;
}
.account-connection-dot {
display: inline-block;
width: 7px;
height: 7px;
flex: 0 0 7px;
border-radius: 50%;
background: #94a3b8;
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.13);
}
.is-online .account-connection-dot {
background: #22a663;
box-shadow: 0 0 0 3px rgba(34, 166, 99, 0.13);
}
.is-degraded .account-connection-dot {
background: #d99b21;
box-shadow: 0 0 0 3px rgba(217, 155, 33, 0.14);
}
.is-offline .account-connection-dot {
background: #e05252;
box-shadow: 0 0 0 3px rgba(224, 82, 82, 0.14);
}
.account-connection-details {
box-sizing: border-box;
width: 290px;
padding: 12px 14px 11px;
color: #273449;
}
.connection-details-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.connection-details-title {
color: #7a8aa0;
font-size: 11px;
font-weight: 700;
}
.connection-state-badge {
padding: 3px 7px;
border-radius: 999px;
background: #f3f6f9;
}
.connection-account-name,
.connection-account-email {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.connection-account-name {
color: #172033;
font-size: 14px;
}
.connection-account-email {
margin-top: 2px;
color: #7a8aa0;
font-size: 11px;
}
.connection-details-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
margin: 12px 0 0;
padding: 10px 0;
border-top: 1px solid #edf1f5;
border-bottom: 1px solid #edf1f5;
}
.connection-details-grid div,
.connection-details-grid dt,
.connection-details-grid dd {
min-width: 0;
margin: 0;
}
.connection-details-grid dt {
margin-bottom: 2px;
color: #91a0b3;
font-size: 10px;
}
.connection-details-grid dd {
overflow: hidden;
color: #334155;
font-size: 11px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.connection-details-note {
margin: 9px 0 0;
color: #8a98aa;
font-size: 10px;
line-height: 1.45;
}
:global([data-ctms-theme="dark"]) .account-connection-details {
color: #dbe5f1;
}
:global([data-ctms-theme="dark"]) .connection-account-name,
:global([data-ctms-theme="dark"]) .connection-details-grid dd {
color: #f8fafc;
}
:global([data-ctms-theme="dark"]) .connection-details-grid {
border-color: #334155;
}
:global([data-ctms-theme="dark"]) .connection-state-badge {
background: #243247;
}
</style>
+8 -2
View File
@@ -246,10 +246,12 @@
<button class="account-button" type="button"> <button class="account-button" type="button">
<span class="account-avatar">{{ userDisplayInitial }}</span> <span class="account-avatar">{{ userDisplayInitial }}</span>
<span class="account-name">{{ userDisplayName }}</span> <span class="account-name">{{ userDisplayName }}</span>
<AccountConnectionStatus mode="indicator" />
<el-icon><ArrowDown /></el-icon> <el-icon><ArrowDown /></el-icon>
</button> </button>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<AccountConnectionStatus mode="details" />
<el-dropdown-item command="profile"> <el-dropdown-item command="profile">
<el-icon><User /></el-icon> <el-icon><User /></el-icon>
<span>{{ TEXT.menu.profile }}</span> <span>{{ TEXT.menu.profile }}</span>
@@ -496,6 +498,7 @@ import { useDesktopShortcuts } from "../composables/useDesktopShortcuts";
import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions"; import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions";
import type { DesktopCommand } from "../types/desktopCommands"; import type { DesktopCommand } from "../types/desktopCommands";
import DesktopCommandPalette from "./DesktopCommandPalette.vue"; import DesktopCommandPalette from "./DesktopCommandPalette.vue";
import AccountConnectionStatus from "./AccountConnectionStatus.vue";
import DesktopPreferences from "../views/DesktopPreferences.vue"; import DesktopPreferences from "../views/DesktopPreferences.vue";
import ProfileSettings from "../views/ProfileSettings.vue"; import ProfileSettings from "../views/ProfileSettings.vue";
import { import {
@@ -1993,7 +1996,7 @@ useDesktopShortcuts(
.account-button { .account-button {
gap: 7px; gap: 7px;
max-width: 188px; max-width: 228px;
padding: 0 8px 0 3px; padding: 0 8px 0 3px;
} }
@@ -2011,7 +2014,7 @@ useDesktopShortcuts(
} }
.account-name { .account-name {
max-width: 110px; max-width: 96px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -2321,6 +2324,9 @@ useDesktopShortcuts(
} }
.desktop-route-shell { .desktop-route-shell {
width: 100%;
max-width: 100%;
box-sizing: border-box;
height: 100%; height: 100%;
min-height: 100%; min-height: 100%;
} }
@@ -475,6 +475,15 @@ describe("desktop layout shell", () => {
expect(styles).toContain(":root[data-ctms-theme=\"dark\"] .desktop-workbench .desktop-route-shell .hero-stat"); expect(styles).toContain(":root[data-ctms-theme=\"dark\"] .desktop-workbench .desktop-route-shell .hero-stat");
}); });
it("keeps routed desktop pages stretched to the content boundary", () => {
const layout = readDesktopLayoutSource();
expect(layout).toContain(".desktop-route-shell {");
expect(layout).toContain("width: 100%;");
expect(layout).toContain("max-width: 100%;");
expect(layout).toContain("box-sizing: border-box;");
});
it("keeps authentication styles self-contained for desktop CSP", () => { it("keeps authentication styles self-contained for desktop CSP", () => {
const sources = [ const sources = [
readMainStyleSource(), readMainStyleSource(),
@@ -15,18 +15,38 @@ describe("PermissionAccessLogs", () => {
expect(source).toContain('parts.join(" / ")'); expect(source).toContain('parts.join(" / ")');
}); });
it("renders user behavior audit instead of endpoint table or region detail", () => { it("renders access log audit with IP ranking as a dialog instead of side panels", () => {
const source = readSource(); const source = readSource();
expect(source).toContain("访问日志");
expect(source).toContain("accessLogDialogVisible");
expect(source).toContain("accessLogDialogVisible");
expect(source).toContain("accessDialogLines");
expect(source).toContain("log-surface");
expect(source).toContain("access-table");
expect(source).toContain("filters.keyword");
expect(source).toContain("clientSourceLabel");
expect(source).toContain("IP访问排行"); expect(source).toContain("IP访问排行");
expect(source).toContain("接口访问审计日志"); expect(source).toContain("ipRankingDialogVisible");
expect(source).toContain("terminal-lines"); expect(source).toContain("openIpRankingDialog");
expect(source).toContain("keyword"); expect(source).toContain("ipRankingPeriodOptions");
expect(source).toContain('class="access-toolbar"');
expect(source).toContain('class="access-toolbar-actions"');
expect(source).toContain('@click="loadData">刷新');
expect(source).not.toContain("来自 permission_access_logs");
expect(source).not.toContain("安全访问只记录匿名、无效令牌和异常状态请求");
expect(source).not.toContain("audit-ranking-section");
expect(source).not.toContain("安全访问日志");
expect(source).not.toContain("log-mode-card");
expect(source).not.toContain("raw-log-panel");
expect(source).not.toContain("终端式访问流水"); expect(source).not.toContain("终端式访问流水");
expect(source).not.toContain("每行保留接口访问情况,便于快速扫日志"); expect(source).not.toContain("每行保留接口访问情况,便于快速扫日志");
expect(source).not.toContain("来源地域明细"); expect(source).not.toContain("来源地域明细");
expect(source).not.toContain('prop="endpoint_key" label="接口"'); expect(source).not.toContain('prop="endpoint_key" label="接口"');
expect(source).not.toContain("fetchIpLocations"); expect(source).not.toContain("fetchIpLocations");
expect(source).toContain("访问日志加载失败,表格中可能是上次成功获取的数据");
expect(source).toContain("lastUpdatedAt");
expect(source).not.toContain("logs.value = []");
}); });
it("exposes QA and CTA in role filter presets", () => { it("exposes QA and CTA in role filter presets", () => {
@@ -47,24 +67,32 @@ describe("PermissionAccessLogs", () => {
expect(source).not.toContain('label="医学审核" value="QA"'); expect(source).not.toContain('label="医学审核" value="QA"');
}); });
it("uses narrow metric cards without helper subtitles", () => { it("integrates compact metrics into the access overview without helper subtitles", () => {
const source = readSource(); const source = readSource();
expect(source).toContain("min-height: 64px"); expect(source).toContain('class="access-overview"');
expect(source).toContain("padding: 10px 16px"); expect(source).toContain('aria-label="访问指标"');
expect(source).toContain("min-height: 44px");
expect(source).toContain("padding: 5px 12px");
expect(source).toContain("border-left: 1px solid #edf1f6");
expect(source).not.toContain("<small>{{ card.hint }}</small>"); expect(source).not.toContain("<small>{{ card.hint }}</small>");
expect(source).not.toContain("hint:"); expect(source).not.toContain("hint:");
expect(source).not.toContain("筛选范围内行为总量"); expect(source).not.toContain("筛选范围内行为总量");
}); });
it("keeps audit grid widths consistent with SecurityCenter", () => { it("uses a full-width log layout instead of the old left-right split", () => {
const source = readSource(); const source = readSource();
expect(source).toContain("grid-template-columns: minmax(320px, 1fr) minmax(0, 1.35fr);"); expect(source).toContain("access-log-card");
expect(source).toContain("log-surface");
expect(source).toContain("@media (max-width: 1100px)"); expect(source).toContain("@media (max-width: 1100px)");
expect(source).toContain("grid-template-columns: repeat(2, minmax(0, 1fr));"); expect(source).toContain("grid-template-columns: repeat(2, minmax(0, 1fr));");
expect(source).toContain("@media (max-width: 720px)"); expect(source).toContain("@media (max-width: 720px)");
expect(source).toContain("grid-template-columns: 1fr;"); expect(source).toContain("grid-template-columns: 1fr;");
expect(source).not.toContain("audit-ranking-section");
expect(source).not.toContain("grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));");
expect(source).not.toContain("audit-grid");
expect(source).not.toContain("grid-template-columns: minmax(320px, 1fr) minmax(0, 1.35fr);");
}); });
it("removes the duplicated audit hero copy", () => { it("removes the duplicated audit hero copy", () => {
@@ -78,117 +106,254 @@ describe("PermissionAccessLogs", () => {
expect(source).not.toContain("查看详细的接口访问与安全事件记录"); expect(source).not.toContain("查看详细的接口访问与安全事件记录");
}); });
it("streams terminal logs from top to bottom and refreshes in realtime", () => { it("keeps structured access rows compact and delegates long fields to details", () => {
const source = readSource(); const source = readSource();
expect(source).toContain("terminalLogRows"); expect(source).toContain('label="时间" width="160"');
expect(source).toContain("new Date(a.created_at).getTime() - new Date(b.created_at).getTime()"); expect(source).toContain('label="IP" width="120"');
expect(source).toContain("REALTIME_POLL_INTERVAL_MS"); expect(source).toContain('label="位置" width="110"');
expect(source).toContain("startRealtimePolling"); expect(source).toContain('label="账号" min-width="160"');
expect(source).toContain("onBeforeUnmount(stopRealtimePolling)"); expect(source).toContain('label="请求" min-width="380"');
expect(source).toContain('ref="terminalWindowRef"'); expect(source).toContain('label="耗时" width="100"');
expect(source).toContain("scrollTerminalToBottom"); expect(source).toContain('label="操作" width="64"');
expect(source).toContain("terminal.scrollTop = terminal.scrollHeight"); expect(source).toContain("account-cell");
expect(source).toContain("request-cell");
expect(source).toContain("elapsed-cell");
expect(source).toContain("accountSecondary(row)");
expect(source).toContain("requestMain(row)");
expect(source).toContain("requestSub(row)");
expect(source).toContain("row.elapsed_ms.toFixed(1)");
expect(source).not.toContain('label="账号/IP"');
expect(source).not.toContain('label="类型" width="88"');
expect(source).not.toContain('label="来源" width="132"');
expect(source).not.toContain('label="接口/角色"');
expect(source).not.toContain('label="状态" width="104"');
expect(source).not.toContain('label="账号" min-width="180"');
expect(source).not.toContain('label="请求来源"');
expect(source).not.toContain('label="结果" width="92"');
}); });
it("surfaces source IPs in terminal logs and user ranking for network monitoring", () => { it("opens all raw access logs in the same dialog pattern as security logs", () => {
const source = readSource();
expect(source).toContain("buildTerminalLine");
expect(source).toContain("REALTIME_POLL_INTERVAL_MS");
expect(source).toContain("startRealtimePolling");
expect(source).toContain("document.addEventListener(\"visibilitychange\", onVisibilityChange)");
expect(source).toContain("document.removeEventListener(\"visibilitychange\", onVisibilityChange)");
expect(source).toContain("stopTimers()");
expect(source).toContain("const REALTIME_POLL_INTERVAL_MS = 30_000;");
expect(source).toContain('const timeRangePreset = ref<TimeRangePreset>("24h")');
expect(source).toContain("accessLogDialogVisible");
expect(source).toContain("accessTerminalWindowRef");
expect(source).toContain("accessDialogLines.join");
expect(source).toContain("openAccessLogDialog");
expect(source).toContain("scrollAccessTerminalToBottom");
expect(source).toContain("fetchAllRawAccessLogLines");
expect(source).toContain("downloadInterfaceLog");
expect(source).toContain("<el-dialog v-model=\"accessLogDialogVisible\"");
expect(source).not.toContain('ref="terminalWindowRef"');
expect(source).not.toContain("scrollTerminalToBottom");
});
it("surfaces source IPs in table, terminal logs and the IP ranking dialog", () => {
const source = readSource(); const source = readSource();
expect(source).toContain("ip=${ip}"); expect(source).toContain("ip=${ip}");
expect(source).toContain("user.sample_ip_address || \"未知 IP\""); expect(source).toContain('label="IP" width="120"');
expect(source).toContain("ipRankingItems"); expect(source).toContain('label="位置" width="110"');
expect(source).toContain("formatIpLocation(row)");
expect(source).toContain("row.ip_address || \"未知 IP\"");
expect(source).toContain("IP访问排行"); expect(source).toContain("IP访问排行");
expect(source).toContain("账号"); expect(source).toContain("loadIpRankingData");
expect(source).toContain("rank-location"); expect(source).toContain("fetchAccessRowsForIpRanking");
expect(source).toContain("buildIpRankingRows");
expect(source).toContain('width="min(1180px, 96vw)"');
expect(source).toContain("ip-rank-grid");
expect(source).toContain("ip-rank-card");
expect(source).toContain("rank-card-stats");
expect(source).toContain("grid-template-columns: repeat(5, minmax(0, 1fr));");
expect(source).toContain("rank-no");
expect(source).toContain("riskDescriptorForRow");
expect(source).toContain("row.riskLabel");
expect(source).toContain("row.riskTagType");
expect(source).toContain("riskRankCardClass");
expect(source).toContain("risk-rank-card--danger");
expect(source).toContain("risk-rank-card--warning");
expect(source).toContain("risk-rank-card--info");
expect(source).toContain("权限拒绝");
expect(source).toContain("敏感探测");
expect(source).toContain("TOP {{ ipRankingRows.length }}");
expect(source).toContain("ranking-period-cards");
expect(source).toContain("ranking-period-card");
expect(source).toContain("selectIpRankingPeriod");
expect(source).toContain("全部");
expect(source).toContain("当前时段暂无风险访问");
expect(source).not.toContain("ranking-period-group");
expect(source).not.toContain("ipLine(row)");
expect(source).not.toContain("user.sample_ip_address || \"未知 IP\"");
expect(source).not.toContain("ipRankingItems");
expect(source).not.toContain("ip-rank-row");
expect(source).not.toContain("rank-location");
expect(source).not.toContain('class="rank-risk-tag">异常IP');
expect(source).not.toContain("TOP {{ userStats.length }}"); expect(source).not.toContain("TOP {{ userStats.length }}");
expect(source).not.toContain("rank-ip-line"); expect(source).not.toContain("rank-ip-line");
expect(source).not.toContain("来源IP"); expect(source).not.toContain("来源IP");
expect(source).not.toContain("去重IP"); expect(source).not.toContain("去重IP");
}); });
it("merges abnormal security IPs into the IP ranking and limits the list to top 10", () => { it("includes security events in the unified access log without a separate security dialog", () => {
const source = readSource(); const source = readSource();
expect(source).toContain("const IP_RANKING_LIMIT = 10;"); expect(source).toContain("isSecurityLog");
expect(source).toContain("const SECURITY_RANKING_PAGE_SIZE = 200;"); expect(source).toContain("安全事件");
expect(source).toContain("const ipRankingItems = computed<IpRankingItem[]>(() =>"); expect(source).toContain("业务访问");
expect(source).toContain("securityLogs.value.forEach((event) =>"); expect(source).toContain("SECURITY_CATEGORY_LABELS");
expect(source).toContain('const isAbnormal = event.category === "ABNORMAL_IP";'); expect(source).toContain("SECURITY_SEVERITY_LABELS");
expect(source).toContain('ABNORMAL_IP: "异常IP"'); expect(source).toContain("endpointTitle(row)");
expect(source).toContain(".slice(0, IP_RANKING_LIMIT)"); expect(source).toContain("endpointMeta(row)");
expect(source).toContain("TOP {{ ipRankingItems.length }}"); expect(source).toContain("accessResultLabel(row)");
expect(source).toContain('v-for="(item, index) in ipRankingItems"'); expect(source).toContain("authStatusLabel(row.auth_status)");
expect(source).toContain("异常IP</el-tag>"); expect(source).toContain("[SECURITY:");
}); expect(source).not.toContain("showSecurityLog");
expect(source).not.toContain('v-if="showSecurityLog"');
it("sorts IP ranking by overall access total before risk labels", () => { expect(source).not.toContain("fetchSecurityAccessLogs");
const source = readSource();
expect(source).toContain("existing.total += user.total_count;");
expect(source).toContain("existing.riskCount += user.denied_count;");
expect(source).toContain("if (b.total !== a.total) return b.total - a.total;");
expect(source.indexOf("if (b.total !== a.total) return b.total - a.total;")).toBeLessThan(
source.indexOf("if (b.riskCount !== a.riskCount) return b.riskCount - a.riskCount;"),
);
expect(source.indexOf("if (b.total !== a.total) return b.total - a.total;")).toBeLessThan(
source.indexOf("if (b.isAbnormal !== a.isAbnormal) return Number(b.isAbnormal) - Number(a.isAbnormal);"),
);
});
it("loads all security pages for complete abnormal IP ranking data", () => {
const source = readSource();
expect(source).toContain("const first = await fetchSecurityAccessLogs({ status_min: 400, page: 1, page_size: SECURITY_RANKING_PAGE_SIZE });");
expect(source).toContain("const totalPages = Math.ceil(first.data.total / SECURITY_RANKING_PAGE_SIZE);");
expect(source).toContain("for (let nextPage = 2; nextPage <= totalPages; nextPage += 1)");
expect(source).toContain("allItems.push(...res.data.items);");
expect(source).toContain("securityLogs.value = allItems;");
expect(source).not.toContain("page_size: 80");
});
it("renders low-level security access logs for anonymous and invalid requests", () => {
const source = readSource();
expect(source).toContain("showSecurityLog");
expect(source).toContain('v-if="showSecurityLog"');
expect(source).toContain("fetchSecurityAccessLogs");
expect(source).toContain("安全事件审计日志");
expect(source).toContain("securityTerminalLines");
expect(source).toContain("auth_status");
expect(source).toContain("account_label");
expect(source).toContain("client_ip");
expect(source).toContain("status_code");
expect(source).toContain("ANONYMOUS");
expect(source).toContain("INVALID_TOKEN");
expect(source).not.toContain("安全访问日志"); expect(source).not.toContain("安全访问日志");
expect(source).not.toContain("记录匿名、无效令牌、拒绝和异常状态请求,用于排查未知 IP 攻击"); expect(source).not.toContain("安全事件审计日志");
expect(source).not.toContain("securityTerminalLines");
expect(source).not.toContain("securityLogDialogVisible");
expect(source).not.toContain("SecurityAccessLogItem");
}); });
it("shows interface audit log total instead of current page line count", () => { it("shows total access logs in the table and access log dialog", () => {
const source = readSource(); const source = readSource();
expect(source).toContain("{{ formatNumber(total) }} 条");
expect(source).toContain("共 {{ formatNumber(total) }} 条"); expect(source).toContain("共 {{ formatNumber(total) }} 条");
expect(source).toContain(":total=\"total\""); expect(source).toContain(":total=\"total\"");
expect(source).toContain("v-model:page-size=\"pageSize\"");
expect(source).toContain(":page-sizes=\"[50, 100, 200]\"");
expect(source).toContain("accessLogDialogVisible");
expect(source).not.toContain("{{ terminalLines.length }} 条"); expect(source).not.toContain("{{ terminalLines.length }} 条");
expect(source).not.toContain("最近 {{ terminalLines.length }} 条"); expect(source).not.toContain("最近 {{ terminalLines.length }} 条");
}); });
it("opens both audit logs in realtime downloadable dialogs", () => { it("renders access rows directly and keeps raw access logs downloadable", () => {
const source = readSource(); const source = readSource();
expect(source).toContain("interfaceLogDialogVisible"); expect(source).toContain("detailDrawerVisible");
expect(source).toContain("securityLogDialogVisible"); expect(source).toContain("selectedLog");
expect(source).toContain("openInterfaceLogDialog"); expect(source).toContain("openLogDetail");
expect(source).toContain("openSecurityLogDialog"); expect(source).toContain("accessLogDialogVisible");
expect(source).toContain("if (!showSecurityLog.value) return"); expect(source).toContain("openAccessLogDialog");
expect(source).toContain("downloadInterfaceLog"); expect(source).toContain("downloadInterfaceLog");
expect(source).toContain("downloadSecurityLog");
expect(source).toContain("downloadLogFile"); expect(source).toContain("downloadLogFile");
expect(source).toContain("<el-dialog v-model=\"interfaceLogDialogVisible\""); expect(source).toContain("accessLogDialogVisible");
expect(source).toContain("<el-dialog v-model=\"securityLogDialogVisible\""); expect(source).toContain("<el-drawer v-model=\"detailDrawerVisible\"");
expect(source).toContain("请求概览");
expect(source).toContain("原始 HTTP 请求");
expect(source).toContain("User-Agent");
expect(source).toContain("更多审计信息");
expect(source).toContain("日志标识");
expect(source).toContain("传输信息");
expect(source).toContain("客户端标识");
expect(source).toContain("requestHeaderEntries(row)");
expect(source).toContain("requestOverviewFieldItems(selectedLog)");
expect(source).toContain("auditMetadataFieldItems(selectedLog)");
expect(source).toContain("requestSnapshotFieldItems(selectedLog)");
expect(source).toContain("buildRawRequestBlock(selectedLog)");
expect(source).toContain("rawRequestBodyLine");
expect(source).toContain("hasCapturedRequestSnapshot");
expect(source).toContain("历史日志未采集原始请求快照");
expect(source).toContain("legacy_permission_endpoint");
expect(source).toContain("# audit_line: ${buildTerminalLine(row)}");
expect(source).not.toContain("detailFieldItems(selectedLog)");
expect(source).not.toContain("<h4>接口</h4>");
expect(source).not.toContain("<h4>访问字段</h4>");
expect(source).not.toContain("<h4>请求快照</h4>");
expect(source).not.toContain("<h4>请求头</h4>");
expect(source).not.toContain("requestHeaderEntries(selectedLog)");
expect(source).not.toContain("未记录请求头");
expect(source).not.toContain("原始请求快照(脱敏)");
expect(source).not.toContain("原始日志行");
expect(source).not.toContain("请求头(脱敏)");
expect(source).toContain("<el-dialog v-model=\"accessLogDialogVisible\"");
expect(source).toContain("下载日志"); expect(source).toContain("下载日志");
expect(source).toContain("securityTerminalWindowRef"); expect(source).not.toContain("securityLogDialogVisible");
expect(source).toContain("scrollSecurityTerminalToBottom"); expect(source).not.toContain("openSecurityLogDialog");
expect(source).not.toContain("downloadSecurityLog");
expect(source).not.toContain("<el-dialog v-model=\"securityLogDialogVisible\"");
expect(source).not.toContain("securityTerminalWindowRef");
expect(source).not.toContain("scrollSecurityTerminalToBottom");
expect(source).not.toContain("interfaceLogDialogVisible");
});
it("exports all raw access logs across paginated backend pages", () => {
const source = readSource();
expect(source).toContain("const ACCESS_LOG_EXPORT_PAGE_SIZE = 200;");
expect(source).toContain("const rawExportLoading = ref(false);");
expect(source).toContain("fetchAllRawAccessLogLines");
expect(source).toContain("page_size: ACCESS_LOG_EXPORT_PAGE_SIZE");
expect(source).toContain("const totalPages = Math.ceil(first.data.total / ACCESS_LOG_EXPORT_PAGE_SIZE);");
expect(source).toContain("for (let nextPage = 2; nextPage <= totalPages; nextPage += 1)");
expect(source).toContain("raw-access-logs.log");
expect(source).not.toContain("interface-access-audit.log");
});
it("supports account, location and client source filtering on the server query", () => {
const source = readSource();
expect(source).toContain('placeholder="搜索账号、IP、属地、接口或来源"');
expect(source).toContain("filter-time-presets");
expect(source).toContain("timeRangePresetOptions");
expect(source).toContain("onTimePresetChange");
expect(source).toContain("buildTimeRangeFromPreset");
expect(source).toContain("全部");
expect(source).toContain("24小时");
expect(source).toContain("7天");
expect(source).toContain("30天");
expect(source).toContain("clientSourceOptions");
expect(source).toContain("filters.clientType");
expect(source).toContain("params.client_type = filters.clientType");
expect(source).toContain("params.keyword = keywordToken");
expect(source).toContain("params.client_ip = keywordToken");
expect(source).toContain("onKeywordInput");
});
it("keeps access-log filter controls on one compact visual scale", () => {
const source = readSource();
expect(source).toContain("--filter-control-height: 26px");
expect(source).toContain("--filter-font-size: 12px");
expect(source).toContain(".filter-time-presets :deep(.el-radio-button__inner)");
expect(source).toContain(".audit-filters :deep(.el-select__wrapper)");
expect(source).toContain(".audit-filters :deep(.el-input__wrapper)");
expect(source).toContain('class="filter-reset"');
expect(source).toContain("height: var(--filter-control-height)");
expect(source).toContain("font-size: var(--filter-font-size)");
});
it("does not render the manual date range filter in access logs", () => {
const source = readSource();
expect(source).not.toContain("el-date-picker");
expect(source).not.toContain("datetimerange");
expect(source).not.toContain("dateRange");
expect(source).not.toContain("onDateRangeChange");
expect(source).not.toContain("filter-date");
expect(source).not.toContain("开始时间");
expect(source).not.toContain("结束时间");
});
it("includes request headers in the access log item contract", () => {
const source = readFileSync(resolve(__dirname, "../types/api.ts"), "utf8");
expect(source).toContain('event_type?: "permission" | "security" | string;');
expect(source).toContain("request_headers: Record<string, string> | null;");
expect(source).toContain("request_snapshot: RequestSnapshot | null;");
expect(source).toContain("export interface RequestSnapshot");
expect(source).toContain("query_params?: RequestSnapshotParam[] | null;");
expect(source).toContain("status_code?: number | null;");
expect(source).toContain("auth_status?: string | null;");
}); });
}); });
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./PermissionIpLocations.vue"), "utf8");
describe("PermissionIpLocations", () => {
it("integrates source metrics into a compact overview above the map", () => {
const source = readSource();
expect(source).toContain('class="ip-overview"');
expect(source).toContain('aria-label="来源指标"');
expect(source).toContain('class="distribution-card"');
expect(source).toContain("min-height: 44px");
expect(source).toContain("grid-template-columns: minmax(0, 1fr) 300px");
expect(source).toContain("padding: 10px");
});
it("keeps compact metric grids responsive", () => {
const source = readSource();
expect(source).toContain("@media (max-width: 1100px)");
expect(source).toContain("@media (max-width: 520px)");
expect(source).toContain(".metric-card:nth-child(odd)");
});
it("uses the complete desktop viewport for fullscreen maps", () => {
const source = readSource();
expect(source).toContain('modal-class="source-map-overlay"');
expect(source).toContain(':option="fullscreenSourceMapOption"');
expect(source).toContain("buildFlowMapOption(flowSourceData.value, \"ctms-world\", 1, \"全球访问链路\")");
expect(source).toContain("buildFlowMapOption(chinaFlowSourceData.value, \"ctms-china\", 0.92, \"中国访问链路\")");
expect(source).toContain(".source-map-overlay .source-map-dialog.el-dialog.is-fullscreen");
expect(source).toContain("inset: 0 !important");
expect(source).toContain("flex: 1 1 auto");
expect(source).toContain("height: 100%;");
expect(source).toContain("min-height: 48px");
});
it("keeps the all-time label and request semantics consistent", () => {
const source = readSource();
expect(source).toContain('all: "全部"');
expect(source).toContain("all: undefined");
expect(source).toContain('all_time: timeRangePreset.value === "all"');
expect(source).not.toContain('all: "近 90 天"');
});
it("keeps fullscreen animation sharp without multiplying large canvas layers", () => {
const source = readSource();
expect(source).toContain('v-if="!fullscreenVisible"');
expect(source).toContain("useDirtyRect: true");
expect(source).toContain("Math.min(getDevicePixelRatio(), 2)");
expect(source).not.toContain("getDevicePixelRatio() * 1.5");
expect(source).not.toContain("zlevel:");
expect(source).toContain("constantSpeed: 72");
expect(source).toContain("trailLength: 0");
expect(source).toContain('symbol: "circle"');
expect(source).toContain('type: "scatter"');
expect(source).toContain("chartAutoresize = { throttle: 120 }");
});
it("does not classify foreign locations as abnormal by geography alone", () => {
const source = readSource();
expect(source).toContain('mapMetric = ref<"traffic" | "risk">("traffic")');
expect(source).toContain("riskColor(item.risk_level)");
expect(source).not.toContain('item.country !== "中国"');
});
it("uses backend coordinates and keeps unmapped locations in the ranking", () => {
const source = readSource();
expect(source).toContain("item.longitude");
expect(source).toContain("item.latitude");
expect(source).toContain("chinaRankRows");
expect(source).toContain('全部(保留期 ${periodMetadata.value.retention_days} 天)');
expect(source).not.toContain('from "./worldMapSource"');
});
});
File diff suppressed because it is too large Load Diff
@@ -35,19 +35,26 @@ vi.mock("@/api/projectPermissions", () => ({
status: "healthy", health_score: 95, issues: [], status: "healthy", health_score: 95, issues: [],
sample_sufficient: true, sample_sufficient: true,
score_breakdown: { score_breakdown: {
performance: { weight: 40, deduction: 0 }, performance: { weight: 25, deduction: 0 },
deny_rate: { weight: 20, deduction: 0 }, deny_rate: { weight: 20, deduction: 0 },
cache: { cache: {
weight: 25, deduction: 5, sample_sufficient: true, weight: 25, deduction: 5, sample_sufficient: true,
scope: "sliding_window", window_seconds: 3600, scope: "sliding_window", window_seconds: 3600,
}, },
alerts: { weight: 15, deduction: 0 }, alerts: { weight: 10, deduction: 0 },
runtime: { weight: 20, deduction: 0 },
}, },
last_hour: { last_hour: {
total_checks: 30, denied_checks: 2, deny_rate: 6.67, total_checks: 30, denied_checks: 2, deny_rate: 6.67,
avg_elapsed_ms: 3.5, error_alerts: 0, warning_alerts: 0, avg_elapsed_ms: 3.5, error_alerts: 0, warning_alerts: 0,
}, },
cache_stats: { cache_items: { total_count: 60 }, cache_metrics: {} }, cache_stats: { cache_items: { total_count: 60 }, cache_metrics: {} },
components: {
database: { status: "healthy", latency_ms: 2.1 },
permission_log_writer: { status: "healthy", running: true, queue_size: 0, queue_capacity: 10000, dropped_entries: 0 },
security_log_writer: { status: "healthy", running: true, queue_size: 0, queue_capacity: 20000, dropped_entries: 0 },
retention: { status: "healthy", running: true, access_log_retention_days: 90, metric_retention_days: 400 },
},
}, },
}), }),
fetchPermissionAlerts: vi.fn().mockResolvedValue({ data: { total: 0, alerts: [] } }), fetchPermissionAlerts: vi.fn().mockResolvedValue({ data: { total: 0, alerts: [] } }),
@@ -98,11 +105,13 @@ describe("PermissionMonitoring.vue", () => {
expect(typeof wrapper.vm.refresh).toBe("function"); expect(typeof wrapper.vm.refresh).toBe("function");
}); });
it("passes admin-only access to security logs", () => { it("keeps security events in security center instead of access logs", () => {
const source = readFileSync(resolve(__dirname, "./PermissionMonitoring.vue"), "utf8"); const source = readFileSync(resolve(__dirname, "./PermissionMonitoring.vue"), "utf8");
expect(source).toContain("isAdmin?: boolean"); expect(source).toContain("isAdmin?: boolean");
expect(source).toContain(':show-security-log="props.isAdmin"'); expect(source).toContain('<SecurityCenter v-if="props.isAdmin"');
expect(source).toContain('<PermissionAccessLogs ref="accessLogsRef" />');
expect(source).not.toContain(':show-security-log="props.isAdmin"');
}); });
it("uses a full-height source-analysis tab without forcing scroll on other tabs", () => { it("uses a full-height source-analysis tab without forcing scroll on other tabs", () => {
@@ -133,24 +142,51 @@ describe("PermissionMonitoring.vue", () => {
expect(source).toContain('label="运行概览"'); expect(source).toContain('label="运行概览"');
expect(source).toContain('label="安全中心"'); expect(source).toContain('label="安全中心"');
expect(source).toContain('label="性能趋势"'); expect(source).toContain('label="性能趋势"');
expect(source).toContain('label="访问审计"'); expect(source).toContain('label="访问日志"');
expect(source).toContain('label="来源分析"'); expect(source).toContain('label="来源分析"');
expect(source).toContain('label="性能趋势" name="trends" lazy');
expect(source).toContain('label="访问日志" name="logs" lazy');
expect(source).toContain('label="来源分析" name="ip-locations" lazy');
expect(source).toContain('<SecurityCenter v-if="props.isAdmin"'); expect(source).toContain('<SecurityCenter v-if="props.isAdmin"');
expect(source).toContain('name="security"'); expect(source).toContain('name="security"');
expect(source).toContain('label: "今日监测事件"'); expect(source).toContain('label: "今日监测事件"');
expect(source).toContain('label: "每分钟事件"'); expect(source).toContain('label: "每分钟事件"');
expect(source).toContain('label: "今日通过率"'); expect(source).toContain('label: "今日通过率"');
expect(source).toContain('label: "历史事件总数"'); expect(source).toContain('class="metric-strip"');
expect(source).toContain('class="metric-strip-detail"');
expect(source).toContain('detail: `历史 ${statsSummary.value.total_logs.toLocaleString()}`');
expect(source).toContain("运行指标");
expect(source).toContain('const formatResponseTime');
expect(source).toContain('return "<0.1ms"');
expect(source).toContain("业务样本不足");
expect(source).toContain("组件在线不代表指标已完成评估");
expect(source).toContain('class="overview-pane"');
expect(source).toContain('class="overview-hero-card"');
expect(source).toContain('class="operations-card"');
expect(source).toContain('class="operations-grid"');
expect(source).toContain('class="alerts-section"');
expect(source).toContain("grid-template-columns: minmax(210px, 0.85fr)");
expect(source).toContain('class="status-section runtime-section"');
expect(source).toContain('class="empty-alert-state"');
expect(source).toContain('const hasCacheSamples = computed');
expect(source).toContain("异常拒绝排行"); expect(source).toContain("异常拒绝排行");
expect(source).toContain("告警数据加载失败,不能据此判断系统正常");
expect(source).toContain("const formatUpdatedTime");
expect(source).toContain("更新 {{ formatUpdatedTime(lastUpdatedAt) }}");
expect(source).toContain("runtimeComponentItems");
expect(source).not.toContain('label="实时概览"'); expect(source).not.toContain('label="实时概览"');
expect(source).not.toContain('label="趋势分析"'); expect(source).not.toContain('label="趋势分析"');
expect(source).not.toContain('label="访问日志"'); expect(source).not.toContain('label="访问审计"');
expect(source).not.toContain('label="IP属地"'); expect(source).not.toContain('label="IP属地"');
expect(source).not.toContain('label: "今日总检查"'); expect(source).not.toContain('label: "今日总检查"');
expect(source).not.toContain('label: "每分钟请求"'); expect(source).not.toContain('label: "每分钟请求"');
expect(source).not.toContain('label: "今日允许率"'); expect(source).not.toContain('label: "今日允许率"');
expect(source).not.toContain('label: "历史总记录"'); expect(source).not.toContain('label: "历史总记录"');
expect(source).not.toContain('label: "历史事件总数"');
expect(source).not.toContain('class="summary-card"');
expect(source).not.toContain('class="overview-toolbar"');
expect(source).not.toContain("被拒绝最多的权限"); expect(source).not.toContain("被拒绝最多的权限");
expect(source).not.toContain("暂无告警,系统运行正常");
}); });
}); });
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./PermissionTrendCharts.vue"), "utf8");
describe("PermissionTrendCharts", () => {
it("renders explicit empty states instead of zero-value charts when samples are unavailable", () => {
const source = readSource();
expect(source).toContain("暂无有效响应时间样本");
expect(source).toContain("暂无缓存访问样本");
expect(source).toContain("暂无权限检查样本");
expect(source).toContain("const hasResponseSamples = computed");
expect(source).toContain("const hasCacheSamples = computed");
});
it("sorts points chronologically and includes the date in 24-hour labels", () => {
const source = readSource();
expect(source).toContain('String(d.getHours()).padStart(2, "0")');
expect(source).toContain("dataPoints.value = [...res.data.data_points].sort(");
expect(source).toContain("new Date(left.bucket_time).getTime() - new Date(right.bucket_time).getTime()");
});
it("keeps period selection, update time and refresh together in the tab toolbar", () => {
const source = readSource();
expect(source).toContain('class="toolbar-actions"');
expect(source).toContain("最近成功更新:{{ formatLastUpdated(lastUpdatedAt) }}");
expect(source).toContain('@click="loadData">刷新');
});
it("uses compact chart surfaces while preserving a two-column desktop layout", () => {
const source = readSource();
expect(source).toContain("padding: 0 12px 12px");
expect(source).toContain("grid-template-columns: repeat(2, 1fr)");
expect(source).toContain("min-height: 276px");
expect(source).toContain("height: 212px");
expect(source).toContain("gap: 10px");
});
});
+192 -41
View File
@@ -3,50 +3,72 @@
<div class="trend-toolbar"> <div class="trend-toolbar">
<div class="toolbar-left"> <div class="toolbar-left">
<span class="toolbar-title">系统性能趋势</span> <span class="toolbar-title">系统性能趋势</span>
<span v-if="lastUpdatedAt" class="ctms-updated-at">最近成功更新{{ formatLastUpdated(lastUpdatedAt) }}</span>
</div>
<div class="toolbar-actions">
<TimeRangeSelector v-model="timeRangePreset" @change="loadData" />
<el-button size="small" :loading="loading" @click="loadData">刷新</el-button>
</div> </div>
<el-radio-group v-model="period" size="small" @change="loadData">
<el-radio-button value="24h">24小时</el-radio-button>
<el-radio-button value="7d">7</el-radio-button>
<el-radio-button value="30d">30</el-radio-button>
</el-radio-group>
</div> </div>
<el-alert v-if="loadError" :title="loadError" type="warning" show-icon :closable="false" class="load-alert" />
<div v-loading="loading" class="charts-grid"> <div v-loading="loading" class="charts-grid">
<div class="chart-card"> <div class="chart-card" :class="{ 'is-empty': !hasEventSamples }">
<div class="chart-header"> <div class="chart-header">
<div class="chart-icon blue"> <div class="chart-icon blue">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
</div> </div>
<h4>监测事件趋势</h4> <div class="chart-title-group">
<h4>监测事件趋势</h4>
<span>允许与拒绝</span>
</div>
<strong class="chart-latest">{{ latestEventLabel }}</strong>
</div> </div>
<v-chart :option="checksChartOption" autoresize class="chart" /> <v-chart v-if="hasEventSamples" :option="checksChartOption" autoresize class="chart" />
<div v-else class="trend-empty-state">所选时段暂无监测事件</div>
</div> </div>
<div class="chart-card"> <div class="chart-card" :class="{ 'is-empty': !hasResponseSamples }">
<div class="chart-header"> <div class="chart-header">
<div class="chart-icon cyan"> <div class="chart-icon cyan">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</div> </div>
<h4>响应时间趋势</h4> <div class="chart-title-group">
<h4>响应时间趋势</h4>
<span>平均值与最大值</span>
</div>
<strong class="chart-latest">{{ latestResponseLabel }}</strong>
</div> </div>
<v-chart :option="responseTimeOption" autoresize class="chart" /> <v-chart v-if="hasResponseSamples" :option="responseTimeOption" autoresize class="chart" />
<div v-else class="trend-empty-state">暂无有效响应时间样本</div>
</div> </div>
<div class="chart-card"> <div class="chart-card" :class="{ 'is-empty': !hasCacheSamples }">
<div class="chart-header"> <div class="chart-header">
<div class="chart-icon green"> <div class="chart-icon green">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
</div> </div>
<h4>缓存命中率</h4> <div class="chart-title-group">
<h4>缓存命中率</h4>
<span>命中 / 总访问</span>
</div>
<strong class="chart-latest">{{ latestCacheLabel }}</strong>
</div> </div>
<v-chart :option="cacheHitOption" autoresize class="chart" /> <v-chart v-if="hasCacheSamples" :option="cacheHitOption" autoresize class="chart" />
<div v-else class="trend-empty-state">暂无缓存访问样本</div>
</div> </div>
<div class="chart-card"> <div class="chart-card" :class="{ 'is-empty': !hasDenySamples }">
<div class="chart-header"> <div class="chart-header">
<div class="chart-icon danger"> <div class="chart-icon danger">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
</div> </div>
<h4>异常拒绝率趋势</h4> <div class="chart-title-group">
<h4>异常拒绝率趋势</h4>
<span>拒绝事件占比</span>
</div>
<strong class="chart-latest">{{ latestDenyLabel }}</strong>
</div> </div>
<v-chart :option="denyRateOption" autoresize class="chart" /> <v-chart v-if="hasDenySamples" :option="denyRateOption" autoresize class="chart" />
<div v-else class="trend-empty-state">暂无权限检查样本</div>
</div> </div>
</div> </div>
</div> </div>
@@ -66,21 +88,65 @@ import {
} from "echarts/components"; } from "echarts/components";
import { fetchPermissionTrends } from "@/api/projectPermissions"; import { fetchPermissionTrends } from "@/api/projectPermissions";
import type { TrendDataPoint } from "@/types/api"; import type { TrendDataPoint } from "@/types/api";
import TimeRangeSelector from "./TimeRangeSelector.vue";
use([CanvasRenderer, LineChart, BarChart, TitleComponent, TooltipComponent, GridComponent, LegendComponent]); use([CanvasRenderer, LineChart, BarChart, TitleComponent, TooltipComponent, GridComponent, LegendComponent]);
const period = ref<"24h" | "7d" | "30d">("24h"); type TimeRangePreset = "all" | "24h" | "7d" | "30d";
const timeRangePreset = ref<TimeRangePreset>("24h");
const timeRangeOptions = [
{ value: "all", label: "全部" },
{ value: "24h", label: "24小时" },
{ value: "7d", label: "7天" },
{ value: "30d", label: "30天" },
] as const;
const loading = ref(false); const loading = ref(false);
const dataPoints = ref<TrendDataPoint[]>([]); const dataPoints = ref<TrendDataPoint[]>([]);
const loadError = ref("");
const lastUpdatedAt = ref<Date | null>(null);
const formatLastUpdated = (value: Date) => {
return `${value.getFullYear()}/${value.getMonth() + 1}/${value.getDate()} ${String(value.getHours()).padStart(2, "0")}:${String(value.getMinutes()).padStart(2, "0")}:${String(value.getSeconds()).padStart(2, "0")}`;
};
const formatTime = (iso: string) => { const formatTime = (iso: string) => {
const d = new Date(iso); const d = new Date(iso);
if (period.value === "24h") return `${d.getHours()}:00`; if (timeRangePreset.value === "24h") return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, "0")}:00`;
return `${d.getMonth() + 1}/${d.getDate()}`; return `${d.getMonth() + 1}/${d.getDate()}`;
}; };
const xLabels = computed(() => dataPoints.value.map((p: TrendDataPoint) => formatTime(p.bucket_time))); const xLabels = computed(() => dataPoints.value.map((p: TrendDataPoint) => formatTime(p.bucket_time)));
const latestPoint = computed(() => dataPoints.value.at(-1) || null);
const hasEventSamples = computed(() => dataPoints.value.some((point) => point.total_checks > 0));
const hasResponseSamples = computed(() =>
dataPoints.value.some((point) => point.avg_elapsed_ms > 0 || point.max_elapsed_ms > 0)
);
const hasCacheSamples = computed(() =>
dataPoints.value.some((point) => point.cache_hits + point.cache_misses > 0)
);
const hasDenySamples = computed(() => dataPoints.value.some((point) => point.total_checks > 0));
const latestEventLabel = computed(() =>
latestPoint.value && latestPoint.value.total_checks > 0
? `${latestPoint.value.total_checks}`
: "暂无样本"
);
const latestResponseLabel = computed(() =>
latestPoint.value && (latestPoint.value.avg_elapsed_ms > 0 || latestPoint.value.max_elapsed_ms > 0)
? `${latestPoint.value.avg_elapsed_ms.toFixed(1)} ms`
: "暂无样本"
);
const latestCacheLabel = computed(() =>
latestPoint.value && latestPoint.value.cache_hits + latestPoint.value.cache_misses > 0
? `${latestPoint.value.cache_hit_rate.toFixed(1)}%`
: "暂无样本"
);
const latestDenyLabel = computed(() => {
if (!latestPoint.value || latestPoint.value.total_checks <= 0) return "暂无样本";
return `${((latestPoint.value.denied_checks / latestPoint.value.total_checks) * 100).toFixed(1)}%`;
});
const baseGridOption = { const baseGridOption = {
grid: { left: 50, right: 20, top: 20, bottom: 25 }, grid: { left: 50, right: 20, top: 20, bottom: 25 },
tooltip: { trigger: "axis" as const }, tooltip: { trigger: "axis" as const },
@@ -186,7 +252,7 @@ const denyRateOption = computed(() => ({
symbol: "circle", symbol: "circle",
symbolSize: 6, symbolSize: 6,
data: dataPoints.value.map((p: TrendDataPoint) => data: dataPoints.value.map((p: TrendDataPoint) =>
p.total_checks > 0 ? Math.round((p.denied_checks / p.total_checks) * 1000) / 10 : 0 p.total_checks > 0 ? Math.round((p.denied_checks / p.total_checks) * 1000) / 10 : null
), ),
itemStyle: { color: "#ef4444" }, itemStyle: { color: "#ef4444" },
lineStyle: { width: 2.5 }, lineStyle: { width: 2.5 },
@@ -198,10 +264,15 @@ const denyRateOption = computed(() => ({
const loadData = async () => { const loadData = async () => {
loading.value = true; loading.value = true;
try { try {
const res = await fetchPermissionTrends(period.value); const apiPeriod = timeRangePreset.value === "all" ? "30d" : timeRangePreset.value;
dataPoints.value = res.data.data_points; const res = await fetchPermissionTrends(apiPeriod);
dataPoints.value = [...res.data.data_points].sort(
(left, right) => new Date(left.bucket_time).getTime() - new Date(right.bucket_time).getTime(),
);
loadError.value = "";
lastUpdatedAt.value = new Date();
} catch { } catch {
dataPoints.value = []; loadError.value = "趋势数据加载失败,图表中可能是上次成功获取的数据";
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -217,16 +288,21 @@ defineExpose({ refresh: loadData });
--trend-ink: #1a2332; --trend-ink: #1a2332;
--trend-muted: #64748b; --trend-muted: #64748b;
--trend-border: #e2e8f0; --trend-border: #e2e8f0;
--trend-radius: 16px; --trend-radius: 14px;
--trend-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03); --trend-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 6px 18px rgba(15, 23, 42, 0.025);
width: 100%;
margin: 0;
padding: 0 12px 12px;
box-sizing: border-box;
} }
.trend-toolbar { .trend-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 14px 18px; min-height: 48px;
margin-bottom: 16px; padding: 8px 14px;
margin-bottom: 8px;
background: #fff; background: #fff;
border: 1px solid var(--trend-border); border: 1px solid var(--trend-border);
border-radius: var(--trend-radius); border-radius: var(--trend-radius);
@@ -235,8 +311,8 @@ defineExpose({ refresh: loadData });
.toolbar-left { .toolbar-left {
display: flex; display: flex;
flex-direction: column; align-items: baseline;
gap: 2px; gap: 8px;
} }
.toolbar-title { .toolbar-title {
@@ -245,22 +321,37 @@ defineExpose({ refresh: loadData });
color: var(--trend-ink); color: var(--trend-ink);
} }
.toolbar-desc {
font-size: 12px;
.toolbar-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.toolbar-updated-at {
color: var(--trend-muted); color: var(--trend-muted);
font-size: 12px;
white-space: nowrap;
} }
.charts-grid { .charts-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 14px; gap: 10px;
}
.load-alert {
margin-bottom: 8px;
} }
.chart-card { .chart-card {
background: #fff; background: #fff;
border: 1px solid var(--trend-border); border: 1px solid var(--trend-border);
border-radius: var(--trend-radius); border-radius: var(--trend-radius);
padding: 18px; min-height: 276px;
padding: 12px 14px;
box-shadow: var(--trend-shadow); box-shadow: var(--trend-shadow);
transition: transform 0.2s ease, box-shadow 0.2s ease; transition: transform 0.2s ease, box-shadow 0.2s ease;
} }
@@ -274,22 +365,22 @@ defineExpose({ refresh: loadData });
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
margin-bottom: 12px; margin-bottom: 8px;
} }
.chart-icon { .chart-icon {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 32px; width: 28px;
height: 32px; height: 28px;
border-radius: 8px; border-radius: 8px;
flex-shrink: 0; flex-shrink: 0;
} }
.chart-icon svg { .chart-icon svg {
width: 16px; width: 14px;
height: 16px; height: 14px;
} }
.chart-icon.blue { background: #eff6ff; color: #3b82f6; } .chart-icon.blue { background: #eff6ff; color: #3b82f6; }
@@ -304,20 +395,80 @@ defineExpose({ refresh: loadData });
color: var(--trend-ink); color: var(--trend-ink);
} }
.chart-title-group {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.chart-title-group span {
color: var(--trend-muted);
font-size: 11px;
}
.chart-latest {
padding: 4px 7px;
border-radius: 8px;
background: #f8fafc;
color: var(--trend-ink);
font-size: 12px;
font-weight: 650;
white-space: nowrap;
}
.chart { .chart {
height: 240px; height: 212px;
width: 100%; width: 100%;
} }
.trend-empty-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 212px;
border: 1px dashed #dbe3ef;
border-radius: 10px;
background: #f8fafc;
color: #94a3b8;
font-size: 13px;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.charts-grid { .charts-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.trend-toolbar { .trend-toolbar {
flex-direction: column; flex-wrap: wrap;
gap: 8px;
}
.toolbar-actions {
flex: 1 1 auto;
flex-wrap: wrap;
}
.chart-card {
min-height: 260px;
}
}
@media (max-width: 600px) {
.trend-charts {
padding: 0 8px 10px;
}
.trend-toolbar,
.toolbar-left {
align-items: flex-start; align-items: flex-start;
gap: 12px; flex-direction: column;
}
.toolbar-actions {
width: 100%;
justify-content: flex-start;
} }
} }
</style> </style>
+97 -24
View File
@@ -5,21 +5,33 @@ import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./SecurityCenter.vue"), "utf8"); const readSource = () => readFileSync(resolve(__dirname, "./SecurityCenter.vue"), "utf8");
describe("SecurityCenter", () => { describe("SecurityCenter", () => {
it("renders the security dashboard in the same audit-card style as access logs", () => { it("renders a compact security analysis summary in the same audit-card style as access logs", () => {
const source = readSource(); const source = readSource();
expect(source).toContain("audit-filters"); expect(source).toContain("audit-filters");
expect(source).toContain("audit-metrics"); expect(source).toContain("audit-metrics");
expect(source).toContain("audit-grid");
expect(source).toContain("metric-card"); expect(source).toContain("metric-card");
expect(source).toContain("ranking-card"); expect(source).toContain("security-analysis-card");
expect(source).toContain("risk-preview-list");
expect(source).toContain("risk-summary-panel");
expect(source).toContain("risk-summary-title");
expect(source).toContain("topRiskPreview");
expect(source).toContain('class="security-overview"');
expect(source).toContain('aria-label="安全指标"');
expect(source).toContain('class="security-toolbar"');
expect(source).toContain("timeRangePreset");
expect(source).toContain("timeRangeOptions");
expect(source).toContain("buildTimeRangeFromPreset");
expect(source).toContain("onTimeRangeChange");
expect(source).not.toContain("audit-grid");
expect(source).not.toContain("ranking-card");
expect(source).not.toContain("security-summary"); expect(source).not.toContain("security-summary");
expect(source).not.toContain("security-card"); expect(source).not.toContain("security-card");
}); });
it("places filters inside the event details section instead of the page header", () => { it("places filters inside the event details section instead of the page header", () => {
const source = readSource(); const source = readSource();
const tableIndex = source.indexOf('<section class="security-table-wrap">'); const tableIndex = source.indexOf('<section ref="securityEventsSectionRef" class="security-table-wrap">');
const filtersIndex = source.indexOf('<section class="audit-filters detail-filters">'); const filtersIndex = source.indexOf('<section class="audit-filters detail-filters">');
expect(tableIndex).toBeGreaterThan(-1); expect(tableIndex).toBeGreaterThan(-1);
@@ -27,20 +39,32 @@ describe("SecurityCenter", () => {
expect(source).not.toContain('<section class="audit-filters">\n <el-select'); expect(source).not.toContain('<section class="audit-filters">\n <el-select');
}); });
it("surfaces abnormal IP location ranking, endpoint type distribution and risk level distribution", () => { it("keeps risk levels on the page and opens abnormal sources in a compact ranking dialog", () => {
const source = readSource(); const source = readSource();
expect(source).toContain("异常排行"); expect(source).toContain("异常分析");
expect(source).toContain("事件分布"); expect(source).toContain("查看异常分析");
expect(source).toContain("访问的接口类型"); expect(source).toContain("analysisDialogVisible");
expect(source).toContain("openAnalysisDialog");
expect(source).toContain("jumpToSecurityEvents");
expect(source).toContain("securityEventsSectionRef");
expect(source).toContain("scrollIntoView");
expect(source).toContain('<el-dialog v-model="analysisDialogVisible"');
expect(source).toContain('width="min(1180px, 96vw)"');
expect(source).toContain("analysis-ranking-dialog");
expect(source).toContain("analysis-ranking-head");
expect(source).toContain("endpoint-summary-chips");
expect(source).toContain("analysis-rank-grid");
expect(source).toContain("analysis-rank-card");
expect(source).toContain("analysis-rank-card-stats");
expect(source).toContain("查看事件明细");
expect(source).not.toContain('<section class="analysis-dialog-panel');
expect(source).toContain("风险等级"); expect(source).toContain("风险等级");
expect(source).toContain("event-distribution-card");
expect(source).toContain("distribution-section");
expect(source).toContain(".event-distribution-card > .section-head");
expect(source).toContain("padding-top: 24px");
expect(source).toContain("ipRiskStats"); expect(source).toContain("ipRiskStats");
expect(source).toContain("endpointTypeStats"); expect(source).toContain("endpointTypeStats");
expect(source).toContain("riskLevelStats"); expect(source).toContain("riskLevelStats");
expect(source.match(/v-for="item in riskLevelStats"/g)).toHaveLength(1);
expect(source).not.toContain("compact-risk-section");
expect(source).toContain("resolveEndpointType"); expect(source).toContain("resolveEndpointType");
expect(source).toContain("formatIpLocation"); expect(source).toContain("formatIpLocation");
expect(source).toContain("fallbackIpLocation"); expect(source).toContain("fallbackIpLocation");
@@ -49,11 +73,13 @@ describe("SecurityCenter", () => {
expect(source).toContain("row.ip_isp"); expect(source).toContain("row.ip_isp");
expect(source).toContain("item.location"); expect(source).toContain("item.location");
expect(source).toContain("categoryText"); expect(source).toContain("categoryText");
expect(source).toContain("rank-main-line"); expect(source).toContain("analysis-rank-card-ip");
expect(source).toContain("rank-meta-line"); expect(source).toContain("analysis-rank-card-meta");
expect(source).toContain("lastSeenAt"); expect(source).toContain("lastSeenAt");
expect(source).toContain("最后访问"); expect(source).toContain("最后访问");
expect(source).toContain("slice(0, 10)"); expect(source).toContain("securitySummary.value.top_ips");
expect(source).toContain("securitySummary.value.endpoint_type_counts");
expect(source).toContain("securitySummary.value.severity_counts");
expect(source).not.toContain("公网位置待解析"); expect(source).not.toContain("公网位置待解析");
expect(source).not.toContain("异常IP(位置)排行"); expect(source).not.toContain("异常IP(位置)排行");
expect(source).not.toContain("risk-level-card"); expect(source).not.toContain("risk-level-card");
@@ -62,37 +88,84 @@ describe("SecurityCenter", () => {
it("keeps detailed security events searchable and inspectable", () => { it("keeps detailed security events searchable and inspectable", () => {
const source = readSource(); const source = readSource();
expect(source).toContain("detailFilteredEvents"); expect(source).toContain("securityQueryParams");
expect(source).toContain("keyword: keyword.value.trim() || undefined");
expect(source).toContain("paginatedEvents"); expect(source).toContain("paginatedEvents");
expect(source).toContain("openDetail"); expect(source).toContain("openDetail");
expect(source).toContain("安全事件明细"); expect(source).toContain("security-table-wrap");
expect(source).toContain('label="IP"'); expect(source).toContain('label="IP"');
expect(source).toContain('label="位置"'); expect(source).toContain('label="位置"');
expect(source).not.toContain('label="来源 IP"'); expect(source).not.toContain('label="来源 IP"');
expect(source).not.toContain('label="IP来源"'); expect(source).not.toContain('label="IP来源"');
expect(source).toContain("搜索 IP、位置、账号、路径或 UA"); expect(source).toContain("搜索 IP、位置、账号、路径或 UA");
expect(source).toContain("loadSecurityEvents"); expect(source).toContain("loadSecurityEvents");
expect(source).toContain("安全事件加载失败,页面中可能是上次成功获取的数据");
expect(source).toContain("lastUpdatedAt");
expect(source).toContain("安全事件详情");
expect(source).toContain("请求概览");
expect(source).toContain("原始 HTTP 请求");
expect(source).toContain("User-Agent");
expect(source).toContain("构建信息");
expect(source).toContain("更多审计信息");
expect(source).toContain("日志标识");
expect(source).toContain("传输信息");
expect(source).toContain("客户端标识");
expect(source).toContain("securityRequestOverviewFields(selectedEvent)");
expect(source).toContain("securityAuditMetadataFields(selectedEvent)");
expect(source).toContain("securityRequestSnapshotFields(selectedEvent)");
expect(source).toContain("buildSecurityRawRequestBlock(selectedEvent)");
expect(source).toContain("securityClientSourceLabel(selectedEvent)");
expect(source).toContain("历史日志未采集原始请求快照");
expect(source).toContain("历史日志未采集原始 HTTP 请求");
expect(source).not.toContain("securityDetailFieldItems(selectedEvent)");
expect(source).not.toContain("<h4>接口</h4>");
expect(source).not.toContain("<h4>访问字段</h4>");
expect(source).not.toContain("<h4>请求快照</h4>");
expect(source).not.toContain("原始请求快照(脱敏)");
expect(source).not.toContain("请求头(脱敏)");
expect(source).not.toContain("未记录请求头");
});
it("opens full security logs as a dialog inside security center", () => {
const source = readSource();
expect(source).toContain("安全日志");
expect(source).toContain("securityLogDialogVisible");
expect(source).toContain("securityLogDialogLoading");
expect(source).toContain("securityTerminalWindowRef");
expect(source).toContain("securityTerminalLines");
expect(source).toContain("buildSecurityTerminalLine");
expect(source).toContain("openSecurityLogDialog");
expect(source).toContain("scrollSecurityTerminalToBottom");
expect(source).toContain("downloadSecurityLog");
expect(source).toContain("saveFileWithFeedback");
expect(source).toContain("securityRequestTarget(event)");
expect(source).toContain("<el-dialog v-model=\"securityLogDialogVisible\"");
expect(source).toContain("securityTerminalLines.join");
expect(source).toContain("security-access-logs.log");
expect(source).toContain("terminal-window dialog-terminal-window");
}); });
it("labels abnormal IP events and paginates details like account management", () => { it("labels abnormal IP events and paginates details like account management", () => {
const source = readSource(); const source = readSource();
expect(source).toContain('{ label: "异常IP", value: "ABNORMAL_IP" }'); expect(source).not.toContain('value: "ABNORMAL_IP"');
expect(source).toContain('label: "风险来源 IP"');
expect(source).toContain('v-model:current-page="page"'); expect(source).toContain('v-model:current-page="page"');
expect(source).toContain('v-model:page-size="pageSize"'); expect(source).toContain('v-model:page-size="pageSize"');
expect(source).toContain(':page-sizes="[10, 20, 50]"'); expect(source).toContain(':page-sizes="[10, 20, 50]"');
expect(source).toContain('layout="prev, pager, next, sizes, total"'); expect(source).toContain('layout="prev, pager, next, sizes, total"');
}); });
it("calculates dashboard statistics from all events instead of detail filters or current page", () => { it("uses server-side pagination and aggregate facets instead of loading every event", () => {
const source = readSource(); const source = readSource();
expect(source).toContain("statsEvents"); expect(source).toContain("statsEvents");
expect(source).toContain("detailFilteredEvents"); expect(source).toContain("securitySummary");
expect(source).toContain("loadSecurityStats"); expect(source).toContain("loadSecurityStats");
expect(source).toContain("SECURITY_STATS_PAGE_SIZE"); expect(source).toContain("response.data.summary");
expect(source).toContain("statsEvents.value"); expect(source).toContain("@current-change=\"loadSecurityEvents\"");
expect(source).not.toContain("const uniqueIpCount = new Set(events.value"); expect(source).not.toContain("for (let nextPage = 2; nextPage <= totalPages; nextPage += 1) {\n const res = await fetchSecurityAccessLogs");
expect(source).not.toContain("filteredStatsEvents"); expect(source).toContain("fetchAllSecurityEvents");
}); });
}); });
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,46 @@
<template>
<el-radio-group
:model-value="props.modelValue"
size="small"
@change="onChange"
>
<el-radio-button
v-for="item in currentOptions"
:key="item.value"
:label="item.value"
>{{ item.label }}</el-radio-button>
</el-radio-group>
</template>
<script setup lang="ts">
import { computed } from "vue";
interface OptionItem {
value: string | number;
label: string;
}
const props = defineProps<{
modelValue: string | number;
options?: readonly OptionItem[] | OptionItem[];
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: string | number): void;
(e: "change", value: string | number): void;
}>();
const defaultOptions = [
{ value: "all", label: "全部" },
{ value: "24h", label: "24小时" },
{ value: "7d", label: "7天" },
{ value: "30d", label: "30天" },
] as const;
const currentOptions = computed(() => props.options || defaultOptions);
const onChange = (val: string | number) => {
emit("update:modelValue", val);
emit("change", val);
};
</script>
+4
View File
@@ -316,10 +316,12 @@
{{ userDisplayInitial }} {{ userDisplayInitial }}
</el-avatar> </el-avatar>
<span class="username">{{ userDisplayName }}</span> <span class="username">{{ userDisplayName }}</span>
<AccountConnectionStatus mode="indicator" />
<el-icon><ArrowDown /></el-icon> <el-icon><ArrowDown /></el-icon>
</span> </span>
<template #dropdown> <template #dropdown>
<el-dropdown-menu class="user-dropdown-menu"> <el-dropdown-menu class="user-dropdown-menu">
<AccountConnectionStatus mode="details" />
<el-dropdown-item command="workbench"> <el-dropdown-item command="workbench">
<el-icon><Suitcase /></el-icon> <el-icon><Suitcase /></el-icon>
<span>工作台</span> <span>工作台</span>
@@ -429,6 +431,7 @@ import { dispatchDesktopRefreshCurrentView } from "../composables/useDesktopRefr
import { useDesktopShortcuts } from "../composables/useDesktopShortcuts"; import { useDesktopShortcuts } from "../composables/useDesktopShortcuts";
import type { DesktopCommand } from "../types/desktopCommands"; import type { DesktopCommand } from "../types/desktopCommands";
import DesktopCommandPalette from "./DesktopCommandPalette.vue"; import DesktopCommandPalette from "./DesktopCommandPalette.vue";
import AccountConnectionStatus from "./AccountConnectionStatus.vue";
import ProfileSettings from "../views/ProfileSettings.vue"; import ProfileSettings from "../views/ProfileSettings.vue";
import DesktopPreferences from "../views/DesktopPreferences.vue"; import DesktopPreferences from "../views/DesktopPreferences.vue";
import { getActiveLayoutPath } from "./layout/navigation"; import { getActiveLayoutPath } from "./layout/navigation";
@@ -2364,6 +2367,7 @@ useDesktopShortcuts(
} }
.ctms-route-shell { .ctms-route-shell {
width: 100%;
min-height: 100%; min-height: 100%;
transform-origin: top center; transform-origin: top center;
} }
-110
View File
@@ -1,110 +0,0 @@
// 地图数据来源:https://unpkg.com/echarts@4.9.0/map/json/world.json
// 组件直接导入本地缓存的 ECharts 世界地图 GeoJSON,避免运行时依赖外部网络。
import type { IpLocationStatItem } from "../types/api";
export const normalizeWorldCountryName = (value?: string | null) => {
const country = String(value || "").trim();
const aliases: Record<string, string> = {
: "China",
China: "China",
"Mainland China": "China",
: "China",
: "China",
: "China",
: "United States",
"United States": "United States",
"United States of America": "United States",
USA: "United States",
Australia: "Australia",
: "Australia",
Japan: "Japan",
: "Japan",
Singapore: "Singapore",
: "Singapore",
Germany: "Germany",
: "Germany",
France: "France",
: "France",
"United Kingdom": "United Kingdom",
: "United Kingdom",
Canada: "Canada",
: "Canada",
India: "India",
: "India",
Russia: "Russia",
: "Russia",
};
return aliases[country] || country;
};
export const countryCoordinates: Record<string, [number, number]> = {
China: [104.1954, 35.8617],
"United States": [-95.7129, 37.0902],
Netherlands: [5.2913, 52.1326],
Türkiye: [35.2433, 38.9637],
Turkey: [35.2433, 38.9637],
Australia: [133.7751, -25.2744],
Japan: [138.2529, 36.2048],
Singapore: [103.8198, 1.3521],
Germany: [10.4515, 51.1657],
France: [2.2137, 46.2276],
"United Kingdom": [-3.436, 55.3781],
Canada: [-106.3468, 56.1304],
India: [78.9629, 20.5937],
Russia: [105.3188, 61.524],
};
export const cityCoordinates: Record<string, [number, number]> = {
"局域网": [121.86, 31.45],
"北京市": [116.4074, 39.9042],
"天津市": [117.2008, 39.0842],
"河北省": [114.5025, 38.0455],
"山西省": [112.5492, 37.857],
"内蒙古自治区": [111.6708, 40.8183],
"辽宁省": [123.4315, 41.8057],
"吉林省": [125.3245, 43.8868],
"黑龙江省": [126.6424, 45.7567],
"上海市": [121.4737, 31.2304],
"江苏省": [118.7633, 32.0617],
"浙江省": [120.1551, 30.2741],
"安徽省": [117.2272, 31.8206],
"福建省": [119.2965, 26.0745],
"江西省": [115.8582, 28.682],
"山东省": [117.1201, 36.6512],
"河南省": [113.6254, 34.7466],
"湖北省": [114.3055, 30.5928],
"湖南省": [112.9388, 28.2282],
"广东省": [113.2644, 23.1291],
"广西壮族自治区": [108.3669, 22.817],
"海南省": [110.3312, 20.0311],
"重庆": [106.5516, 29.563],
"重庆市": [106.5516, 29.563],
"四川省": [104.0665, 30.5723],
"贵州省": [106.6302, 26.647],
"云南省": [102.8329, 24.8801],
"西藏自治区": [91.1322, 29.6604],
"陕西省": [108.9398, 34.3416],
"甘肃省": [103.8343, 36.0611],
"青海省": [101.7782, 36.6171],
"宁夏回族自治区": [106.2309, 38.4872],
"新疆维吾尔自治区": [87.6168, 43.8256],
"台湾省": [121.5654, 25.033],
"香港特别行政区": [114.1694, 22.3193],
"澳门特别行政区": [113.5439, 22.1987],
"南京": [118.7969, 32.0603],
"南京市": [118.7969, 32.0603],
"San Jose": [-121.8863, 37.3382],
"South Holland": [4.493, 52.0208],
"Istanbul": [28.9784, 41.0082],
};
export const resolveSourceCoordinate = (item: IpLocationStatItem): [number, number] | null => {
if (item.location === "局域网") return cityCoordinates["局域网"];
const city = String(item.city || "").trim();
if (city && cityCoordinates[city]) return cityCoordinates[city];
const province = String(item.province || "").trim();
if (province && cityCoordinates[province]) return cityCoordinates[province];
const country = normalizeWorldCountryName(item.country);
return country ? countryCoordinates[country] || null : null;
};
@@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { heartbeatSessionMock, getTokenMock } = vi.hoisted(() => ({
heartbeatSessionMock: vi.fn(),
getTokenMock: vi.fn(),
}));
vi.mock("../api/authClient", () => ({
heartbeatSession: heartbeatSessionMock,
}));
vi.mock("../utils/auth", () => ({
getToken: getTokenMock,
}));
describe("connection status heartbeat", () => {
beforeEach(() => {
heartbeatSessionMock.mockReset();
heartbeatSessionMock.mockResolvedValue({ data: { status: "online" } });
getTokenMock.mockReturnValue("session-token");
Object.defineProperty(window.navigator, "onLine", { configurable: true, value: true });
});
it("sends an authenticated heartbeat and reacts to browser offline state", async () => {
const { useConnectionStatus } = await import("./useConnectionStatus");
const connection = useConnectionStatus();
const stop = connection.startConnectionMonitor();
await vi.waitFor(() => expect(connection.status.value).toBe("online"));
expect(heartbeatSessionMock).toHaveBeenCalledWith("session-token");
expect(connection.latencyMs.value).toBeGreaterThan(0);
expect(connection.lastSuccessAt.value).not.toBeNull();
Object.defineProperty(window.navigator, "onLine", { configurable: true, value: false });
window.dispatchEvent(new Event("offline"));
expect(connection.status.value).toBe("offline");
stop();
});
});
@@ -0,0 +1,99 @@
import { computed, readonly, ref } from "vue";
import { heartbeatSession } from "../api/authClient";
import { getToken } from "../utils/auth";
export type ConnectionStatus = "checking" | "online" | "degraded" | "offline";
const HEARTBEAT_INTERVAL_MS = 30_000;
const status = ref<ConnectionStatus>("checking");
const latencyMs = ref<number | null>(null);
const lastSuccessAt = ref<number | null>(null);
const lastCheckedAt = ref<number | null>(null);
let consumerCount = 0;
let heartbeatTimer: number | undefined;
let heartbeatPromise: Promise<void> | null = null;
let listenersRegistered = false;
const checkConnection = async () => {
if (typeof navigator !== "undefined" && !navigator.onLine) {
status.value = "offline";
latencyMs.value = null;
lastCheckedAt.value = Date.now();
return;
}
if (heartbeatPromise) return heartbeatPromise;
heartbeatPromise = (async () => {
const startedAt = performance.now();
try {
const token = getToken();
if (!token) {
status.value = "offline";
latencyMs.value = null;
return;
}
await heartbeatSession(token);
latencyMs.value = Math.max(1, Math.round(performance.now() - startedAt));
lastSuccessAt.value = Date.now();
status.value = "online";
} catch (error: any) {
latencyMs.value = null;
status.value = error?.response ? "degraded" : "offline";
} finally {
lastCheckedAt.value = Date.now();
heartbeatPromise = null;
}
})();
return heartbeatPromise;
};
const handleOnline = () => {
status.value = "checking";
void checkConnection();
};
const handleOffline = () => {
status.value = "offline";
latencyMs.value = null;
lastCheckedAt.value = Date.now();
};
const registerNetworkListeners = () => {
if (listenersRegistered || typeof window === "undefined") return;
listenersRegistered = true;
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
};
const startConnectionMonitor = () => {
consumerCount += 1;
registerNetworkListeners();
if (consumerCount === 1) {
void checkConnection();
heartbeatTimer = window.setInterval(() => void checkConnection(), HEARTBEAT_INTERVAL_MS);
}
return () => {
consumerCount = Math.max(0, consumerCount - 1);
if (consumerCount === 0 && heartbeatTimer) {
window.clearInterval(heartbeatTimer);
heartbeatTimer = undefined;
}
};
};
const statusLabel = computed(() => {
if (status.value === "online") return "在线";
if (status.value === "degraded") return "连接异常";
if (status.value === "offline") return "已断开";
return "检测中";
});
export const useConnectionStatus = () => ({
status: readonly(status),
statusLabel,
latencyMs: readonly(latencyMs),
lastSuccessAt: readonly(lastSuccessAt),
lastCheckedAt: readonly(lastCheckedAt),
checkConnection,
startConnectionMonitor,
});
+18 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import packageInfo from "../../package.json"; import packageInfo from "../../package.json";
import { clientRuntime } from "./clientRuntime"; import { clientRuntime } from "./clientRuntime";
import { getAppMetadata } from "./appMetadata"; import { getAppMetadata, getAppMetadataHeaders } from "./appMetadata";
afterEach(() => { afterEach(() => {
Reflect.deleteProperty(window, "__TAURI_INTERNALS__"); Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
@@ -15,6 +15,7 @@ describe("client runtime", () => {
commit: "local", commit: "local",
channel: "local", channel: "local",
clientType: "web", clientType: "web",
clientSource: "ctms-web",
platform: "web", platform: "web",
}); });
expect(clientRuntime.capabilities()).toEqual({ expect(clientRuntime.capabilities()).toEqual({
@@ -30,10 +31,26 @@ describe("client runtime", () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true }); Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
expect(getAppMetadata().clientType).toBe("desktop"); expect(getAppMetadata().clientType).toBe("desktop");
expect(getAppMetadata().clientSource).toBe("ctms-desktop");
expect(clientRuntime.capabilities().serverConfiguration).toBe(true); expect(clientRuntime.capabilities().serverConfiguration).toBe(true);
expect(clientRuntime.capabilities().secureSessionStorage).toBe(false); expect(clientRuntime.capabilities().secureSessionStorage).toBe(false);
}); });
it("injects explicit CTMS client source headers for audit classification", () => {
expect(getAppMetadataHeaders()).toMatchObject({
"X-CTMS-Client-Type": "web",
"X-CTMS-Client-Source": "ctms-web",
"X-CTMS-Client-Version": packageInfo.version,
"X-CTMS-Client-Platform": "web",
});
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
expect(getAppMetadataHeaders()).toMatchObject({
"X-CTMS-Client-Type": "desktop",
"X-CTMS-Client-Source": "ctms-desktop",
});
});
it("accepts release build metadata only from the expected CI values", () => { it("accepts release build metadata only from the expected CI values", () => {
vi.stubEnv("VITE_BUILD_CHANNEL", "release"); vi.stubEnv("VITE_BUILD_CHANNEL", "release");
vi.stubEnv("VITE_BUILD_COMMIT", "0123456789abcdef0123456789abcdef01234567"); vi.stubEnv("VITE_BUILD_COMMIT", "0123456789abcdef0123456789abcdef01234567");
+4
View File
@@ -2,6 +2,7 @@ import packageInfo from "../../package.json";
import { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform"; import { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform";
export type ClientType = "web" | "desktop"; export type ClientType = "web" | "desktop";
export type ClientSource = "ctms-web" | "ctms-desktop";
export type BuildChannel = "dev" | "main" | "release" | "local"; export type BuildChannel = "dev" | "main" | "release" | "local";
export interface AppMetadata { export interface AppMetadata {
@@ -9,6 +10,7 @@ export interface AppMetadata {
commit: string; commit: string;
channel: BuildChannel; channel: BuildChannel;
clientType: ClientType; clientType: ClientType;
clientSource: ClientSource;
platform: RuntimePlatform; platform: RuntimePlatform;
} }
@@ -26,6 +28,7 @@ export const getAppMetadata = (): AppMetadata => ({
commit: resolveBuildCommit(import.meta.env.VITE_BUILD_COMMIT), commit: resolveBuildCommit(import.meta.env.VITE_BUILD_COMMIT),
channel: resolveBuildChannel(import.meta.env.VITE_BUILD_CHANNEL), channel: resolveBuildChannel(import.meta.env.VITE_BUILD_CHANNEL),
clientType: isTauriRuntime() ? "desktop" : "web", clientType: isTauriRuntime() ? "desktop" : "web",
clientSource: isTauriRuntime() ? "ctms-desktop" : "ctms-web",
platform: getRuntimePlatform(), platform: getRuntimePlatform(),
}); });
@@ -33,6 +36,7 @@ export const getAppMetadataHeaders = (): Record<string, string> => {
const metadata = getAppMetadata(); const metadata = getAppMetadata();
return { return {
"X-CTMS-Client-Type": metadata.clientType, "X-CTMS-Client-Type": metadata.clientType,
"X-CTMS-Client-Source": metadata.clientSource,
"X-CTMS-Client-Version": metadata.version, "X-CTMS-Client-Version": metadata.version,
"X-CTMS-Client-Platform": metadata.platform, "X-CTMS-Client-Platform": metadata.platform,
"X-CTMS-Build-Channel": metadata.channel, "X-CTMS-Build-Channel": metadata.channel,
+5 -1
View File
@@ -3,7 +3,7 @@ import { useAuthStore } from "../store/auth";
import { useSessionStore } from "../store/session"; import { useSessionStore } from "../store/session";
import { isTauriRuntime } from "../runtime"; import { isTauriRuntime } from "../runtime";
import { getToken, setToken } from "../utils/auth"; import { getToken, setToken } from "../utils/auth";
import { extendToken } from "../api/authClient"; import { extendToken, logoutSession } from "../api/authClient";
import { parseJwtExp } from "./jwt"; import { parseJwtExp } from "./jwt";
export const IDLE_TIMEOUT_MINUTES = 30; export const IDLE_TIMEOUT_MINUTES = 30;
@@ -172,6 +172,10 @@ const performLogout = async (reason?: string) => {
setLogoutReason(reason); setLogoutReason(reason);
const auth = useAuthStore(); const auth = useAuthStore();
const session = useSessionStore(); const session = useSessionStore();
const token = getToken();
if (token) {
await logoutSession(token).catch(() => undefined);
}
const logoutPromise = auth.logout(); const logoutPromise = auth.logout();
session.resetActivity(); session.resetActivity();
router.replace("/login"); router.replace("/login");
+142 -2
View File
@@ -1056,9 +1056,11 @@ body.is-desktop-runtime .el-dialog {
max-width: min(calc(100vw - 72px), var(--el-dialog-width, 960px)); max-width: min(calc(100vw - 72px), var(--el-dialog-width, 960px));
} }
/* profile-settings-dialog / desktop-preferences-dialog:移除外层卡片外壳,内容区自带圆角阴影 */ /* 这些对话框的内容区已具备独立结构,移除桌面端通用的外层卡片外壳。 */
body.is-desktop-runtime .profile-settings-dialog, body.is-desktop-runtime .profile-settings-dialog,
body.is-desktop-runtime .desktop-preferences-dialog { body.is-desktop-runtime .desktop-preferences-dialog,
body.is-desktop-runtime .user-form-dialog,
body.is-desktop-runtime .reset-password-dialog {
overflow: visible !important; overflow: visible !important;
border: none !important; border: none !important;
border-radius: 0 !important; border-radius: 0 !important;
@@ -1108,6 +1110,29 @@ body.is-desktop-runtime .el-dialog__body {
font-size: 13px; font-size: 13px;
} }
/* 账号表单去掉外框后,内容区仍需作为标题与底栏之间的连续面板。 */
body.is-desktop-runtime .user-form-dialog .el-dialog__body,
body.is-desktop-runtime .reset-password-dialog .el-dialog__body {
background: rgba(248, 250, 252, 0.98);
}
/* 账号操作弹窗保留单层面板:以小圆角和细边框明确边界,关闭按钮置于标题栏内。 */
body.is-desktop-runtime .user-form-dialog,
body.is-desktop-runtime .reset-password-dialog {
padding: 0 !important;
overflow: hidden !important;
border: 1px solid rgba(148, 163, 184, 0.5) !important;
border-radius: 8px !important;
}
body.is-desktop-runtime .user-form-dialog .el-dialog__headerbtn,
body.is-desktop-runtime .reset-password-dialog .el-dialog__headerbtn {
top: 5px;
right: 7px;
width: 28px;
height: 28px;
}
body.is-desktop-runtime .desktop-preferences-dialog { body.is-desktop-runtime .desktop-preferences-dialog {
background: transparent !important; background: transparent !important;
box-shadow: none !important; box-shadow: none !important;
@@ -1195,6 +1220,8 @@ body.is-desktop-runtime .msgbox-fade-leave-to .el-message-box {
} }
:root[data-ctms-theme="dark"] body.is-desktop-runtime .desktop-preferences-dialog, :root[data-ctms-theme="dark"] body.is-desktop-runtime .desktop-preferences-dialog,
:root[data-ctms-theme="dark"] body.is-desktop-runtime .user-form-dialog,
:root[data-ctms-theme="dark"] body.is-desktop-runtime .reset-password-dialog,
:root[data-ctms-theme="dark"] body.is-desktop-runtime .desktop-preferences-dialog .el-dialog__body { :root[data-ctms-theme="dark"] body.is-desktop-runtime .desktop-preferences-dialog .el-dialog__body {
background: transparent !important; background: transparent !important;
} }
@@ -1216,6 +1243,16 @@ body.is-desktop-runtime .msgbox-fade-leave-to .el-message-box {
color: #cbd5e1; color: #cbd5e1;
} }
:root[data-ctms-theme="dark"] body.is-desktop-runtime .user-form-dialog .el-dialog__body,
:root[data-ctms-theme="dark"] body.is-desktop-runtime .reset-password-dialog .el-dialog__body {
background: rgba(17, 24, 39, 0.98);
}
:root[data-ctms-theme="dark"] body.is-desktop-runtime .user-form-dialog,
:root[data-ctms-theme="dark"] body.is-desktop-runtime .reset-password-dialog {
border-color: rgba(71, 85, 105, 0.9) !important;
}
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-dialog__footer, :root[data-ctms-theme="dark"] body.is-desktop-runtime .el-dialog__footer,
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-message-box__btns { :root[data-ctms-theme="dark"] body.is-desktop-runtime .el-message-box__btns {
border-top-color: rgba(51, 65, 85, 0.92); border-top-color: rgba(51, 65, 85, 0.92);
@@ -1230,3 +1267,106 @@ body.is-desktop-runtime .msgbox-fade-leave-to .el-message-box {
transition-duration: 1ms !important; transition-duration: 1ms !important;
} }
} }
.ctms-updated-at {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 12px !important;
color: #64748b !important;
font-weight: 400 !important;
line-height: 1.5 !important;
white-space: nowrap !important;
}
:root[data-ctms-theme="dark"] .ctms-updated-at {
color: #94a3b8 !important;
}
/* 触发弹窗/新视窗的专属物理按钮动效 */
.ctms-btn-dialog {
transition: transform 0.15s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.15s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.15s ease !important;
font-weight: 550 !important;
}
.ctms-btn-dialog:hover {
transform: translateY(-1px) !important;
box-shadow: 0 4px 10px rgba(59, 130, 246, 0.12) !important;
}
.ctms-btn-dialog.primary-dialog:hover {
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.25) !important;
}
.ctms-btn-dialog:active {
transform: translateY(1px) !important;
box-shadow: none !important;
}
.ctms-btn-link-dialog {
font-weight: 600 !important;
transition: color 0.15s ease, opacity 0.15s ease !important;
}
.ctms-btn-link-dialog:hover {
opacity: 0.85 !important;
text-decoration: underline !important;
text-underline-offset: 3px !important;
}
/* 专属物理按钮圆角胶囊与渐变色方案 */
/* 1. 危险/高危胶囊(查看异常分析) */
.ctms-btn-capsule-danger {
background: #fff5f5 !important;
color: #e11d48 !important;
border: 1px solid #fee2e2 !important;
border-radius: 999px !important;
font-size: 11px !important;
height: 24px !important;
padding: 0 10px !important;
}
.ctms-btn-capsule-danger:hover {
background: #ffe4e6 !important;
border-color: #fecdd3 !important;
color: #be123c !important;
}
/* 2. 统计/排行胶囊(IP访问排行) */
.ctms-btn-capsule-info {
background: #eff6ff !important;
color: #1d4ed8 !important;
border: 1px solid #dbeafe !important;
border-radius: 999px !important;
}
.ctms-btn-capsule-info:hover {
background: #dbeafe !important;
border-color: #bfdbfe !important;
color: #1e40af !important;
}
/* 3. 靛紫渐变胶囊(安全日志) */
.ctms-btn-gradient-indigo {
background: linear-gradient(135deg, #4f46e5, #6366f1) !important;
color: #ffffff !important;
border: none !important;
border-radius: 999px !important;
box-shadow: 0 2px 6px rgba(99, 102, 241, 0.15) !important;
}
.ctms-btn-gradient-indigo:hover {
background: linear-gradient(135deg, #4338ca, #4f46e5) !important;
color: #ffffff !important;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3) !important;
}
/* 4. 蓝绿双向渐变胶囊(访问日志) */
.ctms-btn-gradient-blue {
background: linear-gradient(135deg, #0284c7, #0369a1) !important;
color: #ffffff !important;
border: none !important;
border-radius: 999px !important;
box-shadow: 0 2px 6px rgba(2, 132, 199, 0.15) !important;
}
.ctms-btn-gradient-blue:hover {
background: linear-gradient(135deg, #0369a1, #075985) !important;
color: #ffffff !important;
box-shadow: 0 4px 12px rgba(2, 132, 199, 0.3) !important;
}
+171 -1
View File
@@ -38,6 +38,7 @@ export interface LoginKeyResponse {
} }
export type UserStatus = "PENDING" | "ACTIVE" | "REJECTED" | "DISABLED"; export type UserStatus = "PENDING" | "ACTIVE" | "REJECTED" | "DISABLED";
export type UserLoginStatus = "ONLINE" | "OFFLINE";
export interface UserInfo { export interface UserInfo {
id: string; id: string;
@@ -52,6 +53,22 @@ export interface UserInfo {
approved_at?: string | null; approved_at?: string | null;
approved_by?: string | null; approved_by?: string | null;
avatar_url?: string | null; avatar_url?: string | null;
login_status?: UserLoginStatus;
last_login_at?: string | null;
last_seen_at?: string | null;
last_client_type?: "web" | "desktop" | null;
active_session_count?: number;
}
export interface UserLoginActivity {
id: string;
client_type: "web" | "desktop";
client_platform?: string | null;
client_version?: string | null;
login_at: string;
last_seen_at: string;
ended_at?: string | null;
end_reason?: string | null;
} }
export type UserMeResponse = UserInfo; export type UserMeResponse = UserInfo;
@@ -298,6 +315,7 @@ export interface HealthResponse {
window_seconds: number; window_seconds: number;
}; };
alerts: HealthScoreItem; alerts: HealthScoreItem;
runtime: HealthScoreItem;
}; };
last_hour: { last_hour: {
total_checks: number; total_checks: number;
@@ -308,6 +326,25 @@ export interface HealthResponse {
warning_alerts: number; warning_alerts: number;
}; };
cache_stats: CacheStatsResponse; cache_stats: CacheStatsResponse;
components?: {
database: HealthRuntimeComponent & { latency_ms?: number };
permission_log_writer: HealthWriterComponent;
security_log_writer: HealthWriterComponent;
retention: HealthRuntimeComponent & {
running?: boolean;
access_log_retention_days?: number;
metric_retention_days?: number;
last_success_at?: string | null;
last_error_at?: string | null;
};
};
storage?: {
permission_access_logs: number;
security_access_logs: number;
permission_metric_snapshots: number;
oldest_permission_access_log_at: string | null;
oldest_security_access_log_at: string | null;
};
} }
export interface HealthScoreItem { export interface HealthScoreItem {
@@ -315,12 +352,59 @@ export interface HealthScoreItem {
deduction: number; deduction: number;
} }
export interface HealthRuntimeComponent {
status: "healthy" | "degraded" | "unhealthy" | "unknown";
reason?: string;
}
export interface HealthWriterComponent extends HealthRuntimeComponent {
running?: boolean;
queue_size?: number;
queue_capacity?: number;
queue_usage_percent?: number;
written_entries?: number;
dropped_entries?: number;
failed_batches?: number;
last_success_at?: string | null;
last_error_at?: string | null;
}
// 系统监测 - 访问日志 // 系统监测 - 访问日志
export interface RequestSnapshotParam {
name: string;
value: string;
}
export interface RequestSnapshot {
method?: string | null;
path?: string | null;
query_string?: string | null;
query_params?: RequestSnapshotParam[] | null;
headers?: Record<string, string> | null;
http_version?: string | null;
scheme?: string | null;
client?: {
host?: string | null;
port?: number | null;
} | null;
content?: {
type?: string | null;
length?: string | null;
} | null;
body?: {
captured?: boolean;
reason?: string | null;
} | null;
[key: string]: unknown;
}
export interface AccessLogItem { export interface AccessLogItem {
id: string; id: string;
event_type?: "permission" | "security" | string;
study_id: string; study_id: string;
user_id: string; user_id: string;
user_name: string; user_name: string;
user_email: string | null;
endpoint_key: string; endpoint_key: string;
role: string; role: string;
allowed: boolean; allowed: boolean;
@@ -331,6 +415,22 @@ export interface AccessLogItem {
ip_province: string; ip_province: string;
ip_city: string; ip_city: string;
ip_isp: string; ip_isp: string;
user_agent: string | null;
client_type: string | null;
client_version: string | null;
client_platform: string | null;
build_channel: string | null;
build_commit: string | null;
request_headers: Record<string, string> | null;
request_snapshot: RequestSnapshot | null;
request_id?: string | null;
method?: string | null;
path?: string | null;
status_code?: number | null;
auth_status?: string | null;
account_label?: string | null;
category?: string | null;
severity?: string | null;
created_at: string; created_at: string;
} }
@@ -345,6 +445,7 @@ export interface AccessLogsSummary {
export interface AccessLogUserStat { export interface AccessLogUserStat {
user_id: string; user_id: string;
user_name: string; user_name: string;
user_email: string | null;
role: string; role: string;
total_count: number; total_count: number;
denied_count: number; denied_count: number;
@@ -381,6 +482,9 @@ export interface SecurityAccessLogItem {
client_platform: string | null; client_platform: string | null;
build_channel: string | null; build_channel: string | null;
build_commit: string | null; build_commit: string | null;
request_headers: Record<string, string> | null;
request_snapshot: RequestSnapshot | null;
request_id?: string | null;
auth_status: "ANONYMOUS" | "INVALID_TOKEN" | "AUTHENTICATED" | string; auth_status: "ANONYMOUS" | "INVALID_TOKEN" | "AUTHENTICATED" | string;
user_identifier: string | null; user_identifier: string | null;
account_label: string; account_label: string;
@@ -398,6 +502,20 @@ export interface SecurityAccessLogsResponse {
anonymous_count: number; anonymous_count: number;
invalid_token_count: number; invalid_token_count: number;
error_count: number; error_count: number;
unique_ip_count: number;
high_risk_count: number;
server_error_count: number;
category_counts: Record<string, number>;
severity_counts: Record<string, number>;
endpoint_type_counts: Record<string, { count: number; sample_path: string }>;
top_ips: Array<{
ip: string;
location: string;
category: string;
total_count: number;
high_risk_count: number;
last_seen_at: string | null;
}>;
}; };
items: SecurityAccessLogItem[]; items: SecurityAccessLogItem[];
} }
@@ -461,25 +579,77 @@ export interface TopDeniedResponse {
export interface IpLocationStatItem { export interface IpLocationStatItem {
country: string; country: string;
country_code: string;
province: string; province: string;
region_code: string;
city: string; city: string;
isp: string; isp: string;
location: string; location: string;
longitude: number | null;
latitude: number | null;
accuracy_level: "city" | "region" | "country" | "private" | "unknown";
total_count: number; total_count: number;
allowed_count: number; allowed_count: number;
denied_count: number; denied_count: number;
denied_rate: number;
unique_ip_count: number; unique_ip_count: number;
unique_user_count: number; unique_user_count: number;
security_event_count: number;
high_risk_count: number;
risk_score: number;
risk_level: "normal" | "attention" | "high";
risk_reasons: string[];
categories: string[];
first_seen_at: string | null;
last_seen_at: string | null;
} }
export interface IpLocationsResponse { export interface IpLocationsResponse {
days: number; days: number | null;
generated_at: string;
period: {
mode: "rolling" | "retained_all";
requested_days: number | null;
start_at: string;
end_at: string;
observed_end_at: string | null;
retention_days: number;
};
server_location: {
name: string;
longitude: number;
latitude: number;
accuracy_level: string;
resolution_source: "configured_public_ip" | "frontend_dns" | "public_ip_discovery";
} | null;
data_quality: {
resolver: string;
located_ip_count: number;
private_ip_count: number;
unknown_ip_count: number;
location_coverage_rate: number;
returned_region_count: number;
};
timeline_granularity: "hour" | "day";
timeline_complete_through: string;
timeline: Array<{
bucket_time: string;
total_count: number;
allowed_count: number;
denied_count: number;
security_event_count: number;
high_risk_count: number;
unique_ip_count: number;
unique_user_count: number;
}>;
summary: { summary: {
total_count: number; total_count: number;
allowed_count: number; allowed_count: number;
denied_count: number; denied_count: number;
unique_ip_count: number; unique_ip_count: number;
unique_user_count: number; unique_user_count: number;
security_event_count: number;
high_risk_count: number;
}; };
items: IpLocationStatItem[]; items: IpLocationStatItem[];
} }
+1 -1
View File
@@ -486,7 +486,7 @@ const onSubmit = async () => {
justify-content: stretch; justify-content: stretch;
overflow: hidden; overflow: hidden;
background: #ffffff; background: #ffffff;
font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif; font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", sans-serif;
} }
.login-split-container { .login-split-container {
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSystemMonitoringPage = () => readFileSync(resolve(__dirname, "./SystemMonitoringPage.vue"), "utf8");
describe("SystemMonitoringPage desktop shell", () => {
it("reuses the web monitoring component and fits it within the desktop workspace", () => {
const source = readSystemMonitoringPage();
expect(source).toContain('<PermissionMonitoring :is-admin="true" />');
expect(source).toContain('import { isTauriRuntime } from "@/runtime";');
expect(source).toContain("const isDesktop = isTauriRuntime();");
expect(source).toContain("system-monitoring-page--desktop");
expect(source).toContain("height: 100%;");
expect(source).toContain(".overview-hero-card");
expect(source).toContain(".access-logs .audit-filters");
});
});
@@ -1,11 +1,14 @@
<template> <template>
<div class="system-monitoring-page"> <div class="system-monitoring-page" :class="{ 'system-monitoring-page--desktop': isDesktop }">
<PermissionMonitoring :is-admin="true" /> <PermissionMonitoring :is-admin="true" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import PermissionMonitoring from "@/components/PermissionMonitoring.vue"; import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
import { isTauriRuntime } from "@/runtime";
const isDesktop = isTauriRuntime();
</script> </script>
<style scoped> <style scoped>
@@ -19,4 +22,49 @@ import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
overflow: hidden; overflow: hidden;
background: #f5f7fa; background: #f5f7fa;
} }
/*
* DesktopLayout already reserves space for its title toolbar and workspace
* tabs. Keep the shared monitoring view inside that remaining area instead
* of sizing it from the browser viewport as the web shell does.
*/
.system-monitoring-page--desktop {
height: 100%;
margin: 0;
}
@media (max-width: 1450px) {
/*
* At the minimum desktop window width, the sidebar leaves the monitoring
* surface with roughly 900px. These rules preserve the web information
* hierarchy while giving the shared cards room to wrap cleanly.
*/
.system-monitoring-page--desktop :deep(.overview-hero-card) {
flex-wrap: wrap;
}
.system-monitoring-page--desktop :deep(.overview-title-group) {
flex: 1 1 112px;
}
.system-monitoring-page--desktop :deep(.overview-hero-actions) {
flex: 0 1 auto;
gap: 8px;
}
.system-monitoring-page--desktop :deep(.metric-strip) {
flex-basis: 100%;
order: 3;
padding-top: 6px;
border-top: 1px solid #edf1f6;
}
.system-monitoring-page--desktop :deep(.access-logs .audit-filters) {
flex-wrap: wrap;
}
.system-monitoring-page--desktop :deep(.access-logs .filter-keyword) {
max-width: none;
}
}
</style> </style>
+1 -1
View File
@@ -1,6 +1,6 @@
<template> <template>
<el-dialog <el-dialog
append-to=".layout-main .content-wrapper" append-to="body"
:title="user ? TEXT.modules.adminUsers.editTitle : TEXT.modules.adminUsers.newTitle" :title="user ? TEXT.modules.adminUsers.editTitle : TEXT.modules.adminUsers.newTitle"
width="540px" width="540px"
v-model="visibleProxy" v-model="visibleProxy"
@@ -0,0 +1,248 @@
<template>
<el-drawer
v-model="visibleProxy"
title="登录记录"
direction="rtl"
size="min(520px, 92vw)"
destroy-on-close
>
<div class="login-activity-drawer">
<div v-if="user" class="drawer-user-summary">
<div class="user-meta-info">
<span class="user-fullname">{{ user.full_name }}</span>
<span class="user-email-text">{{ user.email }}</span>
</div>
<span class="status-badge" :class="user.login_status === 'ONLINE' ? 'is-online' : 'is-offline'">
<span class="status-badge-dot"></span>
<span>{{ user.login_status === 'ONLINE' ? `在线 (${user.active_session_count || 1})` : '离线' }}</span>
</span>
</div>
<el-table v-loading="loading" :data="activities" class="login-activity-table" empty-text="暂无登录记录">
<el-table-column label="状态" width="102">
<template #default="scope">
<span class="status-badge compact" :class="activityStatusClass(scope.row)">
<span class="status-badge-dot"></span>
<span>{{ activityStatusLabel(scope.row) }}</span>
</span>
</template>
</el-table-column>
<el-table-column label="客户端" min-width="130">
<template #default="scope">
<div class="client-cell">
<strong>{{ scope.row.client_type === 'desktop' ? '桌面端' : '网页端' }}</strong>
<span>{{ clientDescription(scope.row) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="登录 / 最近活动" min-width="160">
<template #default="scope">
<div class="time-cell">
<span>登录 {{ displayDateTime(scope.row.login_at) }}</span>
<span>活动 {{ displayDateTime(scope.row.last_seen_at) }}</span>
</div>
</template>
</el-table-column>
</el-table>
</div>
</el-drawer>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { fetchUserLoginActivities } from "../../api/users";
import type { UserInfo, UserLoginActivity } from "../../types/api";
import { displayDateTime } from "../../utils/display";
const props = defineProps<{
modelValue: boolean;
user: UserInfo | null;
}>();
const emit = defineEmits<{ "update:modelValue": [value: boolean] }>();
const activities = ref<UserLoginActivity[]>([]);
const loading = ref(false);
const visibleProxy = computed({
get: () => props.modelValue,
set: (value: boolean) => emit("update:modelValue", value),
});
const isRecent = (value: string) => Date.now() - new Date(value).getTime() <= 5 * 60 * 1000;
const activityStatusLabel = (item: UserLoginActivity) => {
if (item.ended_at) return "已退出";
return isRecent(item.last_seen_at) ? "在线" : "已离线";
};
const activityStatusClass = (item: UserLoginActivity) => {
if (item.ended_at) return "is-ended";
return isRecent(item.last_seen_at) ? "is-online" : "is-offline";
};
const clientDescription = (item: UserLoginActivity) =>
[item.client_platform, item.client_version].filter(Boolean).join(" · ") || "--";
const loadActivities = async () => {
if (!props.user) return;
loading.value = true;
try {
const { data } = await fetchUserLoginActivities(props.user.id);
activities.value = data;
} catch (error: any) {
ElMessage.error(error?.response?.data?.detail || "登录记录加载失败");
} finally {
loading.value = false;
}
};
watch(
() => props.modelValue,
(isOpen) => {
if (isOpen) void loadActivities();
},
{ immediate: true },
);
</script>
<style scoped>
.login-activity-drawer {
display: flex;
flex-direction: column;
gap: 18px;
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.drawer-user-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
padding: 14px 18px;
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.03);
}
.user-meta-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.user-fullname {
color: #0f172a;
font-size: 15px;
font-weight: 700;
}
.user-email-text {
color: #64748b;
font-size: 12.5px;
}
/* 状态徽标 */
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
line-height: 1.2;
}
.status-badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.status-badge.is-online {
background: #ecfdf5;
color: #059669;
border: 1px solid #a7f3d0;
}
.status-badge.is-online .status-badge-dot {
background-color: #10b981;
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.25);
}
.status-badge.is-offline {
background: #f1f5f9;
color: #475569;
border: 1px solid #cbd5e1;
}
.status-badge.is-offline .status-badge-dot {
background-color: #64748b;
}
.status-badge.is-ended {
background: #f8fafc;
color: #94a3b8;
border: 1px solid #e2e8f0;
}
.status-badge.is-ended .status-badge-dot {
background-color: #cbd5e1;
}
.status-badge.compact {
padding: 2.5px 8px;
font-size: 11px;
}
.status-badge.compact .status-badge-dot {
width: 5px;
height: 5px;
}
.login-activity-table {
width: 100%;
border: 1px solid #f1f5f9;
border-radius: 8px;
overflow: hidden;
}
.login-activity-table :deep(.el-table__header-wrapper) th {
background-color: #f8fafc !important;
color: #475569 !important;
font-size: 12px;
font-weight: 600;
height: 38px;
border-bottom: 1px solid #e2e8f0;
}
.login-activity-table :deep(.el-table__row) td {
border-bottom: 1px solid #f1f5f9;
padding: 10px 0;
}
.client-cell,
.time-cell {
display: flex;
flex-direction: column;
gap: 4px;
}
.client-cell strong {
color: #334155;
font-size: 12.5px;
font-weight: 600;
}
.client-cell span,
.time-cell span {
color: #64748b;
font-size: 11.5px;
}
.time-cell span {
line-height: 1.4;
}
</style>
@@ -1,6 +1,6 @@
<template> <template>
<el-dialog <el-dialog
append-to=".layout-main .content-wrapper" append-to="body"
:title="TEXT.modules.adminUsers.resetTitle" :title="TEXT.modules.adminUsers.resetTitle"
width="540px" width="540px"
v-model="visibleProxy" v-model="visibleProxy"
+94 -8
View File
@@ -41,6 +41,19 @@
<span class="stat-label">{{ TEXT.enums.userStatus.DISABLED }}</span> <span class="stat-label">{{ TEXT.enums.userStatus.DISABLED }}</span>
</div> </div>
</div> </div>
<div class="stat-card stat-card--online">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M5 12a7 7 0 0 1 14 0"/>
<path d="M8 12a4 4 0 0 1 8 0"/>
<circle cx="12" cy="16" r="1"/>
</svg>
</div>
<div class="stat-body">
<span class="stat-value">{{ onlineCount }}</span>
<span class="stat-label">当前在线</span>
</div>
</div>
</div> </div>
<!-- 筛选栏 --> <!-- 筛选栏 -->
@@ -70,7 +83,7 @@
<!-- 用户表格 --> <!-- 用户表格 -->
<div class="unified-section user-table-section"> <div class="unified-section user-table-section">
<el-table :data="users" v-loading="loading" class="user-table" style="width: 100%" table-layout="fixed"> <el-table :data="users" v-loading="loading" class="user-table" style="width: 100%" table-layout="fixed">
<el-table-column :label="TEXT.common.fields.name" width="360"> <el-table-column :label="TEXT.common.fields.name" min-width="200">
<template #default="scope"> <template #default="scope">
<div class="user-cell"> <div class="user-cell">
<div class="user-info"> <div class="user-info">
@@ -80,21 +93,36 @@
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="clinical_department" :label="TEXT.modules.adminUsers.clinicalDepartmentLabel" show-overflow-tooltip /> <el-table-column prop="clinical_department" :label="TEXT.modules.adminUsers.clinicalDepartmentLabel" min-width="120" show-overflow-tooltip />
<el-table-column :label="TEXT.common.fields.status"> <el-table-column :label="TEXT.common.fields.status" min-width="100">
<template #default="scope"> <template #default="scope">
<span class="status-dot" :class="'dot--' + (scope.row.status || '').toLowerCase()"></span> <span class="status-dot" :class="'dot--' + (scope.row.status || '').toLowerCase()"></span>
{{ statusLabel(scope.row.status) }} {{ statusLabel(scope.row.status) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" show-overflow-tooltip> <el-table-column label="登录状态" min-width="140">
<template #default="scope"> <template #default="scope">
<span class="text-muted">{{ displayDateTime(scope.row.created_at) }}</span> <div class="login-status-cell">
<span class="status-dot" :class="'dot--' + (scope.row.login_status || 'OFFLINE').toLowerCase()"></span>
<span>{{ loginStatusLabel(scope.row) }}</span>
<small>{{ scope.row.last_client_type === 'desktop' ? '桌面端' : scope.row.last_client_type === 'web' ? '网页端' : '暂无会话' }}</small>
</div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.labels.actions" align="center"> <el-table-column label="最近登录" min-width="150">
<template #default="scope">
<div class="login-time-cell">
<span>{{ scope.row.last_login_at ? displayDateTime(scope.row.last_login_at) : '从未登录' }}</span>
<small v-if="scope.row.last_seen_at">活动 {{ displayDateTime(scope.row.last_seen_at) }}</small>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" align="center" width="168">
<template #default="scope"> <template #default="scope">
<div class="action-row"> <div class="action-row">
<el-tooltip content="登录记录" placement="top">
<el-button link type="info" :icon="Clock" class="action-btn" aria-label="查看登录记录" @click="openLoginActivities(scope.row)" />
</el-tooltip>
<el-tooltip :content="TEXT.common.actions.edit" placement="top"> <el-tooltip :content="TEXT.common.actions.edit" placement="top">
<el-button link type="primary" :icon="Edit" class="action-btn" @click="openEdit(scope.row)" /> <el-button link type="primary" :icon="Edit" class="action-btn" @click="openEdit(scope.row)" />
</el-tooltip> </el-tooltip>
@@ -141,19 +169,21 @@
</div> </div>
<UserForm v-if="formVisible" v-model:visible="formVisible" :user="editingUser" :admin-count="activeAdminCount" @saved="loadUsers" /> <UserForm v-if="formVisible" v-model:visible="formVisible" :user="editingUser" :admin-count="activeAdminCount" @saved="loadUsers" />
<UserResetPassword v-if="resetVisible" v-model:visible="resetVisible" :user="resetUser" @reset="loadUsers" /> <UserResetPassword v-if="resetVisible" v-model:visible="resetVisible" :user="resetUser" @reset="loadUsers" />
<UserLoginActivitiesDrawer v-if="loginActivitiesVisible" v-model="loginActivitiesVisible" :user="loginActivitiesUser" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { Edit, Delete, Key, Lock, Unlock, Search, Plus } from "@element-plus/icons-vue"; import { Edit, Delete, Key, Lock, Unlock, Search, Plus, Clock } from "@element-plus/icons-vue";
import { fetchUsers, updateUser, deleteUser } from "../../api/users"; import { fetchUsers, updateUser, deleteUser } from "../../api/users";
import { fetchStudies } from "../../api/studies"; import { fetchStudies } from "../../api/studies";
import { listMembers } from "../../api/members"; import { listMembers } from "../../api/members";
import type { UserInfo } from "../../types/api"; import type { UserInfo } from "../../types/api";
import UserForm from "./UserForm.vue"; import UserForm from "./UserForm.vue";
import UserResetPassword from "./UserResetPassword.vue"; import UserResetPassword from "./UserResetPassword.vue";
import UserLoginActivitiesDrawer from "./UserLoginActivitiesDrawer.vue";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { displayDateTime } from "../../utils/display"; import { displayDateTime } from "../../utils/display";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
@@ -171,11 +201,14 @@ const searchKeyword = ref("");
const statusFilter = ref(""); const statusFilter = ref("");
const editingUser = ref<UserInfo | null>(null); const editingUser = ref<UserInfo | null>(null);
const resetUser = ref<UserInfo | null>(null); const resetUser = ref<UserInfo | null>(null);
const loginActivitiesUser = ref<UserInfo | null>(null);
const loginActivitiesVisible = ref(false);
const auth = useAuthStore(); const auth = useAuthStore();
let keywordSearchTimer: ReturnType<typeof setTimeout> | null = null; let keywordSearchTimer: ReturnType<typeof setTimeout> | null = null;
const activeCount = computed(() => allUsers.value.filter(u => u.status === 'ACTIVE').length); const activeCount = computed(() => allUsers.value.filter(u => u.status === 'ACTIVE').length);
const disabledCount = computed(() => allUsers.value.filter(u => u.status === 'DISABLED').length); const disabledCount = computed(() => allUsers.value.filter(u => u.status === 'DISABLED').length);
const onlineCount = computed(() => allUsers.value.filter(u => u.login_status === 'ONLINE').length);
const statusLabel = (status: string) => { const statusLabel = (status: string) => {
switch (status) { switch (status) {
@@ -185,6 +218,12 @@ const statusLabel = (status: string) => {
} }
}; };
const loginStatusLabel = (row: UserInfo) => {
if (row.status !== "ACTIVE") return "账号不可用";
if (row.login_status === "ONLINE") return row.active_session_count && row.active_session_count > 1 ? `${row.active_session_count} 个会话` : "在线";
return "离线";
};
const isLastAdmin = (row: UserInfo) => const isLastAdmin = (row: UserInfo) =>
row.is_admin && row.status === "ACTIVE" && activeAdminCount.value <= 1; row.is_admin && row.status === "ACTIVE" && activeAdminCount.value <= 1;
@@ -284,6 +323,11 @@ const openReset = (row: UserInfo) => {
resetVisible.value = true; resetVisible.value = true;
}; };
const openLoginActivities = (row: UserInfo) => {
loginActivitiesUser.value = row;
loginActivitiesVisible.value = true;
};
const onDelete = async (row: UserInfo) => { const onDelete = async (row: UserInfo) => {
const { action } = await ElMessageBox.prompt( const { action } = await ElMessageBox.prompt(
TEXT.modules.adminUsers.deleteConfirm, TEXT.modules.adminUsers.deleteConfirm,
@@ -364,7 +408,7 @@ onBeforeUnmount(() => {
.stats-row { .stats-row {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(4, 1fr);
gap: 10px; gap: 10px;
padding: 10px 16px; padding: 10px 16px;
border-bottom: 1px solid var(--unified-shell-divider); border-bottom: 1px solid var(--unified-shell-divider);
@@ -415,6 +459,11 @@ onBeforeUnmount(() => {
color: var(--ctms-danger); color: var(--ctms-danger);
} }
.stat-card--online .stat-icon {
background: #e8f6ef;
color: #1f9d63;
}
.stat-body { .stat-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -508,6 +557,43 @@ onBeforeUnmount(() => {
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.15); box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.15);
} }
.status-dot.dot--online {
background: #22a663;
box-shadow: 0 0 0 3px rgba(34, 166, 99, 0.15);
}
.status-dot.dot--offline {
background: #94a3b8;
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.15);
}
.login-status-cell,
.login-time-cell {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
column-gap: 6px;
color: var(--ctms-text-main);
font-size: 12px;
}
.login-status-cell small,
.login-time-cell small {
grid-column: 2;
margin-top: 2px;
color: var(--ctms-text-secondary);
font-size: 11px;
}
.login-time-cell {
display: grid;
grid-template-columns: 1fr;
}
.login-time-cell small {
grid-column: 1;
}
.text-muted { .text-muted {
color: var(--ctms-text-secondary); color: var(--ctms-text-secondary);
font-size: 12px; font-size: 12px;
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = (path: string) => readFileSync(resolve(__dirname, path), "utf8");
describe("admin user login status", () => {
it("shows server-provided session state and opens a privacy-safe activity drawer", () => {
const users = readSource("./Users.vue");
const drawer = readSource("./UserLoginActivitiesDrawer.vue");
expect(users).toContain("当前在线");
expect(users).toContain("login_status");
expect(users).toContain("last_login_at");
expect(users).toContain("openLoginActivities");
expect(users).toContain("UserLoginActivitiesDrawer");
expect(drawer).toContain("登录记录");
expect(drawer).not.toContain("不会展示 Token、密码或原始 IP");
expect(drawer).not.toContain("access_token");
});
it("mounts account edit and emergency-reset dialogs to a container shared by web and desktop layouts", () => {
const userForm = readSource("./UserForm.vue");
const resetPassword = readSource("./UserResetPassword.vue");
expect(userForm).toContain('append-to="body"');
expect(resetPassword).toContain('append-to="body"');
expect(userForm).not.toContain('append-to=".layout-main .content-wrapper"');
expect(resetPassword).not.toContain('append-to=".layout-main .content-wrapper"');
});
it("uses the desktop dialog treatment without an additional outer card for account actions", () => {
const desktopStyles = readFileSync(resolve(__dirname, "../../styles/main.css"), "utf8");
expect(desktopStyles).toContain("body.is-desktop-runtime .user-form-dialog");
expect(desktopStyles).toContain("body.is-desktop-runtime .reset-password-dialog");
expect(desktopStyles).toContain("background: transparent !important;");
expect(desktopStyles).toContain("box-shadow: none !important;");
expect(desktopStyles).toContain(".user-form-dialog .el-dialog__body");
expect(desktopStyles).toContain(".reset-password-dialog .el-dialog__body");
expect(desktopStyles).toContain("border-radius: 8px !important;");
expect(desktopStyles).toContain("padding: 0 !important;");
expect(desktopStyles).toContain(".user-form-dialog .el-dialog__headerbtn");
expect(desktopStyles).toContain("top: 5px;");
expect(desktopStyles).toContain("right: 7px;");
});
});
+8
View File
@@ -74,6 +74,14 @@ http {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location = /readyz {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
add_header Cache-Control "no-store, no-cache, must-revalidate" always; add_header Cache-Control "no-store, no-cache, must-revalidate" always;
+8
View File
@@ -72,6 +72,14 @@ http {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location = /readyz {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / { location / {
proxy_pass http://frontend_dev; proxy_pass http://frontend_dev;
proxy_http_version 1.1; proxy_http_version 1.1;
+1
View File
@@ -461,6 +461,7 @@ run_health_checks() {
check_backend_environment "$runtime_env" "$login_key_id" "$expect_rsa" check_backend_environment "$runtime_env" "$login_key_id" "$expect_rsa"
step "探测 HTTP 接口可用性" step "探测 HTTP 接口可用性"
check_http_endpoint "/health" check_http_endpoint "/health"
check_http_endpoint "/readyz"
check_http_endpoint "/api/v1/auth/login-key" check_http_endpoint "/api/v1/auth/login-key"
if [[ "$TARGET_ENV" == "dev" ]]; then if [[ "$TARGET_ENV" == "dev" ]]; then
check_http_endpoint "/" 60 check_http_endpoint "/" 60