feat(监控): 完善系统监控、登录状态与访问审计能力
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
- `docker compose config`
|
||||
- `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/readyz`
|
||||
|
||||
## 账号与注册
|
||||
- 初始化管理员:`admin@huapont.cn / admin123`(通过生产初始化命令显式创建)
|
||||
@@ -38,7 +39,7 @@
|
||||
## 访问方式
|
||||
- 前端:`http://localhost:8888`
|
||||
- 后端 API:同域 `/api/v1/*`
|
||||
- `nginx` 负责托管前端静态资源,并将 `/api` 与 `/health` 转发到 `backend`
|
||||
- `nginx` 负责托管前端静态资源,并将 `/api`、`/health` 与 `/readyz` 转发到 `backend`
|
||||
|
||||
## macOS 桌面端开发
|
||||
- 桌面端约束集中在 `AGENTS.md`:Tauri 在线客户端,不内嵌后端、不保存本地业务权威数据、不做离线同步;在线辅助缓存必须遵守 `docs/desktop-local-cache-plan.md`。
|
||||
@@ -54,6 +55,7 @@
|
||||
- 分支维护中文 SOP:`docs/guides/branch-maintenance-sop-zh.md`
|
||||
- 分支环境安装配置:`docs/guides/branch-environment-installation.md`
|
||||
- 发布检查清单:`docs/guides/release-checklist.md`
|
||||
- 系统监测简易运维:`docs/guides/system-monitoring-operations.md`
|
||||
|
||||
## 本地配置
|
||||
- 本地编辑器配置(如 `.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")
|
||||
@@ -9,7 +9,8 @@ import uuid
|
||||
|
||||
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.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.crud import user as user_crud
|
||||
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.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
|
||||
|
||||
|
||||
@@ -95,7 +102,7 @@ def get_session_policy_for_client_type(client_type: str) -> SessionPolicy:
|
||||
|
||||
|
||||
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:
|
||||
@@ -104,8 +111,9 @@ def policy_expires_at(issued_at: datetime, session_start: datetime, policy: Sess
|
||||
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_id = uuid.uuid4()
|
||||
client_type = get_request_session_client_type(request)
|
||||
policy = get_session_policy_for_client_type(client_type)
|
||||
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,
|
||||
issued_at=session_start,
|
||||
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")
|
||||
|
||||
@@ -284,7 +300,7 @@ async def login_for_access_token(
|
||||
db_user = await authenticate_encrypted_password(payload, db)
|
||||
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)
|
||||
@@ -295,7 +311,7 @@ async def dev_login_for_access_token(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
db_user = await authenticate_plain_password(payload, db)
|
||||
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)
|
||||
@@ -339,11 +355,47 @@ async def extend_access_token(
|
||||
max_age_seconds=policy.absolute_max_seconds,
|
||||
issued_at=issued_at,
|
||||
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)
|
||||
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)
|
||||
async def update_me(
|
||||
payload: UserSelfUpdate,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,8 @@ from app.schemas.common import PaginatedResponse
|
||||
from app.crud import user as user_crud
|
||||
from app.crud import member as member_crud
|
||||
from app.utils.pagination import paginate
|
||||
from app.schemas.user import 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()
|
||||
|
||||
@@ -25,7 +26,35 @@ async def list_users(
|
||||
) -> PaginatedResponse[UserRead]:
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -33,6 +33,18 @@ class Settings(BaseSettings):
|
||||
)
|
||||
IP2REGION_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
|
||||
|
||||
@@ -286,8 +286,9 @@ def _enqueue_permission_log(
|
||||
|
||||
writer = get_log_writer()
|
||||
if writer:
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
ip = forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else None)
|
||||
from app.core.request_context import build_request_audit_context, get_request_audit_context
|
||||
|
||||
context = get_request_audit_context() or build_request_audit_context(request)
|
||||
writer.enqueue({
|
||||
"study_id": study_id,
|
||||
"user_id": user_id,
|
||||
@@ -295,7 +296,16 @@ def _enqueue_permission_log(
|
||||
"role": role,
|
||||
"allowed": allowed,
|
||||
"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,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -2,14 +2,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
import uuid
|
||||
from contextvars import ContextVar, Token
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequestAuditContext:
|
||||
request_id: str | None = None
|
||||
client_ip: str | None = None
|
||||
user_agent: str | None = None
|
||||
client_type: str | None = None
|
||||
@@ -17,6 +22,8 @@ class RequestAuditContext:
|
||||
client_platform: str | None = None
|
||||
build_channel: 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(
|
||||
@@ -25,11 +32,37 @@ _request_audit_context: ContextVar[RequestAuditContext | None] = ContextVar(
|
||||
)
|
||||
|
||||
_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:
|
||||
return None
|
||||
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]
|
||||
|
||||
|
||||
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:
|
||||
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")
|
||||
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")
|
||||
if real_ip:
|
||||
if _parse_ip(real_ip):
|
||||
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:
|
||||
headers = request.headers
|
||||
request_id = str(uuid.uuid4())
|
||||
return RequestAuditContext(
|
||||
request_id=request_id,
|
||||
client_ip=resolve_client_ip(request),
|
||||
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_platform=_clean_header_value(headers.get("x-ctms-client-platform"), 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),
|
||||
request_headers=build_sanitized_request_headers(request),
|
||||
request_snapshot=build_request_snapshot(request, request_id=request_id),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ def create_access_token(
|
||||
max_age_seconds: Optional[int] = None,
|
||||
issued_at: Optional[datetime] = None,
|
||||
client_type: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
) -> str:
|
||||
now = issued_at or datetime.now(timezone.utc)
|
||||
if now.tzinfo is None:
|
||||
@@ -42,6 +43,8 @@ def create_access_token(
|
||||
}
|
||||
if 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)
|
||||
|
||||
|
||||
|
||||
@@ -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_template import PermissionTemplate, PermissionTemplateVersion # 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
|
||||
DesktopNotificationDelivery,
|
||||
DesktopNotificationSubscription,
|
||||
|
||||
+59
-3
@@ -5,8 +5,9 @@ import time
|
||||
from contextlib import asynccontextmanager
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import text
|
||||
|
||||
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.permission_log_writer import start_log_writer, stop_log_writer
|
||||
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 (
|
||||
get_security_log_writer,
|
||||
start_security_log_writer,
|
||||
stop_security_log_writer,
|
||||
)
|
||||
from app.core.security import decode_token
|
||||
from app.core.deps import get_db_session
|
||||
from app.core.request_context import (
|
||||
build_request_audit_context,
|
||||
build_request_snapshot,
|
||||
build_sanitized_request_headers,
|
||||
get_request_audit_context,
|
||||
reset_request_audit_context,
|
||||
resolve_ctms_client_type,
|
||||
resolve_client_ip,
|
||||
set_request_audit_context,
|
||||
)
|
||||
@@ -42,8 +51,12 @@ setup_config_stats: dict[str, int] = defaultdict(int)
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
stop_event = asyncio.Event()
|
||||
await resolve_monitoring_server_location()
|
||||
scheduler_task = asyncio.create_task(run_daily_lost_visit_job(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_security_log_writer()
|
||||
if settings.ENV == "development":
|
||||
@@ -52,12 +65,15 @@ async def lifespan(_: FastAPI):
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
async with SessionLocal() as session:
|
||||
await ensure_admin_exists(session)
|
||||
retention_task = asyncio.create_task(run_monitoring_retention(stop_event))
|
||||
yield
|
||||
stop_event.set()
|
||||
await stop_log_writer()
|
||||
await stop_security_log_writer()
|
||||
await scheduler_task
|
||||
await aggregator_task
|
||||
await source_location_aggregator_task
|
||||
await retention_task
|
||||
|
||||
|
||||
async def _ensure_legacy_primary_keys(conn) -> None:
|
||||
@@ -129,19 +145,23 @@ def create_app() -> FastAPI:
|
||||
"Accept",
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-CTMS-Client-Source",
|
||||
"X-CTMS-Client-Type",
|
||||
"X-CTMS-Client-Version",
|
||||
"X-CTMS-Client-Platform",
|
||||
"X-CTMS-Build-Channel",
|
||||
"X-CTMS-Build-Commit",
|
||||
"X-Request-ID",
|
||||
"X-Correlation-ID",
|
||||
],
|
||||
expose_headers=["X-Request-ID"],
|
||||
)
|
||||
|
||||
@app.middleware("http")
|
||||
async def setup_config_monitoring_middleware(request, call_next):
|
||||
path = request.url.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()
|
||||
audit_context = build_request_audit_context(request)
|
||||
audit_context_token = set_request_audit_context(audit_context)
|
||||
@@ -167,6 +187,8 @@ def create_app() -> FastAPI:
|
||||
raise
|
||||
|
||||
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:
|
||||
normalized_path = UUID_RE.sub("{study_id}", path)
|
||||
duration_ms = int((time.perf_counter() - started_at) * 1000)
|
||||
@@ -229,6 +251,36 @@ def create_app() -> FastAPI:
|
||||
async def health() -> dict[str, str]:
|
||||
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(
|
||||
"/health/setup-config-stats",
|
||||
tags=["health"],
|
||||
@@ -271,6 +323,7 @@ def _enqueue_security_access_log(request, path: str, status_code: int, started_a
|
||||
if not writer:
|
||||
return
|
||||
auth_status, user_identifier = _resolve_auth_context(request)
|
||||
context = get_request_audit_context()
|
||||
writer.enqueue(
|
||||
{
|
||||
"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),
|
||||
"client_ip": resolve_client_ip(request),
|
||||
"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_platform": request.headers.get("x-ctms-client-platform"),
|
||||
"build_channel": request.headers.get("x-ctms-build-channel"),
|
||||
"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,
|
||||
"user_identifier": user_identifier,
|
||||
}
|
||||
|
||||
@@ -6,13 +6,15 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Index, String
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Index, String
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
JSONB_TYPE = JSON().with_variant(JSONB, "postgresql")
|
||||
|
||||
|
||||
class PermissionAccessLog(Base):
|
||||
__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_created_at", "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(
|
||||
@@ -38,6 +43,15 @@ class PermissionAccessLog(Base):
|
||||
allowed: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||
elapsed_ms: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
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(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
@@ -6,13 +6,15 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, Float, Index, Integer, String
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy import JSON, DateTime, Float, Index, Integer, String
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
JSONB_TYPE = JSON().with_variant(JSONB, "postgresql")
|
||||
|
||||
|
||||
class SecurityAccessLog(Base):
|
||||
__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_status_created", "status_code", "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)
|
||||
@@ -35,8 +40,13 @@ class SecurityAccessLog(Base):
|
||||
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)
|
||||
auth_status: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
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(
|
||||
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()
|
||||
)
|
||||
@@ -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)
|
||||
@@ -6,6 +6,7 @@ from typing import Literal, Optional
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
|
||||
|
||||
UserStatus = Literal["PENDING", "ACTIVE", "REJECTED", "DISABLED"]
|
||||
LoginStatus = Literal["ONLINE", "OFFLINE"]
|
||||
|
||||
PASSWORD_REGEX = re.compile(r"^(?=.*[A-Za-z])(?=.*\d).{8,}$")
|
||||
|
||||
@@ -60,6 +61,11 @@ class UserRead(BaseModel):
|
||||
approved_at: Optional[datetime] = None
|
||||
approved_by: Optional[uuid.UUID] = 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)
|
||||
|
||||
@@ -78,6 +84,19 @@ class UserResponse(UserRead):
|
||||
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):
|
||||
full_name: 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",
|
||||
)
|
||||
@@ -9,6 +9,7 @@ import ipaddress
|
||||
import logging
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -125,5 +126,6 @@ class Ip2RegionResolver:
|
||||
_resolver = Ip2RegionResolver()
|
||||
|
||||
|
||||
@lru_cache(maxsize=8192)
|
||||
def resolve_ip_location(ip: str | None) -> IpLocation:
|
||||
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
|
||||
@@ -13,6 +13,7 @@ import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.permission_access_log import PermissionAccessLog
|
||||
@@ -22,57 +23,85 @@ logger = logging.getLogger("ctms.permission_log_writer")
|
||||
BATCH_SIZE = 50
|
||||
FLUSH_INTERVAL = 5.0
|
||||
QUEUE_MAX_SIZE = 10000
|
||||
WRITE_ATTEMPTS = 2
|
||||
_QUEUE_STOP = object()
|
||||
|
||||
|
||||
class PermissionLogWriter:
|
||||
|
||||
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._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:
|
||||
if self._stopping:
|
||||
self._dropped_entries += 1
|
||||
logger.warning("Permission log writer is stopping, dropping entry")
|
||||
return
|
||||
try:
|
||||
self._queue.put_nowait(entry)
|
||||
self._accepted_entries += 1
|
||||
except asyncio.QueueFull:
|
||||
self._dropped_entries += 1
|
||||
logger.warning("Permission log queue full, dropping entry")
|
||||
|
||||
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())
|
||||
logger.info("PermissionLogWriter started")
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
if self._task and not self._task.done():
|
||||
self._stopping = True
|
||||
await self._queue.put(_QUEUE_STOP)
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await self._drain()
|
||||
self._task = None
|
||||
logger.info("PermissionLogWriter stopped")
|
||||
|
||||
async def _flush_loop(self) -> None:
|
||||
while True:
|
||||
batch = await self._collect_batch()
|
||||
batch, should_stop = await self._collect_batch()
|
||||
if 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] = []
|
||||
try:
|
||||
first = await asyncio.wait_for(self._queue.get(), timeout=FLUSH_INTERVAL)
|
||||
if first is _QUEUE_STOP:
|
||||
return batch, True
|
||||
batch.append(first)
|
||||
except asyncio.TimeoutError:
|
||||
return batch
|
||||
return batch, False
|
||||
|
||||
while len(batch) < BATCH_SIZE:
|
||||
try:
|
||||
item = self._queue.get_nowait()
|
||||
if item is _QUEUE_STOP:
|
||||
return batch, True
|
||||
batch.append(item)
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
return batch
|
||||
return batch, False
|
||||
|
||||
async def _write_batch(self, batch: list[dict]) -> None:
|
||||
async def _write_batch(self, batch: list[dict]) -> bool:
|
||||
for attempt in range(WRITE_ATTEMPTS):
|
||||
try:
|
||||
async with SessionLocal() as session:
|
||||
for entry in batch:
|
||||
@@ -85,22 +114,59 @@ class PermissionLogWriter:
|
||||
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:
|
||||
logger.exception("Failed to write permission access log batch (%d entries)", len(batch))
|
||||
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
|
||||
|
||||
async def _drain(self) -> None:
|
||||
batch: list[dict] = []
|
||||
while not self._queue.empty():
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
if batch:
|
||||
await self._write_batch(batch)
|
||||
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
|
||||
|
||||
@@ -6,67 +6,105 @@ import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
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")
|
||||
|
||||
BATCH_SIZE = 100
|
||||
FLUSH_INTERVAL = 3.0
|
||||
QUEUE_MAX_SIZE = 20000
|
||||
WRITE_ATTEMPTS = 2
|
||||
_QUEUE_STOP = object()
|
||||
|
||||
|
||||
class SecurityAccessLogWriter:
|
||||
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._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:
|
||||
if self._stopping:
|
||||
self._dropped_entries += 1
|
||||
logger.warning("Security access log writer is stopping, dropping entry")
|
||||
return
|
||||
try:
|
||||
self._queue.put_nowait(entry)
|
||||
self._accepted_entries += 1
|
||||
except asyncio.QueueFull:
|
||||
self._dropped_entries += 1
|
||||
logger.warning("Security access log queue full, dropping entry")
|
||||
|
||||
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())
|
||||
logger.info("SecurityAccessLogWriter started")
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
if self._task and not self._task.done():
|
||||
self._stopping = True
|
||||
await self._queue.put(_QUEUE_STOP)
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await self._drain()
|
||||
self._task = None
|
||||
logger.info("SecurityAccessLogWriter stopped")
|
||||
|
||||
async def _flush_loop(self) -> None:
|
||||
while True:
|
||||
batch = await self._collect_batch()
|
||||
batch, should_stop = await self._collect_batch()
|
||||
if 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] = []
|
||||
try:
|
||||
first = await asyncio.wait_for(self._queue.get(), timeout=FLUSH_INTERVAL)
|
||||
if first is _QUEUE_STOP:
|
||||
return batch, True
|
||||
batch.append(first)
|
||||
except asyncio.TimeoutError:
|
||||
return batch
|
||||
return batch, False
|
||||
|
||||
while len(batch) < BATCH_SIZE:
|
||||
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:
|
||||
break
|
||||
return batch
|
||||
return batch, False
|
||||
|
||||
async def _write_batch(self, batch: list[dict]) -> None:
|
||||
async def _write_batch(self, batch: list[dict]) -> bool:
|
||||
for attempt in range(WRITE_ATTEMPTS):
|
||||
try:
|
||||
async with SessionLocal() as session:
|
||||
for entry in batch:
|
||||
classification = classify_security_event(
|
||||
path=entry["path"],
|
||||
status_code=entry["status_code"],
|
||||
auth_status=entry["auth_status"],
|
||||
ip_location=resolve_ip_location(entry.get("client_ip")),
|
||||
)
|
||||
session.add(
|
||||
SecurityAccessLog(
|
||||
id=uuid.uuid4(),
|
||||
@@ -81,24 +119,57 @@ class SecurityAccessLogWriter:
|
||||
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:
|
||||
logger.exception("Failed to write security access log batch (%d entries)", len(batch))
|
||||
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 asyncio.sleep(0)
|
||||
continue
|
||||
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:
|
||||
batch: list[dict] = []
|
||||
while not self._queue.empty():
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
if batch:
|
||||
await self._write_batch(batch)
|
||||
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: SecurityAccessLogWriter | None = None
|
||||
|
||||
@@ -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")
|
||||
@@ -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())
|
||||
@@ -246,7 +246,7 @@ async def test_project_member_can_read_own_effective_permissions(db_session: Asy
|
||||
|
||||
|
||||
@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()
|
||||
own_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 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 scope.study_ids == {own_study_id}
|
||||
assert scope.can_access_study(own_study_id)
|
||||
assert not scope.can_access_study(other_study_id)
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -54,6 +54,9 @@ class _DummyTask:
|
||||
def cancel(self):
|
||||
self.cancelled = True
|
||||
|
||||
def done(self):
|
||||
return False
|
||||
|
||||
def __await__(self):
|
||||
async def _wait():
|
||||
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, "ensure_admin_exists", ensure_admin_exists)
|
||||
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, "engine", _DummyEngine())
|
||||
monkeypatch.setattr(main, "SessionLocal", fake_session_local)
|
||||
|
||||
@@ -4,6 +4,7 @@ import pytest
|
||||
|
||||
from app.core.request_context import (
|
||||
RequestAuditContext,
|
||||
build_request_audit_context,
|
||||
reset_request_audit_context,
|
||||
set_request_audit_context,
|
||||
)
|
||||
@@ -22,6 +23,48 @@ class FakeAsyncSession:
|
||||
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
|
||||
async def test_audit_log_action_captures_request_access_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.build_channel == "release"
|
||||
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"
|
||||
|
||||
@@ -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()
|
||||
@@ -1,7 +1,9 @@
|
||||
"""监控API测试:权限系统监控API端点验证。"""
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
@@ -10,6 +12,7 @@ from sqlalchemy import text
|
||||
from app.core.permission_monitor import set_permission_monitor, PermissionMonitor
|
||||
from app.api.v1 import permission_monitoring
|
||||
from app.api.v1.system_permissions import list_system_permissions
|
||||
from app.services.monitoring_server_location import MonitoringServerLocation
|
||||
|
||||
|
||||
class FakeIpInfo:
|
||||
@@ -30,6 +33,18 @@ class ProjectPmUserStub:
|
||||
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
|
||||
async def test_resolve_monitoring_scope_rejects_project_pm(db_session):
|
||||
"""系统监测模块仅允许系统管理员访问,项目 PM 不再具备监测范围。"""
|
||||
@@ -103,9 +118,11 @@ async def _seed_permission_log(db_session, study_id: uuid.UUID, user_id: uuid.UU
|
||||
text(
|
||||
"""
|
||||
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
|
||||
(: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,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"ip_address": "127.0.0.1",
|
||||
"request_snapshot": None,
|
||||
},
|
||||
)
|
||||
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 data["status"] == "unhealthy"
|
||||
assert data["health_score"] == 30
|
||||
assert data["score_breakdown"]["performance"]["deduction"] == 25
|
||||
assert data["health_score"] == 35
|
||||
assert data["score_breakdown"]["performance"]["deduction"] == 20
|
||||
assert data["score_breakdown"]["deny_rate"]["deduction"] == 20
|
||||
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 "score_breakdown" 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 "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
|
||||
|
||||
|
||||
@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
|
||||
async def test_ip_locations_counts_unique_users_per_location(db_session, monkeypatch):
|
||||
"""IP 属地统计应按省市聚合访问次数、来源 IP 数和访问用户数。"""
|
||||
@@ -381,10 +438,26 @@ async def test_ip_locations_counts_unique_users_per_location(db_session, monkeyp
|
||||
)
|
||||
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(
|
||||
permission_monitoring,
|
||||
"resolve_ip_location",
|
||||
lambda ip: FakeIpInfo("广东省", "深圳市"),
|
||||
"resolve_monitoring_server_location",
|
||||
fake_resolve_monitoring_server_location,
|
||||
)
|
||||
|
||||
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"]["unique_ip_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
|
||||
@@ -530,9 +653,11 @@ async def test_ip_locations_keeps_private_network_location_label(db_session, mon
|
||||
text(
|
||||
"""
|
||||
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
|
||||
(: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,
|
||||
"elapsed_ms": 3.2,
|
||||
"ip_address": "192.168.1.10",
|
||||
"request_snapshot": None,
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
@@ -556,8 +682,8 @@ async def test_ip_locations_keeps_private_network_location_label(db_session, mon
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ip_locations_includes_abnormal_security_ips(db_session, monkeypatch):
|
||||
"""来源分析应同时统计安全事件中的异常来源 IP。"""
|
||||
async def test_ip_locations_scores_security_sources_by_behavior(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()
|
||||
@@ -603,9 +729,11 @@ async def test_ip_locations_includes_abnormal_security_ips(db_session, monkeypat
|
||||
text(
|
||||
"""
|
||||
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
|
||||
(: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,
|
||||
"elapsed_ms": 3.2,
|
||||
"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(
|
||||
text(
|
||||
"""
|
||||
@@ -633,7 +762,7 @@ async def test_ip_locations_includes_abnormal_security_ips(db_session, monkeypat
|
||||
"id": str(uuid.uuid4()),
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin",
|
||||
"status_code": 403,
|
||||
"status_code": status_code,
|
||||
"elapsed_ms": 8.5,
|
||||
"client_ip": ip_address,
|
||||
"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"]}
|
||||
|
||||
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_user_count"] == 1
|
||||
assert locations["United States"]["total_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"]["denied_count"] == 0
|
||||
assert locations["Australia"]["risk_level"] == "normal"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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 security_access_logs"))
|
||||
await db_session.commit()
|
||||
|
||||
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
|
||||
|
||||
|
||||
@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
|
||||
async def test_access_logs_ip_ranking_aggregates_by_ip_total(db_session):
|
||||
"""IP 访问排行应按同一 IP 的整体访问量聚合排序,而不是按用户/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-000000001201"
|
||||
@@ -896,10 +1320,13 @@ async def test_security_access_logs_include_anonymous_ip_attempts(db_session, mo
|
||||
text(
|
||||
"""
|
||||
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
|
||||
('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]["account_label"] == "未知账号"
|
||||
assert result["items"][0]["status_code"] == 401
|
||||
assert result["items"][0]["request_snapshot"]["headers"]["authorization"] == "[redacted]"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -993,8 +1421,8 @@ async def test_security_access_logs_prioritize_sensitive_probe_over_foreign_ip(d
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_security_access_logs_classify_non_china_ip_as_abnormal(db_session, monkeypatch):
|
||||
"""安全事件明细应把非中国公网 IP 归类为异常 IP。"""
|
||||
async def test_security_access_logs_classify_foreign_invalid_token_by_behavior(db_session, monkeypatch):
|
||||
"""海外来源不应单凭地域升级风险,仍按认证行为分类。"""
|
||||
await db_session.execute(text("DELETE FROM security_access_logs"))
|
||||
monkeypatch.setattr(
|
||||
permission_monitoring,
|
||||
@@ -1024,8 +1452,8 @@ async def test_security_access_logs_classify_non_china_ip_as_abnormal(db_session
|
||||
)
|
||||
|
||||
item = result["items"][0]
|
||||
assert item["category"] == "ABNORMAL_IP"
|
||||
assert item["severity"] == "HIGH"
|
||||
assert item["category"] == "INVALID_TOKEN"
|
||||
assert item["severity"] == "MEDIUM"
|
||||
assert item["ip_location"] == "United States / California / San Jose / Google"
|
||||
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_province"] == "California"
|
||||
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",
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ from datetime import datetime, timedelta, timezone
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from app.main import create_app
|
||||
from app.core.config import settings
|
||||
@@ -397,6 +398,7 @@ async def test_root_returns_service_metadata(client_and_db):
|
||||
client, _ = client_and_db
|
||||
resp = await client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert uuid.UUID(resp.headers["x-request-id"])
|
||||
assert resp.json() == {
|
||||
"service": "ctms-backend",
|
||||
"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
|
||||
async def test_registered_user_can_login_after_email_verification(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
|
||||
@@ -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"
|
||||
@@ -68,7 +68,7 @@ services:
|
||||
- ./frontend/public/favicon.ico:/usr/share/nginx/html/favicon.ico:ro
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_started
|
||||
condition: service_healthy
|
||||
frontend-dev:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
|
||||
+19
-1
@@ -33,9 +33,26 @@ services:
|
||||
FRONTEND_PUBLIC_URL: ${FRONTEND_PUBLIC_URL:-http://localhost:8888}
|
||||
LOGIN_RSA_PRIVATE_KEY: ${LOGIN_RSA_PRIVATE_KEY:-}
|
||||
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:
|
||||
db:
|
||||
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:
|
||||
- ctms_net
|
||||
|
||||
@@ -76,7 +93,8 @@ services:
|
||||
- ./nginx/certs:/etc/nginx/certs:ro
|
||||
- ./frontend/public/favicon.ico:/usr/share/nginx/html/favicon.ico:ro
|
||||
depends_on:
|
||||
- backend
|
||||
backend:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- 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 类告警链路,并保留本模块作为管理员快速诊断入口。
|
||||
@@ -33,6 +33,11 @@ export type LoginKeyResponse = {
|
||||
expires_at: string;
|
||||
};
|
||||
|
||||
export type SessionHeartbeatResponse = {
|
||||
status: "online";
|
||||
last_seen_at: string;
|
||||
};
|
||||
|
||||
export type EncryptedPasswordRequest = {
|
||||
key_id: string;
|
||||
challenge: string;
|
||||
@@ -53,4 +58,18 @@ export const extendToken = (token: string): Promise<AxiosResponse<ExtendResponse
|
||||
export const getLoginKey = (): Promise<AxiosResponse<LoginKeyResponse>> =>
|
||||
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;
|
||||
|
||||
@@ -78,6 +78,9 @@ export const fetchAccessLogs = (params: {
|
||||
allowed?: boolean;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
client_ip?: string;
|
||||
client_type?: string;
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}) => apiGet<AccessLogsResponse>(`/api/v1/permission-monitoring/access-logs`, { params });
|
||||
@@ -87,6 +90,13 @@ export const fetchSecurityAccessLogs = (params?: {
|
||||
auth_status?: string;
|
||||
client_type?: 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_size?: number;
|
||||
}) => 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 }) =>
|
||||
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 });
|
||||
|
||||
export const fetchStatsSummary = () =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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";
|
||||
|
||||
export const fetchUsers = (params?: Record<string, any>) =>
|
||||
@@ -21,3 +21,8 @@ export const updateUser = (
|
||||
|
||||
export const deleteUser = (userId: string) =>
|
||||
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>
|
||||
@@ -246,10 +246,12 @@
|
||||
<button class="account-button" type="button">
|
||||
<span class="account-avatar">{{ userDisplayInitial }}</span>
|
||||
<span class="account-name">{{ userDisplayName }}</span>
|
||||
<AccountConnectionStatus mode="indicator" />
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<AccountConnectionStatus mode="details" />
|
||||
<el-dropdown-item command="profile">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>{{ TEXT.menu.profile }}</span>
|
||||
@@ -496,6 +498,7 @@ import { useDesktopShortcuts } from "../composables/useDesktopShortcuts";
|
||||
import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions";
|
||||
import type { DesktopCommand } from "../types/desktopCommands";
|
||||
import DesktopCommandPalette from "./DesktopCommandPalette.vue";
|
||||
import AccountConnectionStatus from "./AccountConnectionStatus.vue";
|
||||
import DesktopPreferences from "../views/DesktopPreferences.vue";
|
||||
import ProfileSettings from "../views/ProfileSettings.vue";
|
||||
import {
|
||||
@@ -1993,7 +1996,7 @@ useDesktopShortcuts(
|
||||
|
||||
.account-button {
|
||||
gap: 7px;
|
||||
max-width: 188px;
|
||||
max-width: 228px;
|
||||
padding: 0 8px 0 3px;
|
||||
}
|
||||
|
||||
@@ -2011,7 +2014,7 @@ useDesktopShortcuts(
|
||||
}
|
||||
|
||||
.account-name {
|
||||
max-width: 110px;
|
||||
max-width: 96px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -2321,6 +2324,9 @@ useDesktopShortcuts(
|
||||
}
|
||||
|
||||
.desktop-route-shell {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
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");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const sources = [
|
||||
readMainStyleSource(),
|
||||
|
||||
@@ -15,18 +15,38 @@ describe("PermissionAccessLogs", () => {
|
||||
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();
|
||||
|
||||
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("接口访问审计日志");
|
||||
expect(source).toContain("terminal-lines");
|
||||
expect(source).toContain("keyword");
|
||||
expect(source).toContain("ipRankingDialogVisible");
|
||||
expect(source).toContain("openIpRankingDialog");
|
||||
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('prop="endpoint_key" label="接口"');
|
||||
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", () => {
|
||||
@@ -47,24 +67,32 @@ describe("PermissionAccessLogs", () => {
|
||||
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();
|
||||
|
||||
expect(source).toContain("min-height: 64px");
|
||||
expect(source).toContain("padding: 10px 16px");
|
||||
expect(source).toContain('class="access-overview"');
|
||||
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("hint:");
|
||||
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();
|
||||
|
||||
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("grid-template-columns: repeat(2, minmax(0, 1fr));");
|
||||
expect(source).toContain("@media (max-width: 720px)");
|
||||
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", () => {
|
||||
@@ -78,117 +106,254 @@ describe("PermissionAccessLogs", () => {
|
||||
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();
|
||||
|
||||
expect(source).toContain("terminalLogRows");
|
||||
expect(source).toContain("new Date(a.created_at).getTime() - new Date(b.created_at).getTime()");
|
||||
expect(source).toContain("REALTIME_POLL_INTERVAL_MS");
|
||||
expect(source).toContain("startRealtimePolling");
|
||||
expect(source).toContain("onBeforeUnmount(stopRealtimePolling)");
|
||||
expect(source).toContain('ref="terminalWindowRef"');
|
||||
expect(source).toContain("scrollTerminalToBottom");
|
||||
expect(source).toContain("terminal.scrollTop = terminal.scrollHeight");
|
||||
expect(source).toContain('label="时间" width="160"');
|
||||
expect(source).toContain('label="IP" width="120"');
|
||||
expect(source).toContain('label="位置" width="110"');
|
||||
expect(source).toContain('label="账号" min-width="160"');
|
||||
expect(source).toContain('label="请求" min-width="380"');
|
||||
expect(source).toContain('label="耗时" width="100"');
|
||||
expect(source).toContain('label="操作" width="64"');
|
||||
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();
|
||||
|
||||
expect(source).toContain("ip=${ip}");
|
||||
expect(source).toContain("user.sample_ip_address || \"未知 IP\"");
|
||||
expect(source).toContain("ipRankingItems");
|
||||
expect(source).toContain('label="IP" width="120"');
|
||||
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("账号");
|
||||
expect(source).toContain("rank-location");
|
||||
expect(source).toContain("loadIpRankingData");
|
||||
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("rank-ip-line");
|
||||
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();
|
||||
|
||||
expect(source).toContain("const IP_RANKING_LIMIT = 10;");
|
||||
expect(source).toContain("const SECURITY_RANKING_PAGE_SIZE = 200;");
|
||||
expect(source).toContain("const ipRankingItems = computed<IpRankingItem[]>(() =>");
|
||||
expect(source).toContain("securityLogs.value.forEach((event) =>");
|
||||
expect(source).toContain('const isAbnormal = event.category === "ABNORMAL_IP";');
|
||||
expect(source).toContain('ABNORMAL_IP: "异常IP"');
|
||||
expect(source).toContain(".slice(0, IP_RANKING_LIMIT)");
|
||||
expect(source).toContain("TOP {{ ipRankingItems.length }}");
|
||||
expect(source).toContain('v-for="(item, index) in ipRankingItems"');
|
||||
expect(source).toContain("异常IP</el-tag>");
|
||||
});
|
||||
|
||||
it("sorts IP ranking by overall access total before risk labels", () => {
|
||||
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).toContain("isSecurityLog");
|
||||
expect(source).toContain("安全事件");
|
||||
expect(source).toContain("业务访问");
|
||||
expect(source).toContain("SECURITY_CATEGORY_LABELS");
|
||||
expect(source).toContain("SECURITY_SEVERITY_LABELS");
|
||||
expect(source).toContain("endpointTitle(row)");
|
||||
expect(source).toContain("endpointMeta(row)");
|
||||
expect(source).toContain("accessResultLabel(row)");
|
||||
expect(source).toContain("authStatusLabel(row.auth_status)");
|
||||
expect(source).toContain("[SECURITY:");
|
||||
expect(source).not.toContain("showSecurityLog");
|
||||
expect(source).not.toContain('v-if="showSecurityLog"');
|
||||
expect(source).not.toContain("fetchSecurityAccessLogs");
|
||||
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();
|
||||
|
||||
expect(source).toContain("{{ formatNumber(total) }} 条");
|
||||
expect(source).toContain("共 {{ formatNumber(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 }} 条");
|
||||
});
|
||||
|
||||
it("opens both audit logs in realtime downloadable dialogs", () => {
|
||||
it("renders access rows directly and keeps raw access logs downloadable", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("interfaceLogDialogVisible");
|
||||
expect(source).toContain("securityLogDialogVisible");
|
||||
expect(source).toContain("openInterfaceLogDialog");
|
||||
expect(source).toContain("openSecurityLogDialog");
|
||||
expect(source).toContain("if (!showSecurityLog.value) return");
|
||||
expect(source).toContain("detailDrawerVisible");
|
||||
expect(source).toContain("selectedLog");
|
||||
expect(source).toContain("openLogDetail");
|
||||
expect(source).toContain("accessLogDialogVisible");
|
||||
expect(source).toContain("openAccessLogDialog");
|
||||
expect(source).toContain("downloadInterfaceLog");
|
||||
expect(source).toContain("downloadSecurityLog");
|
||||
expect(source).toContain("downloadLogFile");
|
||||
expect(source).toContain("<el-dialog v-model=\"interfaceLogDialogVisible\"");
|
||||
expect(source).toContain("<el-dialog v-model=\"securityLogDialogVisible\"");
|
||||
expect(source).toContain("accessLogDialogVisible");
|
||||
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("securityTerminalWindowRef");
|
||||
expect(source).toContain("scrollSecurityTerminalToBottom");
|
||||
expect(source).not.toContain("securityLogDialogVisible");
|
||||
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: [],
|
||||
sample_sufficient: true,
|
||||
score_breakdown: {
|
||||
performance: { weight: 40, deduction: 0 },
|
||||
performance: { weight: 25, deduction: 0 },
|
||||
deny_rate: { weight: 20, deduction: 0 },
|
||||
cache: {
|
||||
weight: 25, deduction: 5, sample_sufficient: true,
|
||||
scope: "sliding_window", window_seconds: 3600,
|
||||
},
|
||||
alerts: { weight: 15, deduction: 0 },
|
||||
alerts: { weight: 10, deduction: 0 },
|
||||
runtime: { weight: 20, deduction: 0 },
|
||||
},
|
||||
last_hour: {
|
||||
total_checks: 30, denied_checks: 2, deny_rate: 6.67,
|
||||
avg_elapsed_ms: 3.5, error_alerts: 0, warning_alerts: 0,
|
||||
},
|
||||
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: [] } }),
|
||||
@@ -98,11 +105,13 @@ describe("PermissionMonitoring.vue", () => {
|
||||
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");
|
||||
|
||||
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", () => {
|
||||
@@ -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="性能趋势" 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('name="security"');
|
||||
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("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="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('class="summary-card"');
|
||||
expect(source).not.toContain('class="overview-toolbar"');
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -3,50 +3,72 @@
|
||||
<div class="trend-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<el-alert v-if="loadError" :title="loadError" type="warning" show-icon :closable="false" class="load-alert" />
|
||||
|
||||
<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-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>
|
||||
</div>
|
||||
<div class="chart-title-group">
|
||||
<h4>监测事件趋势</h4>
|
||||
<span>允许与拒绝</span>
|
||||
</div>
|
||||
<v-chart :option="checksChartOption" autoresize class="chart" />
|
||||
<strong class="chart-latest">{{ latestEventLabel }}</strong>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<v-chart v-if="hasEventSamples" :option="checksChartOption" autoresize class="chart" />
|
||||
<div v-else class="trend-empty-state">所选时段暂无监测事件</div>
|
||||
</div>
|
||||
<div class="chart-card" :class="{ 'is-empty': !hasResponseSamples }">
|
||||
<div class="chart-header">
|
||||
<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>
|
||||
</div>
|
||||
<div class="chart-title-group">
|
||||
<h4>响应时间趋势</h4>
|
||||
<span>平均值与最大值</span>
|
||||
</div>
|
||||
<v-chart :option="responseTimeOption" autoresize class="chart" />
|
||||
<strong class="chart-latest">{{ latestResponseLabel }}</strong>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<v-chart v-if="hasResponseSamples" :option="responseTimeOption" autoresize class="chart" />
|
||||
<div v-else class="trend-empty-state">暂无有效响应时间样本</div>
|
||||
</div>
|
||||
<div class="chart-card" :class="{ 'is-empty': !hasCacheSamples }">
|
||||
<div class="chart-header">
|
||||
<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>
|
||||
</div>
|
||||
<div class="chart-title-group">
|
||||
<h4>缓存命中率</h4>
|
||||
<span>命中 / 总访问</span>
|
||||
</div>
|
||||
<v-chart :option="cacheHitOption" autoresize class="chart" />
|
||||
<strong class="chart-latest">{{ latestCacheLabel }}</strong>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<v-chart v-if="hasCacheSamples" :option="cacheHitOption" autoresize class="chart" />
|
||||
<div v-else class="trend-empty-state">暂无缓存访问样本</div>
|
||||
</div>
|
||||
<div class="chart-card" :class="{ 'is-empty': !hasDenySamples }">
|
||||
<div class="chart-header">
|
||||
<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>
|
||||
</div>
|
||||
<div class="chart-title-group">
|
||||
<h4>异常拒绝率趋势</h4>
|
||||
<span>拒绝事件占比</span>
|
||||
</div>
|
||||
<v-chart :option="denyRateOption" autoresize class="chart" />
|
||||
<strong class="chart-latest">{{ latestDenyLabel }}</strong>
|
||||
</div>
|
||||
<v-chart v-if="hasDenySamples" :option="denyRateOption" autoresize class="chart" />
|
||||
<div v-else class="trend-empty-state">暂无权限检查样本</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,21 +88,65 @@ import {
|
||||
} from "echarts/components";
|
||||
import { fetchPermissionTrends } from "@/api/projectPermissions";
|
||||
import type { TrendDataPoint } from "@/types/api";
|
||||
import TimeRangeSelector from "./TimeRangeSelector.vue";
|
||||
|
||||
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 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 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()}`;
|
||||
};
|
||||
|
||||
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 = {
|
||||
grid: { left: 50, right: 20, top: 20, bottom: 25 },
|
||||
tooltip: { trigger: "axis" as const },
|
||||
@@ -186,7 +252,7 @@ const denyRateOption = computed(() => ({
|
||||
symbol: "circle",
|
||||
symbolSize: 6,
|
||||
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" },
|
||||
lineStyle: { width: 2.5 },
|
||||
@@ -198,10 +264,15 @@ const denyRateOption = computed(() => ({
|
||||
const loadData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await fetchPermissionTrends(period.value);
|
||||
dataPoints.value = res.data.data_points;
|
||||
const apiPeriod = timeRangePreset.value === "all" ? "30d" : timeRangePreset.value;
|
||||
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 {
|
||||
dataPoints.value = [];
|
||||
loadError.value = "趋势数据加载失败,图表中可能是上次成功获取的数据";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -217,16 +288,21 @@ defineExpose({ refresh: loadData });
|
||||
--trend-ink: #1a2332;
|
||||
--trend-muted: #64748b;
|
||||
--trend-border: #e2e8f0;
|
||||
--trend-radius: 16px;
|
||||
--trend-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||
--trend-radius: 14px;
|
||||
--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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 16px;
|
||||
min-height: 48px;
|
||||
padding: 8px 14px;
|
||||
margin-bottom: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--trend-border);
|
||||
border-radius: var(--trend-radius);
|
||||
@@ -235,8 +311,8 @@ defineExpose({ refresh: loadData });
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
@@ -245,22 +321,37 @@ defineExpose({ refresh: loadData });
|
||||
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);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.load-alert {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--trend-border);
|
||||
border-radius: var(--trend-radius);
|
||||
padding: 18px;
|
||||
min-height: 276px;
|
||||
padding: 12px 14px;
|
||||
box-shadow: var(--trend-shadow);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
@@ -274,22 +365,22 @@ defineExpose({ refresh: loadData });
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chart-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chart-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.chart-icon.blue { background: #eff6ff; color: #3b82f6; }
|
||||
@@ -304,20 +395,80 @@ defineExpose({ refresh: loadData });
|
||||
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 {
|
||||
height: 240px;
|
||||
height: 212px;
|
||||
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) {
|
||||
.charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.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;
|
||||
gap: 12px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,21 +5,33 @@ import { resolve } from "node:path";
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./SecurityCenter.vue"), "utf8");
|
||||
|
||||
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();
|
||||
|
||||
expect(source).toContain("audit-filters");
|
||||
expect(source).toContain("audit-metrics");
|
||||
expect(source).toContain("audit-grid");
|
||||
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-card");
|
||||
});
|
||||
|
||||
it("places filters inside the event details section instead of the page header", () => {
|
||||
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">');
|
||||
|
||||
expect(tableIndex).toBeGreaterThan(-1);
|
||||
@@ -27,20 +39,32 @@ describe("SecurityCenter", () => {
|
||||
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();
|
||||
|
||||
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("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("endpointTypeStats");
|
||||
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("formatIpLocation");
|
||||
expect(source).toContain("fallbackIpLocation");
|
||||
@@ -49,11 +73,13 @@ describe("SecurityCenter", () => {
|
||||
expect(source).toContain("row.ip_isp");
|
||||
expect(source).toContain("item.location");
|
||||
expect(source).toContain("categoryText");
|
||||
expect(source).toContain("rank-main-line");
|
||||
expect(source).toContain("rank-meta-line");
|
||||
expect(source).toContain("analysis-rank-card-ip");
|
||||
expect(source).toContain("analysis-rank-card-meta");
|
||||
expect(source).toContain("lastSeenAt");
|
||||
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("异常IP(位置)排行");
|
||||
expect(source).not.toContain("risk-level-card");
|
||||
@@ -62,37 +88,84 @@ describe("SecurityCenter", () => {
|
||||
it("keeps detailed security events searchable and inspectable", () => {
|
||||
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("openDetail");
|
||||
expect(source).toContain("安全事件明细");
|
||||
expect(source).toContain("security-table-wrap");
|
||||
expect(source).toContain('label="IP"');
|
||||
expect(source).toContain('label="位置"');
|
||||
expect(source).not.toContain('label="来源 IP"');
|
||||
expect(source).not.toContain('label="IP来源"');
|
||||
expect(source).toContain("搜索 IP、位置、账号、路径或 UA");
|
||||
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", () => {
|
||||
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:page-size="pageSize"');
|
||||
expect(source).toContain(':page-sizes="[10, 20, 50]"');
|
||||
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();
|
||||
|
||||
expect(source).toContain("statsEvents");
|
||||
expect(source).toContain("detailFilteredEvents");
|
||||
expect(source).toContain("securitySummary");
|
||||
expect(source).toContain("loadSecurityStats");
|
||||
expect(source).toContain("SECURITY_STATS_PAGE_SIZE");
|
||||
expect(source).toContain("statsEvents.value");
|
||||
expect(source).not.toContain("const uniqueIpCount = new Set(events.value");
|
||||
expect(source).not.toContain("filteredStatsEvents");
|
||||
expect(source).toContain("response.data.summary");
|
||||
expect(source).toContain("@current-change=\"loadSecurityEvents\"");
|
||||
expect(source).not.toContain("for (let nextPage = 2; nextPage <= totalPages; nextPage += 1) {\n const res = await fetchSecurityAccessLogs");
|
||||
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>
|
||||
@@ -316,10 +316,12 @@
|
||||
{{ userDisplayInitial }}
|
||||
</el-avatar>
|
||||
<span class="username">{{ userDisplayName }}</span>
|
||||
<AccountConnectionStatus mode="indicator" />
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu class="user-dropdown-menu">
|
||||
<AccountConnectionStatus mode="details" />
|
||||
<el-dropdown-item command="workbench">
|
||||
<el-icon><Suitcase /></el-icon>
|
||||
<span>工作台</span>
|
||||
@@ -429,6 +431,7 @@ import { dispatchDesktopRefreshCurrentView } from "../composables/useDesktopRefr
|
||||
import { useDesktopShortcuts } from "../composables/useDesktopShortcuts";
|
||||
import type { DesktopCommand } from "../types/desktopCommands";
|
||||
import DesktopCommandPalette from "./DesktopCommandPalette.vue";
|
||||
import AccountConnectionStatus from "./AccountConnectionStatus.vue";
|
||||
import ProfileSettings from "../views/ProfileSettings.vue";
|
||||
import DesktopPreferences from "../views/DesktopPreferences.vue";
|
||||
import { getActiveLayoutPath } from "./layout/navigation";
|
||||
@@ -2364,6 +2367,7 @@ useDesktopShortcuts(
|
||||
}
|
||||
|
||||
.ctms-route-shell {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import packageInfo from "../../package.json";
|
||||
import { clientRuntime } from "./clientRuntime";
|
||||
import { getAppMetadata } from "./appMetadata";
|
||||
import { getAppMetadata, getAppMetadataHeaders } from "./appMetadata";
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
|
||||
@@ -15,6 +15,7 @@ describe("client runtime", () => {
|
||||
commit: "local",
|
||||
channel: "local",
|
||||
clientType: "web",
|
||||
clientSource: "ctms-web",
|
||||
platform: "web",
|
||||
});
|
||||
expect(clientRuntime.capabilities()).toEqual({
|
||||
@@ -30,10 +31,26 @@ describe("client runtime", () => {
|
||||
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
|
||||
|
||||
expect(getAppMetadata().clientType).toBe("desktop");
|
||||
expect(getAppMetadata().clientSource).toBe("ctms-desktop");
|
||||
expect(clientRuntime.capabilities().serverConfiguration).toBe(true);
|
||||
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", () => {
|
||||
vi.stubEnv("VITE_BUILD_CHANNEL", "release");
|
||||
vi.stubEnv("VITE_BUILD_COMMIT", "0123456789abcdef0123456789abcdef01234567");
|
||||
|
||||
@@ -2,6 +2,7 @@ import packageInfo from "../../package.json";
|
||||
import { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform";
|
||||
|
||||
export type ClientType = "web" | "desktop";
|
||||
export type ClientSource = "ctms-web" | "ctms-desktop";
|
||||
export type BuildChannel = "dev" | "main" | "release" | "local";
|
||||
|
||||
export interface AppMetadata {
|
||||
@@ -9,6 +10,7 @@ export interface AppMetadata {
|
||||
commit: string;
|
||||
channel: BuildChannel;
|
||||
clientType: ClientType;
|
||||
clientSource: ClientSource;
|
||||
platform: RuntimePlatform;
|
||||
}
|
||||
|
||||
@@ -26,6 +28,7 @@ export const getAppMetadata = (): AppMetadata => ({
|
||||
commit: resolveBuildCommit(import.meta.env.VITE_BUILD_COMMIT),
|
||||
channel: resolveBuildChannel(import.meta.env.VITE_BUILD_CHANNEL),
|
||||
clientType: isTauriRuntime() ? "desktop" : "web",
|
||||
clientSource: isTauriRuntime() ? "ctms-desktop" : "ctms-web",
|
||||
platform: getRuntimePlatform(),
|
||||
});
|
||||
|
||||
@@ -33,6 +36,7 @@ export const getAppMetadataHeaders = (): Record<string, string> => {
|
||||
const metadata = getAppMetadata();
|
||||
return {
|
||||
"X-CTMS-Client-Type": metadata.clientType,
|
||||
"X-CTMS-Client-Source": metadata.clientSource,
|
||||
"X-CTMS-Client-Version": metadata.version,
|
||||
"X-CTMS-Client-Platform": metadata.platform,
|
||||
"X-CTMS-Build-Channel": metadata.channel,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useAuthStore } from "../store/auth";
|
||||
import { useSessionStore } from "../store/session";
|
||||
import { isTauriRuntime } from "../runtime";
|
||||
import { getToken, setToken } from "../utils/auth";
|
||||
import { extendToken } from "../api/authClient";
|
||||
import { extendToken, logoutSession } from "../api/authClient";
|
||||
import { parseJwtExp } from "./jwt";
|
||||
|
||||
export const IDLE_TIMEOUT_MINUTES = 30;
|
||||
@@ -172,6 +172,10 @@ const performLogout = async (reason?: string) => {
|
||||
setLogoutReason(reason);
|
||||
const auth = useAuthStore();
|
||||
const session = useSessionStore();
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
await logoutSession(token).catch(() => undefined);
|
||||
}
|
||||
const logoutPromise = auth.logout();
|
||||
session.resetActivity();
|
||||
router.replace("/login");
|
||||
|
||||
@@ -1056,9 +1056,11 @@ body.is-desktop-runtime .el-dialog {
|
||||
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 .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;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
@@ -1108,6 +1110,29 @@ body.is-desktop-runtime .el-dialog__body {
|
||||
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 {
|
||||
background: transparent !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 .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 {
|
||||
background: transparent !important;
|
||||
}
|
||||
@@ -1216,6 +1243,16 @@ body.is-desktop-runtime .msgbox-fade-leave-to .el-message-box {
|
||||
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-message-box__btns {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.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
@@ -38,6 +38,7 @@ export interface LoginKeyResponse {
|
||||
}
|
||||
|
||||
export type UserStatus = "PENDING" | "ACTIVE" | "REJECTED" | "DISABLED";
|
||||
export type UserLoginStatus = "ONLINE" | "OFFLINE";
|
||||
|
||||
export interface UserInfo {
|
||||
id: string;
|
||||
@@ -52,6 +53,22 @@ export interface UserInfo {
|
||||
approved_at?: string | null;
|
||||
approved_by?: 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;
|
||||
@@ -298,6 +315,7 @@ export interface HealthResponse {
|
||||
window_seconds: number;
|
||||
};
|
||||
alerts: HealthScoreItem;
|
||||
runtime: HealthScoreItem;
|
||||
};
|
||||
last_hour: {
|
||||
total_checks: number;
|
||||
@@ -308,6 +326,25 @@ export interface HealthResponse {
|
||||
warning_alerts: number;
|
||||
};
|
||||
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 {
|
||||
@@ -315,12 +352,59 @@ export interface HealthScoreItem {
|
||||
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 {
|
||||
id: string;
|
||||
event_type?: "permission" | "security" | string;
|
||||
study_id: string;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
user_email: string | null;
|
||||
endpoint_key: string;
|
||||
role: string;
|
||||
allowed: boolean;
|
||||
@@ -331,6 +415,22 @@ export interface AccessLogItem {
|
||||
ip_province: string;
|
||||
ip_city: 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;
|
||||
}
|
||||
|
||||
@@ -345,6 +445,7 @@ export interface AccessLogsSummary {
|
||||
export interface AccessLogUserStat {
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
user_email: string | null;
|
||||
role: string;
|
||||
total_count: number;
|
||||
denied_count: number;
|
||||
@@ -381,6 +482,9 @@ export interface SecurityAccessLogItem {
|
||||
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;
|
||||
auth_status: "ANONYMOUS" | "INVALID_TOKEN" | "AUTHENTICATED" | string;
|
||||
user_identifier: string | null;
|
||||
account_label: string;
|
||||
@@ -398,6 +502,20 @@ export interface SecurityAccessLogsResponse {
|
||||
anonymous_count: number;
|
||||
invalid_token_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[];
|
||||
}
|
||||
@@ -461,25 +579,77 @@ export interface TopDeniedResponse {
|
||||
|
||||
export interface IpLocationStatItem {
|
||||
country: string;
|
||||
country_code: string;
|
||||
province: string;
|
||||
region_code: string;
|
||||
city: string;
|
||||
isp: string;
|
||||
location: string;
|
||||
longitude: number | null;
|
||||
latitude: number | null;
|
||||
accuracy_level: "city" | "region" | "country" | "private" | "unknown";
|
||||
total_count: number;
|
||||
allowed_count: number;
|
||||
denied_count: number;
|
||||
denied_rate: number;
|
||||
unique_ip_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 {
|
||||
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: {
|
||||
total_count: number;
|
||||
allowed_count: number;
|
||||
denied_count: number;
|
||||
unique_ip_count: number;
|
||||
unique_user_count: number;
|
||||
security_event_count: number;
|
||||
high_risk_count: number;
|
||||
};
|
||||
items: IpLocationStatItem[];
|
||||
}
|
||||
|
||||
@@ -486,7 +486,7 @@ const onSubmit = async () => {
|
||||
justify-content: stretch;
|
||||
overflow: hidden;
|
||||
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 {
|
||||
|
||||
@@ -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>
|
||||
<div class="system-monitoring-page">
|
||||
<div class="system-monitoring-page" :class="{ 'system-monitoring-page--desktop': isDesktop }">
|
||||
<PermissionMonitoring :is-admin="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
|
||||
import { isTauriRuntime } from "@/runtime";
|
||||
|
||||
const isDesktop = isTauriRuntime();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -19,4 +22,49 @@ import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
|
||||
overflow: hidden;
|
||||
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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
append-to=".layout-main .content-wrapper"
|
||||
append-to="body"
|
||||
:title="user ? TEXT.modules.adminUsers.editTitle : TEXT.modules.adminUsers.newTitle"
|
||||
width="540px"
|
||||
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>
|
||||
<el-dialog
|
||||
append-to=".layout-main .content-wrapper"
|
||||
append-to="body"
|
||||
:title="TEXT.modules.adminUsers.resetTitle"
|
||||
width="540px"
|
||||
v-model="visibleProxy"
|
||||
|
||||
@@ -41,6 +41,19 @@
|
||||
<span class="stat-label">{{ TEXT.enums.userStatus.DISABLED }}</span>
|
||||
</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>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
@@ -70,7 +83,7 @@
|
||||
<!-- 用户表格 -->
|
||||
<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-column :label="TEXT.common.fields.name" width="360">
|
||||
<el-table-column :label="TEXT.common.fields.name" min-width="200">
|
||||
<template #default="scope">
|
||||
<div class="user-cell">
|
||||
<div class="user-info">
|
||||
@@ -80,21 +93,36 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="clinical_department" :label="TEXT.modules.adminUsers.clinicalDepartmentLabel" show-overflow-tooltip />
|
||||
<el-table-column :label="TEXT.common.fields.status">
|
||||
<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" min-width="100">
|
||||
<template #default="scope">
|
||||
<span class="status-dot" :class="'dot--' + (scope.row.status || '').toLowerCase()"></span>
|
||||
{{ statusLabel(scope.row.status) }}
|
||||
</template>
|
||||
</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">
|
||||
<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>
|
||||
</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">
|
||||
<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-button link type="primary" :icon="Edit" class="action-btn" @click="openEdit(scope.row)" />
|
||||
</el-tooltip>
|
||||
@@ -141,19 +169,21 @@
|
||||
</div>
|
||||
<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" />
|
||||
<UserLoginActivitiesDrawer v-if="loginActivitiesVisible" v-model="loginActivitiesVisible" :user="loginActivitiesUser" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
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 { fetchStudies } from "../../api/studies";
|
||||
import { listMembers } from "../../api/members";
|
||||
import type { UserInfo } from "../../types/api";
|
||||
import UserForm from "./UserForm.vue";
|
||||
import UserResetPassword from "./UserResetPassword.vue";
|
||||
import UserLoginActivitiesDrawer from "./UserLoginActivitiesDrawer.vue";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { displayDateTime } from "../../utils/display";
|
||||
import { TEXT } from "../../locales";
|
||||
@@ -171,11 +201,14 @@ const searchKeyword = ref("");
|
||||
const statusFilter = ref("");
|
||||
const editingUser = ref<UserInfo | null>(null);
|
||||
const resetUser = ref<UserInfo | null>(null);
|
||||
const loginActivitiesUser = ref<UserInfo | null>(null);
|
||||
const loginActivitiesVisible = ref(false);
|
||||
const auth = useAuthStore();
|
||||
let keywordSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const activeCount = computed(() => allUsers.value.filter(u => u.status === 'ACTIVE').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) => {
|
||||
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) =>
|
||||
row.is_admin && row.status === "ACTIVE" && activeAdminCount.value <= 1;
|
||||
|
||||
@@ -284,6 +323,11 @@ const openReset = (row: UserInfo) => {
|
||||
resetVisible.value = true;
|
||||
};
|
||||
|
||||
const openLoginActivities = (row: UserInfo) => {
|
||||
loginActivitiesUser.value = row;
|
||||
loginActivitiesVisible.value = true;
|
||||
};
|
||||
|
||||
const onDelete = async (row: UserInfo) => {
|
||||
const { action } = await ElMessageBox.prompt(
|
||||
TEXT.modules.adminUsers.deleteConfirm,
|
||||
@@ -364,7 +408,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--unified-shell-divider);
|
||||
@@ -415,6 +459,11 @@ onBeforeUnmount(() => {
|
||||
color: var(--ctms-danger);
|
||||
}
|
||||
|
||||
.stat-card--online .stat-icon {
|
||||
background: #e8f6ef;
|
||||
color: #1f9d63;
|
||||
}
|
||||
|
||||
.stat-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -508,6 +557,43 @@ onBeforeUnmount(() => {
|
||||
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 {
|
||||
color: var(--ctms-text-secondary);
|
||||
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;");
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,14 @@ http {
|
||||
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 / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
|
||||
@@ -72,6 +72,14 @@ http {
|
||||
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 / {
|
||||
proxy_pass http://frontend_dev;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -461,6 +461,7 @@ run_health_checks() {
|
||||
check_backend_environment "$runtime_env" "$login_key_id" "$expect_rsa"
|
||||
step "探测 HTTP 接口可用性"
|
||||
check_http_endpoint "/health"
|
||||
check_http_endpoint "/readyz"
|
||||
check_http_endpoint "/api/v1/auth/login-key"
|
||||
if [[ "$TARGET_ENV" == "dev" ]]; then
|
||||
check_http_endpoint "/" 60
|
||||
|
||||
Reference in New Issue
Block a user