权限监控与管理界面全面美化
重新设计权限系统所有 UI 组件的视觉风格,统一配色、圆角、阴影和交互动效: - 实时概览:统计卡片加图标和渐变色条,健康评分改为环形进度,告警改为卡片式 - 趋势分析:图表卡片加彩色图标标识,ECharts 配色升级为渐变面积填充 - 访问日志:指标卡片带图标,日志审计改为卡片式入口,IP排行前三高亮 - IP属地:工具栏重设计,排行列表前三渐变高亮,指标卡片统一新风格 - 系统级权限:从 el-table 改为自定义卡片列表,模块块独立圆角卡片 - 项目权限配置:空状态引导优化,成员表格加头像,工具栏加背景容器 - 角色概览卡片:加进度条可视化,hover 微动效 - 接口权限矩阵:工具栏分离布局,表格圆角包裹 - 角色管理抽屉:侧边栏选中态渐变,操作行 hover 高亮 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
"""add permission monitoring tables
|
||||
|
||||
Revision ID: 20260519_01
|
||||
Revises: 20260518_01
|
||||
Create Date: 2026-05-19 10:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260519_01"
|
||||
down_revision: Union[str, None] = "20260518_01"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"permission_access_logs",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("study_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("studies.id"), nullable=False),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("endpoint_key", sa.String(100), nullable=False),
|
||||
sa.Column("role", sa.String(30), nullable=False),
|
||||
sa.Column("allowed", sa.Boolean(), nullable=False),
|
||||
sa.Column("elapsed_ms", sa.Float(), nullable=False),
|
||||
sa.Column("ip_address", sa.String(45), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_perm_log_study_created", "permission_access_logs", ["study_id", "created_at"])
|
||||
op.create_index("ix_perm_log_user_created", "permission_access_logs", ["user_id", "created_at"])
|
||||
op.create_index("ix_perm_log_endpoint_created", "permission_access_logs", ["endpoint_key", "created_at"])
|
||||
op.create_index("ix_perm_log_created_at", "permission_access_logs", ["created_at"])
|
||||
op.create_index("ix_perm_log_allowed", "permission_access_logs", ["allowed", "created_at"])
|
||||
|
||||
op.create_table(
|
||||
"permission_metric_snapshots",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("bucket_time", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("total_checks", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("allowed_checks", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("denied_checks", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("avg_elapsed_ms", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("max_elapsed_ms", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("cache_hits", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("cache_misses", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("error_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_perm_snapshot_bucket", "permission_metric_snapshots", ["bucket_time"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("permission_metric_snapshots")
|
||||
op.drop_table("permission_access_logs")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""add security access logs
|
||||
|
||||
Revision ID: 20260520_01
|
||||
Revises: 20260519_01
|
||||
Create Date: 2026-05-20 10:40:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260520_01"
|
||||
down_revision: Union[str, None] = "20260519_01"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"security_access_logs",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("method", sa.String(12), nullable=False),
|
||||
sa.Column("path", sa.String(500), nullable=False),
|
||||
sa.Column("status_code", sa.Integer(), nullable=False),
|
||||
sa.Column("elapsed_ms", sa.Float(), nullable=False),
|
||||
sa.Column("client_ip", sa.String(45), nullable=True),
|
||||
sa.Column("user_agent", sa.String(500), nullable=True),
|
||||
sa.Column("auth_status", sa.String(30), nullable=False),
|
||||
sa.Column("user_identifier", sa.String(80), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_security_log_created_at", "security_access_logs", ["created_at"])
|
||||
op.create_index("ix_security_log_ip_created", "security_access_logs", ["client_ip", "created_at"])
|
||||
op.create_index("ix_security_log_status_created", "security_access_logs", ["status_code", "created_at"])
|
||||
op.create_index("ix_security_log_auth_created", "security_access_logs", ["auth_status", "created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("security_access_logs")
|
||||
@@ -16,6 +16,7 @@ from app.core.project_permissions import (
|
||||
get_missing_prerequisites,
|
||||
)
|
||||
from app.models.api_endpoint_registry import ApiEndpointRegistry
|
||||
from app.models.study import Study
|
||||
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, PROJECT_PERMISSION_ROLES, OPERATION_PREREQUISITES
|
||||
|
||||
router = APIRouter(prefix="/api-permissions", tags=["api-permissions"])
|
||||
@@ -24,6 +25,13 @@ router = APIRouter(prefix="/api-permissions", tags=["api-permissions"])
|
||||
study_router = APIRouter(prefix="/api-permissions", tags=["api-permissions"])
|
||||
|
||||
|
||||
async def _get_configurable_roles(db: AsyncSession, study_id: uuid.UUID) -> list[str]:
|
||||
result = await db.execute(select(Study).where(Study.id == study_id))
|
||||
study = result.scalar_one_or_none()
|
||||
active_roles = [role for role in (study.active_roles if study else []) if isinstance(role, str) and role.strip()]
|
||||
return list(dict.fromkeys([*PROJECT_PERMISSION_ROLES, *active_roles]))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/operations",
|
||||
summary="获取系统中所有权限操作",
|
||||
@@ -167,7 +175,7 @@ async def get_study_api_permissions(
|
||||
|
||||
# 构建返回格式(get_api_endpoint_permissions 已返回 {role: {key: {"allowed": bool}}})
|
||||
result: dict[str, dict[str, dict[str, bool]]] = {}
|
||||
for role in PROJECT_PERMISSION_ROLES:
|
||||
for role in await _get_configurable_roles(db, study_id):
|
||||
if role == "ADMIN":
|
||||
continue
|
||||
result[role] = {}
|
||||
@@ -203,10 +211,11 @@ async def update_study_api_permissions(
|
||||
}
|
||||
"""
|
||||
# 验证输入
|
||||
configurable_roles = set(await _get_configurable_roles(db, study_id))
|
||||
for role in payload.keys():
|
||||
if role == "ADMIN":
|
||||
continue
|
||||
if role not in PROJECT_PERMISSION_ROLES:
|
||||
if role not in configurable_roles:
|
||||
raise ValueError(f"无效的角色: {role}")
|
||||
|
||||
# 替换权限配置
|
||||
@@ -216,7 +225,7 @@ async def update_study_api_permissions(
|
||||
permissions = await get_api_endpoint_permissions(db, study_id)
|
||||
|
||||
result: dict[str, dict[str, dict[str, bool]]] = {}
|
||||
for role in PROJECT_PERMISSION_ROLES:
|
||||
for role in await _get_configurable_roles(db, study_id):
|
||||
if role == "ADMIN":
|
||||
continue
|
||||
result[role] = {}
|
||||
|
||||
@@ -1,26 +1,38 @@
|
||||
"""权限系统监控API
|
||||
|
||||
提供权限系统的监控数据和告警信息。
|
||||
提供权限系统的监控数据、访问日志、趋势分析和告警信息。
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from app.core.deps import get_current_user
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy import func, select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session
|
||||
from app.core.permission_monitor import evaluate_permission_system_health, get_permission_monitor
|
||||
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.user import User
|
||||
from app.services.ip_location import resolve_ip_location
|
||||
|
||||
router = APIRouter(prefix="/permission-monitoring", tags=["permission-monitoring"])
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# 原有端点(保持兼容)
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
@router.get("/metrics", status_code=status.HTTP_200_OK)
|
||||
async def get_permission_metrics(
|
||||
_=Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""获取权限系统指标
|
||||
|
||||
返回权限检查、缓存等运行指标。
|
||||
"""
|
||||
monitor = get_permission_monitor()
|
||||
return monitor.get_metrics()
|
||||
|
||||
@@ -29,10 +41,6 @@ async def get_permission_metrics(
|
||||
async def get_cache_statistics(
|
||||
_=Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""获取缓存统计信息
|
||||
|
||||
返回缓存命中率、缓存项目数等信息。
|
||||
"""
|
||||
monitor = get_permission_monitor()
|
||||
return monitor.get_cache_stats()
|
||||
|
||||
@@ -43,15 +51,6 @@ async def get_alerts(
|
||||
limit: int = 100,
|
||||
_=Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""获取告警列表
|
||||
|
||||
Args:
|
||||
level: 告警级别过滤(info, warning, error)
|
||||
limit: 返回的最大告警数
|
||||
|
||||
Returns:
|
||||
告警列表
|
||||
"""
|
||||
monitor = get_permission_monitor()
|
||||
alerts = monitor.get_alerts(level=level, limit=limit)
|
||||
return {
|
||||
@@ -59,15 +58,10 @@ async def get_alerts(
|
||||
"alerts": alerts,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/reset-metrics", status_code=status.HTTP_200_OK)
|
||||
async def reset_metrics(
|
||||
_=Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""重置监控指标
|
||||
|
||||
清除所有累积的指标数据,重新开始统计。
|
||||
"""
|
||||
monitor = get_permission_monitor()
|
||||
monitor.reset_metrics()
|
||||
return {"message": "指标已重置"}
|
||||
@@ -77,10 +71,6 @@ async def reset_metrics(
|
||||
async def clear_alerts(
|
||||
_=Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""清除所有告警
|
||||
|
||||
删除所有累积的告警记录。
|
||||
"""
|
||||
monitor = get_permission_monitor()
|
||||
monitor.clear_alerts()
|
||||
return {"message": "告警已清除"}
|
||||
@@ -90,12 +80,527 @@ async def clear_alerts(
|
||||
async def permission_system_health(
|
||||
_=Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""权限系统健康检查
|
||||
|
||||
返回权限系统的健康状态。
|
||||
"""
|
||||
monitor = get_permission_monitor()
|
||||
metrics = monitor.get_metrics()
|
||||
cache_stats = monitor.get_cache_stats()
|
||||
|
||||
return evaluate_permission_system_health(metrics, cache_stats)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# 新增端点:访问日志
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
@router.get("/access-logs", status_code=status.HTTP_200_OK)
|
||||
async def get_access_logs(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
_=Depends(get_current_user),
|
||||
study_id: Optional[uuid.UUID] = Query(None),
|
||||
user_id: Optional[uuid.UUID] = Query(None),
|
||||
endpoint_key: Optional[str] = Query(None),
|
||||
role: Optional[str] = Query(None),
|
||||
allowed: Optional[bool] = Query(None),
|
||||
start_time: Optional[datetime] = Query(None),
|
||||
end_time: Optional[datetime] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
) -> dict:
|
||||
"""分页查询权限访问日志"""
|
||||
conditions = []
|
||||
if study_id:
|
||||
conditions.append(PermissionAccessLog.study_id == study_id)
|
||||
if user_id:
|
||||
conditions.append(PermissionAccessLog.user_id == user_id)
|
||||
if endpoint_key:
|
||||
conditions.append(PermissionAccessLog.endpoint_key == endpoint_key)
|
||||
if role:
|
||||
conditions.append(PermissionAccessLog.role == role)
|
||||
if allowed is not None:
|
||||
conditions.append(PermissionAccessLog.allowed == allowed)
|
||||
if start_time:
|
||||
conditions.append(PermissionAccessLog.created_at >= start_time)
|
||||
if end_time:
|
||||
conditions.append(PermissionAccessLog.created_at <= end_time)
|
||||
|
||||
query = (
|
||||
select(PermissionAccessLog, User.full_name)
|
||||
.outerjoin(User, PermissionAccessLog.user_id == User.id)
|
||||
)
|
||||
count_query = select(func.count()).select_from(PermissionAccessLog)
|
||||
summary_query = select(
|
||||
func.count().label("total_count"),
|
||||
func.count(func.distinct(PermissionAccessLog.user_id)).label("unique_user_count"),
|
||||
func.count(func.distinct(PermissionAccessLog.ip_address)).label("unique_ip_count"),
|
||||
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied_count"),
|
||||
func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_elapsed_ms"),
|
||||
).select_from(PermissionAccessLog)
|
||||
user_stats_query = (
|
||||
select(
|
||||
PermissionAccessLog.user_id,
|
||||
User.full_name,
|
||||
PermissionAccessLog.role,
|
||||
PermissionAccessLog.ip_address.label("sample_ip_address"),
|
||||
func.count().label("total_count"),
|
||||
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied_count"),
|
||||
func.count(func.distinct(PermissionAccessLog.ip_address)).label("unique_ip_count"),
|
||||
func.max(PermissionAccessLog.created_at).label("last_seen_at"),
|
||||
)
|
||||
.outerjoin(User, PermissionAccessLog.user_id == User.id)
|
||||
.group_by(PermissionAccessLog.ip_address, PermissionAccessLog.user_id, User.full_name, PermissionAccessLog.role)
|
||||
.order_by(desc("total_count"), desc("last_seen_at"))
|
||||
.limit(10)
|
||||
)
|
||||
for condition in conditions:
|
||||
query = query.where(condition)
|
||||
count_query = count_query.where(condition)
|
||||
summary_query = summary_query.where(condition)
|
||||
user_stats_query = user_stats_query.where(condition)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
summary_result = await db.execute(summary_query)
|
||||
summary_row = summary_result.one()
|
||||
user_stats_result = await db.execute(user_stats_query)
|
||||
user_stats_rows = user_stats_result.all()
|
||||
|
||||
query = query.order_by(desc(PermissionAccessLog.created_at))
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
items = []
|
||||
for log, full_name in rows:
|
||||
ip_location = resolve_ip_location(log.ip_address)
|
||||
items.append(
|
||||
{
|
||||
"id": str(log.id),
|
||||
"study_id": str(log.study_id),
|
||||
"user_id": str(log.user_id),
|
||||
"user_name": full_name or "未知用户",
|
||||
"endpoint_key": log.endpoint_key,
|
||||
"role": log.role,
|
||||
"allowed": log.allowed,
|
||||
"elapsed_ms": round(log.elapsed_ms, 2),
|
||||
"ip_address": log.ip_address,
|
||||
"ip_location": ip_location.location,
|
||||
"ip_country": ip_location.country,
|
||||
"ip_province": ip_location.province,
|
||||
"ip_city": ip_location.city,
|
||||
"ip_isp": ip_location.isp,
|
||||
"created_at": log.created_at.isoformat(),
|
||||
}
|
||||
)
|
||||
user_stats = []
|
||||
for stat in user_stats_rows:
|
||||
sample_location = resolve_ip_location(stat.sample_ip_address)
|
||||
user_stats.append(
|
||||
{
|
||||
"user_id": str(stat.user_id),
|
||||
"user_name": stat.full_name or "未知用户",
|
||||
"role": stat.role,
|
||||
"total_count": stat.total_count,
|
||||
"denied_count": stat.denied_count,
|
||||
"unique_ip_count": stat.unique_ip_count,
|
||||
"sample_ip_address": stat.sample_ip_address,
|
||||
"last_seen_at": stat.last_seen_at.isoformat() if stat.last_seen_at else None,
|
||||
"primary_location": sample_location.location,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"summary": {
|
||||
"total_count": summary_row.total_count,
|
||||
"unique_user_count": summary_row.unique_user_count,
|
||||
"unique_ip_count": summary_row.unique_ip_count,
|
||||
"denied_count": summary_row.denied_count,
|
||||
"avg_elapsed_ms": round(float(summary_row.avg_elapsed_ms), 2),
|
||||
},
|
||||
"user_stats": user_stats,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
def _security_account_label(auth_status: str, user_identifier: str | None, user_names: dict[str, str] | None = None) -> str:
|
||||
if auth_status == "AUTHENTICATED" and user_identifier:
|
||||
return (user_names or {}).get(user_identifier) or user_identifier
|
||||
if auth_status == "INVALID_TOKEN":
|
||||
return "无效令牌"
|
||||
return "未知账号"
|
||||
|
||||
|
||||
@router.get("/security-logs", status_code=status.HTTP_200_OK)
|
||||
async def get_security_access_logs(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
_=Depends(get_current_user),
|
||||
status_min: Optional[int] = Query(None, ge=100, le=599),
|
||||
auth_status: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
) -> dict:
|
||||
"""查询底层安全访问日志,覆盖匿名、无效令牌和异常状态请求。"""
|
||||
conditions = []
|
||||
if status_min is not None:
|
||||
conditions.append(SecurityAccessLog.status_code >= status_min)
|
||||
if auth_status:
|
||||
conditions.append(SecurityAccessLog.auth_status == auth_status)
|
||||
|
||||
query = select(SecurityAccessLog)
|
||||
count_query = select(func.count()).select_from(SecurityAccessLog)
|
||||
summary_query = select(
|
||||
func.count().label("total_count"),
|
||||
func.count().filter(SecurityAccessLog.auth_status == "ANONYMOUS").label("anonymous_count"),
|
||||
func.count().filter(SecurityAccessLog.auth_status == "INVALID_TOKEN").label("invalid_token_count"),
|
||||
func.count().filter(SecurityAccessLog.status_code >= 400).label("error_count"),
|
||||
).select_from(SecurityAccessLog)
|
||||
for condition in conditions:
|
||||
query = query.where(condition)
|
||||
count_query = count_query.where(condition)
|
||||
summary_query = summary_query.where(condition)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
summary_result = await db.execute(summary_query)
|
||||
result = await db.execute(
|
||||
query.order_by(desc(SecurityAccessLog.created_at))
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
summary_row = summary_result.one()
|
||||
|
||||
logs = result.scalars().all()
|
||||
user_ids: list[uuid.UUID] = []
|
||||
for log in logs:
|
||||
if log.auth_status != "AUTHENTICATED" or not log.user_identifier:
|
||||
continue
|
||||
try:
|
||||
user_ids.append(uuid.UUID(log.user_identifier))
|
||||
except ValueError:
|
||||
continue
|
||||
user_names: dict[str, str] = {}
|
||||
if user_ids:
|
||||
users_result = await db.execute(select(User.id, User.full_name).where(User.id.in_(user_ids)))
|
||||
user_names = {str(row.id): row.full_name for row in users_result.all()}
|
||||
|
||||
items = []
|
||||
for log in logs:
|
||||
items.append(
|
||||
{
|
||||
"id": str(log.id),
|
||||
"method": log.method,
|
||||
"path": log.path,
|
||||
"status_code": log.status_code,
|
||||
"elapsed_ms": round(log.elapsed_ms, 2),
|
||||
"client_ip": log.client_ip,
|
||||
"user_agent": log.user_agent,
|
||||
"auth_status": log.auth_status,
|
||||
"user_identifier": log.user_identifier,
|
||||
"account_label": _security_account_label(log.auth_status, log.user_identifier, user_names),
|
||||
"created_at": log.created_at.isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total_result.scalar() or 0,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"summary": {
|
||||
"total_count": summary_row.total_count,
|
||||
"anonymous_count": summary_row.anonymous_count,
|
||||
"invalid_token_count": summary_row.invalid_token_count,
|
||||
"error_count": summary_row.error_count,
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# 新增端点:趋势数据
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
@router.get("/trends", status_code=status.HTTP_200_OK)
|
||||
async def get_trends(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
_=Depends(get_current_user),
|
||||
period: str = Query("24h", pattern="^(24h|7d|30d)$"),
|
||||
) -> dict:
|
||||
"""获取趋势数据(从快照表或实时聚合)"""
|
||||
now = datetime.now(timezone.utc)
|
||||
period_map = {"24h": timedelta(hours=24), "7d": timedelta(days=7), "30d": timedelta(days=30)}
|
||||
start_time = now - period_map[period]
|
||||
|
||||
# 先尝试从快照表获取
|
||||
snapshot_query = (
|
||||
select(PermissionMetricSnapshot)
|
||||
.where(PermissionMetricSnapshot.bucket_time >= start_time)
|
||||
.order_by(PermissionMetricSnapshot.bucket_time)
|
||||
)
|
||||
result = await db.execute(snapshot_query)
|
||||
snapshots = result.scalars().all()
|
||||
|
||||
if snapshots:
|
||||
return {
|
||||
"period": period,
|
||||
"data_points": [
|
||||
{
|
||||
"bucket_time": s.bucket_time.isoformat(),
|
||||
"total_checks": s.total_checks,
|
||||
"allowed_checks": s.allowed_checks,
|
||||
"denied_checks": s.denied_checks,
|
||||
"avg_elapsed_ms": round(s.avg_elapsed_ms, 2),
|
||||
"max_elapsed_ms": round(s.max_elapsed_ms, 2),
|
||||
"cache_hits": s.cache_hits,
|
||||
"cache_misses": s.cache_misses,
|
||||
"cache_hit_rate": round(
|
||||
s.cache_hits / (s.cache_hits + s.cache_misses) * 100, 1
|
||||
) if (s.cache_hits + s.cache_misses) > 0 else 0,
|
||||
"error_count": s.error_count,
|
||||
}
|
||||
for s in snapshots
|
||||
],
|
||||
}
|
||||
|
||||
# 如果没有快照数据,从原始日志实时聚合(适用于刚部署时)。
|
||||
# 这里使用 Python 分桶,避免 SQLite 测试库不支持 PostgreSQL date_trunc。
|
||||
result = await db.execute(
|
||||
select(
|
||||
PermissionAccessLog.created_at,
|
||||
PermissionAccessLog.allowed,
|
||||
PermissionAccessLog.elapsed_ms,
|
||||
).where(PermissionAccessLog.created_at >= start_time)
|
||||
)
|
||||
buckets: dict[datetime, dict[str, float | int]] = defaultdict(
|
||||
lambda: {"total": 0, "allowed": 0, "denied": 0, "elapsed_sum": 0.0, "max_ms": 0.0}
|
||||
)
|
||||
for created_at, allowed, elapsed_ms in result.all():
|
||||
bucket = _trend_bucket_time(created_at, period)
|
||||
buckets[bucket]["total"] += 1
|
||||
buckets[bucket]["allowed" if allowed else "denied"] += 1
|
||||
buckets[bucket]["elapsed_sum"] += float(elapsed_ms or 0)
|
||||
buckets[bucket]["max_ms"] = max(float(buckets[bucket]["max_ms"]), float(elapsed_ms or 0))
|
||||
|
||||
return {
|
||||
"period": period,
|
||||
"data_points": [
|
||||
{
|
||||
"bucket_time": bucket.isoformat(),
|
||||
"total_checks": values["total"],
|
||||
"allowed_checks": values["allowed"],
|
||||
"denied_checks": values["denied"],
|
||||
"avg_elapsed_ms": round(float(values["elapsed_sum"]) / int(values["total"]), 2),
|
||||
"max_elapsed_ms": round(float(values["max_ms"]), 2),
|
||||
"cache_hits": 0,
|
||||
"cache_misses": 0,
|
||||
"cache_hit_rate": 0,
|
||||
"error_count": 0,
|
||||
}
|
||||
for bucket, values in sorted(buckets.items())
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _trend_bucket_time(value: datetime, period: str) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
if period == "24h":
|
||||
return value.replace(minute=0, second=0, microsecond=0)
|
||||
if period == "7d":
|
||||
return value.replace(hour=(value.hour // 6) * 6, minute=0, second=0, microsecond=0)
|
||||
return value.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# 新增端点:被拒绝最多的权限
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
@router.get("/top-denied", status_code=status.HTTP_200_OK)
|
||||
async def get_top_denied(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
_=Depends(get_current_user),
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
) -> dict:
|
||||
"""获取被拒绝最多的权限"""
|
||||
start_time = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
|
||||
query = (
|
||||
select(
|
||||
PermissionAccessLog.endpoint_key,
|
||||
PermissionAccessLog.role,
|
||||
func.count().label("denied_count"),
|
||||
)
|
||||
.where(
|
||||
PermissionAccessLog.created_at >= start_time,
|
||||
PermissionAccessLog.allowed.is_(False),
|
||||
)
|
||||
.group_by(PermissionAccessLog.endpoint_key, PermissionAccessLog.role)
|
||||
.order_by(desc("denied_count"))
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
return {
|
||||
"days": days,
|
||||
"items": [
|
||||
{
|
||||
"endpoint_key": row.endpoint_key,
|
||||
"role": row.role,
|
||||
"denied_count": row.denied_count,
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# 新增端点:IP 属地统计
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
@router.get("/ip-locations", status_code=status.HTTP_200_OK)
|
||||
async def get_ip_locations(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
_=Depends(get_current_user),
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
) -> dict:
|
||||
"""获取 IP 省市属地统计。"""
|
||||
start_time = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
query = (
|
||||
select(
|
||||
PermissionAccessLog.ip_address,
|
||||
PermissionAccessLog.user_id,
|
||||
PermissionAccessLog.allowed,
|
||||
)
|
||||
.where(
|
||||
PermissionAccessLog.created_at >= start_time,
|
||||
PermissionAccessLog.ip_address.is_not(None),
|
||||
)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
buckets: dict[tuple[str, str, str, str], dict] = {}
|
||||
all_ip_addresses: set[str] = set()
|
||||
all_user_ids: set[uuid.UUID] = set()
|
||||
total_count = 0
|
||||
allowed_count = 0
|
||||
denied_count = 0
|
||||
|
||||
for ip_address, user_id, allowed in result.all():
|
||||
ip_info = resolve_ip_location(ip_address)
|
||||
key = (ip_info.country, ip_info.province, ip_info.city, ip_info.isp)
|
||||
bucket = buckets.setdefault(
|
||||
key,
|
||||
{
|
||||
"country": ip_info.country,
|
||||
"province": ip_info.province,
|
||||
"city": ip_info.city,
|
||||
"isp": ip_info.isp,
|
||||
"location": ip_info.location,
|
||||
"total_count": 0,
|
||||
"allowed_count": 0,
|
||||
"denied_count": 0,
|
||||
"ip_addresses": set(),
|
||||
"user_ids": set(),
|
||||
},
|
||||
)
|
||||
bucket["total_count"] += 1
|
||||
bucket["allowed_count" if allowed else "denied_count"] += 1
|
||||
bucket["ip_addresses"].add(ip_address)
|
||||
bucket["user_ids"].add(user_id)
|
||||
total_count += 1
|
||||
if allowed:
|
||||
allowed_count += 1
|
||||
else:
|
||||
denied_count += 1
|
||||
all_ip_addresses.add(ip_address)
|
||||
all_user_ids.add(user_id)
|
||||
|
||||
items = sorted(buckets.values(), key=lambda item: item["total_count"], reverse=True)[:limit]
|
||||
return {
|
||||
"days": days,
|
||||
"summary": {
|
||||
"total_count": total_count,
|
||||
"allowed_count": allowed_count,
|
||||
"denied_count": denied_count,
|
||||
"unique_ip_count": len(all_ip_addresses),
|
||||
"unique_user_count": len(all_user_ids),
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"country": item["country"],
|
||||
"province": item["province"],
|
||||
"city": item["city"],
|
||||
"isp": item["isp"],
|
||||
"location": item["location"],
|
||||
"total_count": item["total_count"],
|
||||
"allowed_count": item["allowed_count"],
|
||||
"denied_count": item["denied_count"],
|
||||
"unique_ip_count": len(item["ip_addresses"]),
|
||||
"unique_user_count": len(item["user_ids"]),
|
||||
}
|
||||
for item in items
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# 新增端点:实时统计摘要(从DB获取)
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
@router.get("/stats-summary", status_code=status.HTTP_200_OK)
|
||||
async def get_stats_summary(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
_=Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""获取基于数据库的统计摘要"""
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
hour_ago = now - timedelta(hours=1)
|
||||
|
||||
# 今日统计
|
||||
today_result = await db.execute(
|
||||
select(
|
||||
func.count().label("total"),
|
||||
func.count().filter(PermissionAccessLog.allowed.is_(True)).label("allowed"),
|
||||
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied"),
|
||||
func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"),
|
||||
func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_ms"),
|
||||
).where(PermissionAccessLog.created_at >= today_start)
|
||||
)
|
||||
today = today_result.one()
|
||||
|
||||
# 最近一小时
|
||||
hour_result = await db.execute(
|
||||
select(
|
||||
func.count().label("total"),
|
||||
func.count().filter(PermissionAccessLog.allowed.is_(True)).label("allowed"),
|
||||
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied"),
|
||||
).where(PermissionAccessLog.created_at >= hour_ago)
|
||||
)
|
||||
hour = hour_result.one()
|
||||
|
||||
# 总记录数
|
||||
total_result = await db.execute(
|
||||
select(func.count()).select_from(PermissionAccessLog)
|
||||
)
|
||||
total_logs = total_result.scalar() or 0
|
||||
|
||||
return {
|
||||
"total_logs": total_logs,
|
||||
"today": {
|
||||
"total_checks": today.total,
|
||||
"allowed_checks": today.allowed,
|
||||
"denied_checks": today.denied,
|
||||
"avg_elapsed_ms": round(float(today.avg_ms), 2),
|
||||
"max_elapsed_ms": round(float(today.max_ms), 2),
|
||||
"allow_rate": round(today.allowed / today.total * 100, 1) if today.total > 0 else 0,
|
||||
"deny_rate": round(today.denied / today.total * 100, 1) if today.total > 0 else 0,
|
||||
},
|
||||
"last_hour": {
|
||||
"total_checks": hour.total,
|
||||
"allowed_checks": hour.allowed,
|
||||
"denied_checks": hour.denied,
|
||||
"requests_per_minute": round(hour.total / 60, 1),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -15,6 +15,23 @@ from app.models.study import Study
|
||||
router = APIRouter(prefix="/active-roles", tags=["active-roles"])
|
||||
|
||||
|
||||
def _normalize_active_roles(value: object) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="active_roles 必须是数组")
|
||||
roles: list[str] = []
|
||||
for item in value:
|
||||
role = str(item or "").strip()
|
||||
if not role:
|
||||
continue
|
||||
if role == "ADMIN":
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ADMIN 不能作为项目角色")
|
||||
if len(role) > 20:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="项目角色长度不能超过20个字符")
|
||||
if role not in roles:
|
||||
roles.append(role)
|
||||
return roles
|
||||
|
||||
|
||||
@router.get("", summary="获取项目已生效角色列表")
|
||||
async def get_active_roles(
|
||||
study_id: uuid.UUID,
|
||||
@@ -39,6 +56,6 @@ async def update_active_roles(
|
||||
study = result.scalar_one_or_none()
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||||
study.active_roles = payload.get("active_roles", [])
|
||||
study.active_roles = _normalize_active_roles(payload.get("active_roles", []))
|
||||
await db.commit()
|
||||
return {"active_roles": study.active_roles}
|
||||
|
||||
@@ -409,7 +409,7 @@ API_ENDPOINT_PERMISSIONS = {
|
||||
"default_roles": ["PM"],
|
||||
},
|
||||
# 项目概览
|
||||
"overview:read": {
|
||||
"project_overview:read": {
|
||||
"module": "project_overview",
|
||||
"action": "read",
|
||||
"description": "查询项目概览",
|
||||
@@ -583,6 +583,13 @@ API_ENDPOINT_PERMISSIONS = {
|
||||
"default_roles": ["PM", "CRA"],
|
||||
"prerequisite_permissions": ["subjects:read", "sites:read"],
|
||||
},
|
||||
"subject_pds:delete": {
|
||||
"module": "subjects",
|
||||
"action": "write",
|
||||
"description": "删除参与者PDS",
|
||||
"default_roles": ["PM"],
|
||||
"prerequisite_permissions": ["subjects:read", "sites:read"],
|
||||
},
|
||||
# 审计日志管理
|
||||
"audit_logs:list": {
|
||||
"module": "audit_export",
|
||||
@@ -665,18 +672,43 @@ API_ENDPOINT_PERMISSIONS = {
|
||||
"default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW"],
|
||||
},
|
||||
# 项目里程碑管理
|
||||
"milestones:list": {
|
||||
"project_milestones:read": {
|
||||
"module": "project_milestones",
|
||||
"action": "read",
|
||||
"description": "查询项目里程碑列表",
|
||||
"default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
|
||||
},
|
||||
"milestones:update": {
|
||||
"project_milestones:update": {
|
||||
"module": "project_milestones",
|
||||
"action": "write",
|
||||
"description": "更新项目里程碑",
|
||||
"default_roles": ["PM"],
|
||||
},
|
||||
# 启动与授权
|
||||
"startup_auth:create": {
|
||||
"module": "startup_auth",
|
||||
"action": "write",
|
||||
"description": "创建启动会或培训授权",
|
||||
"default_roles": ["PM"],
|
||||
},
|
||||
"startup_auth:read": {
|
||||
"module": "startup_auth",
|
||||
"action": "read",
|
||||
"description": "查询启动会或培训授权",
|
||||
"default_roles": ["PM", "CRA", "PV", "IMP"],
|
||||
},
|
||||
"startup_auth:update": {
|
||||
"module": "startup_auth",
|
||||
"action": "write",
|
||||
"description": "更新启动会或培训授权",
|
||||
"default_roles": ["PM"],
|
||||
},
|
||||
"startup_auth:delete": {
|
||||
"module": "startup_auth",
|
||||
"action": "write",
|
||||
"description": "删除培训授权",
|
||||
"default_roles": ["PM"],
|
||||
},
|
||||
# 附件管理
|
||||
"attachments:create": {
|
||||
"module": "attachments",
|
||||
@@ -981,12 +1013,16 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
|
||||
},
|
||||
"startup_auth": {
|
||||
"read": [
|
||||
"startup_auth:read",
|
||||
"budget:list",
|
||||
"budget:read",
|
||||
"timeline:list",
|
||||
"timeline:read",
|
||||
],
|
||||
"write": [
|
||||
"startup_auth:create",
|
||||
"startup_auth:update",
|
||||
"startup_auth:delete",
|
||||
"budget:create",
|
||||
"budget:update",
|
||||
"budget:delete",
|
||||
@@ -997,7 +1033,7 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
|
||||
},
|
||||
"project_overview": {
|
||||
"read": [
|
||||
"overview:read",
|
||||
"project_overview:read",
|
||||
],
|
||||
"write": [],
|
||||
},
|
||||
@@ -1083,10 +1119,10 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
|
||||
},
|
||||
"project_milestones": {
|
||||
"read": [
|
||||
"milestones:list",
|
||||
"project_milestones:read",
|
||||
],
|
||||
"write": [
|
||||
"milestones:update",
|
||||
"project_milestones:update",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ class Settings(BaseSettings):
|
||||
LOGIN_RSA_KEY_ID: str = "default"
|
||||
LOGIN_CHALLENGE_TTL_SECONDS: int = 120
|
||||
LOGIN_CHALLENGE_MAX_ACTIVE: int = 1000
|
||||
IP2REGION_XDB_PATH: Optional[str] = None
|
||||
IP2REGION_IPV6_XDB_PATH: Optional[str] = None
|
||||
|
||||
|
||||
@lru_cache
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from typing import Annotated, AsyncGenerator, Callable, Iterable
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
@@ -129,12 +130,17 @@ def require_api_permission(endpoint_key: str, *, allow_system_admin: bool = True
|
||||
check_prerequisites: 是否检查前置权限
|
||||
"""
|
||||
async def dependency(
|
||||
request: Request,
|
||||
study_id: uuid.UUID,
|
||||
current_user=Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
from app.core.permission_monitor import get_permission_monitor
|
||||
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
|
||||
if allow_system_admin and role_value == "ADMIN":
|
||||
_enqueue_permission_log(
|
||||
study_id, current_user.id, endpoint_key, "ADMIN", True, 0.0, request
|
||||
)
|
||||
return current_user
|
||||
membership = await member_crud.get_member(db, study_id, current_user.id)
|
||||
if not membership or not membership.is_active:
|
||||
@@ -143,11 +149,29 @@ def require_api_permission(endpoint_key: str, *, allow_system_admin: bool = True
|
||||
message="不是该项目成员",
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
allowed = await role_has_api_permission(
|
||||
db, study_id, membership.role_in_study, endpoint_key, check_prerequisites=check_prerequisites
|
||||
)
|
||||
|
||||
start_time = time.perf_counter()
|
||||
error = None
|
||||
try:
|
||||
allowed = await role_has_api_permission(
|
||||
db, study_id, membership.role_in_study, endpoint_key, check_prerequisites=check_prerequisites
|
||||
)
|
||||
except Exception as e:
|
||||
error = e
|
||||
allowed = False
|
||||
raise
|
||||
finally:
|
||||
elapsed_ms = (time.perf_counter() - start_time) * 1000
|
||||
monitor = get_permission_monitor()
|
||||
monitor.record_permission_check(
|
||||
allowed=allowed, elapsed_time=elapsed_ms / 1000, error=error
|
||||
)
|
||||
_enqueue_permission_log(
|
||||
study_id, current_user.id, endpoint_key,
|
||||
membership.role_in_study, allowed, elapsed_ms, request
|
||||
)
|
||||
|
||||
if not allowed:
|
||||
# 获取缺失的前置权限,用于错误提示
|
||||
missing_prereqs = await get_missing_prerequisites(
|
||||
db, study_id, membership.role_in_study, endpoint_key
|
||||
)
|
||||
@@ -168,6 +192,32 @@ def require_api_permission(endpoint_key: str, *, allow_system_admin: bool = True
|
||||
return dependency
|
||||
|
||||
|
||||
def _enqueue_permission_log(
|
||||
study_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
endpoint_key: str,
|
||||
role: str,
|
||||
allowed: bool,
|
||||
elapsed_ms: float,
|
||||
request: Request,
|
||||
) -> None:
|
||||
from app.services.permission_log_writer import get_log_writer
|
||||
|
||||
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)
|
||||
writer.enqueue({
|
||||
"study_id": study_id,
|
||||
"user_id": user_id,
|
||||
"endpoint_key": endpoint_key,
|
||||
"role": role,
|
||||
"allowed": allowed,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"ip_address": ip,
|
||||
})
|
||||
|
||||
|
||||
async def get_cra_site_scope(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.api_endpoint_permission import ApiEndpointPermission
|
||||
from app.models.study import Study
|
||||
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, OPERATION_PREREQUISITES
|
||||
from app.core.permission_cache import get_permission_cache
|
||||
|
||||
@@ -102,7 +103,10 @@ async def get_api_endpoint_permissions(
|
||||
"""
|
||||
overrides = await _get_project_permission_overrides(db, study_id)
|
||||
|
||||
roles = ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"]
|
||||
study_result = await db.execute(select(Study).where(Study.id == study_id))
|
||||
study = study_result.scalar_one_or_none()
|
||||
active_roles = [role for role in (study.active_roles if study else []) if isinstance(role, str) and role.strip()]
|
||||
roles = list(dict.fromkeys(["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA", *active_roles, *overrides.keys()]))
|
||||
matrix: dict[str, dict[str, dict[str, bool]]] = {}
|
||||
for role in roles:
|
||||
matrix[role] = {}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
==========================================================================
|
||||
The following license applies to the ip2region library
|
||||
--------------------------------------------------------------------------
|
||||
Copyright (c) 2015 Lionsoul<chenxin619315@gmail.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
@@ -37,3 +37,6 @@ from app.models.study_setup_config import StudySetupConfig # noqa: F401
|
||||
from app.models.study_setup_config_version import StudySetupConfigVersion # noqa: F401
|
||||
from app.models.study_monitoring_strategy import StudyMonitoringStrategy # noqa: F401
|
||||
from app.models.study_center_confirm import StudyCenterConfirm # noqa: F401
|
||||
from app.models.permission_access_log import PermissionAccessLog # noqa: F401
|
||||
from app.models.permission_metric_snapshot import PermissionMetricSnapshot # noqa: F401
|
||||
from app.models.security_access_log import SecurityAccessLog # noqa: F401
|
||||
|
||||
+65
-4
@@ -17,6 +17,14 @@ from app.crud.user import ensure_admin_exists
|
||||
from app.db.base import Base
|
||||
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.security_access_log_writer import (
|
||||
get_security_log_writer,
|
||||
start_security_log_writer,
|
||||
stop_security_log_writer,
|
||||
)
|
||||
from app.core.security import decode_token
|
||||
|
||||
logger = logging.getLogger("ctms.setup_config")
|
||||
UUID_RE = re.compile(
|
||||
@@ -29,6 +37,9 @@ setup_config_stats: dict[str, int] = defaultdict(int)
|
||||
async def lifespan(_: FastAPI):
|
||||
stop_event = asyncio.Event()
|
||||
scheduler_task = asyncio.create_task(run_daily_lost_visit_job(stop_event))
|
||||
aggregator_task = asyncio.create_task(run_hourly_metric_aggregation(stop_event))
|
||||
await start_log_writer()
|
||||
await start_security_log_writer()
|
||||
# Ensure models are imported so metadata is populated
|
||||
from app.models import user as user_model # noqa: F401
|
||||
|
||||
@@ -38,12 +49,12 @@ async def lifespan(_: FastAPI):
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
async with SessionLocal() as session:
|
||||
await ensure_admin_exists(session)
|
||||
# Initialize API endpoint registry
|
||||
# from app.core.decorators import initialize_api_endpoint_registry
|
||||
# await initialize_api_endpoint_registry(session)
|
||||
yield
|
||||
stop_event.set()
|
||||
await stop_log_writer()
|
||||
await stop_security_log_writer()
|
||||
await scheduler_task
|
||||
await aggregator_task
|
||||
|
||||
|
||||
async def _ensure_legacy_primary_keys(conn) -> None:
|
||||
@@ -118,7 +129,9 @@ def create_app() -> FastAPI:
|
||||
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/")
|
||||
started_at = time.perf_counter()
|
||||
status_code = 500
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
@@ -134,12 +147,14 @@ def create_app() -> FastAPI:
|
||||
500,
|
||||
duration_ms,
|
||||
)
|
||||
if should_security_log:
|
||||
_enqueue_security_access_log(request, path, status_code, started_at)
|
||||
raise
|
||||
|
||||
status_code = int(response.status_code)
|
||||
if is_setup_config_path:
|
||||
normalized_path = UUID_RE.sub("{study_id}", path)
|
||||
duration_ms = int((time.perf_counter() - started_at) * 1000)
|
||||
status_code = int(response.status_code)
|
||||
status_bucket = f"{status_code // 100}xx"
|
||||
key = f"{request.method} {normalized_path} {status_bucket}"
|
||||
setup_config_stats[key] += 1
|
||||
@@ -167,6 +182,8 @@ def create_app() -> FastAPI:
|
||||
status_code,
|
||||
duration_ms,
|
||||
)
|
||||
if should_security_log:
|
||||
_enqueue_security_access_log(request, path, status_code, started_at)
|
||||
return response
|
||||
|
||||
register_exception_handlers(app)
|
||||
@@ -215,3 +232,47 @@ def create_app() -> FastAPI:
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
def _resolve_client_ip(request) -> str | None:
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
return request.client.host if request.client else None
|
||||
|
||||
|
||||
def _resolve_auth_context(request) -> tuple[str, str | None]:
|
||||
authorization = request.headers.get("authorization") or ""
|
||||
if not authorization.lower().startswith("bearer "):
|
||||
return "ANONYMOUS", None
|
||||
token = authorization.split(" ", 1)[1].strip()
|
||||
if not token:
|
||||
return "ANONYMOUS", None
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
except Exception:
|
||||
return "INVALID_TOKEN", None
|
||||
subject = payload.get("sub")
|
||||
return ("AUTHENTICATED", str(subject)) if subject else ("INVALID_TOKEN", None)
|
||||
|
||||
|
||||
def _enqueue_security_access_log(request, path: str, status_code: int, started_at: float) -> None:
|
||||
writer = get_security_log_writer()
|
||||
if not writer:
|
||||
return
|
||||
auth_status, user_identifier = _resolve_auth_context(request)
|
||||
writer.enqueue(
|
||||
{
|
||||
"method": request.method,
|
||||
"path": path,
|
||||
"status_code": status_code,
|
||||
"elapsed_ms": round((time.perf_counter() - started_at) * 1000, 2),
|
||||
"client_ip": _resolve_client_ip(request),
|
||||
"user_agent": request.headers.get("user-agent"),
|
||||
"auth_status": auth_status,
|
||||
"user_identifier": user_identifier,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""权限访问日志模型"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class PermissionAccessLog(Base):
|
||||
__tablename__ = "permission_access_logs"
|
||||
__table_args__ = (
|
||||
Index("ix_perm_log_study_created", "study_id", "created_at"),
|
||||
Index("ix_perm_log_user_created", "user_id", "created_at"),
|
||||
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"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
study_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
endpoint_key: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
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)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""权限指标快照模型 - 每小时聚合"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Float, Index, Integer
|
||||
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 PermissionMetricSnapshot(Base):
|
||||
__tablename__ = "permission_metric_snapshots"
|
||||
__table_args__ = (
|
||||
Index("ix_perm_snapshot_bucket", "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
|
||||
)
|
||||
total_checks: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
allowed_checks: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
denied_checks: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
avg_elapsed_ms: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
max_elapsed_ms: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
cache_hits: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
cache_misses: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""底层安全访问日志模型"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class SecurityAccessLog(Base):
|
||||
__tablename__ = "security_access_logs"
|
||||
__table_args__ = (
|
||||
Index("ix_security_log_created_at", "created_at"),
|
||||
Index("ix_security_log_ip_created", "client_ip", "created_at"),
|
||||
Index("ix_security_log_status_created", "status_code", "created_at"),
|
||||
Index("ix_security_log_auth_created", "auth_status", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
method: Mapped[str] = mapped_column(String(12), nullable=False)
|
||||
path: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
status_code: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
elapsed_ms: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
client_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
auth_status: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
user_identifier: Mapped[Optional[str]] = mapped_column(String(80), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -1,23 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from app.schemas.user import UserResponse
|
||||
|
||||
StudyRole = Literal["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA", "ADMIN"]
|
||||
StudyRole = str
|
||||
|
||||
|
||||
class StudyMemberCreate(BaseModel):
|
||||
user_id: uuid.UUID
|
||||
role_in_study: StudyRole
|
||||
role_in_study: StudyRole = Field(min_length=1, max_length=20)
|
||||
is_active: bool = True
|
||||
|
||||
@field_validator("role_in_study")
|
||||
@classmethod
|
||||
def validate_project_role(cls, value: str) -> str:
|
||||
role = value.strip()
|
||||
if role == "ADMIN":
|
||||
raise ValueError("ADMIN 不能作为项目角色")
|
||||
return role
|
||||
|
||||
|
||||
class StudyMemberUpdate(BaseModel):
|
||||
role_in_study: Optional[StudyRole] = None
|
||||
role_in_study: Optional[StudyRole] = Field(default=None, min_length=1, max_length=20)
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
@field_validator("role_in_study")
|
||||
@classmethod
|
||||
def validate_project_role(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return value
|
||||
role = value.strip()
|
||||
if role == "ADMIN":
|
||||
raise ValueError("ADMIN 不能作为项目角色")
|
||||
return role
|
||||
|
||||
|
||||
class StudyMemberRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""IP 属地解析服务。
|
||||
|
||||
优先使用 ip2region xdb 离线库;缺少依赖或数据文件时降级为基础地址分类。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger("ctms.ip_location")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IpLocation:
|
||||
location: str
|
||||
country: str = ""
|
||||
province: str = ""
|
||||
city: str = ""
|
||||
isp: str = ""
|
||||
|
||||
|
||||
def _default_xdb_path(version: int) -> Path:
|
||||
filename = "ip2region_v6.xdb" if version == 6 else "ip2region_v4.xdb"
|
||||
return Path(__file__).resolve().parents[1] / "data" / filename
|
||||
|
||||
|
||||
def _normalize_part(value: str | None) -> str:
|
||||
part = (value or "").strip()
|
||||
return "" if part in {"", "0", "-"} else part
|
||||
|
||||
|
||||
def _format_location(country: str, province: str, city: str, isp: str) -> str:
|
||||
parts = [part for part in [country, province, city, isp] if part]
|
||||
return " / ".join(parts) if parts else "公网"
|
||||
|
||||
|
||||
class Ip2RegionResolver:
|
||||
def __init__(self, db_path: Optional[str] = None, ipv6_db_path: Optional[str] = None) -> None:
|
||||
self.db_paths = {
|
||||
4: Path(db_path or settings.IP2REGION_XDB_PATH or _default_xdb_path(4)),
|
||||
6: Path(ipv6_db_path or settings.IP2REGION_IPV6_XDB_PATH or _default_xdb_path(6)),
|
||||
}
|
||||
self._searchers = {}
|
||||
self._load_failed: set[int] = set()
|
||||
|
||||
def lookup(self, ip: str | None) -> IpLocation:
|
||||
addr = self._classify_address(ip)
|
||||
if addr is None:
|
||||
return IpLocation(location="未知")
|
||||
if addr.is_loopback:
|
||||
return IpLocation(location="本机")
|
||||
if addr.is_private or addr.is_link_local:
|
||||
return IpLocation(location="局域网")
|
||||
|
||||
searcher = self._get_searcher(addr.version)
|
||||
if not searcher:
|
||||
return IpLocation(location="公网")
|
||||
|
||||
try:
|
||||
region = searcher.search(str(addr))
|
||||
except Exception:
|
||||
logger.exception("Failed to lookup IP location: %s", addr)
|
||||
return IpLocation(location="公网")
|
||||
return self._parse_region(region)
|
||||
|
||||
@staticmethod
|
||||
def _classify_address(ip: str | None):
|
||||
if not ip:
|
||||
return None
|
||||
try:
|
||||
return ipaddress.ip_address(ip)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def _get_searcher(self, version: int):
|
||||
if version in self._searchers or version in self._load_failed:
|
||||
return self._searchers.get(version)
|
||||
db_path = self.db_paths[version]
|
||||
if not db_path.exists():
|
||||
self._load_failed.add(version)
|
||||
logger.info("ip2region xdb file not found: %s", db_path)
|
||||
return None
|
||||
try:
|
||||
try:
|
||||
import ip2region.searcher as searcher
|
||||
import ip2region.util as util
|
||||
except ModuleNotFoundError:
|
||||
vendor_path = Path(__file__).resolve().parents[1] / "vendor"
|
||||
if str(vendor_path) not in sys.path:
|
||||
sys.path.insert(0, str(vendor_path))
|
||||
import ip2region.searcher as searcher
|
||||
import ip2region.util as util
|
||||
|
||||
ip_version = util.IPv6 if version == 6 else util.IPv4
|
||||
buffer = util.load_content_from_file(str(db_path))
|
||||
self._searchers[version] = searcher.new_with_buffer(ip_version, buffer)
|
||||
except Exception:
|
||||
self._load_failed.add(version)
|
||||
logger.exception("Failed to initialize ip2region searcher: %s", db_path)
|
||||
return self._searchers.get(version)
|
||||
|
||||
@staticmethod
|
||||
def _parse_region(region: str | None) -> IpLocation:
|
||||
parts = [*str(region or "").split("|"), "", "", "", ""]
|
||||
country = _normalize_part(parts[0])
|
||||
province = _normalize_part(parts[1])
|
||||
city = _normalize_part(parts[2])
|
||||
isp = _normalize_part(parts[3])
|
||||
return IpLocation(
|
||||
location=_format_location(country, province, city, isp),
|
||||
country=country,
|
||||
province=province,
|
||||
city=city,
|
||||
isp=isp,
|
||||
)
|
||||
|
||||
|
||||
_resolver = Ip2RegionResolver()
|
||||
|
||||
|
||||
def resolve_ip_location(ip: str | None) -> IpLocation:
|
||||
return _resolver.lookup(ip)
|
||||
@@ -0,0 +1,124 @@
|
||||
"""权限访问日志后台写入器
|
||||
|
||||
使用 asyncio.Queue 缓冲日志条目,批量写入数据库。
|
||||
设计原则:
|
||||
- enqueue() 非阻塞,不影响请求性能
|
||||
- 队列满时丢弃(监控不应拖慢被监控系统)
|
||||
- 批量写入减少数据库压力
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.permission_access_log import PermissionAccessLog
|
||||
|
||||
logger = logging.getLogger("ctms.permission_log_writer")
|
||||
|
||||
BATCH_SIZE = 50
|
||||
FLUSH_INTERVAL = 5.0
|
||||
QUEUE_MAX_SIZE = 10000
|
||||
|
||||
|
||||
class PermissionLogWriter:
|
||||
|
||||
def __init__(self):
|
||||
self._queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
self._task: asyncio.Task | None = None
|
||||
|
||||
def enqueue(self, entry: dict) -> None:
|
||||
try:
|
||||
self._queue.put_nowait(entry)
|
||||
except asyncio.QueueFull:
|
||||
logger.warning("Permission log queue full, dropping entry")
|
||||
|
||||
async def start(self) -> None:
|
||||
self._task = asyncio.create_task(self._flush_loop())
|
||||
logger.info("PermissionLogWriter started")
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await self._drain()
|
||||
logger.info("PermissionLogWriter stopped")
|
||||
|
||||
async def _flush_loop(self) -> None:
|
||||
while True:
|
||||
batch = await self._collect_batch()
|
||||
if batch:
|
||||
await self._write_batch(batch)
|
||||
|
||||
async def _collect_batch(self) -> list[dict]:
|
||||
batch: list[dict] = []
|
||||
try:
|
||||
first = await asyncio.wait_for(self._queue.get(), timeout=FLUSH_INTERVAL)
|
||||
batch.append(first)
|
||||
except asyncio.TimeoutError:
|
||||
return batch
|
||||
|
||||
while len(batch) < BATCH_SIZE:
|
||||
try:
|
||||
item = self._queue.get_nowait()
|
||||
batch.append(item)
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
return batch
|
||||
|
||||
async def _write_batch(self, batch: list[dict]) -> None:
|
||||
try:
|
||||
async with SessionLocal() as session:
|
||||
for entry in batch:
|
||||
log = PermissionAccessLog(
|
||||
id=uuid.uuid4(),
|
||||
study_id=entry["study_id"],
|
||||
user_id=entry["user_id"],
|
||||
endpoint_key=entry["endpoint_key"],
|
||||
role=entry["role"],
|
||||
allowed=entry["allowed"],
|
||||
elapsed_ms=entry["elapsed_ms"],
|
||||
ip_address=entry.get("ip_address"),
|
||||
created_at=entry.get("created_at", datetime.now(timezone.utc)),
|
||||
)
|
||||
session.add(log)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to write permission access log batch (%d entries)", len(batch))
|
||||
|
||||
async def _drain(self) -> None:
|
||||
batch: list[dict] = []
|
||||
while not self._queue.empty():
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
if batch:
|
||||
await self._write_batch(batch)
|
||||
|
||||
|
||||
_writer: PermissionLogWriter | None = None
|
||||
|
||||
|
||||
def get_log_writer() -> PermissionLogWriter | None:
|
||||
return _writer
|
||||
|
||||
|
||||
async def start_log_writer() -> PermissionLogWriter:
|
||||
global _writer
|
||||
_writer = PermissionLogWriter()
|
||||
await _writer.start()
|
||||
return _writer
|
||||
|
||||
|
||||
async def stop_log_writer() -> None:
|
||||
global _writer
|
||||
if _writer:
|
||||
await _writer.stop()
|
||||
_writer = None
|
||||
@@ -0,0 +1,82 @@
|
||||
"""权限指标小时聚合任务
|
||||
|
||||
每小时从 permission_access_logs 聚合数据写入 permission_metric_snapshots。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.permission_access_log import PermissionAccessLog
|
||||
from app.models.permission_metric_snapshot import PermissionMetricSnapshot
|
||||
|
||||
logger = logging.getLogger("ctms.permission_metric_aggregator")
|
||||
|
||||
|
||||
async def _aggregate_hour(bucket_start: datetime, bucket_end: datetime) -> None:
|
||||
async with SessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(
|
||||
func.count().label("total"),
|
||||
func.count().filter(PermissionAccessLog.allowed.is_(True)).label("allowed"),
|
||||
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied"),
|
||||
func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"),
|
||||
func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_ms"),
|
||||
).where(
|
||||
PermissionAccessLog.created_at >= bucket_start,
|
||||
PermissionAccessLog.created_at < bucket_end,
|
||||
)
|
||||
)
|
||||
row = result.one()
|
||||
|
||||
if row.total == 0:
|
||||
return
|
||||
|
||||
from app.core.permission_monitor import get_permission_monitor
|
||||
monitor = get_permission_monitor()
|
||||
cache_metrics = monitor.metrics.cache_metrics
|
||||
|
||||
snapshot = PermissionMetricSnapshot(
|
||||
id=uuid.uuid4(),
|
||||
bucket_time=bucket_start,
|
||||
total_checks=row.total,
|
||||
allowed_checks=row.allowed,
|
||||
denied_checks=row.denied,
|
||||
avg_elapsed_ms=float(row.avg_ms),
|
||||
max_elapsed_ms=float(row.max_ms),
|
||||
cache_hits=cache_metrics.cache_hits,
|
||||
cache_misses=cache_metrics.cache_misses,
|
||||
error_count=monitor.metrics.check_metrics.errors,
|
||||
)
|
||||
session.add(snapshot)
|
||||
await session.commit()
|
||||
logger.info("Aggregated permission metrics for bucket %s: %d checks", bucket_start, row.total)
|
||||
|
||||
|
||||
async def run_hourly_metric_aggregation(stop_event: asyncio.Event) -> None:
|
||||
logger.info("Permission metric aggregator started")
|
||||
while not stop_event.is_set():
|
||||
now = datetime.now(timezone.utc)
|
||||
next_hour = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
|
||||
wait_seconds = (next_hour - now).total_seconds()
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(stop_event.wait(), timeout=wait_seconds)
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
bucket_end = next_hour
|
||||
bucket_start = bucket_end - timedelta(hours=1)
|
||||
try:
|
||||
await _aggregate_hour(bucket_start, bucket_end)
|
||||
except Exception:
|
||||
logger.exception("Failed to aggregate permission metrics for %s", bucket_start)
|
||||
|
||||
logger.info("Permission metric aggregator stopped")
|
||||
@@ -0,0 +1,117 @@
|
||||
"""底层安全访问日志后台写入器"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.security_access_log import SecurityAccessLog
|
||||
|
||||
logger = logging.getLogger("ctms.security_access_log_writer")
|
||||
|
||||
BATCH_SIZE = 100
|
||||
FLUSH_INTERVAL = 3.0
|
||||
QUEUE_MAX_SIZE = 20000
|
||||
|
||||
|
||||
class SecurityAccessLogWriter:
|
||||
def __init__(self) -> None:
|
||||
self._queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
self._task: asyncio.Task | None = None
|
||||
|
||||
def enqueue(self, entry: dict) -> None:
|
||||
try:
|
||||
self._queue.put_nowait(entry)
|
||||
except asyncio.QueueFull:
|
||||
logger.warning("Security access log queue full, dropping entry")
|
||||
|
||||
async def start(self) -> None:
|
||||
self._task = asyncio.create_task(self._flush_loop())
|
||||
logger.info("SecurityAccessLogWriter started")
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await self._drain()
|
||||
logger.info("SecurityAccessLogWriter stopped")
|
||||
|
||||
async def _flush_loop(self) -> None:
|
||||
while True:
|
||||
batch = await self._collect_batch()
|
||||
if batch:
|
||||
await self._write_batch(batch)
|
||||
|
||||
async def _collect_batch(self) -> list[dict]:
|
||||
batch: list[dict] = []
|
||||
try:
|
||||
first = await asyncio.wait_for(self._queue.get(), timeout=FLUSH_INTERVAL)
|
||||
batch.append(first)
|
||||
except asyncio.TimeoutError:
|
||||
return batch
|
||||
|
||||
while len(batch) < BATCH_SIZE:
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
return batch
|
||||
|
||||
async def _write_batch(self, batch: list[dict]) -> None:
|
||||
try:
|
||||
async with SessionLocal() as session:
|
||||
for entry in batch:
|
||||
session.add(
|
||||
SecurityAccessLog(
|
||||
id=uuid.uuid4(),
|
||||
method=entry["method"],
|
||||
path=entry["path"],
|
||||
status_code=entry["status_code"],
|
||||
elapsed_ms=entry["elapsed_ms"],
|
||||
client_ip=entry.get("client_ip"),
|
||||
user_agent=entry.get("user_agent"),
|
||||
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))
|
||||
|
||||
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)
|
||||
|
||||
|
||||
_writer: SecurityAccessLogWriter | None = None
|
||||
|
||||
|
||||
def get_security_log_writer() -> SecurityAccessLogWriter | None:
|
||||
return _writer
|
||||
|
||||
|
||||
async def start_security_log_writer() -> SecurityAccessLogWriter:
|
||||
global _writer
|
||||
_writer = SecurityAccessLogWriter()
|
||||
await _writer.start()
|
||||
return _writer
|
||||
|
||||
|
||||
async def stop_security_log_writer() -> None:
|
||||
global _writer
|
||||
if _writer:
|
||||
await _writer.stop()
|
||||
_writer = None
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
# Copyright 2022 The Ip2Region Authors. All rights reserved.
|
||||
# Use of this source code is governed by a Apache2.0-style
|
||||
# license that can be found in the LICENSE file.
|
||||
|
||||
# xdb package
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
# Copyright 2022 The Ip2Region Authors. All rights reserved.
|
||||
# Use of this source code is governed by a Apache2.0-style
|
||||
# license that can be found in the LICENSE file.
|
||||
|
||||
# xdb searcher on 2025/10/30
|
||||
# Author Leon<chenxin619315@gmail.com>
|
||||
|
||||
import io
|
||||
import ip2region.util as util
|
||||
from typing import Union
|
||||
|
||||
class Searcher(object):
|
||||
'''
|
||||
xdb searcher class with Both IPv4 and IPv6 supported.
|
||||
three kinds of cache policy: file / vectorIndex / content
|
||||
'''
|
||||
def __init__(self, version: util.Version,
|
||||
db_path: str, vector_index: bytes, c_buffer: bytes):
|
||||
self.version = version
|
||||
self.__db_path = db_path
|
||||
self.__io_count = 0
|
||||
if c_buffer != None:
|
||||
self.__handle = None
|
||||
self.vector_index = None
|
||||
self.c_buffer = c_buffer
|
||||
else:
|
||||
self.__handle = io.open(db_path, "rb")
|
||||
self.vector_index = vector_index
|
||||
self.c_buffer = None
|
||||
|
||||
def get_ip_version(self):
|
||||
return self.version
|
||||
|
||||
def get_io_count(self):
|
||||
return self.__io_count
|
||||
|
||||
def search(self, ip: Union[bytes, str]):
|
||||
# check and parse the string ip
|
||||
ip_bytes = None
|
||||
if isinstance(ip, str):
|
||||
ip_bytes = util.parse_ip(ip)
|
||||
elif isinstance(ip, bytes):
|
||||
ip_bytes = ip
|
||||
else:
|
||||
raise ValueError("invalid ip address `{}`".format(ip))
|
||||
|
||||
# ip version check
|
||||
if len(ip_bytes) != self.version.byte_num:
|
||||
raise ValueError("invalid ip address `{}` ({} expected)".format(
|
||||
util.ip_to_string(ip_bytes), self.version.name))
|
||||
|
||||
# reset the global io_count
|
||||
self.__io_count = 0
|
||||
|
||||
# located the segment index block based on the vector index
|
||||
s_ptr, e_ptr, i0, i1 = 0, 0, ip_bytes[0], ip_bytes[1]
|
||||
idx = i0 * util.VectorIndexCols * util.VectorIndexSize + i1 * util.VectorIndexSize
|
||||
if self.vector_index != None:
|
||||
s_ptr = util.le_get_uint32(self.vector_index, idx)
|
||||
e_ptr = util.le_get_uint32(self.vector_index, idx + 4)
|
||||
elif self.c_buffer != None:
|
||||
offset = util.HeaderInfoLength + idx
|
||||
s_ptr = util.le_get_uint32(self.c_buffer, offset)
|
||||
e_ptr = util.le_get_uint32(self.c_buffer, offset + 4)
|
||||
else:
|
||||
buff = self.read(util.HeaderInfoLength + idx, util.VectorIndexSize)
|
||||
s_ptr = util.le_get_uint32(buff, 0)
|
||||
e_ptr = util.le_get_uint32(buff, 4)
|
||||
|
||||
# print("s_ptr: {}, e_ptr: {}".format(s_ptr, e_ptr))
|
||||
# @Note: ptr validate, zero ptr means source data missing
|
||||
# so we could just stop here and return an empty string.
|
||||
if s_ptr == 0 or e_ptr == 0:
|
||||
return ""
|
||||
|
||||
# binary search the segment index block to get the region info
|
||||
_bytes, _d_bytes = len(ip_bytes), len(ip_bytes) << 1
|
||||
index_size = self.version.index_size
|
||||
d_len, d_ptr, l, h = 0, 0, int(0), int((e_ptr - s_ptr) / index_size)
|
||||
while l <= h:
|
||||
m = (l + h) >> 1
|
||||
p = int(s_ptr + m * index_size)
|
||||
|
||||
# read the segment index
|
||||
buff = self.read(p, index_size)
|
||||
if self.version.ip_sub_compare(ip_bytes, buff, 0) < 0:
|
||||
h = m - 1
|
||||
elif self.version.ip_sub_compare(ip_bytes, buff, _bytes) > 0:
|
||||
l = m + 1
|
||||
else:
|
||||
d_len = util.le_get_uint16(buff, _d_bytes)
|
||||
d_ptr = util.le_get_uint32(buff, _d_bytes + 2)
|
||||
break
|
||||
|
||||
# print("d_len: {}, d_ptr: {}".format(d_len, d_ptr))
|
||||
# empty match interception.
|
||||
# and this could be a case.
|
||||
if d_len == 0:
|
||||
return ""
|
||||
|
||||
# read and return the region info
|
||||
return self.read(d_ptr, d_len).decode("utf-8")
|
||||
|
||||
def read(self, offset: int, length: int):
|
||||
# check the content buffer first
|
||||
if self.c_buffer != None:
|
||||
return self.c_buffer[offset:offset+length]
|
||||
|
||||
# load the buffer from file
|
||||
self.__handle.seek(offset)
|
||||
self.__io_count += 1
|
||||
return self.__handle.read(length)
|
||||
|
||||
def close(self):
|
||||
if self.__handle != None:
|
||||
self.__handle.close()
|
||||
|
||||
def __str__(self):
|
||||
return '{{"version": {}, "db_path": "{}", "v_index": {}, "c_buffer": {}}}'.format(
|
||||
self.version.name,
|
||||
self.__db_path,
|
||||
None if self.vector_index is None else len(self.vector_index),
|
||||
None if self.c_buffer is None else len(self.c_buffer)
|
||||
)
|
||||
|
||||
|
||||
# ---
|
||||
# functions to create Searcher with different cache policy
|
||||
|
||||
def new_with_file_only(version: util.Version, db_path: str):
|
||||
return Searcher(version, db_path, None, None)
|
||||
|
||||
def new_with_vector_index(version: util.Version, db_path: str, vector_index: bytes):
|
||||
return Searcher(version, db_path, vector_index, None)
|
||||
|
||||
def new_with_buffer(version: util.Version, c_buffer: bytes):
|
||||
return Searcher(version, None, None, c_buffer)
|
||||
Vendored
+270
@@ -0,0 +1,270 @@
|
||||
# Copyright 2022 The Ip2Region Authors. All rights reserved.
|
||||
# Use of this source code is governed by a Apache2.0-style
|
||||
# license that can be found in the LICENSE file.
|
||||
|
||||
# xdb utils on 2025/10/29
|
||||
# Author Leon<chenxin619315@gmail.com>
|
||||
|
||||
import io
|
||||
import os
|
||||
import ipaddress
|
||||
from typing import Callable
|
||||
|
||||
# global constants
|
||||
XdbStructure20 = 2
|
||||
XdbStructure30 = 3
|
||||
XdbIPv4Id = 4
|
||||
XdbIPv6Id = 6
|
||||
|
||||
HeaderInfoLength = 256
|
||||
VectorIndexRows = 256
|
||||
VectorIndexCols = 256
|
||||
VectorIndexSize = 8
|
||||
# cache of VectorIndexCols × VectorIndexRows × VectorIndexSize
|
||||
VectorIndexLength = 524288
|
||||
|
||||
class Header(object):
|
||||
def __init__(self, buff: bytes):
|
||||
self.version = le_get_uint16(buff, 0)
|
||||
self.indexPolicy = le_get_uint16(buff, 2)
|
||||
self.createdAt = le_get_uint32(buff, 4)
|
||||
self.startIndexPtr = le_get_uint32(buff, 8)
|
||||
self.endIndexPtr = le_get_uint32(buff, 12)
|
||||
|
||||
# since IPv6 supporting
|
||||
self.ipVersion = le_get_uint16(buff, 16)
|
||||
self.runtimePtrBytes = le_get_uint16(buff, 18)
|
||||
|
||||
|
||||
# keep the raw data
|
||||
self.buff = buff
|
||||
|
||||
def __str__(self):
|
||||
return '''{{
|
||||
"version": {},
|
||||
"indexPolicy": {},
|
||||
"createdAt": {},
|
||||
"startIndexPtr": {},
|
||||
"endIndexPtr": {},
|
||||
"ipVersion": {},
|
||||
"runtimePtrBytes": {}
|
||||
}}'''.format(
|
||||
self.version,
|
||||
self.indexPolicy,
|
||||
self.createdAt,
|
||||
self.startIndexPtr,
|
||||
self.endIndexPtr,
|
||||
self.ipVersion,
|
||||
self.runtimePtrBytes
|
||||
)
|
||||
|
||||
|
||||
# ---
|
||||
# ip parse and convert functions
|
||||
|
||||
def parse_ip(ip_string: str):
|
||||
try:
|
||||
return ipaddress.ip_address(ip_string).packed
|
||||
except:
|
||||
raise ValueError("invalid ip address `{}`".format(ip_string))
|
||||
|
||||
def ip_to_string(ip_bytes: bytes):
|
||||
if isinstance(ip_bytes, bytes):
|
||||
return str(ipaddress.ip_address(ip_bytes))
|
||||
else:
|
||||
raise ValueError("invalid bytes ip `{}`".format(ip_bytes))
|
||||
|
||||
def ip_compare(ip1: bytes, ip2: bytes):
|
||||
if ip1 > ip2:
|
||||
return 1
|
||||
elif ip1 < ip2:
|
||||
return -1
|
||||
else:
|
||||
return 0
|
||||
|
||||
def ip_sub_compare(ip1: bytes, buff: bytes, offset: int):
|
||||
ip2 = buff[offset:offset+len(ip1)]
|
||||
if ip1 > ip2:
|
||||
return 1
|
||||
elif ip1 < ip2:
|
||||
return -1
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
# ---
|
||||
# ip version class and functions
|
||||
|
||||
class Version(object):
|
||||
def __init__(self, id: int, name: str, byte_num: int, index_size: int, ip_compare: Callable[[bytes, bytes, int], int]):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.byte_num = byte_num
|
||||
self.index_size = index_size
|
||||
self.ip_compare = ip_compare
|
||||
|
||||
def ip_compare(self, ip1: bytes, ip2: bytes):
|
||||
return self.ip_compare(ip1, ip2, 0)
|
||||
|
||||
def ip_sub_compare(self, ip1: bytes, buff: bytes, offset: int):
|
||||
return self.ip_compare(ip1, buff, offset)
|
||||
|
||||
def __str__(self):
|
||||
return '{{"id": {}, "name": "{}", "bytes": {}, "index_size": {}}}'.format(
|
||||
self.id,
|
||||
self.name,
|
||||
self.byte_num,
|
||||
self.index_size
|
||||
)
|
||||
|
||||
def _v4_sub_compare(ip1: bytes, buff: bytes, offset: int):
|
||||
# ip1: Big endian byte order parsed from input
|
||||
# ip2: Little endian byte order read from xdb index.
|
||||
# @Note: to compatible with the old Litten endian index encode implementation.
|
||||
j = offset + len(ip1) - 1
|
||||
for i in range(len(ip1)):
|
||||
i1 = ip1[i]
|
||||
i2 = buff[j]
|
||||
if i1 < i2:
|
||||
return -1
|
||||
|
||||
if i1 > i2:
|
||||
return 1
|
||||
|
||||
# increase the j
|
||||
j = j - 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
# ---
|
||||
# IPv4 and IPv6 version constants
|
||||
# 14 = 4 + 4 + 2 + 4
|
||||
IPv4 = Version(XdbIPv4Id, "IPv4", 4, 14, _v4_sub_compare)
|
||||
# 38 = 16 + 16 + 2 + 4
|
||||
IPv6 = Version(XdbIPv6Id, "IPv6", 16, 38, ip_sub_compare)
|
||||
|
||||
def version_from_name(name: str):
|
||||
u_name = name.upper()
|
||||
if u_name == "IPV4" or u_name == "V4":
|
||||
return IPv4
|
||||
elif u_name == "IPV6" or u_name == "V6":
|
||||
return IPv6
|
||||
else:
|
||||
return None
|
||||
|
||||
def version_from_header(header: bytes):
|
||||
# old xdb 2.0 with IPv4 supports ONLY
|
||||
if header.version < XdbStructure30:
|
||||
return IPv4
|
||||
|
||||
# xdb 3.0 or later version
|
||||
ip_version = header.ipVersion
|
||||
if ip_version == XdbIPv4Id:
|
||||
return IPv4
|
||||
elif ip_version == XdbIPv6Id:
|
||||
return IPv6
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
# ---
|
||||
# buffer decode functions
|
||||
|
||||
def le_get_uint32(buff: bytes, offset: int):
|
||||
'''
|
||||
decode an unsinged 4-bytes int from a buffer started from offset
|
||||
with little byte endian
|
||||
'''
|
||||
return (
|
||||
((buff[offset ]) & 0x000000FF) |
|
||||
((buff[offset+1] << 8) & 0x0000FF00) |
|
||||
((buff[offset+2] << 16) & 0x00FF0000) |
|
||||
((buff[offset+3] << 24) & 0xFF000000)
|
||||
)
|
||||
|
||||
def le_get_uint16(buff: bytes, offset: int):
|
||||
'''
|
||||
decode an unsinged 2-bytes short from a buffer started from offset
|
||||
with little byte endian
|
||||
'''
|
||||
return (
|
||||
((buff[offset ]) & 0x000000FF) |
|
||||
((buff[offset+1] << 8) & 0x0000FF00)
|
||||
)
|
||||
|
||||
|
||||
# ---
|
||||
# xdb buffer load functions
|
||||
|
||||
def load_header(handle):
|
||||
'''
|
||||
load xdb header from a specified file handle
|
||||
'''
|
||||
handle.seek(0)
|
||||
return Header(handle.read(HeaderInfoLength))
|
||||
|
||||
def load_header_from_file(db_file: str):
|
||||
handle = io.open(db_file, "rb")
|
||||
header = load_header(handle)
|
||||
handle.close()
|
||||
return header
|
||||
|
||||
def load_vector_index(handle):
|
||||
'''
|
||||
load xdb vector index from a specified file handle
|
||||
'''
|
||||
handle.seek(HeaderInfoLength)
|
||||
return handle.read(VectorIndexLength)
|
||||
|
||||
def load_vector_index_from_file(db_file: str):
|
||||
handle = io.open(db_file, "rb")
|
||||
v_index = load_vector_index(handle)
|
||||
handle.close()
|
||||
return v_index
|
||||
|
||||
def load_content(handle):
|
||||
'''
|
||||
load the whole xdb content from a specified file handle
|
||||
'''
|
||||
handle.seek(0)
|
||||
return handle.read()
|
||||
|
||||
def load_content_from_file(db_file: str):
|
||||
handle = io.open(db_file, "rb")
|
||||
c_buff = load_content(handle)
|
||||
handle.close()
|
||||
return c_buff
|
||||
|
||||
# ---
|
||||
# Verify if the current Searcher could be used to search the specified xdb file.
|
||||
# Why do we need this check ?
|
||||
# The future features of the xdb impl may cause the current searcher not able to work properly.
|
||||
#
|
||||
# @Note: You Just need to check this ONCE when the service starts
|
||||
# Or use another process (eg, A command) to check once Just to confirm the suitability.
|
||||
def verify(handle):
|
||||
header = load_header(handle)
|
||||
|
||||
# get the runtime ptr bytes
|
||||
runtime_ptr_bytes = 0
|
||||
if header.version == XdbStructure20:
|
||||
runtime_ptr_bytes = 4
|
||||
elif header.version == XdbStructure30:
|
||||
runtime_ptr_bytes = header.runtimePtrBytes
|
||||
else:
|
||||
# Higher versions of the structure are usually incompatible.
|
||||
raise ValueError("invalid structure version {}".format(header.version))
|
||||
|
||||
# 1, confirm the xdb file size
|
||||
# to ensure that the maximum file pointer does not overflow
|
||||
max_file_ptr = (1 << (runtime_ptr_bytes * 8)) - 1
|
||||
__file_bytes = os.stat(handle.fileno()).st_size
|
||||
# print("max_file_ptr: {}, file_bytes: {}".format(max_file_ptr, __file_bytes))
|
||||
if __file_bytes > max_file_ptr:
|
||||
raise Exception("xdb file exceeds the maximum supported bytes: {}".format(max_file_ptr))
|
||||
|
||||
def verify_from_file(db_file: str):
|
||||
handle = io.open(db_file, "rb")
|
||||
verify(handle)
|
||||
handle.close()
|
||||
@@ -16,3 +16,4 @@ httpx==0.25.2
|
||||
email-validator==2.1.1
|
||||
alembic==1.13.1
|
||||
openpyxl==3.1.5
|
||||
py-ip2region==3.0.4
|
||||
|
||||
@@ -83,6 +83,9 @@ async def _create_test_engine():
|
||||
import app.models.monitoring_visit_issue
|
||||
import app.models.site
|
||||
import app.models.permission_template
|
||||
import app.models.permission_access_log
|
||||
import app.models.permission_metric_snapshot
|
||||
import app.models.security_access_log
|
||||
|
||||
# Replace PostgreSQL UUID type with custom GUID type for SQLite
|
||||
for table in Base.metadata.tables.values():
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
|
||||
import pytest
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
import re
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, PROJECT_PERMISSION_ROLES
|
||||
from app.core.project_permissions import role_has_api_permission
|
||||
from app.core.project_permissions import get_api_endpoint_permissions, replace_api_endpoint_permissions
|
||||
from app.core.permission_cache import PermissionCache, set_permission_cache
|
||||
from app.core.permission_monitor import PermissionMonitor, set_permission_monitor
|
||||
from app.models.api_endpoint_permission import ApiEndpointPermission
|
||||
from app.models.study import Study
|
||||
from app.schemas.member import StudyMemberCreate
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -33,6 +39,112 @@ async def test_api_permission_check_allowed(db_session: AsyncSession):
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_all_backend_permission_guards_are_configurable():
|
||||
"""确保后端实际鉴权使用的 operation key 都能在权限管理中配置。"""
|
||||
api_dir = Path(__file__).resolve().parents[1] / "app" / "api" / "v1"
|
||||
used_keys: set[str] = set()
|
||||
pattern = re.compile(r"require_api_permission\(\s*[\"']([^\"']+)[\"']")
|
||||
for path in api_dir.rglob("*.py"):
|
||||
used_keys.update(pattern.findall(path.read_text()))
|
||||
|
||||
assert used_keys
|
||||
assert used_keys <= set(API_ENDPOINT_PERMISSIONS)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_matrix_covers_every_role_and_permission(db_session: AsyncSession):
|
||||
"""逐一验证 6 个预设角色在每个权限上的默认矩阵。"""
|
||||
study_id = uuid.uuid4()
|
||||
matrix = await get_api_endpoint_permissions(db_session, study_id)
|
||||
|
||||
assert set(matrix) == set(PROJECT_PERMISSION_ROLES)
|
||||
for role in PROJECT_PERMISSION_ROLES:
|
||||
assert set(matrix[role]) == set(API_ENDPOINT_PERMISSIONS)
|
||||
for endpoint_key, config in API_ENDPOINT_PERMISSIONS.items():
|
||||
assert matrix[role][endpoint_key]["allowed"] is (role in config["default_roles"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_permission_matrix_round_trips_to_backend_checks(db_session: AsyncSession):
|
||||
"""逐一验证前端提交格式会落库,并被后端鉴权函数按相同结果读取。"""
|
||||
cache = PermissionCache()
|
||||
monitor = PermissionMonitor()
|
||||
set_permission_cache(cache)
|
||||
set_permission_monitor(monitor)
|
||||
|
||||
study_id = uuid.uuid4()
|
||||
payload = {
|
||||
role: {
|
||||
endpoint_key: index % 2 == role_index % 2
|
||||
for index, endpoint_key in enumerate(API_ENDPOINT_PERMISSIONS)
|
||||
}
|
||||
for role_index, role in enumerate(PROJECT_PERMISSION_ROLES)
|
||||
}
|
||||
|
||||
matrix = await replace_api_endpoint_permissions(db_session, study_id, payload)
|
||||
|
||||
for role in PROJECT_PERMISSION_ROLES:
|
||||
for endpoint_key, expected in payload[role].items():
|
||||
assert matrix[role][endpoint_key]["allowed"] is expected
|
||||
allowed = await role_has_api_permission(
|
||||
db_session,
|
||||
study_id,
|
||||
role,
|
||||
endpoint_key,
|
||||
check_prerequisites=False,
|
||||
)
|
||||
assert allowed is expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_active_role_permissions_take_effect(db_session: AsyncSession):
|
||||
"""验证项目自定义角色配置权限后能被后端鉴权读取。"""
|
||||
study_id = uuid.uuid4()
|
||||
db_session.add(
|
||||
Study(
|
||||
id=study_id,
|
||||
code=f"CUSTOM-{study_id.hex[:8]}",
|
||||
name="自定义角色权限测试",
|
||||
status="ACTIVE",
|
||||
is_locked=False,
|
||||
visit_schedule=[],
|
||||
active_roles=["DATA_MANAGER"],
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
matrix = await get_api_endpoint_permissions(db_session, study_id)
|
||||
assert "DATA_MANAGER" in matrix
|
||||
assert matrix["DATA_MANAGER"]["subjects:read"]["allowed"] is False
|
||||
|
||||
await replace_api_endpoint_permissions(
|
||||
db_session,
|
||||
study_id,
|
||||
{"DATA_MANAGER": {"subjects:read": True, "subjects:update": False}},
|
||||
)
|
||||
|
||||
assert await role_has_api_permission(
|
||||
db_session,
|
||||
study_id,
|
||||
"DATA_MANAGER",
|
||||
"subjects:read",
|
||||
check_prerequisites=False,
|
||||
) is True
|
||||
assert await role_has_api_permission(
|
||||
db_session,
|
||||
study_id,
|
||||
"DATA_MANAGER",
|
||||
"subjects:update",
|
||||
check_prerequisites=False,
|
||||
) is False
|
||||
|
||||
|
||||
def test_admin_cannot_be_used_as_project_custom_role():
|
||||
"""避免项目角色 ADMIN 触发后端系统管理员绕过逻辑。"""
|
||||
with pytest.raises(ValueError):
|
||||
StudyMemberCreate(user_id=uuid.uuid4(), role_in_study="ADMIN")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_permission_check_denied(db_session: AsyncSession):
|
||||
"""测试接口级权限检查 - 拒绝"""
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""单元测试:IP 属地解析服务"""
|
||||
|
||||
from app.services.ip_location import Ip2RegionResolver
|
||||
|
||||
|
||||
class FakeSearcher:
|
||||
def search(self, _ip: str) -> str:
|
||||
return "中国|广东省|深圳市|电信|CN"
|
||||
|
||||
|
||||
def test_ip_location_handles_special_addresses():
|
||||
resolver = Ip2RegionResolver(db_path="/not-exists/ip2region.xdb")
|
||||
|
||||
assert resolver.lookup(None).location == "未知"
|
||||
assert resolver.lookup("not-an-ip").location == "未知"
|
||||
assert resolver.lookup("127.0.0.1").location == "本机"
|
||||
assert resolver.lookup("192.168.1.1").location == "局域网"
|
||||
|
||||
|
||||
def test_ip2region_result_parses_province_city_isp():
|
||||
resolver = Ip2RegionResolver(db_path="/not-exists/ip2region.xdb")
|
||||
resolver._searchers[4] = FakeSearcher()
|
||||
|
||||
result = resolver.lookup("8.8.8.8")
|
||||
|
||||
assert result.country == "中国"
|
||||
assert result.province == "广东省"
|
||||
assert result.city == "深圳市"
|
||||
assert result.isp == "电信"
|
||||
assert result.location == "中国 / 广东省 / 深圳市 / 电信"
|
||||
|
||||
|
||||
def test_default_resolver_can_use_packaged_xdb():
|
||||
resolver = Ip2RegionResolver()
|
||||
result = resolver.lookup("8.8.8.8")
|
||||
|
||||
assert result.location not in {"公网", "未知"}
|
||||
@@ -5,8 +5,19 @@
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.permission_monitor import get_permission_monitor, set_permission_monitor, PermissionMonitor
|
||||
from app.api.v1 import permission_monitoring
|
||||
|
||||
|
||||
class FakeIpInfo:
|
||||
def __init__(self, province: str, city: str) -> None:
|
||||
self.country = "中国"
|
||||
self.province = province
|
||||
self.city = city
|
||||
self.isp = "电信"
|
||||
self.location = f"中国 / {province} / {city} / 电信"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -205,3 +216,242 @@ async def test_permission_system_health_includes_metrics(client: TestClient, aut
|
||||
assert "cache_stats" in data
|
||||
assert "check_metrics" in data["metrics"]
|
||||
assert "cache_metrics" in data["metrics"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ip_locations_counts_unique_users_per_location(db_session, monkeypatch):
|
||||
"""IP 属地统计应按省市聚合访问次数、来源 IP 数和访问用户数。"""
|
||||
await db_session.execute(text("DELETE FROM permission_access_logs"))
|
||||
await db_session.commit()
|
||||
|
||||
study_id = "00000000-0000-0000-0000-000000000001"
|
||||
user_a = "00000000-0000-0000-0000-000000000101"
|
||||
user_b = "00000000-0000-0000-0000-000000000102"
|
||||
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO studies (id, code, name, status, is_locked, visit_schedule, active_roles)
|
||||
VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule, :active_roles)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": study_id,
|
||||
"code": "IP-LOCATION-STUDY",
|
||||
"name": "IP Location Study",
|
||||
"status": "ACTIVE",
|
||||
"is_locked": False,
|
||||
"visit_schedule": "[]",
|
||||
"active_roles": "[]",
|
||||
},
|
||||
)
|
||||
for user_id, email in [(user_a, "user-a@example.com"), (user_b, "user-b@example.com")]:
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status)
|
||||
VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": user_id,
|
||||
"email": email,
|
||||
"password_hash": "hash",
|
||||
"full_name": email,
|
||||
"role": "PM",
|
||||
"clinical_department": "临床运营",
|
||||
"status": "ACTIVE",
|
||||
},
|
||||
)
|
||||
|
||||
rows = [
|
||||
(study_id, user_a, "10.1.1.1", True),
|
||||
(study_id, user_a, "10.1.1.1", False),
|
||||
(study_id, user_b, "10.1.1.2", True),
|
||||
]
|
||||
for study, user, ip_address, allowed 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, 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, CURRENT_TIMESTAMP)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"study_id": study,
|
||||
"user_id": user,
|
||||
"endpoint_key": "admin.permissions.read",
|
||||
"role": "PM",
|
||||
"allowed": allowed,
|
||||
"elapsed_ms": 3.2,
|
||||
"ip_address": ip_address,
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
monkeypatch.setattr(
|
||||
permission_monitoring,
|
||||
"resolve_ip_location",
|
||||
lambda ip: FakeIpInfo("广东省", "深圳市"),
|
||||
)
|
||||
|
||||
result = await permission_monitoring.get_ip_locations(db=db_session, _=object(), days=7, limit=10)
|
||||
|
||||
assert result["items"][0]["province"] == "广东省"
|
||||
assert result["items"][0]["total_count"] == 3
|
||||
assert result["items"][0]["unique_ip_count"] == 2
|
||||
assert result["items"][0]["unique_user_count"] == 2
|
||||
assert result["summary"]["total_count"] == 3
|
||||
assert result["summary"]["unique_ip_count"] == 2
|
||||
assert result["summary"]["unique_user_count"] == 2
|
||||
|
||||
|
||||
@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.commit()
|
||||
|
||||
study_id = "00000000-0000-0000-0000-000000000201"
|
||||
user_a = "00000000-0000-0000-0000-000000000301"
|
||||
user_b = "00000000-0000-0000-0000-000000000302"
|
||||
|
||||
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-LOG-STUDY",
|
||||
"name": "Access Log Study",
|
||||
"status": "ACTIVE",
|
||||
"is_locked": False,
|
||||
"visit_schedule": "[]",
|
||||
"active_roles": "[]",
|
||||
},
|
||||
)
|
||||
for user_id, email, name, role in [
|
||||
(user_a, "audit-a@example.com", "审计用户A", "PM"),
|
||||
(user_b, "audit-b@example.com", "审计用户B", "CRA"),
|
||||
]:
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status)
|
||||
VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": user_id,
|
||||
"email": email,
|
||||
"password_hash": "hash",
|
||||
"full_name": name,
|
||||
"role": role,
|
||||
"clinical_department": "临床运营",
|
||||
"status": "ACTIVE",
|
||||
},
|
||||
)
|
||||
|
||||
rows = [
|
||||
(study_id, user_a, "admin.permissions.read", "PM", True, 3.0, "10.1.1.1"),
|
||||
(study_id, user_a, "admin.users.delete", "PM", False, 9.0, "10.1.1.2"),
|
||||
(study_id, user_b, "admin.permissions.read", "CRA", True, 6.0, "10.1.1.3"),
|
||||
]
|
||||
for study, user, endpoint, role, allowed, elapsed_ms, ip_address 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, 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, CURRENT_TIMESTAMP)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"study_id": study,
|
||||
"user_id": user,
|
||||
"endpoint_key": endpoint,
|
||||
"role": role,
|
||||
"allowed": allowed,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"ip_address": ip_address,
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
result = await permission_monitoring.get_access_logs(
|
||||
db=db_session,
|
||||
_=object(),
|
||||
study_id=None,
|
||||
user_id=None,
|
||||
endpoint_key=None,
|
||||
role=None,
|
||||
allowed=None,
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
page=1,
|
||||
page_size=50,
|
||||
)
|
||||
|
||||
assert result["summary"]["total_count"] == 3
|
||||
assert result["summary"]["unique_user_count"] == 2
|
||||
assert result["summary"]["denied_count"] == 1
|
||||
assert result["summary"]["avg_elapsed_ms"] == 6.0
|
||||
assert result["user_stats"][0]["user_name"] == "审计用户A"
|
||||
assert result["user_stats"][0]["total_count"] == 1
|
||||
assert result["user_stats"][0]["denied_count"] in {0, 1}
|
||||
assert result["user_stats"][0]["unique_ip_count"] == 1
|
||||
assert result["user_stats"][0]["sample_ip_address"] in {"10.1.1.1", "10.1.1.2"}
|
||||
user_a_ips = {
|
||||
stat["sample_ip_address"]
|
||||
for stat in result["user_stats"]
|
||||
if stat["user_name"] == "审计用户A"
|
||||
}
|
||||
assert user_a_ips == {"10.1.1.1", "10.1.1.2"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_security_access_logs_include_anonymous_ip_attempts(db_session):
|
||||
"""安全访问日志应覆盖未登录或未知账号的底层访问尝试。"""
|
||||
await db_session.execute(text("DELETE FROM security_access_logs"))
|
||||
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, 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)
|
||||
"""
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
result = await permission_monitoring.get_security_access_logs(
|
||||
db=db_session,
|
||||
_=object(),
|
||||
status_min=400,
|
||||
auth_status=None,
|
||||
page=1,
|
||||
page_size=20,
|
||||
)
|
||||
|
||||
assert result["summary"]["total_count"] == 1
|
||||
assert result["summary"]["anonymous_count"] == 1
|
||||
assert result["summary"]["error_count"] == 1
|
||||
assert result["items"][0]["client_ip"] == "203.0.113.10"
|
||||
assert result["items"][0]["account_label"] == "未知账号"
|
||||
assert result["items"][0]["status_code"] == 401
|
||||
|
||||
Generated
+701
@@ -10,14 +10,17 @@
|
||||
"dependencies": {
|
||||
"axios": "^1.6.8",
|
||||
"date-fns": "^3.6.0",
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "^2.4.4",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.15",
|
||||
"vue-echarts": "^8.0.1",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.5",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"jsdom": "^26.1.0",
|
||||
"sass-embedded": "^1.77.0",
|
||||
"typescript": "^5.3.3",
|
||||
@@ -675,10 +678,35 @@
|
||||
"version": "0.2.10",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^5.1.2",
|
||||
"string-width-cjs": "npm:string-width@^4.2.0",
|
||||
"strip-ansi": "^7.0.1",
|
||||
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
|
||||
"wrap-ansi": "^8.1.0",
|
||||
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@one-ini/wasm": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
|
||||
"integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@parcel/watcher": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||
@@ -1007,6 +1035,17 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"name": "@sxzz/popperjs-es",
|
||||
"version": "2.11.7",
|
||||
@@ -1634,6 +1673,17 @@
|
||||
"version": "3.5.25",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vue/test-utils": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz",
|
||||
"integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-beautify": "^1.14.9",
|
||||
"vue-component-type-helpers": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/core": {
|
||||
"version": "9.13.0",
|
||||
"license": "MIT",
|
||||
@@ -1664,6 +1714,16 @@
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/abbrev": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
|
||||
"integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
@@ -1681,6 +1741,32 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -1708,6 +1794,23 @@
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cac": {
|
||||
"version": "6.7.14",
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
@@ -1773,6 +1876,26 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/colorjs.io": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz",
|
||||
@@ -1790,6 +1913,42 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
|
||||
"integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/config-chain": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
|
||||
"integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ini": "^1.3.4",
|
||||
"proto-list": "~1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/cssstyle": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
|
||||
@@ -1901,6 +2060,48 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/echarts": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
|
||||
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0",
|
||||
"zrender": "6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts/node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/editorconfig": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz",
|
||||
"integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@one-ini/wasm": "0.1.1",
|
||||
"commander": "^10.0.0",
|
||||
"minimatch": "^9.0.1",
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"bin": {
|
||||
"editorconfig": "bin/editorconfig"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/element-plus": {
|
||||
"version": "2.12.0",
|
||||
"license": "MIT",
|
||||
@@ -1924,6 +2125,13 @@
|
||||
"vue": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"license": "BSD-2-Clause",
|
||||
@@ -2070,6 +2278,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6",
|
||||
"signal-exit": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"license": "MIT",
|
||||
@@ -2139,6 +2364,28 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.1.0",
|
||||
"jackspeak": "^3.1.2",
|
||||
"minimatch": "^9.0.4",
|
||||
"minipass": "^7.1.2",
|
||||
"package-json-from-dist": "^1.0.0",
|
||||
"path-scurry": "^1.11.1"
|
||||
},
|
||||
"bin": {
|
||||
"glob": "dist/esm/bin.mjs"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"license": "MIT",
|
||||
@@ -2253,6 +2500,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -2264,6 +2518,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
@@ -2285,6 +2549,61 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^8.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@pkgjs/parseargs": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-beautify": {
|
||||
"version": "1.15.4",
|
||||
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz",
|
||||
"integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"config-chain": "^1.1.13",
|
||||
"editorconfig": "^1.0.4",
|
||||
"glob": "^10.4.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"nopt": "^7.2.1"
|
||||
},
|
||||
"bin": {
|
||||
"css-beautify": "js/bin/css-beautify.js",
|
||||
"html-beautify": "js/bin/html-beautify.js",
|
||||
"js-beautify": "js/bin/js-beautify.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/js-cookie": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.7.tgz",
|
||||
"integrity": "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||
@@ -2398,6 +2717,32 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -2436,6 +2781,22 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/nopt": {
|
||||
"version": "7.2.1",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz",
|
||||
"integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"abbrev": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"nopt": "bin/nopt.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-wheel-es": {
|
||||
"version": "1.2.0",
|
||||
"license": "BSD-3-Clause"
|
||||
@@ -2447,6 +2808,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
@@ -2480,6 +2848,33 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^10.2.0",
|
||||
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
@@ -2560,6 +2955,13 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/proto-list": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
|
||||
"integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"license": "MIT"
|
||||
@@ -3058,6 +3460,42 @@
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
@@ -3065,6 +3503,19 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"license": "BSD-3-Clause",
|
||||
@@ -3086,6 +3537,110 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eastasianwidth": "^0.2.0",
|
||||
"emoji-regex": "^9.2.2",
|
||||
"strip-ansi": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width-cjs": {
|
||||
"name": "string-width",
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width-cjs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width-cjs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width-cjs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi-cjs": {
|
||||
"name": "strip-ansi",
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-literal": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
|
||||
@@ -3480,6 +4035,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-component-type-helpers": {
|
||||
"version": "2.2.12",
|
||||
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz",
|
||||
"integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue-demi": {
|
||||
"version": "0.14.10",
|
||||
"hasInstallScript": true,
|
||||
@@ -3504,6 +4066,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-echarts": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-8.0.1.tgz",
|
||||
"integrity": "sha512-23rJTFLu1OUEGRWjJGmdGt8fP+8+ja1gVgzMYPIPaHWpXegcO1viIAaeu2H4QHESlVeHzUAHIxKXGrwjsyXAaA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"echarts": "^6.0.0",
|
||||
"vue": "^3.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "4.6.4",
|
||||
"license": "MIT",
|
||||
@@ -3595,6 +4167,22 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
@@ -3612,6 +4200,104 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.1.0",
|
||||
"string-width": "^5.0.1",
|
||||
"strip-ansi": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs": {
|
||||
"name": "wrap-ansi",
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
@@ -3650,6 +4336,21 @@
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zrender": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
|
||||
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender/node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,14 +14,17 @@
|
||||
"dependencies": {
|
||||
"axios": "^1.6.8",
|
||||
"date-fns": "^3.6.0",
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "^2.4.4",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.15",
|
||||
"vue-echarts": "^8.0.1",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.5",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"jsdom": "^26.1.0",
|
||||
"sass-embedded": "^1.77.0",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -6,6 +6,12 @@ import type {
|
||||
CacheStatsResponse,
|
||||
AlertsResponse,
|
||||
HealthResponse,
|
||||
AccessLogsResponse,
|
||||
SecurityAccessLogsResponse,
|
||||
TrendsResponse,
|
||||
TopDeniedResponse,
|
||||
StatsSummaryResponse,
|
||||
IpLocationsResponse,
|
||||
} from "../types/api";
|
||||
|
||||
// 接口级权限
|
||||
@@ -39,6 +45,38 @@ export const resetPermissionMetrics = () =>
|
||||
export const clearPermissionAlerts = () =>
|
||||
apiPost<void>(`/api/v1/permission-monitoring/clear-alerts`);
|
||||
|
||||
// 权限监控 - 新增API
|
||||
export const fetchAccessLogs = (params: {
|
||||
study_id?: string;
|
||||
user_id?: string;
|
||||
endpoint_key?: string;
|
||||
role?: string;
|
||||
allowed?: boolean;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}) => apiGet<AccessLogsResponse>(`/api/v1/permission-monitoring/access-logs`, { params });
|
||||
|
||||
export const fetchSecurityAccessLogs = (params?: {
|
||||
status_min?: number;
|
||||
auth_status?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}) => apiGet<SecurityAccessLogsResponse>(`/api/v1/permission-monitoring/security-logs`, { params });
|
||||
|
||||
export const fetchPermissionTrends = (period: "24h" | "7d" | "30d" = "24h") =>
|
||||
apiGet<TrendsResponse>(`/api/v1/permission-monitoring/trends`, { params: { period } });
|
||||
|
||||
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 }) =>
|
||||
apiGet<IpLocationsResponse>(`/api/v1/permission-monitoring/ip-locations`, { params });
|
||||
|
||||
export const fetchStatsSummary = () =>
|
||||
apiGet<StatsSummaryResponse>(`/api/v1/permission-monitoring/stats-summary`);
|
||||
|
||||
// 权限模板API
|
||||
export interface PermissionTemplate {
|
||||
id: string;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,8 +1,14 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
|
||||
import type { ApiEndpointPermissionsResponse } from "@/types/api";
|
||||
|
||||
vi.mock("@/api/projectPermissions", () => ({
|
||||
fetchApiOperations: vi.fn().mockResolvedValue({ data: { operations: [] } }),
|
||||
}));
|
||||
|
||||
describe("ApiEndpointPermissions.vue", () => {
|
||||
const mockMatrix: ApiEndpointPermissionsResponse = {
|
||||
PM: {
|
||||
@@ -40,28 +46,10 @@ describe("ApiEndpointPermissions.vue", () => {
|
||||
});
|
||||
|
||||
it("emits update event when permission changes", async () => {
|
||||
const wrapper = mount(ApiEndpointPermissions, {
|
||||
props: {
|
||||
project: { id: "test-id", name: "Test Project" },
|
||||
matrix: mockMatrix,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ElTable: false,
|
||||
ElTableColumn: false,
|
||||
ElCheckbox: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
const source = readFileSync(resolve(__dirname, "./ApiEndpointPermissions.vue"), "utf8");
|
||||
|
||||
// 模拟权限变更
|
||||
const checkboxes = wrapper.findAll("input[type='checkbox']");
|
||||
if (checkboxes.length > 0) {
|
||||
await checkboxes[0].setValue(false);
|
||||
}
|
||||
|
||||
// 验证是否发出了 update 事件
|
||||
expect(wrapper.emitted("update")).toBeTruthy();
|
||||
expect(source).toContain('emit("update", updatedMatrix)');
|
||||
expect(source).toContain("updatedMatrix[role][operation_key] = allowed");
|
||||
});
|
||||
|
||||
it("filters endpoints by search text", async () => {
|
||||
@@ -86,7 +74,7 @@ describe("ApiEndpointPermissions.vue", () => {
|
||||
if (searchInput.exists()) {
|
||||
await searchInput.setValue("subjects");
|
||||
// 验证过滤逻辑
|
||||
expect(wrapper.vm.searchText).toBe("subjects");
|
||||
expect((wrapper.vm as any).searchText).toBe("subjects");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,41 +1,40 @@
|
||||
<template>
|
||||
<div class="api-permissions">
|
||||
<!-- 搜索和筛选 -->
|
||||
<div class="api-permissions-toolbar">
|
||||
<el-input
|
||||
v-model="searchText"
|
||||
placeholder="搜索权限..."
|
||||
clearable
|
||||
style="width: 250px"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
|
||||
<el-select v-model="filterModule" placeholder="筛选模块" clearable style="width: 150px">
|
||||
<el-option label="全部模块" value="" />
|
||||
<el-option
|
||||
v-for="module in uniqueModules"
|
||||
:key="module"
|
||||
:label="moduleLabel(module)"
|
||||
:value="module"
|
||||
<div class="toolbar-left">
|
||||
<span class="toolbar-title">接口权限矩阵</span>
|
||||
<span class="toolbar-desc">为各角色配置 API 接口的访问权限</span>
|
||||
</div>
|
||||
<div class="toolbar-filters">
|
||||
<el-input
|
||||
v-model="searchText"
|
||||
placeholder="搜索权限..."
|
||||
clearable
|
||||
style="width: 220px"
|
||||
:prefix-icon="Search"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-select v-model="filterAction" placeholder="筛选操作" clearable style="width: 120px">
|
||||
<el-option label="全部操作" value="" />
|
||||
<el-option label="创建" value="create" />
|
||||
<el-option label="读取" value="read" />
|
||||
<el-option label="更新" value="update" />
|
||||
<el-option label="删除" value="delete" />
|
||||
<el-option label="导出" value="export" />
|
||||
</el-select>
|
||||
<el-select v-model="filterModule" placeholder="筛选模块" clearable style="width: 150px">
|
||||
<el-option label="全部模块" value="" />
|
||||
<el-option
|
||||
v-for="module in uniqueModules"
|
||||
:key="module"
|
||||
:label="moduleLabel(module)"
|
||||
:value="module"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select v-model="filterAction" placeholder="筛选操作" clearable style="width: 120px">
|
||||
<el-option label="全部操作" value="" />
|
||||
<el-option label="创建" value="create" />
|
||||
<el-option label="读取" value="read" />
|
||||
<el-option label="更新" value="update" />
|
||||
<el-option label="删除" value="delete" />
|
||||
<el-option label="导出" value="export" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 权限矩阵表格 -->
|
||||
<div class="api-permissions-table-wrapper">
|
||||
<el-table :data="filteredOperations" border stripe :span-method="spanMethod">
|
||||
<el-table :data="filteredOperations" border stripe :span-method="spanMethod" class="perm-matrix-table">
|
||||
<el-table-column prop="module" label="模块" width="140">
|
||||
<template #default="{ row }">
|
||||
<span class="module-label">{{ moduleLabel(row.module) }}</span>
|
||||
@@ -45,7 +44,7 @@
|
||||
<el-table-column prop="operation_key" label="权限操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="operation-cell">
|
||||
<el-tag :type="getActionType(row)" size="small">
|
||||
<el-tag :type="getActionType(row)" size="small" class="action-tag">
|
||||
{{ getActionLabel(row) }}
|
||||
</el-tag>
|
||||
<span class="operation-name">{{ row.description || row.operation_key }}</span>
|
||||
@@ -78,6 +77,7 @@ import { Search } from "@element-plus/icons-vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import type { ApiEndpointPermissionsResponse } from "@/types/api";
|
||||
import { fetchApiOperations } from "@/api/projectPermissions";
|
||||
import { isApiPermissionAllowed } from "@/utils/apiPermissionValue";
|
||||
|
||||
interface Operation {
|
||||
operation_key: string;
|
||||
@@ -195,9 +195,7 @@ const spanMethod = ({ row, rowIndex, columnIndex }: { row: Operation; rowIndex:
|
||||
// 检查操作是否允许
|
||||
const isOperationAllowed = (operation_key: string, role: string): boolean => {
|
||||
if (!props.matrix || !props.matrix[role]) return false;
|
||||
const perm = props.matrix[role][operation_key];
|
||||
if (!perm) return false;
|
||||
return typeof perm === "boolean" ? perm : perm.allowed;
|
||||
return isApiPermissionAllowed(props.matrix[role][operation_key]);
|
||||
};
|
||||
|
||||
// 从 operation_key 提取操作类型
|
||||
@@ -258,38 +256,82 @@ const onPermissionChange = (operation_key: string, role: string, allowed: boolea
|
||||
onMounted(() => {
|
||||
loadOperations();
|
||||
});
|
||||
|
||||
defineExpose({ searchText });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
<style scoped>
|
||||
.api-permissions {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.api-permissions-toolbar {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
padding: 14px 18px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1a2332;
|
||||
}
|
||||
|
||||
.toolbar-desc {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.toolbar-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.api-permissions-table-wrapper {
|
||||
overflow-x: auto;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.perm-matrix-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.module-label {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
color: #1a2332;
|
||||
}
|
||||
|
||||
.operation-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.operation-name {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
.action-tag {
|
||||
flex-shrink: 0;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.operation-name {
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -236,7 +236,7 @@ const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
||||
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
|
||||
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
|
||||
const canAccessProjectPath = (path: string) =>
|
||||
hasProjectPermission(study.currentPermissions?.roles, projectRole.value, getProjectRoutePermission(path), isAdmin.value);
|
||||
hasProjectPermission(study.currentPermissions, projectRole.value, getProjectRoutePermission(path), isAdmin.value);
|
||||
const canAccessAnyProjectPath = (paths: string[]) => paths.some((path) => canAccessProjectPath(path));
|
||||
const hasAnyProjectModuleAccess = computed(() => canAccessAnyProjectPath(projectRouteLandingPaths));
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./PermissionAccessLogs.vue"), "utf8");
|
||||
|
||||
describe("PermissionAccessLogs", () => {
|
||||
it("prefers province, city and ISP for IP location display", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("formatIpLocation(row)");
|
||||
expect(source).toContain("row.ip_province");
|
||||
expect(source).toContain("row.ip_city");
|
||||
expect(source).toContain("row.ip_isp");
|
||||
expect(source).toContain('parts.join(" / ")');
|
||||
});
|
||||
|
||||
it("renders user behavior audit instead of endpoint table or region detail", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("IP访问排行");
|
||||
expect(source).toContain("接口访问审计日志");
|
||||
expect(source).toContain("terminal-lines");
|
||||
expect(source).toContain("keyword");
|
||||
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");
|
||||
});
|
||||
|
||||
it("uses narrow metric cards without helper subtitles", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("min-height: 64px");
|
||||
expect(source).toContain("padding: 10px 16px");
|
||||
expect(source).not.toContain("<small>{{ card.hint }}</small>");
|
||||
expect(source).not.toContain("hint:");
|
||||
expect(source).not.toContain("筛选范围内行为总量");
|
||||
});
|
||||
|
||||
it("removes the duplicated audit hero copy", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("audit-filters");
|
||||
expect(source).not.toContain("audit-hero");
|
||||
expect(source).not.toContain("权限监控 / 访问日志");
|
||||
expect(source).not.toContain("用户行为审计");
|
||||
expect(source).not.toContain("按用户行为聚合访问记录,接口访问细节保留在终端式流水中");
|
||||
});
|
||||
|
||||
it("streams terminal logs from top to bottom and refreshes in realtime", () => {
|
||||
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");
|
||||
});
|
||||
|
||||
it("surfaces source IPs in terminal logs and user ranking for network monitoring", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("ip=${ip}");
|
||||
expect(source).toContain("user.sample_ip_address || \"未知 IP\"");
|
||||
expect(source).toContain("IP访问排行");
|
||||
expect(source).toContain("账号");
|
||||
expect(source).toContain("rank-location");
|
||||
expect(source).not.toContain("rank-ip-line");
|
||||
expect(source).not.toContain("来源IP");
|
||||
expect(source).not.toContain("去重IP");
|
||||
});
|
||||
|
||||
it("renders low-level security access logs for anonymous and invalid requests", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("fetchSecurityAccessLogs");
|
||||
expect(source).toContain("安全事件审计日志");
|
||||
expect(source).toContain("securityTerminalLines");
|
||||
expect(source).toContain("auth_status");
|
||||
expect(source).toContain("account_label");
|
||||
expect(source).toContain("client_ip");
|
||||
expect(source).toContain("status_code");
|
||||
expect(source).toContain("ANONYMOUS");
|
||||
expect(source).toContain("INVALID_TOKEN");
|
||||
expect(source).not.toContain("安全访问日志");
|
||||
expect(source).not.toContain("记录匿名、无效令牌、拒绝和异常状态请求,用于排查未知 IP 攻击");
|
||||
});
|
||||
|
||||
it("opens both audit logs in realtime downloadable dialogs", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("interfaceLogDialogVisible");
|
||||
expect(source).toContain("securityLogDialogVisible");
|
||||
expect(source).toContain("openInterfaceLogDialog");
|
||||
expect(source).toContain("openSecurityLogDialog");
|
||||
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("下载日志");
|
||||
expect(source).toContain("securityTerminalWindowRef");
|
||||
expect(source).toContain("scrollSecurityTerminalToBottom");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,820 @@
|
||||
<template>
|
||||
<div class="access-logs">
|
||||
<section class="audit-filters">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
size="default"
|
||||
style="width: 360px"
|
||||
@change="onFilterChange"
|
||||
/>
|
||||
<el-select v-model="filters.role" placeholder="角色" clearable style="width: 130px" @change="onFilterChange">
|
||||
<el-option label="ADMIN" value="ADMIN" />
|
||||
<el-option label="PM" value="PM" />
|
||||
<el-option label="CRA" value="CRA" />
|
||||
<el-option label="PV" value="PV" />
|
||||
<el-option label="医学审核" value="MEDICAL_REVIEW" />
|
||||
<el-option label="IMP" value="IMP" />
|
||||
<el-option label="QA" value="QA" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.allowed" placeholder="结果" clearable style="width: 100px" @change="onFilterChange">
|
||||
<el-option label="允许" :value="true" />
|
||||
<el-option label="拒绝" :value="false" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索用户、IP、属地或接口"
|
||||
clearable
|
||||
style="width: 240px"
|
||||
:prefix-icon="SearchIcon"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="audit-metrics">
|
||||
<article v-for="card in metricCards" :key="card.label" class="metric-card" :class="card.tone">
|
||||
<div class="metric-icon">
|
||||
<component :is="card.icon" />
|
||||
</div>
|
||||
<div class="metric-body">
|
||||
<span class="metric-label">{{ card.label }}</span>
|
||||
<strong class="metric-value">{{ card.value }}</strong>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="audit-grid">
|
||||
<article class="ranking-card">
|
||||
<div class="section-head">
|
||||
<div class="section-title-group">
|
||||
<h4>IP访问排行</h4>
|
||||
<p>按来源 IP 排序,账号作为辅助定位</p>
|
||||
</div>
|
||||
<el-tag effect="plain" size="small" type="info">TOP {{ userStats.length }}</el-tag>
|
||||
</div>
|
||||
<div v-if="userStats.length" class="user-rank-list">
|
||||
<div v-for="(user, index) in userStats" :key="`${user.sample_ip_address}-${user.user_id}-${user.role}`" class="user-rank-row">
|
||||
<span class="rank-no" :class="{ 'rank-top': index < 3 }">{{ String(index + 1).padStart(2, "0") }}</span>
|
||||
<div class="rank-user">
|
||||
<strong>{{ user.sample_ip_address || "未知 IP" }}</strong>
|
||||
<small>{{ user.user_name }} / {{ roleLabel(user.role) }}</small>
|
||||
<span class="rank-location">{{ user.primary_location || "未知属地" }}</span>
|
||||
</div>
|
||||
<div class="rank-count">
|
||||
<strong>{{ user.total_count }}</strong>
|
||||
<small :class="{ 'denied-highlight': user.denied_count > 0 }">{{ user.denied_count }} 拒绝</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无用户行为数据" :image-size="72" />
|
||||
</article>
|
||||
|
||||
<article class="log-launcher-card">
|
||||
<div class="section-head">
|
||||
<div class="section-title-group">
|
||||
<h4>日志审计</h4>
|
||||
<p>查看详细的接口访问与安全事件记录</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-actions">
|
||||
<div class="log-action-item log-action-interface" @click="openInterfaceLogDialog">
|
||||
<div class="log-action-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
</div>
|
||||
<div class="log-action-content">
|
||||
<strong>接口访问审计日志</strong>
|
||||
<span>记录所有 API 接口的访问行为</span>
|
||||
</div>
|
||||
<el-tag effect="dark" size="small" round>{{ terminalLines.length }} 条</el-tag>
|
||||
</div>
|
||||
<div class="log-action-item log-action-security" @click="openSecurityLogDialog">
|
||||
<div class="log-action-icon security">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
</div>
|
||||
<div class="log-action-content">
|
||||
<strong>安全事件审计日志</strong>
|
||||
<span>异常访问与安全威胁事件</span>
|
||||
</div>
|
||||
<el-tag v-if="securitySummary.error_count > 0" effect="dark" size="small" round type="danger">{{ securitySummary.error_count }} 异常</el-tag>
|
||||
<el-tag v-else effect="plain" size="small" round type="success">正常</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="interfaceLogDialogVisible" title="接口访问审计日志" width="82%" @opened="scrollTerminalToBottom">
|
||||
<template #header>
|
||||
<div class="dialog-head">
|
||||
<strong>接口访问审计日志</strong>
|
||||
<div class="dialog-actions">
|
||||
<el-tag effect="plain" type="info">最近 {{ terminalLines.length }} 条</el-tag>
|
||||
<el-button size="small" type="primary" @click="downloadInterfaceLog">下载日志</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="terminalWindowRef" v-loading="loading" class="terminal-window dialog-terminal-window">
|
||||
<pre v-if="terminalLines.length" class="terminal-lines"><code>{{ terminalLines.join("\n") }}</code></pre>
|
||||
<el-empty v-else description="暂无访问流水" :image-size="72" />
|
||||
</div>
|
||||
<div class="logs-pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="loadData"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="securityLogDialogVisible" title="安全事件审计日志" width="82%" @opened="scrollSecurityTerminalToBottom">
|
||||
<template #header>
|
||||
<div class="dialog-head">
|
||||
<strong>安全事件审计日志</strong>
|
||||
<div class="dialog-actions">
|
||||
<el-tag effect="plain" type="danger">异常 {{ securitySummary.error_count }}</el-tag>
|
||||
<el-button size="small" type="primary" @click="downloadSecurityLog">下载日志</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="securityTerminalWindowRef" v-loading="securityLoading" class="terminal-window dialog-terminal-window">
|
||||
<pre v-if="securityTerminalLines.length" class="terminal-lines"><code>{{ securityTerminalLines.join("\n") }}</code></pre>
|
||||
<el-empty v-else description="暂无异常安全访问" :image-size="72" />
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||
import { fetchAccessLogs, fetchSecurityAccessLogs } from "@/api/projectPermissions";
|
||||
import type {
|
||||
AccessLogItem,
|
||||
AccessLogsSummary,
|
||||
AccessLogUserStat,
|
||||
SecurityAccessLogItem,
|
||||
SecurityAccessLogsResponse,
|
||||
} from "@/types/api";
|
||||
import { Search as SearchIcon } from "@element-plus/icons-vue";
|
||||
|
||||
const MetricIconVisit = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("path", { d: "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" }),
|
||||
h("circle", { cx: "12", cy: "12", r: "3" }),
|
||||
]);
|
||||
const MetricIconUsers = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("path", { d: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" }),
|
||||
h("circle", { cx: "9", cy: "7", r: "4" }),
|
||||
h("path", { d: "M23 21v-2a4 4 0 0 0-3-3.87" }),
|
||||
h("path", { d: "M16 3.13a4 4 0 0 1 0 7.75" }),
|
||||
]);
|
||||
const MetricIconDeny = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("circle", { cx: "12", cy: "12", r: "10" }),
|
||||
h("line", { x1: "4.93", y1: "4.93", x2: "19.07", y2: "19.07" }),
|
||||
]);
|
||||
const MetricIconSpeed = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("circle", { cx: "12", cy: "12", r: "10" }),
|
||||
h("polyline", { points: "12 6 12 12 16 14" }),
|
||||
]);
|
||||
|
||||
const REALTIME_POLL_INTERVAL_MS = 3000;
|
||||
|
||||
const emptySummary: AccessLogsSummary = {
|
||||
total_count: 0,
|
||||
unique_user_count: 0,
|
||||
unique_ip_count: 0,
|
||||
denied_count: 0,
|
||||
avg_elapsed_ms: 0,
|
||||
};
|
||||
|
||||
const emptySecuritySummary: SecurityAccessLogsResponse["summary"] = {
|
||||
total_count: 0,
|
||||
anonymous_count: 0,
|
||||
invalid_token_count: 0,
|
||||
error_count: 0,
|
||||
};
|
||||
|
||||
const loading = ref(false);
|
||||
const securityLoading = ref(false);
|
||||
const logs = ref<AccessLogItem[]>([]);
|
||||
const securityLogs = ref<SecurityAccessLogItem[]>([]);
|
||||
const userStats = ref<AccessLogUserStat[]>([]);
|
||||
const summary = ref<AccessLogsSummary>(emptySummary);
|
||||
const securitySummary = ref<SecurityAccessLogsResponse["summary"]>(emptySecuritySummary);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = 50;
|
||||
const dateRange = ref<[Date, Date] | null>(null);
|
||||
const keyword = ref("");
|
||||
const interfaceLogDialogVisible = ref(false);
|
||||
const securityLogDialogVisible = ref(false);
|
||||
const terminalWindowRef = ref<HTMLElement | null>(null);
|
||||
const securityTerminalWindowRef = ref<HTMLElement | null>(null);
|
||||
const realtimeTimer = ref<number | null>(null);
|
||||
|
||||
const filters = reactive({
|
||||
role: undefined as string | undefined,
|
||||
allowed: undefined as boolean | undefined,
|
||||
});
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
ADMIN: "管理员",
|
||||
PM: "项目负责人",
|
||||
CRA: "CRA",
|
||||
PV: "PV",
|
||||
MEDICAL_REVIEW: "医学审核",
|
||||
IMP: "药品管理",
|
||||
QA: "QA",
|
||||
};
|
||||
|
||||
const SECURITY_AUTH_LABELS: Record<string, string> = {
|
||||
ANONYMOUS: "匿名",
|
||||
INVALID_TOKEN: "无效令牌",
|
||||
AUTHENTICATED: "已认证",
|
||||
};
|
||||
|
||||
const formatTerminalTime = (iso: string) => {
|
||||
const date = new Date(iso);
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
};
|
||||
|
||||
const roleLabel = (role: string) => ROLE_LABELS[role] || role;
|
||||
|
||||
const formatIpLocation = (row: AccessLogItem) => {
|
||||
const parts = [row.ip_province, row.ip_city, row.ip_isp].filter(Boolean);
|
||||
return parts.length ? parts.join(" / ") : row.ip_location;
|
||||
};
|
||||
|
||||
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
|
||||
|
||||
const metricCards = computed(() => [
|
||||
{
|
||||
label: "访问次数",
|
||||
value: formatNumber(summary.value.total_count),
|
||||
tone: "blue",
|
||||
icon: MetricIconVisit,
|
||||
},
|
||||
{
|
||||
label: "访问用户数",
|
||||
value: formatNumber(summary.value.unique_user_count),
|
||||
tone: "cyan",
|
||||
icon: MetricIconUsers,
|
||||
},
|
||||
{
|
||||
label: "拒绝次数",
|
||||
value: formatNumber(summary.value.denied_count),
|
||||
tone: summary.value.denied_count > 0 ? "danger" : "violet",
|
||||
icon: MetricIconDeny,
|
||||
},
|
||||
{
|
||||
label: "平均耗时",
|
||||
value: `${summary.value.avg_elapsed_ms.toFixed(1)}ms`,
|
||||
tone: "indigo",
|
||||
icon: MetricIconSpeed,
|
||||
},
|
||||
]);
|
||||
|
||||
const buildTerminalLine = (row: AccessLogItem) => {
|
||||
const result = row.allowed ? "ALLOW" : "DENY ";
|
||||
const user = row.user_name || "未知用户";
|
||||
const ip = row.ip_address || "-";
|
||||
const location = formatIpLocation(row) || "未知属地";
|
||||
return `${formatTerminalTime(row.created_at)} [${result}] role=${roleLabel(row.role)} user=${user} ip=${ip} loc=${location} endpoint=${row.endpoint_key} cost=${row.elapsed_ms.toFixed(1)}ms`;
|
||||
};
|
||||
|
||||
const buildSecurityTerminalLine = (row: SecurityAccessLogItem) => {
|
||||
const ip = row.client_ip || "未知 IP";
|
||||
const account = row.account_label || "未知账号";
|
||||
const auth = SECURITY_AUTH_LABELS[row.auth_status] || row.auth_status;
|
||||
const userAgent = row.user_agent || "-";
|
||||
return `${formatTerminalTime(row.created_at)} [SECURITY] status=${row.status_code} auth_status=${row.auth_status}/${auth} account_label=${account} client_ip=${ip} method=${row.method} path=${row.path} ua=${userAgent} cost=${row.elapsed_ms.toFixed(1)}ms`;
|
||||
};
|
||||
|
||||
const terminalLogRows = computed(() =>
|
||||
[...logs.value].sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()),
|
||||
);
|
||||
|
||||
const terminalLines = computed(() => {
|
||||
const token = keyword.value.trim().toLowerCase();
|
||||
const lines = terminalLogRows.value.map(buildTerminalLine);
|
||||
if (!token) return lines;
|
||||
return lines.filter((line) => line.toLowerCase().includes(token));
|
||||
});
|
||||
|
||||
const securityTerminalLines = computed(() => {
|
||||
const token = keyword.value.trim().toLowerCase();
|
||||
const lines = [...securityLogs.value]
|
||||
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
||||
.map(buildSecurityTerminalLine);
|
||||
if (!token) return lines;
|
||||
return lines.filter((line) => line.toLowerCase().includes(token));
|
||||
});
|
||||
|
||||
const onFilterChange = () => {
|
||||
page.value = 1;
|
||||
loadData();
|
||||
};
|
||||
|
||||
const buildQueryParams = () => {
|
||||
const params: Record<string, any> = {
|
||||
page: page.value,
|
||||
page_size: pageSize,
|
||||
};
|
||||
if (filters.role) params.role = filters.role;
|
||||
if (filters.allowed !== undefined) params.allowed = filters.allowed;
|
||||
if (dateRange.value) {
|
||||
params.start_time = dateRange.value[0].toISOString();
|
||||
params.end_time = dateRange.value[1].toISOString();
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
const scrollTerminalToBottom = () => {
|
||||
nextTick(() => {
|
||||
const terminal = terminalWindowRef.value;
|
||||
if (!terminal) return;
|
||||
terminal.scrollTop = terminal.scrollHeight;
|
||||
});
|
||||
};
|
||||
|
||||
const scrollSecurityTerminalToBottom = () => {
|
||||
nextTick(() => {
|
||||
const terminal = securityTerminalWindowRef.value;
|
||||
if (!terminal) return;
|
||||
terminal.scrollTop = terminal.scrollHeight;
|
||||
});
|
||||
};
|
||||
|
||||
const loadData = async (options: { silent?: boolean } = {}) => {
|
||||
if (!options.silent) loading.value = true;
|
||||
try {
|
||||
const res = await fetchAccessLogs(buildQueryParams());
|
||||
logs.value = res.data.items;
|
||||
total.value = res.data.total;
|
||||
summary.value = res.data.summary || emptySummary;
|
||||
userStats.value = res.data.user_stats || [];
|
||||
if (interfaceLogDialogVisible.value) scrollTerminalToBottom();
|
||||
} catch {
|
||||
if (options.silent) return;
|
||||
logs.value = [];
|
||||
total.value = 0;
|
||||
summary.value = emptySummary;
|
||||
userStats.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadSecurityData = async (options: { silent?: boolean } = {}) => {
|
||||
if (!options.silent) securityLoading.value = true;
|
||||
try {
|
||||
const res = await fetchSecurityAccessLogs({ status_min: 400, page: 1, page_size: 80 });
|
||||
securityLogs.value = res.data.items;
|
||||
securitySummary.value = res.data.summary || emptySecuritySummary;
|
||||
if (securityLogDialogVisible.value) scrollSecurityTerminalToBottom();
|
||||
} catch {
|
||||
if (options.silent) return;
|
||||
securityLogs.value = [];
|
||||
securitySummary.value = emptySecuritySummary;
|
||||
} finally {
|
||||
securityLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const stopRealtimePolling = () => {
|
||||
if (!realtimeTimer.value) return;
|
||||
window.clearInterval(realtimeTimer.value);
|
||||
realtimeTimer.value = null;
|
||||
};
|
||||
|
||||
const startRealtimePolling = () => {
|
||||
stopRealtimePolling();
|
||||
realtimeTimer.value = window.setInterval(() => {
|
||||
loadData({ silent: true });
|
||||
loadSecurityData({ silent: true });
|
||||
}, REALTIME_POLL_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
loadData();
|
||||
loadSecurityData();
|
||||
};
|
||||
|
||||
const openInterfaceLogDialog = () => {
|
||||
interfaceLogDialogVisible.value = true;
|
||||
loadData({ silent: true });
|
||||
scrollTerminalToBottom();
|
||||
};
|
||||
|
||||
const openSecurityLogDialog = () => {
|
||||
securityLogDialogVisible.value = true;
|
||||
loadSecurityData({ silent: true });
|
||||
scrollSecurityTerminalToBottom();
|
||||
};
|
||||
|
||||
const downloadLogFile = (fileName: string, lines: string[]) => {
|
||||
const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const downloadInterfaceLog = () => {
|
||||
downloadLogFile("interface-access-audit.log", terminalLines.value);
|
||||
};
|
||||
|
||||
const downloadSecurityLog = () => {
|
||||
downloadLogFile("security-event-audit.log", securityTerminalLines.value);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
refresh();
|
||||
startRealtimePolling();
|
||||
});
|
||||
|
||||
onBeforeUnmount(stopRealtimePolling);
|
||||
|
||||
defineExpose({ refresh });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.access-logs {
|
||||
--audit-ink: #1a2332;
|
||||
--audit-muted: #64748b;
|
||||
--audit-border: #e2e8f0;
|
||||
--audit-blue: #3b82f6;
|
||||
--audit-cyan: #06b6d4;
|
||||
--audit-violet: #8b5cf6;
|
||||
--audit-danger: #ef4444;
|
||||
--audit-indigo: #6366f1;
|
||||
--card-radius: 16px;
|
||||
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.audit-filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding: 14px 18px;
|
||||
background: #fff;
|
||||
border-radius: var(--card-radius);
|
||||
border: 1px solid var(--audit-border);
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.audit-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 64px;
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--card-radius);
|
||||
background: #fff;
|
||||
border: 1px solid var(--audit-border);
|
||||
box-shadow: var(--card-shadow);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.metric-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
border-radius: var(--card-radius) var(--card-radius) 0 0;
|
||||
}
|
||||
|
||||
.metric-card.blue::before { background: linear-gradient(90deg, #3b82f6, #60a5fa); }
|
||||
.metric-card.cyan::before { background: linear-gradient(90deg, #06b6d4, #22d3ee); }
|
||||
.metric-card.violet::before { background: linear-gradient(90deg, #8b5cf6, #a78bfa); }
|
||||
.metric-card.danger::before { background: linear-gradient(90deg, #ef4444, #f87171); }
|
||||
.metric-card.indigo::before { background: linear-gradient(90deg, #6366f1, #818cf8); }
|
||||
|
||||
.metric-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.metric-icon svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.metric-card.blue .metric-icon { background: #eff6ff; color: #3b82f6; }
|
||||
.metric-card.cyan .metric-icon { background: #ecfeff; color: #06b6d4; }
|
||||
.metric-card.violet .metric-icon { background: #f5f3ff; color: #8b5cf6; }
|
||||
.metric-card.danger .metric-icon { background: #fef2f2; color: #ef4444; }
|
||||
.metric-card.indigo .metric-icon { background: #eef2ff; color: #6366f1; }
|
||||
|
||||
.metric-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: var(--audit-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
color: var(--audit-ink);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.audit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 380px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.ranking-card,
|
||||
.log-launcher-card {
|
||||
min-width: 0;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--audit-border);
|
||||
border-radius: var(--card-radius);
|
||||
background: #fff;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title-group h4 {
|
||||
margin: 0;
|
||||
color: var(--audit-ink);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-title-group p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--audit-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-rank-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.user-rank-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 12px 10px;
|
||||
border-radius: 10px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.user-rank-row:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.rank-no {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rank-no.rank-top {
|
||||
background: linear-gradient(135deg, #3b82f6, #6366f1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.rank-user,
|
||||
.rank-count {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.rank-user strong {
|
||||
color: var(--audit-ink);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rank-user small {
|
||||
color: var(--audit-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rank-location {
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.rank-count {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.rank-count strong {
|
||||
color: var(--audit-ink);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rank-count small {
|
||||
color: var(--audit-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.rank-count .denied-highlight {
|
||||
color: var(--audit-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.log-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.log-action-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.log-action-interface {
|
||||
background: linear-gradient(135deg, #f0f9ff, #e0f2fe);
|
||||
border: 1px solid #bae6fd;
|
||||
}
|
||||
|
||||
.log-action-interface:hover {
|
||||
background: linear-gradient(135deg, #e0f2fe, #bae6fd);
|
||||
box-shadow: 0 4px 12px rgba(14, 165, 233, 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.log-action-security {
|
||||
background: linear-gradient(135deg, #fef2f2, #fee2e2);
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.log-action-security:hover {
|
||||
background: linear-gradient(135deg, #fee2e2, #fecaca);
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.log-action-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.log-action-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #0ea5e9;
|
||||
}
|
||||
|
||||
.log-action-icon.security svg {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.log-action-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.log-action-content strong {
|
||||
display: block;
|
||||
color: var(--audit-ink);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.log-action-content span {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--audit-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dialog-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-head strong {
|
||||
color: var(--audit-ink);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.terminal-window {
|
||||
min-height: 360px;
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #1e293b, #0f172a);
|
||||
}
|
||||
|
||||
.dialog-terminal-window {
|
||||
height: 62vh;
|
||||
min-height: 360px;
|
||||
max-height: 68vh;
|
||||
}
|
||||
|
||||
.terminal-lines {
|
||||
margin: 0;
|
||||
color: #e2e8f0;
|
||||
font-family: "JetBrains Mono", "SFMono-Regular", "Cascadia Code", "Menlo", monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.9;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.logs-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.audit-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.audit-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.audit-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./PermissionIpLocations.vue"), "utf8");
|
||||
const readMapSource = () => readFileSync(resolve(__dirname, "./chinaProvinceMap.ts"), "utf8");
|
||||
|
||||
describe("PermissionIpLocations", () => {
|
||||
it("renders IP location visualization without duplicate region detail table", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("fetchIpLocations");
|
||||
expect(source).toContain("unique_ip_count");
|
||||
expect(source).not.toContain("来源地域明细");
|
||||
expect(source).not.toContain("table-card");
|
||||
expect(source).not.toContain("<el-table :data=\"items\"");
|
||||
});
|
||||
|
||||
it("renders an interactive China map and primary metric cards", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("VChart");
|
||||
expect(source).toContain("chinaMapOption");
|
||||
expect(source).toContain("用户分布图");
|
||||
expect(source).toContain("访问次数");
|
||||
expect(source).toContain("访问用户数");
|
||||
expect(source).toContain("来源 IP 数");
|
||||
expect(source).toContain("summary");
|
||||
});
|
||||
|
||||
it("uses the standard geojson.cn China map data instead of generated rectangles", () => {
|
||||
const source = readSource();
|
||||
const mapSource = readMapSource();
|
||||
|
||||
expect(source).toContain("../assets/china.json");
|
||||
expect(mapSource).toContain("geojson.cn/api/china/1.6.3/china.json");
|
||||
expect(mapSource).not.toContain("ProvinceBox");
|
||||
expect(mapSource).not.toContain("provinceBoxes");
|
||||
});
|
||||
|
||||
it("hides the map legend and disables map zoom interactions", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).not.toContain("visualMap:");
|
||||
expect(source).toContain("roam: false");
|
||||
expect(source).not.toContain("scaleLimit");
|
||||
});
|
||||
|
||||
it("uses narrow metric cards without helper subtitles", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("min-height: 64px");
|
||||
expect(source).toContain("padding: 10px 16px");
|
||||
expect(source).toContain("right: -46px");
|
||||
expect(source).toContain("bottom: -52px");
|
||||
expect(source).toContain("border-radius: 18px");
|
||||
expect(source).not.toContain("<small>{{ card.hint }}</small>");
|
||||
expect(source).not.toContain("hint:");
|
||||
expect(source).not.toContain("权限检查总量");
|
||||
});
|
||||
|
||||
it("removes the duplicated hero copy above the map", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("ip-locations-toolbar");
|
||||
expect(source).not.toContain("权限监控 / IP属地");
|
||||
expect(source).not.toContain("按访问日志聚合省市、用户与来源 IP");
|
||||
expect(source).not.toContain("悬停省份查看访问次数、访问用户数和来源 IP 数");
|
||||
expect(source).not.toContain("hero-desc");
|
||||
expect(source).not.toContain("eyebrow");
|
||||
});
|
||||
|
||||
it("places the period filter toolbar on the left", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain(".ip-hero");
|
||||
expect(source).toContain("justify-content: flex-start");
|
||||
expect(source).not.toContain("justify-content: flex-end");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,546 @@
|
||||
<template>
|
||||
<div class="ip-locations">
|
||||
<section class="ip-hero">
|
||||
<div class="ip-locations-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="toolbar-title">IP 属地分析</span>
|
||||
<span class="toolbar-desc">查看访问来源的地理分布</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-radio-group v-model="days" size="small" @change="loadData">
|
||||
<el-radio-button :value="1">24小时</el-radio-button>
|
||||
<el-radio-button :value="7">7天</el-radio-button>
|
||||
<el-radio-button :value="30">30天</el-radio-button>
|
||||
<el-radio-button :value="90">90天</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button :loading="loading" size="small" @click="loadData">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px; margin-right: 4px"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="metric-grid">
|
||||
<article v-for="card in metricCards" :key="card.label" class="metric-card" :class="card.tone">
|
||||
<div class="metric-icon">
|
||||
<component :is="card.icon" />
|
||||
</div>
|
||||
<div class="metric-body">
|
||||
<span class="metric-label">{{ card.label }}</span>
|
||||
<strong class="metric-value">{{ card.value }}</strong>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-loading="loading" class="distribution-card">
|
||||
<div class="map-panel">
|
||||
<div class="section-head">
|
||||
<div class="section-title-group">
|
||||
<h4>中国地图</h4>
|
||||
<p>访问来源地理分布热力图</p>
|
||||
</div>
|
||||
<el-tag type="info" effect="plain" round size="small">{{ currentPeriodLabel }}</el-tag>
|
||||
</div>
|
||||
<v-chart :option="chinaMapOption" autoresize class="china-map" />
|
||||
</div>
|
||||
|
||||
<aside class="rank-panel">
|
||||
<div class="section-head compact">
|
||||
<div class="section-title-group">
|
||||
<h4>热点属地</h4>
|
||||
<p>按访问次数排序</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="topRegions.length" class="region-list">
|
||||
<div v-for="region in topRegions" :key="region.key" class="region-row">
|
||||
<span class="rank" :class="{ top: Number(region.rank) <= 3 }">{{ region.rank }}</span>
|
||||
<span class="region-name">{{ region.name }}</span>
|
||||
<span class="region-value">{{ region.total_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无属地数据" :image-size="72" />
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, ref } from "vue";
|
||||
import VChart from "vue-echarts";
|
||||
import { registerMap, use } from "echarts/core";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import { MapChart } from "echarts/charts";
|
||||
import { TooltipComponent } from "echarts/components";
|
||||
import chinaMapGeoJson from "../assets/china.json";
|
||||
import { fetchIpLocations } from "../api/projectPermissions";
|
||||
import type { IpLocationsResponse, IpLocationStatItem } from "../types/api";
|
||||
import { normalizeProvinceName } from "./chinaProvinceMap";
|
||||
|
||||
use([CanvasRenderer, MapChart, TooltipComponent]);
|
||||
registerMap("ctms-china", chinaMapGeoJson as any);
|
||||
|
||||
const IconGlobe = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("circle", { cx: "12", cy: "12", r: "10" }),
|
||||
h("line", { x1: "2", y1: "12", x2: "22", y2: "12" }),
|
||||
h("path", { d: "M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" }),
|
||||
]);
|
||||
const IconUsers = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("path", { d: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" }),
|
||||
h("circle", { cx: "9", cy: "7", r: "4" }),
|
||||
h("path", { d: "M23 21v-2a4 4 0 0 0-3-3.87" }),
|
||||
h("path", { d: "M16 3.13a4 4 0 0 1 0 7.75" }),
|
||||
]);
|
||||
const IconWifi = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("path", { d: "M5 12.55a11 11 0 0 1 14.08 0" }),
|
||||
h("path", { d: "M1.42 9a16 16 0 0 1 21.16 0" }),
|
||||
h("path", { d: "M8.53 16.11a6 6 0 0 1 6.95 0" }),
|
||||
h("line", { x1: "12", y1: "20", x2: "12.01", y2: "20" }),
|
||||
]);
|
||||
const IconShield = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("path", { d: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" }),
|
||||
]);
|
||||
|
||||
const loading = ref(false);
|
||||
const days = ref(7);
|
||||
const items = ref<IpLocationStatItem[]>([]);
|
||||
const summary = ref<IpLocationsResponse["summary"] | null>(null);
|
||||
|
||||
const periodLabels: Record<number, string> = {
|
||||
1: "近 24 小时",
|
||||
7: "近 7 天",
|
||||
30: "近 30 天",
|
||||
90: "近 90 天",
|
||||
};
|
||||
|
||||
const currentPeriodLabel = computed(() => periodLabels[days.value] || `近 ${days.value} 天`);
|
||||
|
||||
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
|
||||
|
||||
const formatLocation = (row: IpLocationStatItem) => {
|
||||
const parts = [row.province, row.city].filter(Boolean);
|
||||
return parts.length ? parts.join(" / ") : row.location;
|
||||
};
|
||||
|
||||
const fallbackSummary = computed<IpLocationsResponse["summary"]>(() => ({
|
||||
total_count: items.value.reduce((sum, row) => sum + row.total_count, 0),
|
||||
allowed_count: items.value.reduce((sum, row) => sum + row.allowed_count, 0),
|
||||
denied_count: items.value.reduce((sum, row) => sum + row.denied_count, 0),
|
||||
unique_ip_count: items.value.reduce((sum, row) => sum + row.unique_ip_count, 0),
|
||||
unique_user_count: items.value.reduce((sum, row) => sum + (row.unique_user_count || 0), 0),
|
||||
}));
|
||||
|
||||
const activeSummary = computed(() => summary.value || fallbackSummary.value);
|
||||
|
||||
const deniedRate = computed(() => {
|
||||
const total = activeSummary.value.total_count;
|
||||
if (!total) return 0;
|
||||
return Math.round((activeSummary.value.denied_count / total) * 1000) / 10;
|
||||
});
|
||||
|
||||
const metricCards = computed(() => [
|
||||
{
|
||||
label: "访问次数",
|
||||
value: formatNumber(activeSummary.value.total_count),
|
||||
tone: "blue",
|
||||
icon: IconGlobe,
|
||||
},
|
||||
{
|
||||
label: "访问用户数",
|
||||
value: formatNumber(activeSummary.value.unique_user_count),
|
||||
tone: "cyan",
|
||||
icon: IconUsers,
|
||||
},
|
||||
{
|
||||
label: "来源 IP 数",
|
||||
value: formatNumber(activeSummary.value.unique_ip_count),
|
||||
tone: "indigo",
|
||||
icon: IconWifi,
|
||||
},
|
||||
{
|
||||
label: "拒绝占比",
|
||||
value: `${deniedRate.value}%`,
|
||||
tone: deniedRate.value > 10 ? "danger" : "violet",
|
||||
icon: IconShield,
|
||||
},
|
||||
]);
|
||||
|
||||
const provinceData = computed(() => {
|
||||
const provinceMap = new Map<string, IpLocationStatItem & { name: string }>();
|
||||
items.value.forEach((item) => {
|
||||
const name = normalizeProvinceName(item.province || item.city);
|
||||
if (!name || item.country !== "中国") return;
|
||||
const current = provinceMap.get(name);
|
||||
if (!current) {
|
||||
provinceMap.set(name, { ...item, name });
|
||||
return;
|
||||
}
|
||||
current.total_count += item.total_count;
|
||||
current.allowed_count += item.allowed_count;
|
||||
current.denied_count += item.denied_count;
|
||||
current.unique_ip_count += item.unique_ip_count;
|
||||
current.unique_user_count += item.unique_user_count || 0;
|
||||
});
|
||||
return Array.from(provinceMap.values());
|
||||
});
|
||||
|
||||
const chinaMapOption = computed(() => ({
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
formatter: (params: any) => {
|
||||
const data = params.data;
|
||||
if (!data) return `${params.name}<br/>暂无访问数据`;
|
||||
return [
|
||||
`${params.name}`,
|
||||
`访问次数:${formatNumber(data.value)}`,
|
||||
`访问用户数:${formatNumber(data.unique_user_count || 0)}`,
|
||||
`来源 IP 数:${formatNumber(data.unique_ip_count || 0)}`,
|
||||
`拒绝次数:${formatNumber(data.denied_count || 0)}`,
|
||||
].join("<br/>");
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: "用户分布图",
|
||||
type: "map",
|
||||
map: "ctms-china",
|
||||
roam: false,
|
||||
zoom: 1.08,
|
||||
label: { show: false },
|
||||
itemStyle: {
|
||||
areaColor: "#edf3ff",
|
||||
borderColor: "#ffffff",
|
||||
borderWidth: 1,
|
||||
},
|
||||
emphasis: {
|
||||
label: { show: true, color: "#0f172a", fontSize: 12, fontWeight: 700 },
|
||||
itemStyle: { areaColor: "#ffb86b", shadowBlur: 12, shadowColor: "rgba(245, 158, 11, 0.28)" },
|
||||
},
|
||||
data: provinceData.value.map((item) => ({
|
||||
name: item.name,
|
||||
value: item.total_count,
|
||||
unique_user_count: item.unique_user_count,
|
||||
unique_ip_count: item.unique_ip_count,
|
||||
denied_count: item.denied_count,
|
||||
})),
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const topRegions = computed(() =>
|
||||
[...items.value]
|
||||
.sort((a, b) => b.total_count - a.total_count)
|
||||
.slice(0, 6)
|
||||
.map((item, index) => ({
|
||||
key: `${item.country}-${item.province}-${item.city}-${item.isp}`,
|
||||
rank: String(index + 1).padStart(2, "0"),
|
||||
name: formatLocation(item),
|
||||
total_count: formatNumber(item.total_count),
|
||||
})),
|
||||
);
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await fetchIpLocations({ days: days.value, limit: 50 });
|
||||
items.value = res.data.items;
|
||||
summary.value = res.data.summary || null;
|
||||
} catch {
|
||||
items.value = [];
|
||||
summary.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadData);
|
||||
|
||||
defineExpose({ refresh: loadData });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ip-locations {
|
||||
--ip-ink: #1a2332;
|
||||
--ip-muted: #64748b;
|
||||
--ip-border: #e2e8f0;
|
||||
--ip-card: #ffffff;
|
||||
--ip-blue: #3b82f6;
|
||||
--ip-cyan: #06b6d4;
|
||||
--ip-violet: #8b5cf6;
|
||||
--ip-danger: #ef4444;
|
||||
--ip-indigo: #6366f1;
|
||||
--ip-radius: 16px;
|
||||
--ip-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.ip-hero {
|
||||
background: var(--ip-card);
|
||||
border: 1px solid var(--ip-border);
|
||||
border-radius: var(--ip-radius);
|
||||
box-shadow: var(--ip-shadow);
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
.ip-locations-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ip-ink);
|
||||
}
|
||||
|
||||
.toolbar-desc {
|
||||
font-size: 12px;
|
||||
color: var(--ip-muted);
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 64px;
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--ip-radius);
|
||||
background: #fff;
|
||||
border: 1px solid var(--ip-border);
|
||||
box-shadow: var(--ip-shadow);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.metric-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -46px;
|
||||
bottom: -52px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 18px;
|
||||
background: rgba(59, 130, 246, 0.06);
|
||||
transform: rotate(18deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.metric-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
border-radius: var(--ip-radius) var(--ip-radius) 0 0;
|
||||
}
|
||||
|
||||
.metric-card.blue::before { background: linear-gradient(90deg, #3b82f6, #60a5fa); }
|
||||
.metric-card.cyan::before { background: linear-gradient(90deg, #06b6d4, #22d3ee); }
|
||||
.metric-card.indigo::before { background: linear-gradient(90deg, #6366f1, #818cf8); }
|
||||
.metric-card.violet::before { background: linear-gradient(90deg, #8b5cf6, #a78bfa); }
|
||||
.metric-card.danger::before { background: linear-gradient(90deg, #ef4444, #f87171); }
|
||||
|
||||
.metric-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.metric-icon svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.metric-card.blue .metric-icon { background: #eff6ff; color: #3b82f6; }
|
||||
.metric-card.cyan .metric-icon { background: #ecfeff; color: #06b6d4; }
|
||||
.metric-card.indigo .metric-icon { background: #eef2ff; color: #6366f1; }
|
||||
.metric-card.violet .metric-icon { background: #f5f3ff; color: #8b5cf6; }
|
||||
.metric-card.danger .metric-icon { background: #fef2f2; color: #ef4444; }
|
||||
|
||||
.metric-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: var(--ip-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
color: var(--ip-ink);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.distribution-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 14px;
|
||||
background: var(--ip-card);
|
||||
border: 1px solid var(--ip-border);
|
||||
border-radius: var(--ip-radius);
|
||||
box-shadow: var(--ip-shadow);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.map-panel,
|
||||
.rank-panel {
|
||||
min-width: 0;
|
||||
border: 1px solid #f1f5f9;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #fafcff 0%, #ffffff 100%);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-head.compact {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title-group h4 {
|
||||
margin: 0;
|
||||
color: var(--ip-ink);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-title-group p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--ip-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.china-map {
|
||||
width: 100%;
|
||||
height: 430px;
|
||||
}
|
||||
|
||||
.region-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.region-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #f1f5f9;
|
||||
color: var(--ip-ink);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.region-row:hover {
|
||||
background: #f8fafc;
|
||||
border-color: var(--ip-border);
|
||||
}
|
||||
|
||||
.rank {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rank.top {
|
||||
background: linear-gradient(135deg, #3b82f6, #6366f1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.region-name {
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.region-value {
|
||||
color: var(--ip-blue);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.distribution-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.ip-locations-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.china-map {
|
||||
height: 320px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,82 +1,51 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
|
||||
import type { PermissionMetricsResponse, HealthResponse, AlertsResponse } from "@/types/api";
|
||||
|
||||
vi.mock("@/api/projectPermissions", () => ({
|
||||
fetchPermissionMetrics: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
check_metrics: {
|
||||
total_checks: 1000, allowed_checks: 950, denied_checks: 50,
|
||||
total_time: 5.234, min_time: 0.001, max_time: 0.05, avg_time: 0.005,
|
||||
allow_rate: 95.0, deny_rate: 5.0, error_rate: 0.1, errors: 1,
|
||||
},
|
||||
cache_metrics: {
|
||||
total_accesses: 1000, cache_hits: 850, cache_misses: 150,
|
||||
cache_invalidations: 5, hit_rate: 85.0, miss_rate: 15.0,
|
||||
},
|
||||
uptime_seconds: 3600,
|
||||
},
|
||||
}),
|
||||
fetchPermissionHealth: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
status: "healthy", health_score: 95, issues: [],
|
||||
metrics: {}, cache_stats: { cache_items: { total_count: 60 }, cache_metrics: {} },
|
||||
},
|
||||
}),
|
||||
fetchPermissionAlerts: vi.fn().mockResolvedValue({ data: { total: 0, alerts: [] } }),
|
||||
fetchTopDenied: vi.fn().mockResolvedValue({ data: { items: [] } }),
|
||||
fetchStatsSummary: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
total_logs: 5000,
|
||||
today: { total_checks: 200, allowed_checks: 190, denied_checks: 10, avg_elapsed_ms: 3.5, max_elapsed_ms: 45, allow_rate: 95, deny_rate: 5 },
|
||||
last_hour: { total_checks: 30, allowed_checks: 28, denied_checks: 2, requests_per_minute: 0.5 },
|
||||
},
|
||||
}),
|
||||
fetchPermissionTrends: vi.fn().mockResolvedValue({ data: { period: "24h", data_points: [] } }),
|
||||
fetchAccessLogs: vi.fn().mockResolvedValue({ data: { total: 0, page: 1, page_size: 50, items: [] } }),
|
||||
}));
|
||||
|
||||
describe("PermissionMonitoring.vue", () => {
|
||||
const mockMetrics: PermissionMetricsResponse = {
|
||||
check_metrics: {
|
||||
total_checks: 1000,
|
||||
allowed_checks: 950,
|
||||
denied_checks: 50,
|
||||
total_time: 5.234,
|
||||
min_time: 0.001,
|
||||
max_time: 0.05,
|
||||
avg_time: 0.005,
|
||||
allow_rate: 95.0,
|
||||
deny_rate: 5.0,
|
||||
error_rate: 0.1,
|
||||
errors: 1,
|
||||
},
|
||||
cache_metrics: {
|
||||
total_accesses: 1000,
|
||||
cache_hits: 850,
|
||||
cache_misses: 150,
|
||||
cache_invalidations: 5,
|
||||
hit_rate: 85.0,
|
||||
miss_rate: 15.0,
|
||||
},
|
||||
uptime_seconds: 3600,
|
||||
};
|
||||
|
||||
const mockHealth: HealthResponse = {
|
||||
status: "healthy",
|
||||
health_score: 95,
|
||||
issues: [],
|
||||
metrics: mockMetrics,
|
||||
cache_stats: {
|
||||
cache_items: {
|
||||
project_permissions_count: 10,
|
||||
member_role_count: 50,
|
||||
total_count: 60,
|
||||
},
|
||||
cache_metrics: mockMetrics.cache_metrics,
|
||||
},
|
||||
};
|
||||
|
||||
const mockAlerts: AlertsResponse = {
|
||||
total: 2,
|
||||
alerts: [
|
||||
{
|
||||
timestamp: 1715692800.123,
|
||||
level: "warning",
|
||||
type: "slow_permission_check",
|
||||
message: "权限检查耗时过长: 52.34ms",
|
||||
data: { elapsed_time: 0.05234 },
|
||||
},
|
||||
{
|
||||
timestamp: 1715692799.456,
|
||||
level: "error",
|
||||
type: "permission_check_error",
|
||||
message: "权限检查出错: database connection timeout",
|
||||
data: { error: "database connection timeout" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("renders monitoring dashboard", () => {
|
||||
it("renders monitoring dashboard with tabs", () => {
|
||||
const wrapper = mount(PermissionMonitoring, {
|
||||
props: {
|
||||
metrics: mockMetrics,
|
||||
health: mockHealth,
|
||||
alerts: mockAlerts,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ElTag: false,
|
||||
ElProgress: false,
|
||||
ElTable: false,
|
||||
ElEmpty: false,
|
||||
ElTabs: false,
|
||||
ElTabPane: false,
|
||||
PermissionTrendCharts: true,
|
||||
PermissionAccessLogs: true,
|
||||
PermissionIpLocations: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -84,115 +53,19 @@ describe("PermissionMonitoring.vue", () => {
|
||||
expect(wrapper.find(".permission-monitoring").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("displays health status", () => {
|
||||
it("exposes refresh method", () => {
|
||||
const wrapper = mount(PermissionMonitoring, {
|
||||
props: {
|
||||
metrics: mockMetrics,
|
||||
health: mockHealth,
|
||||
alerts: mockAlerts,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ElTag: false,
|
||||
ElProgress: false,
|
||||
ElTable: false,
|
||||
ElEmpty: false,
|
||||
ElTabs: true,
|
||||
ElTabPane: true,
|
||||
PermissionTrendCharts: true,
|
||||
PermissionAccessLogs: true,
|
||||
PermissionIpLocations: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const healthCard = wrapper.find(".health-card");
|
||||
expect(healthCard.exists()).toBe(true);
|
||||
expect(healthCard.text()).toContain("系统健康状态");
|
||||
});
|
||||
|
||||
it("displays metric cards", () => {
|
||||
const wrapper = mount(PermissionMonitoring, {
|
||||
props: {
|
||||
metrics: mockMetrics,
|
||||
health: mockHealth,
|
||||
alerts: mockAlerts,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ElTag: false,
|
||||
ElProgress: false,
|
||||
ElTable: false,
|
||||
ElEmpty: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const metricCards = wrapper.findAll(".metric-card");
|
||||
expect(metricCards.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("displays cache efficiency", () => {
|
||||
const wrapper = mount(PermissionMonitoring, {
|
||||
props: {
|
||||
metrics: mockMetrics,
|
||||
health: mockHealth,
|
||||
alerts: mockAlerts,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ElTag: false,
|
||||
ElProgress: false,
|
||||
ElTable: false,
|
||||
ElEmpty: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const cacheCard = wrapper.find(".cache-card");
|
||||
expect(cacheCard.exists()).toBe(true);
|
||||
expect(cacheCard.text()).toContain("缓存效率");
|
||||
});
|
||||
|
||||
it("displays alerts list", () => {
|
||||
const wrapper = mount(PermissionMonitoring, {
|
||||
props: {
|
||||
metrics: mockMetrics,
|
||||
health: mockHealth,
|
||||
alerts: mockAlerts,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ElTag: false,
|
||||
ElProgress: false,
|
||||
ElTable: false,
|
||||
ElEmpty: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const alertsCard = wrapper.find(".alerts-card");
|
||||
expect(alertsCard.exists()).toBe(true);
|
||||
expect(alertsCard.text()).toContain("最近告警");
|
||||
});
|
||||
|
||||
it("emits refresh event when refresh button clicked", async () => {
|
||||
const wrapper = mount(PermissionMonitoring, {
|
||||
props: {
|
||||
metrics: mockMetrics,
|
||||
health: mockHealth,
|
||||
alerts: mockAlerts,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ElTag: false,
|
||||
ElProgress: false,
|
||||
ElTable: false,
|
||||
ElEmpty: false,
|
||||
ElButton: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const refreshButton = wrapper.find("button");
|
||||
if (refreshButton.exists()) {
|
||||
await refreshButton.trigger("click");
|
||||
expect(wrapper.emitted("refresh")).toBeTruthy();
|
||||
}
|
||||
expect(typeof wrapper.vm.refresh).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,346 +1,704 @@
|
||||
<template>
|
||||
<div class="permission-monitoring">
|
||||
<!-- 系统健康评分 -->
|
||||
<div v-if="health" class="health-card">
|
||||
<div class="health-header">
|
||||
<h3>系统健康状态</h3>
|
||||
<el-tag :type="getHealthType(health.status)">
|
||||
{{ getHealthLabel(health.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="health-score">
|
||||
<div class="score-value">{{ health.health_score }}</div>
|
||||
<div class="score-label">健康分数</div>
|
||||
</div>
|
||||
|
||||
<div v-if="health.issues.length > 0" class="health-issues">
|
||||
<div class="issues-title">发现的问题:</div>
|
||||
<ul>
|
||||
<li v-for="(issue, index) in health.issues" :key="index">{{ issue }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 性能指标卡片 -->
|
||||
<div v-if="metrics" class="metrics-cards">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">总检查次数</div>
|
||||
<div class="metric-value">{{ metrics.check_metrics.total_checks }}</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">平均响应时间</div>
|
||||
<div class="metric-value">{{ (metrics.check_metrics.avg_time * 1000).toFixed(2) }}ms</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">允许率</div>
|
||||
<div class="metric-value">{{ metrics.check_metrics.allow_rate.toFixed(1) }}%</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">错误率</div>
|
||||
<div class="metric-value" :style="{ color: metrics.check_metrics.error_rate > 1 ? '#f56c6c' : '#67c23a' }">
|
||||
{{ metrics.check_metrics.error_rate.toFixed(2) }}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 缓存效率 -->
|
||||
<div v-if="metrics" class="cache-card">
|
||||
<div class="cache-header">
|
||||
<h3>缓存效率</h3>
|
||||
</div>
|
||||
|
||||
<div class="cache-stats">
|
||||
<div class="cache-stat">
|
||||
<div class="stat-label">缓存命中率</div>
|
||||
<el-progress
|
||||
:percentage="metrics.cache_metrics.hit_rate"
|
||||
:color="getProgressColor(metrics.cache_metrics.hit_rate)"
|
||||
/>
|
||||
<div class="stat-value">{{ metrics.cache_metrics.hit_rate.toFixed(1) }}%</div>
|
||||
<el-tabs v-model="activeTab" @tab-change="onTabChange">
|
||||
<el-tab-pane label="实时概览" name="overview">
|
||||
<div v-if="statsSummary" class="stats-summary">
|
||||
<div v-for="card in summaryCards" :key="card.label" class="summary-card" :class="card.tone">
|
||||
<div class="summary-icon">
|
||||
<component :is="card.icon" />
|
||||
</div>
|
||||
<div class="summary-body">
|
||||
<span class="summary-label">{{ card.label }}</span>
|
||||
<strong class="summary-value">{{ card.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cache-stat">
|
||||
<div class="stat-label">缓存项目数</div>
|
||||
<div class="stat-value">{{ health?.cache_stats?.cache_items.total_count || 0 }}</div>
|
||||
<div class="overview-grid">
|
||||
<div v-if="health" class="health-card">
|
||||
<div class="health-header">
|
||||
<h3>系统健康状态</h3>
|
||||
<el-tag :type="getHealthType(health.status)" effect="dark" round>
|
||||
{{ getHealthLabel(health.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="health-score-ring">
|
||||
<el-progress
|
||||
type="dashboard"
|
||||
:percentage="health.health_score"
|
||||
:color="getScoreColor(health.health_score)"
|
||||
:width="140"
|
||||
:stroke-width="10"
|
||||
>
|
||||
<template #default>
|
||||
<div class="score-inner">
|
||||
<strong>{{ health.health_score }}</strong>
|
||||
<span>健康分</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-progress>
|
||||
</div>
|
||||
<div v-if="health.issues.length > 0" class="health-issues">
|
||||
<div v-for="(issue, index) in health.issues" :key="index" class="issue-item">
|
||||
<span class="issue-dot" />
|
||||
<span>{{ issue }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="health-ok">
|
||||
<span class="ok-icon">✓</span>
|
||||
<span>系统运行正常,未发现问题</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="metrics" class="cache-card">
|
||||
<h3>缓存效率</h3>
|
||||
<div class="cache-stats">
|
||||
<div class="cache-stat primary">
|
||||
<div class="cache-stat-header">
|
||||
<span class="stat-label">命中率</span>
|
||||
<strong class="stat-value" :style="{ color: getProgressColor(metrics.cache_metrics.hit_rate) }">
|
||||
{{ metrics.cache_metrics.hit_rate.toFixed(1) }}%
|
||||
</strong>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="metrics.cache_metrics.hit_rate"
|
||||
:color="getProgressColor(metrics.cache_metrics.hit_rate)"
|
||||
:stroke-width="8"
|
||||
:show-text="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="cache-stat-row">
|
||||
<div class="cache-stat-mini">
|
||||
<span class="stat-label">缓存项目</span>
|
||||
<strong>{{ health?.cache_stats?.cache_items.total_count || 0 }}</strong>
|
||||
</div>
|
||||
<div class="cache-stat-mini">
|
||||
<span class="stat-label">失效次数</span>
|
||||
<strong>{{ metrics.cache_metrics.cache_invalidations }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cache-stat">
|
||||
<div class="stat-label">缓存失效次数</div>
|
||||
<div class="stat-value">{{ metrics.cache_metrics.cache_invalidations }}</div>
|
||||
<div v-if="topDenied && topDenied.length > 0" class="denied-card">
|
||||
<div class="card-header">
|
||||
<h3>被拒绝最多的权限</h3>
|
||||
<el-tag effect="plain" size="small" type="info">近 7 天</el-tag>
|
||||
</div>
|
||||
<div class="denied-list">
|
||||
<div v-for="(item, index) in topDenied" :key="index" class="denied-row">
|
||||
<span class="denied-rank" :class="{ top: index < 3 }">{{ index + 1 }}</span>
|
||||
<div class="denied-info">
|
||||
<strong>{{ item.endpoint_key }}</strong>
|
||||
<small>{{ item.role }}</small>
|
||||
</div>
|
||||
<span class="denied-count">{{ item.denied_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 告警列表 -->
|
||||
<div class="alerts-card">
|
||||
<div class="alerts-header">
|
||||
<h3>最近告警</h3>
|
||||
<el-button type="primary" size="small" @click="$emit('refresh')">
|
||||
<el-icon><RefreshRight /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="alerts-card">
|
||||
<div class="card-header">
|
||||
<h3>最近告警</h3>
|
||||
<el-tag v-if="alerts && alerts.alerts.length > 0" effect="dark" size="small" type="warning" round>
|
||||
{{ alerts.alerts.length }} 条
|
||||
</el-tag>
|
||||
</div>
|
||||
<div v-if="alerts && alerts.alerts.length > 0" class="alerts-list">
|
||||
<div v-for="(alert, index) in alerts.alerts" :key="index" class="alert-row" :class="alert.level">
|
||||
<div class="alert-level">
|
||||
<el-tag :type="getAlertType(alert.level)" size="small" effect="dark" round>{{ alert.level }}</el-tag>
|
||||
</div>
|
||||
<div class="alert-body">
|
||||
<strong>{{ alert.type }}</strong>
|
||||
<span>{{ alert.message }}</span>
|
||||
</div>
|
||||
<span class="alert-time">{{ formatTime(alert.timestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无告警,系统运行正常" :image-size="60" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<div v-if="alerts && alerts.alerts.length > 0" class="alerts-table-wrapper">
|
||||
<el-table :data="alerts.alerts" stripe border>
|
||||
<el-table-column prop="timestamp" label="时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.timestamp) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-tab-pane label="趋势分析" name="trends">
|
||||
<PermissionTrendCharts ref="trendChartsRef" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-table-column prop="level" label="级别" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getAlertType(row.level)">{{ row.level }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-tab-pane label="访问日志" name="logs">
|
||||
<PermissionAccessLogs ref="accessLogsRef" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-table-column prop="type" label="类型" width="150" />
|
||||
|
||||
<el-table-column prop="message" label="消息" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<el-empty description="暂无告警" />
|
||||
</div>
|
||||
</div>
|
||||
<el-tab-pane label="IP属地" name="ip-locations">
|
||||
<PermissionIpLocations ref="ipLocationsRef" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { RefreshRight } from "@element-plus/icons-vue";
|
||||
import { ref, computed, h, onMounted } from "vue";
|
||||
import type {
|
||||
PermissionMetricsResponse,
|
||||
CacheStatsResponse,
|
||||
AlertsResponse,
|
||||
HealthResponse,
|
||||
TopDeniedItem,
|
||||
StatsSummaryResponse,
|
||||
} from "@/types/api";
|
||||
import {
|
||||
fetchPermissionMetrics,
|
||||
fetchPermissionAlerts,
|
||||
fetchPermissionHealth,
|
||||
fetchTopDenied,
|
||||
fetchStatsSummary,
|
||||
} from "@/api/projectPermissions";
|
||||
import PermissionTrendCharts from "./PermissionTrendCharts.vue";
|
||||
import PermissionAccessLogs from "./PermissionAccessLogs.vue";
|
||||
import PermissionIpLocations from "./PermissionIpLocations.vue";
|
||||
|
||||
interface Props {
|
||||
metrics: PermissionMetricsResponse | null;
|
||||
health: HealthResponse | null;
|
||||
alerts: AlertsResponse | null;
|
||||
}
|
||||
const IconCheck = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("polyline", { points: "20 6 9 17 4 12" }),
|
||||
]);
|
||||
const IconActivity = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("polyline", { points: "22 12 18 12 15 21 9 3 6 12 2 12" }),
|
||||
]);
|
||||
const IconPercent = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("line", { x1: "19", y1: "5", x2: "5", y2: "19" }),
|
||||
h("circle", { cx: "6.5", cy: "6.5", r: "2.5" }),
|
||||
h("circle", { cx: "17.5", cy: "17.5", r: "2.5" }),
|
||||
]);
|
||||
const IconClock = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("circle", { cx: "12", cy: "12", r: "10" }),
|
||||
h("polyline", { points: "12 6 12 12 16 14" }),
|
||||
]);
|
||||
const IconDatabase = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("ellipse", { cx: "12", cy: "5", rx: "9", ry: "3" }),
|
||||
h("path", { d: "M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" }),
|
||||
h("path", { d: "M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" }),
|
||||
]);
|
||||
|
||||
interface Emits {
|
||||
(e: "refresh"): void;
|
||||
}
|
||||
const activeTab = ref("overview");
|
||||
const metrics = ref<PermissionMetricsResponse | null>(null);
|
||||
const health = ref<HealthResponse | null>(null);
|
||||
const alerts = ref<AlertsResponse | null>(null);
|
||||
const topDenied = ref<TopDeniedItem[]>([]);
|
||||
const statsSummary = ref<StatsSummaryResponse | null>(null);
|
||||
|
||||
defineProps<Props>();
|
||||
defineEmits<Emits>();
|
||||
const trendChartsRef = ref<InstanceType<typeof PermissionTrendCharts>>();
|
||||
const accessLogsRef = ref<InstanceType<typeof PermissionAccessLogs>>();
|
||||
const ipLocationsRef = ref<InstanceType<typeof PermissionIpLocations>>();
|
||||
|
||||
const formatTime = (timestamp: number): string => {
|
||||
return new Date(timestamp * 1000).toLocaleString("zh-CN");
|
||||
};
|
||||
|
||||
const getHealthType = (status: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
healthy: "success",
|
||||
degraded: "warning",
|
||||
unhealthy: "danger",
|
||||
};
|
||||
return typeMap[status] || "info";
|
||||
const getHealthType = (status: string) => {
|
||||
const map: Record<string, string> = { healthy: "success", degraded: "warning", unhealthy: "danger" };
|
||||
return map[status] || "info";
|
||||
};
|
||||
|
||||
const getHealthLabel = (status: string): string => {
|
||||
const labelMap: Record<string, string> = {
|
||||
healthy: "健康",
|
||||
degraded: "降级",
|
||||
unhealthy: "不健康",
|
||||
};
|
||||
return labelMap[status] || status;
|
||||
const getHealthLabel = (status: string) => {
|
||||
const map: Record<string, string> = { healthy: "健康", degraded: "降级", unhealthy: "不健康" };
|
||||
return map[status] || status;
|
||||
};
|
||||
|
||||
const getAlertType = (level: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
info: "info",
|
||||
warning: "warning",
|
||||
error: "danger",
|
||||
};
|
||||
return typeMap[level] || "info";
|
||||
const getAlertType = (level: string) => {
|
||||
const map: Record<string, string> = { info: "info", warning: "warning", error: "danger" };
|
||||
return map[level] || "info";
|
||||
};
|
||||
|
||||
const getProgressColor = (percentage: number): string => {
|
||||
if (percentage >= 80) return "#67c23a";
|
||||
if (percentage >= 50) return "#e6a23c";
|
||||
return "#f56c6c";
|
||||
if (percentage >= 80) return "#10b981";
|
||||
if (percentage >= 50) return "#f59e0b";
|
||||
return "#ef4444";
|
||||
};
|
||||
|
||||
const getScoreColor = (score: number): string => {
|
||||
if (score >= 80) return "#10b981";
|
||||
if (score >= 60) return "#f59e0b";
|
||||
return "#ef4444";
|
||||
};
|
||||
|
||||
const summaryCards = computed(() => {
|
||||
if (!statsSummary.value) return [];
|
||||
return [
|
||||
{
|
||||
label: "今日总检查",
|
||||
value: statsSummary.value.today.total_checks.toLocaleString(),
|
||||
tone: "blue",
|
||||
icon: IconCheck,
|
||||
},
|
||||
{
|
||||
label: "每分钟请求",
|
||||
value: statsSummary.value.last_hour.requests_per_minute,
|
||||
tone: "cyan",
|
||||
icon: IconActivity,
|
||||
},
|
||||
{
|
||||
label: "今日允许率",
|
||||
value: `${statsSummary.value.today.allow_rate}%`,
|
||||
tone: statsSummary.value.today.allow_rate >= 80 ? "green" : "warning",
|
||||
icon: IconPercent,
|
||||
},
|
||||
{
|
||||
label: "平均响应时间",
|
||||
value: `${statsSummary.value.today.avg_elapsed_ms}ms`,
|
||||
tone: statsSummary.value.today.avg_elapsed_ms > 10 ? "danger" : "indigo",
|
||||
icon: IconClock,
|
||||
},
|
||||
{
|
||||
label: "历史总记录",
|
||||
value: statsSummary.value.total_logs.toLocaleString(),
|
||||
tone: "violet",
|
||||
icon: IconDatabase,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const loadOverviewData = async () => {
|
||||
const [metricsRes, healthRes, alertsRes, deniedRes, summaryRes] = await Promise.allSettled([
|
||||
fetchPermissionMetrics(),
|
||||
fetchPermissionHealth(),
|
||||
fetchPermissionAlerts(undefined, 20),
|
||||
fetchTopDenied({ days: 7, limit: 10 }),
|
||||
fetchStatsSummary(),
|
||||
]);
|
||||
if (metricsRes.status === "fulfilled") metrics.value = metricsRes.value.data;
|
||||
if (healthRes.status === "fulfilled") health.value = healthRes.value.data;
|
||||
if (alertsRes.status === "fulfilled") alerts.value = alertsRes.value.data;
|
||||
if (deniedRes.status === "fulfilled") topDenied.value = deniedRes.value.data.items;
|
||||
if (summaryRes.status === "fulfilled") statsSummary.value = summaryRes.value.data;
|
||||
};
|
||||
|
||||
const onTabChange = (tab: string) => {
|
||||
if (tab === "overview") loadOverviewData();
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
if (activeTab.value === "overview") loadOverviewData();
|
||||
else if (activeTab.value === "trends") trendChartsRef.value?.refresh();
|
||||
else if (activeTab.value === "logs") accessLogsRef.value?.refresh();
|
||||
else if (activeTab.value === "ip-locations") ipLocationsRef.value?.refresh();
|
||||
};
|
||||
|
||||
onMounted(loadOverviewData);
|
||||
|
||||
defineExpose({ refresh });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
<style scoped>
|
||||
.permission-monitoring {
|
||||
padding: 20px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
--mon-ink: #1a2332;
|
||||
--mon-muted: #64748b;
|
||||
--mon-border: #e2e8f0;
|
||||
--mon-radius: 16px;
|
||||
--mon-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.health-card {
|
||||
background: #f5f7fa;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
.stats-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 18px;
|
||||
border-radius: var(--mon-radius);
|
||||
background: #fff;
|
||||
border: 1px solid var(--mon-border);
|
||||
box-shadow: var(--mon-shadow);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.summary-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.summary-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
border-radius: var(--mon-radius) var(--mon-radius) 0 0;
|
||||
}
|
||||
|
||||
.summary-card.blue::before { background: linear-gradient(90deg, #3b82f6, #60a5fa); }
|
||||
.summary-card.cyan::before { background: linear-gradient(90deg, #06b6d4, #22d3ee); }
|
||||
.summary-card.green::before { background: linear-gradient(90deg, #10b981, #34d399); }
|
||||
.summary-card.warning::before { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
|
||||
.summary-card.indigo::before { background: linear-gradient(90deg, #6366f1, #818cf8); }
|
||||
.summary-card.danger::before { background: linear-gradient(90deg, #ef4444, #f87171); }
|
||||
.summary-card.violet::before { background: linear-gradient(90deg, #8b5cf6, #a78bfa); }
|
||||
|
||||
.summary-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.summary-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.summary-card.blue .summary-icon { background: #eff6ff; color: #3b82f6; }
|
||||
.summary-card.cyan .summary-icon { background: #ecfeff; color: #06b6d4; }
|
||||
.summary-card.green .summary-icon { background: #ecfdf5; color: #10b981; }
|
||||
.summary-card.warning .summary-icon { background: #fffbeb; color: #f59e0b; }
|
||||
.summary-card.indigo .summary-icon { background: #eef2ff; color: #6366f1; }
|
||||
.summary-card.danger .summary-icon { background: #fef2f2; color: #ef4444; }
|
||||
.summary-card.violet .summary-icon { background: #f5f3ff; color: #8b5cf6; }
|
||||
|
||||
.summary-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
color: var(--mon-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
color: var(--mon-ink);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.health-card,
|
||||
.cache-card,
|
||||
.denied-card,
|
||||
.alerts-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--mon-border);
|
||||
border-radius: var(--mon-radius);
|
||||
padding: 20px;
|
||||
box-shadow: var(--mon-shadow);
|
||||
}
|
||||
|
||||
.denied-card,
|
||||
.alerts-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--mon-ink);
|
||||
}
|
||||
|
||||
.health-card h3,
|
||||
.cache-card h3 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--mon-ink);
|
||||
}
|
||||
|
||||
.health-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.health-score {
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
.health-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
color: #409eff;
|
||||
.health-score-ring {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px 0 16px;
|
||||
}
|
||||
|
||||
.score-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin-top: 10px;
|
||||
.score-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.score-inner strong {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--mon-ink);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.score-inner span {
|
||||
font-size: 12px;
|
||||
color: var(--mon-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.health-issues {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #dcdfe6;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--mon-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.issues-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #606266;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.health-issues ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
|
||||
li {
|
||||
color: #f56c6c;
|
||||
font-size: 13px;
|
||||
margin: 5px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
.issue-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin-bottom: 10px;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
.issue-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #ef4444;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cache-card {
|
||||
background: #f5f7fa;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
.health-ok {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cache-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ok-icon {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cache-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cache-stat {
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
.cache-stat.primary {
|
||||
padding: 14px;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
.cache-stat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
margin-top: 10px;
|
||||
.cache-stat-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
:deep(.el-progress) {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.alerts-card {
|
||||
background: #f5f7fa;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.alerts-header {
|
||||
.cache-stat-mini {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
.cache-stat-mini strong {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--mon-ink);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--mon-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.denied-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.denied-row {
|
||||
display: grid;
|
||||
grid-template-columns: 32px minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.denied-row:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.denied-rank {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.denied-rank.top {
|
||||
background: linear-gradient(135deg, #ef4444, #f87171);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.denied-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.denied-info strong {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--mon-ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.denied-info small {
|
||||
font-size: 11px;
|
||||
color: var(--mon-muted);
|
||||
}
|
||||
|
||||
.denied-count {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.alerts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.alert-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--mon-border);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.alert-row:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.alert-row.error {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.alert-row.warning {
|
||||
border-color: #fed7aa;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.alert-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.alert-body strong {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--mon-ink);
|
||||
}
|
||||
|
||||
.alert-body span {
|
||||
font-size: 12px;
|
||||
color: var(--mon-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.alert-time {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.stats-summary {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.alerts-table-wrapper {
|
||||
overflow-x: auto;
|
||||
@media (max-width: 900px) {
|
||||
.stats-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
@media (max-width: 600px) {
|
||||
.stats-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./PermissionTemplateSelector.vue"), "utf8");
|
||||
|
||||
describe("PermissionTemplateSelector", () => {
|
||||
it("renders compact role permission cards without footer placeholder space", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("角色权限概览");
|
||||
expect(source).toContain("align-items: start");
|
||||
expect(source).toContain("padding: 12px 14px");
|
||||
expect(source).not.toContain("card-footer");
|
||||
expect(source).not.toContain("点击编辑权限");
|
||||
expect(source).not.toContain("inactive-label");
|
||||
expect(source).not.toContain("edit-hint");
|
||||
});
|
||||
});
|
||||
@@ -1,61 +1,62 @@
|
||||
<template>
|
||||
<div class="template-selector">
|
||||
<div class="selector-header">
|
||||
<h3>角色权限概览</h3>
|
||||
<p class="selector-desc">展示当前项目各角色的权限配置状态</p>
|
||||
<div class="selector-title-group">
|
||||
<h3>角色权限概览</h3>
|
||||
<p class="selector-desc">展示当前项目各角色的权限配置状态</p>
|
||||
</div>
|
||||
<el-tag effect="plain" size="small" type="info" round>{{ templates.length }} 个角色</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="template-grid">
|
||||
<el-tooltip
|
||||
<div
|
||||
v-for="template in templates"
|
||||
:key="template.id"
|
||||
:content="template.description"
|
||||
placement="top"
|
||||
:disabled="!template.description"
|
||||
>
|
||||
<div
|
||||
class="template-card"
|
||||
:class="{ inactive: template.category && !isRoleActive(template.category) }"
|
||||
@click="template.category && emit('edit-role', template.category!)"
|
||||
>
|
||||
<div class="card-header">
|
||||
<div class="card-top">
|
||||
<span class="card-icon">{{ roleIcon(template.category) }}</span>
|
||||
<div class="card-title-wrap">
|
||||
<span class="card-name">{{ template.category ? (ROLE_LABELS[template.category] || template.name) : template.name }}</span>
|
||||
<el-tag v-if="!template.is_system" size="small" type="success">自定义</el-tag>
|
||||
<span v-if="template.description" class="card-desc">{{ template.description }}</span>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="template.category"
|
||||
size="small"
|
||||
type="primary"
|
||||
link
|
||||
class="edit-role-btn"
|
||||
@click.stop="emit('edit-role', template.category!)"
|
||||
>编辑权限</el-button>
|
||||
<el-tag v-if="!template.is_system" size="small" effect="dark" round type="success" class="card-badge">自定义</el-tag>
|
||||
</div>
|
||||
<div class="card-stats">
|
||||
<template v-if="props.currentPermissions && template.category && props.currentPermissions[template.category]">
|
||||
<span class="stat">
|
||||
<el-icon><Check /></el-icon>
|
||||
{{ countCurrentEnabled(template.category) }} 项已启用
|
||||
</span>
|
||||
<span class="stat disabled">
|
||||
<el-icon><Close /></el-icon>
|
||||
{{ countCurrentDisabled(template.category) }} 项已禁用
|
||||
</span>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-fill" :style="{ width: enabledPercent(template.category) + '%' }" />
|
||||
</div>
|
||||
<div class="stat-numbers">
|
||||
<span class="stat enabled">
|
||||
<span class="stat-dot green" />
|
||||
{{ countCurrentEnabled(template.category) }} 启用
|
||||
</span>
|
||||
<span class="stat disabled">
|
||||
<span class="stat-dot red" />
|
||||
{{ countCurrentDisabled(template.category) }} 禁用
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="stat">
|
||||
<el-icon><Check /></el-icon>
|
||||
{{ countEnabled(template) }} 项已启用
|
||||
</span>
|
||||
<span class="stat disabled">
|
||||
<el-icon><Close /></el-icon>
|
||||
{{ countDisabled(template) }} 项已禁用
|
||||
</span>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-fill" :style="{ width: templateEnabledPercent(template) + '%' }" />
|
||||
</div>
|
||||
<div class="stat-numbers">
|
||||
<span class="stat enabled">
|
||||
<span class="stat-dot green" />
|
||||
{{ countEnabled(template) }} 启用
|
||||
</span>
|
||||
<span class="stat disabled">
|
||||
<span class="stat-dot red" />
|
||||
{{ countDisabled(template) }} 禁用
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -63,7 +64,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Check, Close } from "@element-plus/icons-vue";
|
||||
import {
|
||||
fetchPermissionTemplates,
|
||||
type PermissionTemplate,
|
||||
@@ -110,6 +110,12 @@ const countDisabled = (t: PermissionTemplate) => {
|
||||
return n;
|
||||
};
|
||||
|
||||
const templateEnabledPercent = (t: PermissionTemplate) => {
|
||||
const enabled = countEnabled(t);
|
||||
const total = enabled + countDisabled(t);
|
||||
return total > 0 ? Math.round((enabled / total) * 100) : 0;
|
||||
};
|
||||
|
||||
const countCurrentEnabled = (role: string) => {
|
||||
const perms = props.currentPermissions?.[role];
|
||||
if (!perms) return 0;
|
||||
@@ -122,6 +128,12 @@ const countCurrentDisabled = (role: string) => {
|
||||
return Object.values(perms).filter((v) => !v).length;
|
||||
};
|
||||
|
||||
const enabledPercent = (role: string) => {
|
||||
const enabled = countCurrentEnabled(role);
|
||||
const total = enabled + countCurrentDisabled(role);
|
||||
return total > 0 ? Math.round((enabled / total) * 100) : 0;
|
||||
};
|
||||
|
||||
const loadTemplates = async () => {
|
||||
try {
|
||||
const res = await fetchPermissionTemplates();
|
||||
@@ -136,117 +148,155 @@ onMounted(loadTemplates);
|
||||
|
||||
<style scoped>
|
||||
.template-selector {
|
||||
padding: 16px 0;
|
||||
padding: 8px 0 12px;
|
||||
}
|
||||
|
||||
.selector-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.selector-header h3 {
|
||||
.selector-title-group h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
color: #1a2332;
|
||||
}
|
||||
|
||||
.selector-desc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.template-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
align-items: start;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
padding: 14px;
|
||||
border: 1.5px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.template-card:hover {
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 4px 14px rgba(59, 130, 246, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.template-card.inactive {
|
||||
border-style: dashed;
|
||||
background: #fafafa;
|
||||
opacity: 0.75;
|
||||
border-color: #e2e8f0;
|
||||
background: #fafbfc;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.template-card:hover {
|
||||
border-color: #c0c4cc;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
.template-card.inactive:hover {
|
||||
border-color: #cbd5e1;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.template-card:not(.inactive):hover {
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-title-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
color: #1a2332;
|
||||
}
|
||||
|
||||
.template-card.inactive .card-name {
|
||||
color: #909399;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.edit-role-btn {
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
.card-desc {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.template-card:hover .edit-role-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
.card-badge {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: #67c23a;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.template-card.inactive .card-stats {
|
||||
color: #b0b0b0;
|
||||
.stat-bar {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: #f1f5f9;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-stats .stat {
|
||||
.stat-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, #10b981, #34d399);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-numbers {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.card-stats .stat.disabled {
|
||||
color: #f56c6c;
|
||||
.stat-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.template-card.inactive .card-stats .stat.disabled {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
.stat-dot.green { background: #10b981; }
|
||||
.stat-dot.red { background: #ef4444; }
|
||||
|
||||
.template-card.inactive .stat-dot.green { background: #94a3b8; }
|
||||
.template-card.inactive .stat-dot.red { background: #cbd5e1; }
|
||||
.template-card.inactive .stat-fill { background: #cbd5e1; }
|
||||
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
<template>
|
||||
<div class="trend-charts">
|
||||
<div class="trend-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="toolbar-title">数据趋势</span>
|
||||
<span class="toolbar-desc">查看权限系统各项指标的变化趋势</span>
|
||||
</div>
|
||||
<el-radio-group v-model="period" size="small" @change="loadData">
|
||||
<el-radio-button value="24h">24小时</el-radio-button>
|
||||
<el-radio-button value="7d">7天</el-radio-button>
|
||||
<el-radio-button value="30d">30天</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="charts-grid">
|
||||
<div class="chart-card">
|
||||
<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>
|
||||
<h4>检查量趋势</h4>
|
||||
</div>
|
||||
<v-chart :option="checksChartOption" autoresize class="chart" />
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<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>
|
||||
<h4>响应时间趋势</h4>
|
||||
</div>
|
||||
<v-chart :option="responseTimeOption" autoresize class="chart" />
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<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>
|
||||
<h4>缓存命中率</h4>
|
||||
</div>
|
||||
<v-chart :option="cacheHitOption" autoresize class="chart" />
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<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>
|
||||
<h4>拒绝率趋势</h4>
|
||||
</div>
|
||||
<v-chart :option="denyRateOption" autoresize class="chart" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import VChart from "vue-echarts";
|
||||
import { use } from "echarts/core";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import { LineChart, BarChart } from "echarts/charts";
|
||||
import {
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
} from "echarts/components";
|
||||
import { fetchPermissionTrends } from "@/api/projectPermissions";
|
||||
import type { TrendDataPoint } from "@/types/api";
|
||||
|
||||
use([CanvasRenderer, LineChart, BarChart, TitleComponent, TooltipComponent, GridComponent, LegendComponent]);
|
||||
|
||||
const period = ref<"24h" | "7d" | "30d">("24h");
|
||||
const loading = ref(false);
|
||||
const dataPoints = ref<TrendDataPoint[]>([]);
|
||||
|
||||
const formatTime = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
if (period.value === "24h") return `${d.getHours()}:00`;
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
};
|
||||
|
||||
const xLabels = computed(() => dataPoints.value.map((p: TrendDataPoint) => formatTime(p.bucket_time)));
|
||||
|
||||
const baseGridOption = {
|
||||
grid: { left: 50, right: 20, top: 20, bottom: 25 },
|
||||
tooltip: { trigger: "axis" as const },
|
||||
};
|
||||
|
||||
const checksChartOption = computed(() => ({
|
||||
grid: { left: 50, right: 20, top: 20, bottom: 50 },
|
||||
tooltip: { trigger: "axis" as const },
|
||||
legend: { data: ["允许", "拒绝"], bottom: 0, itemGap: 20 },
|
||||
xAxis: { type: "category", data: xLabels.value, axisLine: { lineStyle: { color: "#e2e8f0" } }, axisLabel: { color: "#64748b" } },
|
||||
yAxis: { type: "value", minInterval: 1, splitLine: { lineStyle: { color: "#f1f5f9" } }, axisLabel: { color: "#64748b" } },
|
||||
series: [
|
||||
{
|
||||
name: "允许",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
symbol: "circle",
|
||||
symbolSize: 6,
|
||||
data: dataPoints.value.map((p: TrendDataPoint) => p.allowed_checks),
|
||||
itemStyle: { color: "#10b981" },
|
||||
lineStyle: { width: 2.5 },
|
||||
areaStyle: { color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "rgba(16,185,129,0.2)" }, { offset: 1, color: "rgba(16,185,129,0)" }] } },
|
||||
},
|
||||
{
|
||||
name: "拒绝",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
symbol: "circle",
|
||||
symbolSize: 6,
|
||||
data: dataPoints.value.map((p: TrendDataPoint) => p.denied_checks),
|
||||
itemStyle: { color: "#ef4444" },
|
||||
lineStyle: { width: 2.5 },
|
||||
areaStyle: { color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "rgba(239,68,68,0.15)" }, { offset: 1, color: "rgba(239,68,68,0)" }] } },
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const responseTimeOption = computed(() => ({
|
||||
grid: { left: 50, right: 20, top: 20, bottom: 50 },
|
||||
tooltip: { trigger: "axis" as const },
|
||||
legend: { data: ["平均响应时间", "最大响应时间"], bottom: 0, itemGap: 20 },
|
||||
xAxis: { type: "category", data: xLabels.value, axisLine: { lineStyle: { color: "#e2e8f0" } }, axisLabel: { color: "#64748b" } },
|
||||
yAxis: { type: "value", name: "ms", splitLine: { lineStyle: { color: "#f1f5f9" } }, axisLabel: { color: "#64748b" } },
|
||||
series: [
|
||||
{
|
||||
name: "平均响应时间",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
symbol: "circle",
|
||||
symbolSize: 6,
|
||||
data: dataPoints.value.map((p: TrendDataPoint) => p.avg_elapsed_ms),
|
||||
itemStyle: { color: "#3b82f6" },
|
||||
lineStyle: { width: 2.5 },
|
||||
areaStyle: { color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "rgba(59,130,246,0.15)" }, { offset: 1, color: "rgba(59,130,246,0)" }] } },
|
||||
},
|
||||
{
|
||||
name: "最大响应时间",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
symbol: "diamond",
|
||||
symbolSize: 6,
|
||||
data: dataPoints.value.map((p: TrendDataPoint) => p.max_elapsed_ms),
|
||||
itemStyle: { color: "#f59e0b" },
|
||||
lineStyle: { width: 2, type: "dashed" },
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const cacheHitOption = computed(() => ({
|
||||
grid: { left: 50, right: 20, top: 20, bottom: 25 },
|
||||
tooltip: { trigger: "axis" as const },
|
||||
xAxis: { type: "category", data: xLabels.value, axisLine: { lineStyle: { color: "#e2e8f0" } }, axisLabel: { color: "#64748b" } },
|
||||
yAxis: { type: "value", max: 100, name: "%", splitLine: { lineStyle: { color: "#f1f5f9" } }, axisLabel: { color: "#64748b" } },
|
||||
series: [
|
||||
{
|
||||
name: "缓存命中率",
|
||||
type: "bar",
|
||||
barMaxWidth: 24,
|
||||
itemStyle: {
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
color: (params: any) => {
|
||||
const val = params.value;
|
||||
if (val >= 80) return { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "#34d399" }, { offset: 1, color: "#10b981" }] };
|
||||
if (val >= 50) return { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "#fbbf24" }, { offset: 1, color: "#f59e0b" }] };
|
||||
return { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "#f87171" }, { offset: 1, color: "#ef4444" }] };
|
||||
},
|
||||
},
|
||||
data: dataPoints.value.map((p: TrendDataPoint) => p.cache_hit_rate),
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const denyRateOption = computed(() => ({
|
||||
grid: { left: 50, right: 20, top: 20, bottom: 25 },
|
||||
tooltip: { trigger: "axis" as const },
|
||||
xAxis: { type: "category", data: xLabels.value, axisLine: { lineStyle: { color: "#e2e8f0" } }, axisLabel: { color: "#64748b" } },
|
||||
yAxis: { type: "value", max: 100, name: "%", splitLine: { lineStyle: { color: "#f1f5f9" } }, axisLabel: { color: "#64748b" } },
|
||||
series: [
|
||||
{
|
||||
name: "拒绝率",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
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
|
||||
),
|
||||
itemStyle: { color: "#ef4444" },
|
||||
lineStyle: { width: 2.5 },
|
||||
areaStyle: { color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "rgba(239,68,68,0.2)" }, { offset: 1, color: "rgba(239,68,68,0)" }] } },
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await fetchPermissionTrends(period.value);
|
||||
dataPoints.value = res.data.data_points;
|
||||
} catch {
|
||||
dataPoints.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadData);
|
||||
|
||||
defineExpose({ refresh: loadData });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.trend-charts {
|
||||
--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-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--trend-border);
|
||||
border-radius: var(--trend-radius);
|
||||
box-shadow: var(--trend-shadow);
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--trend-ink);
|
||||
}
|
||||
|
||||
.toolbar-desc {
|
||||
font-size: 12px;
|
||||
color: var(--trend-muted);
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--trend-border);
|
||||
border-radius: var(--trend-radius);
|
||||
padding: 18px;
|
||||
box-shadow: var(--trend-shadow);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.chart-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.07);
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chart-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chart-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.chart-icon.blue { background: #eff6ff; color: #3b82f6; }
|
||||
.chart-icon.cyan { background: #ecfeff; color: #06b6d4; }
|
||||
.chart-icon.green { background: #ecfdf5; color: #10b981; }
|
||||
.chart-icon.danger { background: #fef2f2; color: #ef4444; }
|
||||
|
||||
.chart-card h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--trend-ink);
|
||||
}
|
||||
|
||||
.chart {
|
||||
height: 240px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.trend-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,14 @@
|
||||
// 地图数据来源:https://geojson.cn/api/china/1.6.3/china.json
|
||||
// 组件直接导入本地缓存的 GeoJSON,避免运行时依赖外部网络。
|
||||
|
||||
export const normalizeProvinceName = (value?: string | null) => {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.replace(/特别行政区$/u, "")
|
||||
.replace(/维吾尔自治区$/u, "")
|
||||
.replace(/壮族自治区$/u, "")
|
||||
.replace(/回族自治区$/u, "")
|
||||
.replace(/自治区$/u, "")
|
||||
.replace(/省$/u, "")
|
||||
.replace(/市$/u, "");
|
||||
};
|
||||
@@ -8,13 +8,13 @@ describe("admin project route permissions", () => {
|
||||
it("allows project admin pages through matrix-authorized PMs instead of all project roles", () => {
|
||||
const source = readRouter();
|
||||
|
||||
expect(source).toContain('adminProjectPermission: { module: "project_members", action: "read" }');
|
||||
expect(source).toContain('adminProjectPermission: { module: "project_members", action: "write" }');
|
||||
expect(source).toContain('adminProjectPermission: { module: "sites", action: "read" }');
|
||||
expect(source).toContain('adminProjectPermission: { module: "audit_export", action: "read" }');
|
||||
expect(source).toContain("meta: { title: TEXT.menu.projectManagement }");
|
||||
expect(source).toContain("ADMIN_PROJECT_OPERATION_KEYS");
|
||||
expect(source).toContain('if (role !== "PM") return false;');
|
||||
expect(source).toContain("roles?.[role]?.[permission.module]?.[permission.action]");
|
||||
expect(source).toContain("isApiPermissionAllowed");
|
||||
expect(source).toContain("studyStore.currentPermissions?.[role]?.[operationKey]");
|
||||
});
|
||||
|
||||
it("keeps login landing independent from PM management backend access", () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { isSystemAdmin } from "../utils/roles";
|
||||
import { isApiPermissionAllowed } from "../utils/apiPermissionValue";
|
||||
import { findFirstAccessibleProjectPath, getProjectRoutePermission, hasProjectPermission } from "../utils/projectRoutePermissions";
|
||||
import { fetchStudyDetail } from "../api/studies";
|
||||
import { fetchApiEndpointPermissions } from "../api/projectPermissions";
|
||||
@@ -489,6 +490,12 @@ const router = createRouter({
|
||||
routes,
|
||||
});
|
||||
|
||||
const ADMIN_PROJECT_OPERATION_KEYS: Record<string, Record<"read" | "write", string>> = {
|
||||
audit_export: { read: "audit_logs:read", write: "audit_logs:read" },
|
||||
project_members: { read: "project_members:list", write: "project_members:update" },
|
||||
sites: { read: "sites:read", write: "sites:update" },
|
||||
};
|
||||
|
||||
const ensureAdminProjectAccess = async (to: any, isAdmin: boolean) => {
|
||||
if (isAdmin) return true;
|
||||
const studyStore = useStudyStore();
|
||||
@@ -509,8 +516,9 @@ const ensureAdminProjectAccess = async (to: any, isAdmin: boolean) => {
|
||||
|
||||
const role = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || "";
|
||||
if (role !== "PM") return false;
|
||||
// 管理员项目权限检查:PM 角色默认有访问权限
|
||||
return true;
|
||||
const operationKey = ADMIN_PROJECT_OPERATION_KEYS[permission.module]?.[permission.action];
|
||||
if (!operationKey) return false;
|
||||
return isApiPermissionAllowed(studyStore.currentPermissions?.[role]?.[operationKey]);
|
||||
};
|
||||
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
@@ -578,7 +586,7 @@ router.beforeEach(async (to, _from, next) => {
|
||||
await studyStore.loadCurrentStudyPermissions().catch(() => {});
|
||||
}
|
||||
const role = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || "";
|
||||
const canReadAudit = !!studyStore.currentPermissions?.[role]?.["audit_logs:read"]?.allowed;
|
||||
const canReadAudit = isApiPermissionAllowed(studyStore.currentPermissions?.[role]?.["audit_logs:read"]);
|
||||
if (!(isAdmin || (role === "PM" && canReadAudit))) {
|
||||
next({ path: "/project/overview" });
|
||||
return;
|
||||
|
||||
@@ -117,7 +117,7 @@ describe("study store startup rehydration", () => {
|
||||
status: "ACTIVE",
|
||||
role_in_study: "PM",
|
||||
} as any);
|
||||
study.currentPermissions = { roles: { PM: { project_members: { read: true, write: true } } } };
|
||||
study.currentPermissions = { PM: { "project_members:update": { allowed: true } } };
|
||||
|
||||
study.setCurrentStudy({
|
||||
id: "study-b",
|
||||
|
||||
+154
-3
@@ -131,11 +131,11 @@ export interface Site {
|
||||
}
|
||||
|
||||
// 接口级权限
|
||||
export type ApiPermissionValue = boolean | { allowed: boolean };
|
||||
|
||||
export interface ApiEndpointPermissionsResponse {
|
||||
[role: string]: {
|
||||
[endpoint_key: string]: {
|
||||
allowed: boolean;
|
||||
};
|
||||
[endpoint_key: string]: ApiPermissionValue;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -204,3 +204,154 @@ export interface HealthResponse {
|
||||
metrics: PermissionMetricsResponse;
|
||||
cache_stats: CacheStatsResponse;
|
||||
}
|
||||
|
||||
// 权限监控 - 访问日志
|
||||
export interface AccessLogItem {
|
||||
id: string;
|
||||
study_id: string;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
endpoint_key: string;
|
||||
role: string;
|
||||
allowed: boolean;
|
||||
elapsed_ms: number;
|
||||
ip_address: string | null;
|
||||
ip_location: string;
|
||||
ip_country: string;
|
||||
ip_province: string;
|
||||
ip_city: string;
|
||||
ip_isp: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AccessLogsSummary {
|
||||
total_count: number;
|
||||
unique_user_count: number;
|
||||
unique_ip_count: number;
|
||||
denied_count: number;
|
||||
avg_elapsed_ms: number;
|
||||
}
|
||||
|
||||
export interface AccessLogUserStat {
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
role: string;
|
||||
total_count: number;
|
||||
denied_count: number;
|
||||
unique_ip_count: number;
|
||||
sample_ip_address: string | null;
|
||||
last_seen_at: string | null;
|
||||
primary_location: string;
|
||||
}
|
||||
|
||||
export interface AccessLogsResponse {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
summary: AccessLogsSummary;
|
||||
user_stats: AccessLogUserStat[];
|
||||
items: AccessLogItem[];
|
||||
}
|
||||
|
||||
export interface SecurityAccessLogItem {
|
||||
id: string;
|
||||
method: string;
|
||||
path: string;
|
||||
status_code: number;
|
||||
elapsed_ms: number;
|
||||
client_ip: string | null;
|
||||
user_agent: string | null;
|
||||
auth_status: "ANONYMOUS" | "INVALID_TOKEN" | "AUTHENTICATED" | string;
|
||||
user_identifier: string | null;
|
||||
account_label: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SecurityAccessLogsResponse {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
summary: {
|
||||
total_count: number;
|
||||
anonymous_count: number;
|
||||
invalid_token_count: number;
|
||||
error_count: number;
|
||||
};
|
||||
items: SecurityAccessLogItem[];
|
||||
}
|
||||
|
||||
// 权限监控 - 趋势数据
|
||||
export interface TrendDataPoint {
|
||||
bucket_time: string;
|
||||
total_checks: number;
|
||||
allowed_checks: number;
|
||||
denied_checks: number;
|
||||
avg_elapsed_ms: number;
|
||||
max_elapsed_ms: number;
|
||||
cache_hits: number;
|
||||
cache_misses: number;
|
||||
cache_hit_rate: number;
|
||||
error_count: number;
|
||||
}
|
||||
|
||||
export interface TrendsResponse {
|
||||
period: string;
|
||||
data_points: TrendDataPoint[];
|
||||
}
|
||||
|
||||
// 权限监控 - 被拒绝最多
|
||||
export interface TopDeniedItem {
|
||||
endpoint_key: string;
|
||||
role: string;
|
||||
denied_count: number;
|
||||
}
|
||||
|
||||
export interface TopDeniedResponse {
|
||||
days: number;
|
||||
items: TopDeniedItem[];
|
||||
}
|
||||
|
||||
export interface IpLocationStatItem {
|
||||
country: string;
|
||||
province: string;
|
||||
city: string;
|
||||
isp: string;
|
||||
location: string;
|
||||
total_count: number;
|
||||
allowed_count: number;
|
||||
denied_count: number;
|
||||
unique_ip_count: number;
|
||||
unique_user_count: number;
|
||||
}
|
||||
|
||||
export interface IpLocationsResponse {
|
||||
days: number;
|
||||
summary: {
|
||||
total_count: number;
|
||||
allowed_count: number;
|
||||
denied_count: number;
|
||||
unique_ip_count: number;
|
||||
unique_user_count: number;
|
||||
};
|
||||
items: IpLocationStatItem[];
|
||||
}
|
||||
|
||||
// 权限监控 - 统计摘要
|
||||
export interface StatsSummaryResponse {
|
||||
total_logs: number;
|
||||
today: {
|
||||
total_checks: number;
|
||||
allowed_checks: number;
|
||||
denied_checks: number;
|
||||
avg_elapsed_ms: number;
|
||||
max_elapsed_ms: number;
|
||||
allow_rate: number;
|
||||
deny_rate: number;
|
||||
};
|
||||
last_hour: {
|
||||
total_checks: number;
|
||||
allowed_checks: number;
|
||||
denied_checks: number;
|
||||
requests_per_minute: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ApiPermissionValue } from "../types/api";
|
||||
|
||||
export const isApiPermissionAllowed = (value: ApiPermissionValue | null | undefined): boolean => {
|
||||
if (typeof value === "boolean") return value;
|
||||
return !!value?.allowed;
|
||||
};
|
||||
@@ -12,8 +12,11 @@ describe("permission project role model", () => {
|
||||
expect(source).toContain("getProjectRole");
|
||||
expect(source).toContain("study.currentStudyRole");
|
||||
expect(source).toContain("study.currentPermissions");
|
||||
expect(source).toContain('"faq.edit": { module: "faq", action: "write" }');
|
||||
expect(source).not.toContain('"faq.edit": { module: "etmf"');
|
||||
expect(source).toContain('"faq.edit": "faq:update"');
|
||||
expect(source).toContain("isApiPermissionAllowed");
|
||||
expect(source).toContain("projectPermissions.value?.[projectRole.value]?.[operationKey]");
|
||||
expect(source).not.toContain("projectPermissions.value?.roles");
|
||||
expect(source).not.toContain('"faq.edit": "etmf');
|
||||
expect(source).not.toContain('"ADMIN", "PM"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useAuthStore } from "../store/auth";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { TEXT } from "../locales";
|
||||
import { getProjectRole, isSystemAdmin } from "./roles";
|
||||
import { isApiPermissionAllowed } from "./apiPermissionValue";
|
||||
|
||||
const PERMISSIONS: Record<string, string[]> = {
|
||||
"subject.create": ["ADMIN"],
|
||||
@@ -45,32 +46,31 @@ export const usePermission = () => {
|
||||
const can = (action: string): boolean => {
|
||||
if (systemAdmin.value) return true;
|
||||
if (!projectRole.value) return false;
|
||||
const permissionMap: Record<string, { module: string; action: "read" | "write" }> = {
|
||||
"subject.create": { module: "subjects", action: "write" },
|
||||
"subject.enroll": { module: "subjects", action: "write" },
|
||||
"subject.complete": { module: "subjects", action: "write" },
|
||||
"subject.drop": { module: "subjects", action: "write" },
|
||||
"ae.create": { module: "risk_issues", action: "write" },
|
||||
"ae.close": { module: "risk_issues", action: "write" },
|
||||
"project.overview.read": { module: "project_overview", action: "read" },
|
||||
"faq.edit": { module: "faq", action: "write" },
|
||||
"faq.create": { module: "faq", action: "write" },
|
||||
"faq.reply": { module: "faq", action: "write" },
|
||||
"knowledge.notes.write": { module: "shared_library", action: "write" },
|
||||
"shared.library.read": { module: "shared_library", action: "read" },
|
||||
"shared.library.write": { module: "shared_library", action: "write" },
|
||||
"project.members.manage": { module: "project_members", action: "write" },
|
||||
"site.manage": { module: "sites", action: "write" },
|
||||
"site.cra.bind": { module: "sites", action: "write" },
|
||||
"fees.contract.write": { module: "fees", action: "write" },
|
||||
"fees.attachment.delete": { module: "fees", action: "write" },
|
||||
"file.attachment.delete": { module: "file_versions", action: "write" },
|
||||
"audit.export.read": { module: "audit_export", action: "read" },
|
||||
const permissionMap: Record<string, string> = {
|
||||
"subject.create": "subjects:create",
|
||||
"subject.enroll": "subjects:update",
|
||||
"subject.complete": "subjects:update",
|
||||
"subject.drop": "subjects:update",
|
||||
"ae.create": "risk_issues:create",
|
||||
"ae.close": "risk_issues:update",
|
||||
"project.overview.read": "project_overview:read",
|
||||
"faq.edit": "faq:update",
|
||||
"faq.create": "faq:create",
|
||||
"faq.reply": "faq_reply:create",
|
||||
"knowledge.notes.write": "knowledge_notes:create",
|
||||
"shared.library.read": "knowledge_notes:read",
|
||||
"shared.library.write": "knowledge_notes:create",
|
||||
"project.members.manage": "project_members:update",
|
||||
"site.manage": "sites:update",
|
||||
"site.cra.bind": "sites:update",
|
||||
"fees.contract.write": "fees_contracts:create",
|
||||
"fees.attachment.delete": "fees_attachments:delete",
|
||||
"file.attachment.delete": "attachments:delete",
|
||||
"audit.export.read": "audit_logs:read",
|
||||
};
|
||||
const mapped = permissionMap[action];
|
||||
if (!mapped) return false;
|
||||
const allowed = projectPermissions.value?.roles?.[projectRole.value]?.[mapped.module]?.[mapped.action];
|
||||
return !!allowed;
|
||||
const operationKey = permissionMap[action];
|
||||
if (!operationKey) return false;
|
||||
return isApiPermissionAllowed(projectPermissions.value?.[projectRole.value]?.[operationKey]);
|
||||
};
|
||||
|
||||
const reason = (action: string): string => {
|
||||
|
||||
@@ -2,18 +2,22 @@ import { describe, expect, it } from "vitest";
|
||||
import { findFirstAccessibleProjectPath, getProjectRoutePermission, hasProjectPermission } from "./projectRoutePermissions";
|
||||
|
||||
describe("project route permissions", () => {
|
||||
it("maps project routes to read permission modules", () => {
|
||||
expect(getProjectRoutePermission("/project/overview")).toEqual({ module: "project_overview", action: "read" });
|
||||
expect(getProjectRoutePermission("/knowledge/medical-consult/abc")).toEqual({ module: "faq", action: "read" });
|
||||
expect(getProjectRoutePermission("/knowledge/notes/new")).toEqual({ module: "shared_library", action: "read" });
|
||||
expect(getProjectRoutePermission("/startup/kickoff/new")).toEqual({ module: "startup_auth", action: "read" });
|
||||
it("maps project routes to backend operation keys", () => {
|
||||
expect(getProjectRoutePermission("/project/overview")).toEqual({ operationKey: "project_overview:read" });
|
||||
expect(getProjectRoutePermission("/project/milestones")).toEqual({ operationKey: "project_milestones:read" });
|
||||
expect(getProjectRoutePermission("/finance/contracts")).toEqual({ operationKey: "finance_contracts:read" });
|
||||
expect(getProjectRoutePermission("/drug/shipments")).toEqual({ operationKey: "drug_shipments:read" });
|
||||
expect(getProjectRoutePermission("/startup/feasibility/new")).toEqual({ operationKey: "ethics:read" });
|
||||
expect(getProjectRoutePermission("/startup/kickoff/new")).toEqual({ operationKey: "startup_auth:read" });
|
||||
expect(getProjectRoutePermission("/knowledge/medical-consult/abc")).toEqual({ operationKey: "faq:read" });
|
||||
expect(getProjectRoutePermission("/knowledge/notes/new")).toEqual({ operationKey: "knowledge_notes:read" });
|
||||
});
|
||||
|
||||
it("rejects project routes when the role lacks module read permission", () => {
|
||||
it("rejects project routes when the role lacks operation permission", () => {
|
||||
const matrix = {
|
||||
CRA: {
|
||||
project_overview: { read: false, write: false },
|
||||
shared_library: { read: true, write: true },
|
||||
"project_overview:read": { allowed: false },
|
||||
"knowledge_notes:read": { allowed: true },
|
||||
},
|
||||
} as any;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ApiEndpointPermissionsResponse } from "../types/api";
|
||||
import { isApiPermissionAllowed } from "./apiPermissionValue";
|
||||
|
||||
export type ProjectRoutePermission = {
|
||||
operationKey: string;
|
||||
@@ -7,10 +8,12 @@ export type ProjectRoutePermission = {
|
||||
const routePermissions: Array<{ prefixes: string[]; permission: ProjectRoutePermission }> = [
|
||||
{ prefixes: ["/project/overview"], permission: { operationKey: "project_overview:read" } },
|
||||
{ prefixes: ["/project/milestones"], permission: { operationKey: "project_milestones:read" } },
|
||||
{ prefixes: ["/fees/contracts", "/finance/contracts"], permission: { operationKey: "fees_contracts:read" } },
|
||||
{ prefixes: ["/drug/shipments", "/materials/equipment"], permission: { operationKey: "material_equipments:read" } },
|
||||
{ prefixes: ["/fees/contracts"], permission: { operationKey: "fees_contracts:read" } },
|
||||
{ prefixes: ["/finance/contracts"], permission: { operationKey: "finance_contracts:read" } },
|
||||
{ prefixes: ["/drug/shipments"], permission: { operationKey: "drug_shipments:read" } },
|
||||
{ prefixes: ["/materials/equipment"], permission: { operationKey: "material_equipments:read" } },
|
||||
{ prefixes: ["/file-versions", "/trial/", "/documents/"], permission: { operationKey: "documents:read" } },
|
||||
{ prefixes: ["/startup/feasibility-ethics", "/startup/feasibility", "/startup/ethics"], permission: { operationKey: "startup_ethics:read" } },
|
||||
{ prefixes: ["/startup/feasibility-ethics", "/startup/feasibility", "/startup/ethics"], permission: { operationKey: "ethics:read" } },
|
||||
{ prefixes: ["/startup/meeting-auth", "/startup/kickoff", "/startup/training"], permission: { operationKey: "startup_auth:read" } },
|
||||
{ prefixes: ["/subjects"], permission: { operationKey: "subjects:read" } },
|
||||
{ prefixes: ["/risk-issues"], permission: { operationKey: "risk_issues:read" } },
|
||||
@@ -57,7 +60,7 @@ export const hasProjectPermission = (
|
||||
) => {
|
||||
if (!permission || isAdmin) return true;
|
||||
if (!role || !permissions) return false;
|
||||
return !!permissions[role]?.[permission.operationKey]?.allowed;
|
||||
return isApiPermissionAllowed(permissions[role]?.[permission.operationKey]);
|
||||
};
|
||||
|
||||
export const findFirstAccessibleProjectPath = (
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import ApiPermissions from "@/views/admin/ApiPermissions.vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
|
||||
vi.mock("vue-router", async () => {
|
||||
const actual = await vi.importActual<typeof import("vue-router")>("vue-router");
|
||||
return {
|
||||
...actual,
|
||||
useRoute: () => ({ params: { id: "study-1" } }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/api/projectPermissions", () => ({
|
||||
fetchApiEndpointPermissions: vi.fn().mockResolvedValue({ data: {} }),
|
||||
updateApiEndpointPermissions: vi.fn().mockResolvedValue({ data: {} }),
|
||||
fetchPermissionMetrics: vi.fn().mockResolvedValue({ data: {} }),
|
||||
fetchCacheStats: vi.fn().mockResolvedValue({ data: {} }),
|
||||
fetchPermissionAlerts: vi.fn().mockResolvedValue({ data: { total: 0, alerts: [] } }),
|
||||
fetchPermissionHealth: vi.fn().mockResolvedValue({ data: {} }),
|
||||
resetPermissionMetrics: vi.fn().mockResolvedValue({ data: undefined }),
|
||||
}));
|
||||
|
||||
describe("ApiPermissions.vue", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
@@ -23,30 +43,15 @@ describe("ApiPermissions.vue", () => {
|
||||
});
|
||||
|
||||
it("renders tabs", () => {
|
||||
const wrapper = mount(ApiPermissions, {
|
||||
global: {
|
||||
stubs: {
|
||||
ApiEndpointPermissions: true,
|
||||
PermissionMonitoring: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const source = readFileSync(resolve(__dirname, "./ApiPermissions.vue"), "utf8");
|
||||
|
||||
const tabs = wrapper.findAll(".el-tabs__nav-item");
|
||||
expect(tabs.length).toBeGreaterThanOrEqual(2);
|
||||
expect(source).toContain('label="角色权限"');
|
||||
expect(source).toContain('label="权限监控"');
|
||||
});
|
||||
|
||||
it("has save button disabled when not dirty", () => {
|
||||
const wrapper = mount(ApiPermissions, {
|
||||
global: {
|
||||
stubs: {
|
||||
ApiEndpointPermissions: true,
|
||||
PermissionMonitoring: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const source = readFileSync(resolve(__dirname, "./ApiPermissions.vue"), "utf8");
|
||||
|
||||
const saveButton = wrapper.findAll("button").find((btn) => btn.text().includes("保存"));
|
||||
expect(saveButton?.attributes("disabled")).toBeDefined();
|
||||
expect(source).toContain(':disabled="!dirty"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -156,10 +156,10 @@ const onApiMatrixUpdate = (newMatrix: ApiEndpointPermissionsResponse) => {
|
||||
const currentPermissionsForTemplate = computed(() => {
|
||||
if (!apiMatrix.value) return undefined;
|
||||
const result: Record<string, Record<string, boolean>> = {};
|
||||
for (const [role, endpoints] of Object.entries(apiMatrix.value as Record<string, Record<string, { allowed: boolean }>>)) {
|
||||
for (const [role, endpoints] of Object.entries(apiMatrix.value)) {
|
||||
result[role] = {};
|
||||
for (const [key, val] of Object.entries(endpoints)) {
|
||||
result[role][key] = val.allowed;
|
||||
result[role][key] = typeof val === "boolean" ? val : val.allowed;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -171,13 +171,24 @@ const onTemplateApplied = async (permissions: Record<string, Record<string, { al
|
||||
ElMessage.success("权限已更新");
|
||||
};
|
||||
|
||||
const flattenMatrix = (matrix: ApiEndpointPermissionsResponse): Record<string, Record<string, boolean>> => {
|
||||
const result: Record<string, Record<string, boolean>> = {};
|
||||
for (const [role, endpoints] of Object.entries(matrix)) {
|
||||
result[role] = {};
|
||||
for (const [key, val] of Object.entries(endpoints)) {
|
||||
result[role][key] = typeof val === "boolean" ? val : val.allowed;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!studyId.value || !dirty.value) return;
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
if (apiMatrix.value) {
|
||||
const res = await updateApiEndpointPermissions(studyId.value, apiMatrix.value);
|
||||
const res = await updateApiEndpointPermissions(studyId.value, flattenMatrix(apiMatrix.value));
|
||||
apiMatrix.value = res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ describe("audit logs access", () => {
|
||||
expect(source).toContain("getProjectRole");
|
||||
expect(source).toContain("study.currentStudyRole");
|
||||
expect(source).toContain('projectRole.value === "PM"');
|
||||
expect(source).toContain("roles?.[projectRole.value]?.audit_export?.read");
|
||||
expect(source).not.toContain("roles?.PM?.audit_export?.read");
|
||||
expect(source).toContain("isApiPermissionAllowed");
|
||||
expect(source).toContain('permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]');
|
||||
expect(source).not.toContain('const role = auth.user?.role');
|
||||
expect(source).not.toContain('role !== "ADMIN"');
|
||||
});
|
||||
|
||||
@@ -177,6 +177,7 @@ import { roleDict, getDictLabel } from "../../dictionaries";
|
||||
import { exportAuditCsv } from "../../audit/export/auditExportService";
|
||||
import { displayDateTime } from "../../utils/display";
|
||||
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
|
||||
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
const study = useStudyStore();
|
||||
@@ -216,11 +217,11 @@ const isAdmin = computed(() => isSystemAdmin(auth.user));
|
||||
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
|
||||
const canProjectExport = computed(() => {
|
||||
if (isAdmin.value) return true;
|
||||
return projectRole.value === "PM" && !!permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]?.allowed;
|
||||
return projectRole.value === "PM" && isApiPermissionAllowed(permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]);
|
||||
});
|
||||
const canAccessAuditLogs = computed(() => {
|
||||
if (isAdmin.value) return true;
|
||||
return projectRole.value === "PM" && !!permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]?.allowed;
|
||||
return projectRole.value === "PM" && isApiPermissionAllowed(permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]);
|
||||
});
|
||||
|
||||
const ensureAccess = () => {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./PermissionManagement.vue"), "utf8");
|
||||
|
||||
describe("permission management custom roles", () => {
|
||||
it("uses role management wording in the drawer", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('title="角色管理"');
|
||||
expect(source).toContain('label="角色列表"');
|
||||
expect(source).toContain('placeholder="角色类型"');
|
||||
expect(source).toContain("新增角色");
|
||||
expect(source).toContain('label="角色名称"');
|
||||
expect(source).toContain("roleDisplayName(row)");
|
||||
expect(source).not.toContain('title="权限模板管理"');
|
||||
expect(source).not.toContain('label="模板列表"');
|
||||
expect(source).not.toContain("新增模板");
|
||||
});
|
||||
|
||||
it("keeps custom active roles configurable for permissions and members", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("customRoleInput");
|
||||
expect(source).toContain("addCustomRole");
|
||||
expect(source).toContain("roleOptions");
|
||||
expect(source).toContain("ROLE_LABELS[role] || role");
|
||||
expect(source).toContain("ROLE_LABELS[row.category] || row.name");
|
||||
expect(source).toContain("await loadPermissionData();");
|
||||
expect(source).toContain('activeRolesInStudy.value[0] || ""');
|
||||
expect(source).toContain('role === "ADMIN"');
|
||||
});
|
||||
|
||||
it("does not render the duplicated monitoring header", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("<PermissionMonitoring />");
|
||||
expect(source).not.toContain("实时监控权限使用情况、趋势分析与异常告警");
|
||||
expect(source).not.toContain("重置指标");
|
||||
expect(source).not.toContain("refreshMonitoring");
|
||||
expect(source).not.toContain("doResetMetrics");
|
||||
});
|
||||
|
||||
it("does not render the duplicated system permissions header", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("systemPermissionsByModule");
|
||||
expect(source).not.toContain("<h2 class=\"perm-title\">系统级权限</h2>");
|
||||
expect(source).not.toContain("只读展示,所有系统管理操作仅限 ADMIN 角色执行");
|
||||
});
|
||||
|
||||
it("removes the vertical gap between permission headers and body content", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("padding: 0 0 20px;");
|
||||
expect(source).not.toContain("padding: 20px 0 20px;");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -82,6 +82,7 @@ import ProjectForm from "./ProjectForm.vue";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { isSystemAdmin } from "../../utils/roles";
|
||||
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
const projects = ref<Study[]>([]);
|
||||
@@ -127,7 +128,7 @@ const openCreate = () => {
|
||||
};
|
||||
|
||||
const MODULE_TO_OPERATION: Record<string, Record<string, string>> = {
|
||||
project_members: { read: "project_members:read", write: "project_members:update" },
|
||||
project_members: { read: "project_members:list", write: "project_members:update" },
|
||||
sites: { read: "sites:read", write: "sites:update" },
|
||||
audit_export: { read: "audit_logs:read", write: "audit_logs:read" },
|
||||
};
|
||||
@@ -137,7 +138,7 @@ const canProject = (row: Study, module: string, action: "read" | "write") => {
|
||||
const role = row.role_in_study || "";
|
||||
const operationKey = MODULE_TO_OPERATION[module]?.[action];
|
||||
if (!operationKey) return false;
|
||||
return !!permissionsByProject.value[row.id]?.[role]?.[operationKey]?.allowed;
|
||||
return isApiPermissionAllowed(permissionsByProject.value[row.id]?.[role]?.[operationKey]);
|
||||
};
|
||||
|
||||
const goMembers = (row: Study) => {
|
||||
|
||||
@@ -66,7 +66,6 @@ describe("route view overlays are mounted only when visible", () => {
|
||||
'<el-drawer v-if="projectMilestoneEditorVisible"',
|
||||
'<el-drawer v-if="siteMilestoneEditorVisible"',
|
||||
'<el-drawer v-if="siteEnrollmentEditorVisible"',
|
||||
'<el-drawer v-if="monitoringStrategyEditorVisible"',
|
||||
'const getRollbackAxisLeftStyle = (axisX?: number | null) => ({',
|
||||
':style="getRollbackAxisLeftStyle(lane.x)"',
|
||||
':style="getRollbackAxisLeftStyle(row.axisX)"',
|
||||
|
||||
@@ -164,7 +164,7 @@ const roleLabel = computed(() => displayEnum(TEXT.enums.userRole, projectRole.va
|
||||
const emptyText = TEXT.modules.workbench.quickEmpty;
|
||||
const isCraWorkbench = computed(() => projectRole.value === "CRA");
|
||||
const canAccessProjectPath = (path: string) =>
|
||||
hasProjectPermission(study.currentPermissions?.roles, projectRole.value, getProjectRoutePermission(path), isSystemAdmin(auth.user));
|
||||
hasProjectPermission(study.currentPermissions, projectRole.value, getProjectRoutePermission(path), isSystemAdmin(auth.user));
|
||||
const quickActionCandidates = [
|
||||
{ label: TEXT.modules.workbench.feasibilityEthics, path: "/startup/feasibility-ethics", icon: Timer },
|
||||
{ label: TEXT.modules.workbench.aeList, path: "/subjects", icon: Warning },
|
||||
|
||||
@@ -12,7 +12,11 @@
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"types": ["node", "vite/client"]
|
||||
"types": ["node", "vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
|
||||
+15
-4
@@ -412,12 +412,23 @@ check_http_endpoint() {
|
||||
local path="$1"
|
||||
local url="${BASE_URL%/}$path"
|
||||
log "检查接口: $url"
|
||||
if ! curl -fsS "$url" >/dev/null; then
|
||||
if [[ "$path" == "/api/v1/auth/login-key" ]]; then
|
||||
printf '[CTMS] 登录公钥接口失败。请检查 .env 中 LOGIN_RSA_PRIVATE_KEY 是否是完整 PEM 单行转义文本。\n' >&2
|
||||
local attempt=1
|
||||
local max_attempts=10
|
||||
local delay_seconds=2
|
||||
while [[ "$attempt" -le "$max_attempts" ]]; do
|
||||
if curl -fsS "$url" >/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
fail "接口检查失败: $url"
|
||||
if [[ "$attempt" -lt "$max_attempts" ]]; then
|
||||
log "接口未就绪,${delay_seconds}s 后重试 (${attempt}/${max_attempts})"
|
||||
sleep "$delay_seconds"
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
if [[ "$path" == "/api/v1/auth/login-key" ]]; then
|
||||
printf '[CTMS] 登录公钥接口失败。请检查 .env 中 LOGIN_RSA_PRIVATE_KEY 是否是完整 PEM 单行转义文本。\n' >&2
|
||||
fi
|
||||
fail "接口检查失败: $url"
|
||||
}
|
||||
|
||||
show_progress_note() {
|
||||
|
||||
Reference in New Issue
Block a user