merge(dev): 同步审计访问上下文更新

This commit is contained in:
Cheng Zhou
2026-07-10 10:48:35 +08:00
20 changed files with 776 additions and 112 deletions
@@ -0,0 +1,46 @@
"""add access context to audit logs
Revision ID: 20260709_01
Revises: 20260630_02
Create Date: 2026-07-09 10:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260709_01"
down_revision: Union[str, None] = "20260630_02"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("audit_logs", sa.Column("client_ip", sa.String(45), nullable=True))
op.add_column("audit_logs", sa.Column("user_agent", sa.String(500), nullable=True))
op.add_column("audit_logs", sa.Column("client_type", sa.String(16), nullable=True))
op.add_column("audit_logs", sa.Column("client_version", sa.String(32), nullable=True))
op.add_column("audit_logs", sa.Column("client_platform", sa.String(16), nullable=True))
op.add_column("audit_logs", sa.Column("build_channel", sa.String(16), nullable=True))
op.add_column("audit_logs", sa.Column("build_commit", sa.String(64), nullable=True))
op.create_index("ix_audit_logs_operator_created", "audit_logs", ["operator_id", "created_at"])
op.create_index("ix_audit_logs_client_ip_created", "audit_logs", ["client_ip", "created_at"])
op.create_index("ix_audit_logs_client_source_created", "audit_logs", ["client_type", "created_at"])
def downgrade() -> None:
op.drop_index("ix_audit_logs_client_source_created", table_name="audit_logs")
op.drop_index("ix_audit_logs_client_ip_created", table_name="audit_logs")
op.drop_index("ix_audit_logs_operator_created", table_name="audit_logs")
for column in (
"build_commit",
"build_channel",
"client_platform",
"client_version",
"client_type",
"user_agent",
"client_ip",
):
op.drop_column("audit_logs", column)
+51 -1
View File
@@ -1,13 +1,18 @@
import json import json
import uuid import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_api_permission from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_api_permission
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import study as study_crud from app.crud import study as study_crud
from app.models.user import User
from app.schemas.audit import AuditEventCreate, AuditLogRead from app.schemas.audit import AuditEventCreate, AuditLogRead
from app.services.ip_location import resolve_ip_location
router = APIRouter() router = APIRouter()
@@ -40,6 +45,10 @@ async def list_audit_logs(
entity_id: uuid.UUID | None = None, entity_id: uuid.UUID | None = None,
action: str | None = None, action: str | None = None,
operator_id: uuid.UUID | None = None, operator_id: uuid.UUID | None = None,
client_ip: str | None = None,
client_type: str | None = None,
start_time: datetime | None = None,
end_time: datetime | None = None,
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
@@ -51,10 +60,51 @@ async def list_audit_logs(
entity_id=entity_id, entity_id=entity_id,
action=action, action=action,
operator_id=operator_id, operator_id=operator_id,
client_ip=client_ip,
client_type=client_type,
start_time=start_time,
end_time=end_time,
skip=skip, skip=skip,
limit=limit, limit=limit,
) )
return list(logs) operator_ids = {log.operator_id for log in logs}
operator_map: dict[uuid.UUID, User] = {}
if operator_ids:
result = await db.execute(select(User).where(User.id.in_(operator_ids)))
operator_map = {user.id: user for user in result.scalars().all()}
items: list[AuditLogRead] = []
for log in logs:
ip_location = resolve_ip_location(log.client_ip)
operator = operator_map.get(log.operator_id)
items.append(
AuditLogRead(
id=log.id,
study_id=log.study_id,
entity_type=log.entity_type,
entity_id=log.entity_id,
action=log.action,
detail=log.detail,
operator_id=log.operator_id,
operator_name=operator.full_name if operator else None,
operator_email=operator.email if operator else None,
operator_role=log.operator_role,
client_ip=log.client_ip,
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,
user_agent=log.user_agent,
client_type=log.client_type,
client_version=log.client_version,
client_platform=log.client_platform,
build_channel=log.build_channel,
build_commit=log.build_commit,
created_at=log.created_at,
)
)
return items
@router.post( @router.post(
+73
View File
@@ -0,0 +1,73 @@
"""Request-scoped metadata used by server-side audit writers."""
from __future__ import annotations
import re
from contextvars import ContextVar, Token
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class RequestAuditContext:
client_ip: str | None = None
user_agent: str | None = None
client_type: str | None = None
client_version: str | None = None
client_platform: str | None = None
build_channel: str | None = None
build_commit: str | None = None
_request_audit_context: ContextVar[RequestAuditContext | None] = ContextVar(
"request_audit_context",
default=None,
)
_SENSITIVE_TEXT_PATTERN = re.compile(
r"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]+|((?:access_)?token|authorization)=([^&\s]+)"
)
def _clean_header_value(value: str | None, max_length: int) -> str | None:
if value is None:
return None
cleaned = _SENSITIVE_TEXT_PATTERN.sub(lambda m: f"{m.group(1) or m.group(2) + '='}[redacted]", value.strip())
if not cleaned:
return None
return cleaned[:max_length]
def resolve_client_ip(request: Any) -> str | None:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return _clean_header_value(forwarded.split(",")[0].strip(), 45)
real_ip = request.headers.get("x-real-ip")
if real_ip:
return _clean_header_value(real_ip.strip(), 45)
return _clean_header_value(request.client.host if request.client else None, 45)
def build_request_audit_context(request: Any) -> RequestAuditContext:
headers = request.headers
return RequestAuditContext(
client_ip=resolve_client_ip(request),
user_agent=_clean_header_value(headers.get("user-agent"), 500),
client_type=_clean_header_value(headers.get("x-ctms-client-type"), 16),
client_version=_clean_header_value(headers.get("x-ctms-client-version"), 32),
client_platform=_clean_header_value(headers.get("x-ctms-client-platform"), 16),
build_channel=_clean_header_value(headers.get("x-ctms-build-channel"), 16),
build_commit=_clean_header_value(headers.get("x-ctms-build-commit"), 64),
)
def set_request_audit_context(context: RequestAuditContext) -> Token[RequestAuditContext | None]:
return _request_audit_context.set(context)
def reset_request_audit_context(token: Token[RequestAuditContext | None]) -> None:
_request_audit_context.reset(token)
def get_request_audit_context() -> RequestAuditContext | None:
return _request_audit_context.get()
+39
View File
@@ -4,8 +4,10 @@ import uuid
from typing import Sequence from typing import Sequence
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy import or_
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.request_context import get_request_audit_context
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
@@ -19,8 +21,16 @@ async def log_action(
detail: str | None, detail: str | None,
operator_id: uuid.UUID, operator_id: uuid.UUID,
operator_role: str, operator_role: str,
client_ip: str | None = None,
user_agent: str | None = None,
client_type: str | None = None,
client_version: str | None = None,
client_platform: str | None = None,
build_channel: str | None = None,
build_commit: str | None = None,
auto_commit: bool = True, auto_commit: bool = True,
) -> AuditLog: ) -> AuditLog:
request_context = get_request_audit_context()
log = AuditLog( log = AuditLog(
study_id=study_id, study_id=study_id,
entity_type=entity_type, entity_type=entity_type,
@@ -29,6 +39,13 @@ async def log_action(
detail=detail, detail=detail,
operator_id=operator_id, operator_id=operator_id,
operator_role=operator_role, operator_role=operator_role,
client_ip=client_ip if client_ip is not None else (request_context.client_ip if request_context else None),
user_agent=user_agent if user_agent is not None else (request_context.user_agent if request_context else None),
client_type=client_type if client_type is not None else (request_context.client_type if request_context else None),
client_version=client_version if client_version is not None else (request_context.client_version if request_context else None),
client_platform=client_platform if client_platform is not None else (request_context.client_platform if request_context else None),
build_channel=build_channel if build_channel is not None else (request_context.build_channel if request_context else None),
build_commit=build_commit if build_commit is not None else (request_context.build_commit if request_context else None),
) )
db.add(log) db.add(log)
if auto_commit: if auto_commit:
@@ -47,6 +64,10 @@ async def list_logs(
entity_id: uuid.UUID | None = None, entity_id: uuid.UUID | None = None,
action: str | None = None, action: str | None = None,
operator_id: uuid.UUID | None = None, operator_id: uuid.UUID | None = None,
client_ip: str | None = None,
client_type: str | None = None,
start_time=None,
end_time=None,
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
) -> Sequence[AuditLog]: ) -> Sequence[AuditLog]:
@@ -59,6 +80,24 @@ async def list_logs(
stmt = stmt.where(AuditLog.action == action) stmt = stmt.where(AuditLog.action == action)
if operator_id: if operator_id:
stmt = stmt.where(AuditLog.operator_id == operator_id) stmt = stmt.where(AuditLog.operator_id == operator_id)
if client_ip:
stmt = stmt.where(AuditLog.client_ip.ilike(f"%{client_ip.strip()}%"))
if client_type:
normalized_client_type = client_type.strip().lower()
if normalized_client_type == "unknown":
stmt = stmt.where(
or_(
AuditLog.client_type.is_(None),
AuditLog.client_type == "",
~AuditLog.client_type.in_(("web", "desktop")),
)
)
else:
stmt = stmt.where(AuditLog.client_type == normalized_client_type)
if start_time:
stmt = stmt.where(AuditLog.created_at >= start_time)
if end_time:
stmt = stmt.where(AuditLog.created_at <= end_time)
stmt = stmt.order_by(AuditLog.created_at.desc()).offset(skip).limit(limit) stmt = stmt.order_by(AuditLog.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt) result = await db.execute(stmt)
return result.scalars().all() return result.scalars().all()
+58 -57
View File
@@ -25,6 +25,12 @@ from app.services.security_access_log_writer import (
stop_security_log_writer, stop_security_log_writer,
) )
from app.core.security import decode_token from app.core.security import decode_token
from app.core.request_context import (
build_request_audit_context,
reset_request_audit_context,
resolve_client_ip,
set_request_audit_context,
)
logger = logging.getLogger("ctms.setup_config") logger = logging.getLogger("ctms.setup_config")
UUID_RE = re.compile( UUID_RE = re.compile(
@@ -137,60 +143,65 @@ def create_app() -> FastAPI:
is_setup_config_path = "/api/v1/studies/" in path and "/setup-config" in path is_setup_config_path = "/api/v1/studies/" in path and "/setup-config" in path
should_security_log = path.startswith("/api/") should_security_log = path.startswith("/api/")
started_at = time.perf_counter() started_at = time.perf_counter()
audit_context = build_request_audit_context(request)
audit_context_token = set_request_audit_context(audit_context)
status_code = 500 status_code = 500
try: try:
response = await call_next(request) try:
except Exception: response = await call_next(request)
except Exception:
if is_setup_config_path:
normalized_path = UUID_RE.sub("{study_id}", path)
duration_ms = int((time.perf_counter() - started_at) * 1000)
key = f"{request.method} {normalized_path} 5xx"
setup_config_stats[key] += 1
logger.exception(
"setup_config_request_error method=%s path=%s status=%s duration_ms=%s",
request.method,
normalized_path,
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: if is_setup_config_path:
normalized_path = UUID_RE.sub("{study_id}", path) normalized_path = UUID_RE.sub("{study_id}", path)
duration_ms = int((time.perf_counter() - started_at) * 1000) duration_ms = int((time.perf_counter() - started_at) * 1000)
key = f"{request.method} {normalized_path} 5xx" status_bucket = f"{status_code // 100}xx"
key = f"{request.method} {normalized_path} {status_bucket}"
setup_config_stats[key] += 1 setup_config_stats[key] += 1
logger.exception( if status_code >= 500:
"setup_config_request_error method=%s path=%s status=%s duration_ms=%s", logger.error(
request.method, "setup_config_request method=%s path=%s status=%s duration_ms=%s",
normalized_path, request.method,
500, normalized_path,
duration_ms, status_code,
) duration_ms,
)
elif status_code >= 400:
logger.warning(
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
request.method,
normalized_path,
status_code,
duration_ms,
)
else:
logger.info(
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
request.method,
normalized_path,
status_code,
duration_ms,
)
if should_security_log: if should_security_log:
_enqueue_security_access_log(request, path, status_code, started_at) _enqueue_security_access_log(request, path, status_code, started_at)
raise return response
finally:
status_code = int(response.status_code) reset_request_audit_context(audit_context_token)
if is_setup_config_path:
normalized_path = UUID_RE.sub("{study_id}", path)
duration_ms = int((time.perf_counter() - started_at) * 1000)
status_bucket = f"{status_code // 100}xx"
key = f"{request.method} {normalized_path} {status_bucket}"
setup_config_stats[key] += 1
if status_code >= 500:
logger.error(
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
request.method,
normalized_path,
status_code,
duration_ms,
)
elif status_code >= 400:
logger.warning(
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
request.method,
normalized_path,
status_code,
duration_ms,
)
else:
logger.info(
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
request.method,
normalized_path,
status_code,
duration_ms,
)
if should_security_log:
_enqueue_security_access_log(request, path, status_code, started_at)
return response
register_exception_handlers(app) register_exception_handlers(app)
@@ -240,16 +251,6 @@ def create_app() -> FastAPI:
app = create_app() 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]: def _resolve_auth_context(request) -> tuple[str, str | None]:
authorization = request.headers.get("authorization") or "" authorization = request.headers.get("authorization") or ""
if not authorization.lower().startswith("bearer "): if not authorization.lower().startswith("bearer "):
@@ -276,7 +277,7 @@ def _enqueue_security_access_log(request, path: str, status_code: int, started_a
"path": path, "path": path,
"status_code": status_code, "status_code": status_code,
"elapsed_ms": round((time.perf_counter() - started_at) * 1000, 2), "elapsed_ms": round((time.perf_counter() - started_at) * 1000, 2),
"client_ip": _resolve_client_ip(request), "client_ip": resolve_client_ip(request),
"user_agent": request.headers.get("user-agent"), "user_agent": request.headers.get("user-agent"),
"client_type": request.headers.get("x-ctms-client-type"), "client_type": request.headers.get("x-ctms-client-type"),
"client_version": request.headers.get("x-ctms-client-version"), "client_version": request.headers.get("x-ctms-client-version"),
+13 -1
View File
@@ -13,7 +13,12 @@ from app.db.base_class import Base
class AuditLog(Base): class AuditLog(Base):
__tablename__ = "audit_logs" __tablename__ = "audit_logs"
__table_args__ = (Index("ix_audit_logs_entity_at", "entity_type", "entity_id", "created_at"),) __table_args__ = (
Index("ix_audit_logs_entity_at", "entity_type", "entity_id", "created_at"),
Index("ix_audit_logs_operator_created", "operator_id", "created_at"),
Index("ix_audit_logs_client_ip_created", "client_ip", "created_at"),
Index("ix_audit_logs_client_source_created", "client_type", "created_at"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=True) study_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=True)
@@ -23,4 +28,11 @@ class AuditLog(Base):
detail: Mapped[Optional[str]] = mapped_column(Text, nullable=True) detail: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
operator_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) operator_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
operator_role: Mapped[str] = mapped_column(String(50), nullable=False) operator_role: Mapped[str] = mapped_column(String(50), nullable=False)
client_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
client_type: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
client_version: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
client_platform: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
build_channel: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
build_commit: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+17
View File
@@ -7,10 +7,27 @@ from pydantic import BaseModel, ConfigDict
class AuditLogRead(BaseModel): class AuditLogRead(BaseModel):
id: uuid.UUID id: uuid.UUID
study_id: Optional[uuid.UUID] = None
entity_type: str
entity_id: Optional[uuid.UUID] = None
action: str action: str
detail: Optional[str] detail: Optional[str]
operator_id: uuid.UUID operator_id: uuid.UUID
operator_name: Optional[str] = None
operator_email: Optional[str] = None
operator_role: str operator_role: str
client_ip: Optional[str] = None
ip_location: str = ""
ip_country: str = ""
ip_province: str = ""
ip_city: str = ""
ip_isp: str = ""
user_agent: Optional[str] = None
client_type: Optional[str] = None
client_version: Optional[str] = None
client_platform: Optional[str] = None
build_channel: Optional[str] = None
build_commit: Optional[str] = None
created_at: datetime created_at: datetime
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -0,0 +1,62 @@
import uuid
import pytest
from app.core.request_context import (
RequestAuditContext,
reset_request_audit_context,
set_request_audit_context,
)
from app.crud.audit import log_action
class FakeAsyncSession:
def __init__(self) -> None:
self.added = None
self.flushed = False
def add(self, value) -> None:
self.added = value
async def flush(self) -> None:
self.flushed = True
@pytest.mark.asyncio
async def test_audit_log_action_captures_request_access_context():
context_token = set_request_audit_context(
RequestAuditContext(
client_ip="203.0.113.10",
user_agent="curl/8.1.2",
client_type="desktop",
client_version="1.2.3",
client_platform="macos",
build_channel="release",
build_commit="abcdef1",
)
)
db = FakeAsyncSession()
try:
log = await log_action(
db,
study_id=uuid.uuid4(),
entity_type="subject",
entity_id=uuid.uuid4(),
action="UPDATE_SUBJECT",
detail=None,
operator_id=uuid.uuid4(),
operator_role="PM",
auto_commit=False,
)
finally:
reset_request_audit_context(context_token)
assert db.added is log
assert db.flushed is True
assert log.client_ip == "203.0.113.10"
assert log.user_agent == "curl/8.1.2"
assert log.client_type == "desktop"
assert log.client_version == "1.2.3"
assert log.client_platform == "macos"
assert log.build_channel == "release"
assert log.build_commit == "abcdef1"
+10
View File
@@ -455,6 +455,13 @@ CREATE TABLE IF NOT EXISTS public.audit_logs (
detail text, detail text,
operator_id uuid NOT NULL, operator_id uuid NOT NULL,
operator_role character varying(50) NOT NULL, operator_role character varying(50) NOT NULL,
client_ip character varying(45),
user_agent character varying(500),
client_type character varying(16),
client_version character varying(32),
client_platform character varying(16),
build_channel character varying(16),
build_commit character varying(64),
created_at timestamp with time zone NOT NULL DEFAULT now(), created_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT fk_audit_logs_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT, CONSTRAINT fk_audit_logs_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT,
CONSTRAINT fk_audit_logs_operator_id FOREIGN KEY (operator_id) REFERENCES public.users(id) ON DELETE RESTRICT CONSTRAINT fk_audit_logs_operator_id FOREIGN KEY (operator_id) REFERENCES public.users(id) ON DELETE RESTRICT
@@ -950,6 +957,9 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_document_versions_pending ON public.documen
CREATE INDEX IF NOT EXISTS ix_distributions_version_target ON public.distributions (version_id, target_type, target_id); CREATE INDEX IF NOT EXISTS ix_distributions_version_target ON public.distributions (version_id, target_type, target_id);
CREATE INDEX IF NOT EXISTS ix_acknowledgements_distribution_type ON public.acknowledgements (distribution_id, ack_type); CREATE INDEX IF NOT EXISTS ix_acknowledgements_distribution_type ON public.acknowledgements (distribution_id, ack_type);
CREATE INDEX IF NOT EXISTS ix_audit_logs_entity_at ON public.audit_logs (entity_type, entity_id, created_at); CREATE INDEX IF NOT EXISTS ix_audit_logs_entity_at ON public.audit_logs (entity_type, entity_id, created_at);
CREATE INDEX IF NOT EXISTS ix_audit_logs_operator_created ON public.audit_logs (operator_id, created_at);
CREATE INDEX IF NOT EXISTS ix_audit_logs_client_ip_created ON public.audit_logs (client_ip, created_at);
CREATE INDEX IF NOT EXISTS ix_audit_logs_client_source_created ON public.audit_logs (client_type, created_at);
DO $$ DO $$
BEGIN BEGIN
+2 -2
View File
@@ -1,8 +1,8 @@
import { apiGet, apiPost } from "./axios"; import { apiGet, apiPost } from "./axios";
import type { ApiListResponse } from "../types/api"; import type { ApiListResponse, AuditLogItem } from "../types/api";
export const fetchAuditLogs = (studyId: string, params?: Record<string, any>) => export const fetchAuditLogs = (studyId: string, params?: Record<string, any>) =>
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/audit-logs/`, { params }); apiGet<ApiListResponse<AuditLogItem>>(`/api/v1/studies/${studyId}/audit-logs/`, { params });
export const createAuditEvent = (studyId: string, payload: Record<string, any>) => export const createAuditEvent = (studyId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/audit-logs/events`, payload); apiPost(`/api/v1/studies/${studyId}/audit-logs/events`, payload);
+43 -1
View File
@@ -1,7 +1,49 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { normalizeAuditEvent } from "."; import { normalizeAuditEvent, resolveAuditClientSourceLabel } from ".";
describe("normalizeAuditEvent", () => { describe("normalizeAuditEvent", () => {
it("normalizes request source and IP context for access audit troubleshooting", () => {
const event = normalizeAuditEvent(
{
action: "UPDATE_SUBJECT",
detail: JSON.stringify({ targetName: "S001" }),
operator_id: "operator-1",
operator_name: "张三",
operator_email: "pm@example.com",
operator_role: "PM",
entity_type: "subject",
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
client_ip: "203.0.113.10",
ip_country: "中国",
ip_province: "重庆",
ip_city: "渝中",
ip_isp: "电信",
client_type: "desktop",
client_version: "1.2.3",
client_platform: "macos",
build_channel: "release",
build_commit: "abcdef1",
user_agent: "CTMS Desktop",
created_at: "2026-07-09T10:00:00Z",
},
{}
);
expect(event.actorName).toBe("张三");
expect(event.actorAccount).toBe("pm@example.com");
expect(event.clientIp).toBe("203.0.113.10");
expect(event.ipLocation).toBe("重庆 / 渝中 / 电信");
expect(event.clientSourceLabel).toBe("桌面端 / macos");
expect(event.clientType).toBe("desktop");
expect(event.clientVersion).toBe("1.2.3");
expect(event.buildCommit).toBe("abcdef1");
});
it("labels unmarked automation clients as script or command-line sources", () => {
expect(resolveAuditClientSourceLabel({ user_agent: "curl/8.1.2" })).toBe("脚本/命令行");
expect(resolveAuditClientSourceLabel({ user_agent: "" })).toBe("未知来源");
});
it("removes UUID tokens from legacy audit detail text", () => { it("removes UUID tokens from legacy audit detail text", () => {
const event = normalizeAuditEvent( const event = normalizeAuditEvent(
{ {
@@ -19,5 +19,9 @@ export const auditExportColumns: AuditExportColumn[] = [
{ key: "resultLabel", label: TEXT.audit.exportColumns.resultLabel }, { key: "resultLabel", label: TEXT.audit.exportColumns.resultLabel },
{ key: "reason", label: TEXT.audit.exportColumns.reason }, { key: "reason", label: TEXT.audit.exportColumns.reason },
{ key: "ip", label: TEXT.audit.exportColumns.ip }, { key: "ip", label: TEXT.audit.exportColumns.ip },
{ key: "ipLocation", label: TEXT.audit.exportColumns.ipLocation },
{ key: "clientSource", label: TEXT.audit.exportColumns.clientSource },
{ key: "clientType", label: TEXT.audit.exportColumns.clientType },
{ key: "userAgent", label: TEXT.audit.exportColumns.userAgent },
{ key: "remark", label: TEXT.audit.exportColumns.remark }, { key: "remark", label: TEXT.audit.exportColumns.remark },
]; ];
@@ -43,7 +43,11 @@ export const formatAuditRow = (event: AuditEvent): AuditExportRow => {
afterStatus, afterStatus,
resultLabel: event.resultLabel || "", resultLabel: event.resultLabel || "",
reason: event.reason || "", reason: event.reason || "",
ip: "", ip: event.clientIp || "",
ipLocation: event.ipLocation || "",
clientSource: event.clientSourceLabel || "",
clientType: [event.clientType, event.clientVersion, event.clientPlatform].filter(Boolean).join("/") || "",
userAgent: event.userAgent || "",
remark: event.diffText?.join("") || "", remark: event.diffText?.join("") || "",
}; };
}; };
+80 -1
View File
@@ -7,6 +7,7 @@ export interface AuditEvent {
eventLabel: string; eventLabel: string;
actorId: string; actorId: string;
actorName: string; actorName: string;
actorAccount?: string;
actorRole: string; actorRole: string;
actorRoleLabel: string; actorRoleLabel: string;
targetType: string; targetType: string;
@@ -21,6 +22,19 @@ export interface AuditEvent {
result: "SUCCESS" | "FAIL"; result: "SUCCESS" | "FAIL";
resultLabel: string; resultLabel: string;
timestamp: string; timestamp: string;
clientIp?: string;
ipLocation?: string;
ipCountry?: string;
ipProvince?: string;
ipCity?: string;
ipIsp?: string;
clientType?: string;
clientVersion?: string;
clientPlatform?: string;
buildChannel?: string;
buildCommit?: string;
userAgent?: string;
clientSourceLabel?: string;
} }
const entityTypeLabelMap: Record<string, string> = { const entityTypeLabelMap: Record<string, string> = {
@@ -619,6 +633,57 @@ const buildDiffText = (
return lines; return lines;
}; };
const fallbackIpLocation = (ip: string | null | undefined): string => {
if (!ip) return "";
if (ip === "127.0.0.1" || ip === "::1") return "本机访问";
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[0-1])\.)/.test(ip)) return "内网地址";
return "";
};
export const formatAuditIpLocation = (raw: {
ip_country?: string | null;
ip_province?: string | null;
ip_city?: string | null;
ip_isp?: string | null;
ip_location?: string | null;
client_ip?: string | null;
clientIp?: string | null;
}): string => {
const parts = [
raw.ip_country && raw.ip_country !== "中国" ? raw.ip_country : "",
raw.ip_province,
raw.ip_city,
raw.ip_isp,
].filter(Boolean);
if (parts.length) return parts.join(" / ");
return raw.ip_location || fallbackIpLocation(raw.client_ip || raw.clientIp) || "";
};
const isAutomationUserAgent = (userAgent = ""): boolean =>
/curl|wget|python-requests|httpie|go-http-client|node-fetch|axios|postman|insomnia|okhttp|java|powershell|libwww/i.test(userAgent);
const isBrowserUserAgent = (userAgent = ""): boolean =>
/mozilla|chrome|safari|firefox|edg\//i.test(userAgent);
export const resolveAuditClientSourceLabel = (raw: {
client_type?: string | null;
client_platform?: string | null;
user_agent?: string | null;
clientType?: string | null;
clientPlatform?: string | null;
userAgent?: string | null;
}): string => {
const clientType = String(raw.client_type || raw.clientType || "").trim().toLowerCase();
const platform = String(raw.client_platform || raw.clientPlatform || "").trim();
const userAgent = String(raw.user_agent || raw.userAgent || "").trim();
if (clientType === "web") return "网页端";
if (clientType === "desktop") return platform ? `桌面端 / ${platform}` : "桌面端";
if (clientType) return `未知客户端 / ${clientType}`;
if (isAutomationUserAgent(userAgent)) return "脚本/命令行";
if (isBrowserUserAgent(userAgent)) return "浏览器(未标识)";
return "未知来源";
};
export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>): AuditEvent => { export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>): AuditEvent => {
const dict = auditDict[raw.action] || buildReadableAuditDict(raw.action, raw.entity_type); const dict = auditDict[raw.action] || buildReadableAuditDict(raw.action, raw.entity_type);
let detailObj: any = {}; let detailObj: any = {};
@@ -668,7 +733,8 @@ export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>):
eventType: raw.action, eventType: raw.action,
eventLabel: dict.label, eventLabel: dict.label,
actorId: raw.operator_id, actorId: raw.operator_id,
actorName: userMap[raw.operator_id] || raw.operator_id, actorName: raw.operator_name || userMap[raw.operator_id] || raw.operator_id,
actorAccount: raw.operator_email || raw.operator_id,
actorRole: raw.operator_role, actorRole: raw.operator_role,
actorRoleLabel: getDictLabel(roleDict, raw.operator_role), actorRoleLabel: getDictLabel(roleDict, raw.operator_role),
targetType: raw.entity_type || "", targetType: raw.entity_type || "",
@@ -683,6 +749,19 @@ export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>):
result: detailObj.result || "SUCCESS", result: detailObj.result || "SUCCESS",
resultLabel: detailObj.result === "FAIL" ? TEXT.audit.resultFail : TEXT.audit.resultSuccess, resultLabel: detailObj.result === "FAIL" ? TEXT.audit.resultFail : TEXT.audit.resultSuccess,
timestamp: raw.created_at, timestamp: raw.created_at,
clientIp: raw.client_ip || "",
ipLocation: formatAuditIpLocation(raw),
ipCountry: raw.ip_country || "",
ipProvince: raw.ip_province || "",
ipCity: raw.ip_city || "",
ipIsp: raw.ip_isp || "",
clientType: raw.client_type || "",
clientVersion: raw.client_version || "",
clientPlatform: raw.client_platform || "",
buildChannel: raw.build_channel || "",
buildCommit: raw.build_commit || "",
userAgent: raw.user_agent || "",
clientSourceLabel: resolveAuditClientSourceLabel(raw),
}; };
}; };
@@ -73,9 +73,11 @@ describe("desktop layout shell", () => {
it("keeps web project switching routed through the workbench entry", () => { it("keeps web project switching routed through the workbench entry", () => {
const webLayout = readWebLayoutSource(); const webLayout = readWebLayoutSource();
expect(webLayout).toContain('class="workbench-return-button"'); expect(webLayout).toContain('v-if="hasProjectContext" class="workbench-return-button"');
expect(webLayout).toContain('await router.push("/workbench")'); expect(webLayout).toContain('await router.push("/workbench")');
expect(webLayout).toContain("const showAdminNavigation = computed(() => Boolean(auth.user) && (!study.currentStudy || isAdminContext.value))"); expect(webLayout).toContain("const hasProjectContext = computed(() => Boolean(study.currentStudy) && !isAdminContext.value);");
expect(webLayout).toContain('v-if="hasProjectContext && hasAnyProjectModuleAccess"');
expect(webLayout).toContain("const showAdminNavigation = computed(() => Boolean(auth.user) && !hasProjectContext.value)");
expect(webLayout).not.toContain("hasDropdown: true"); expect(webLayout).not.toContain("hasDropdown: true");
expect(webLayout).not.toContain("type: 'study'"); expect(webLayout).not.toContain("type: 'study'");
expect(webLayout).not.toContain('type: "study"'); expect(webLayout).not.toContain('type: "study"');
+13 -12
View File
@@ -59,7 +59,7 @@
</el-sub-menu> </el-sub-menu>
</el-menu-item-group> </el-menu-item-group>
<el-menu-item-group v-if="study.currentStudy && hasAnyProjectModuleAccess" class="menu-group"> <el-menu-item-group v-if="hasProjectContext && hasAnyProjectModuleAccess" class="menu-group">
<template #title> <template #title>
<span class="menu-divider">{{ TEXT.menu.currentProject }}</span> <span class="menu-divider">{{ TEXT.menu.currentProject }}</span>
</template> </template>
@@ -204,7 +204,7 @@
</div> </div>
<div class="header-right"> <div class="header-right">
<button v-if="study.currentStudy" class="workbench-return-button" type="button" @click="openWorkbenchEntry"> <button v-if="hasProjectContext" class="workbench-return-button" type="button" @click="openWorkbenchEntry">
<el-icon><Suitcase /></el-icon> <el-icon><Suitcase /></el-icon>
<span>工作台</span> <span>工作台</span>
</button> </button>
@@ -468,6 +468,8 @@ const headerReminderStats = ref({
const headerClockNow = ref(new Date()); const headerClockNow = ref(new Date());
let headerClockTimer: number | undefined; let headerClockTimer: number | undefined;
let desktopMenuUnlisten: (() => void) | undefined; let desktopMenuUnlisten: (() => void) | undefined;
const isAdminContext = computed(() => route.path.startsWith("/admin"));
const hasProjectContext = computed(() => Boolean(study.currentStudy) && !isAdminContext.value);
/* 动态设置 body 上的侧边栏宽度 CSS 变量,用于全局弹窗定位偏移 */ /* 动态设置 body 上的侧边栏宽度 CSS 变量,用于全局弹窗定位偏移 */
const hasSidebar = computed(() => isAdmin.value || !!study.currentStudy); const hasSidebar = computed(() => isAdmin.value || !!study.currentStudy);
@@ -489,10 +491,9 @@ const userDisplayInitial = computed(() => {
return source.charAt(0).toUpperCase(); return source.charAt(0).toUpperCase();
}); });
const isAdminContext = computed(() => route.path.startsWith("/admin")); const showAdminNavigation = computed(() => Boolean(auth.user) && !hasProjectContext.value);
const showAdminNavigation = computed(() => Boolean(auth.user) && (!study.currentStudy || isAdminContext.value)); const canReadRiskIssueAes = computed(() => hasProjectContext.value && canAccessProjectPath("/risk-issues/sae"));
const canReadRiskIssueAes = computed(() => !isAdminContext.value && canAccessProjectPath("/risk-issues/sae")); const canReadMonitoringIssues = computed(() => hasProjectContext.value && canAccessProjectPath("/risk-issues/monitoring-visits"));
const canReadMonitoringIssues = computed(() => !isAdminContext.value && canAccessProjectPath("/risk-issues/monitoring-visits"));
const hasAnyRiskReminderAccess = computed(() => canReadRiskIssueAes.value || canReadMonitoringIssues.value); const hasAnyRiskReminderAccess = computed(() => canReadRiskIssueAes.value || canReadMonitoringIssues.value);
const toHeaderNumber = (value: unknown) => { const toHeaderNumber = (value: unknown) => {
@@ -518,7 +519,7 @@ const headerClockText = computed(() => formatHeaderDateTime(headerClockNow.value
const loadHeaderOverviewStats = async () => { const loadHeaderOverviewStats = async () => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
if (!studyId || isAdminContext.value) { if (!studyId || !hasProjectContext.value) {
headerOverviewStats.value = null; headerOverviewStats.value = null;
return; return;
} }
@@ -541,7 +542,7 @@ const loadHeaderOverviewStats = async () => {
const loadHeaderReminders = async () => { const loadHeaderReminders = async () => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
if (!studyId || isAdminContext.value || !hasAnyRiskReminderAccess.value) { if (!studyId || !hasProjectContext.value || !hasAnyRiskReminderAccess.value) {
headerReminderStats.value = { overdueAes: 0, overdueMonitoringIssues: 0 }; headerReminderStats.value = { overdueAes: 0, overdueMonitoringIssues: 0 };
return; return;
} }
@@ -602,7 +603,7 @@ const headerReminderBadgeValue = computed(() =>
// 顶栏中间区:项目上下文(状态 + 关键进度 + 数据时间) // 顶栏中间区:项目上下文(状态 + 关键进度 + 数据时间)
const headerProjectInfo = computed(() => { const headerProjectInfo = computed(() => {
if (isAdminContext.value || !study.currentStudy) return null; if (!hasProjectContext.value || !study.currentStudy) return null;
const s = study.currentStudy; const s = study.currentStudy;
const status = (s.status || "").toUpperCase(); const status = (s.status || "").toUpperCase();
const statusLabel = (TEXT.enums.projectStatus as Record<string, string>)[status] || s.status || ""; const statusLabel = (TEXT.enums.projectStatus as Record<string, string>)[status] || s.status || "";
@@ -640,7 +641,7 @@ const headerProjectInfo = computed(() => {
}); });
const accountProjectRoleLabel = computed(() => { const accountProjectRoleLabel = computed(() => {
if (isAdminContext.value || !study.currentStudy) return ""; if (!hasProjectContext.value || !study.currentStudy) return "";
const roleCode = (projectRole.value || "").toUpperCase(); const roleCode = (projectRole.value || "").toUpperCase();
return roleCode ? ((TEXT.enums.userRole as Record<string, string>)[roleCode] || projectRole.value) : ""; return roleCode ? ((TEXT.enums.userRole as Record<string, string>)[roleCode] || projectRole.value) : "";
}); });
@@ -673,7 +674,7 @@ const desktopNavigationItems = computed<DesktopNavigationItem[]>(() => {
} }
} }
if (study.currentStudy && hasAnyProjectModuleAccess.value) { if (hasProjectContext.value && hasAnyProjectModuleAccess.value) {
const projectItems: DesktopNavigationItem[] = [ const projectItems: DesktopNavigationItem[] = [
{ label: TEXT.menu.projectOverview, path: "/project/overview", group: TEXT.menu.currentProject, keywords: ["overview"] }, { label: TEXT.menu.projectOverview, path: "/project/overview", group: TEXT.menu.currentProject, keywords: ["overview"] },
{ label: TEXT.menu.projectMilestones, path: "/project/milestones", group: TEXT.menu.currentProject, keywords: ["milestone"] }, { label: TEXT.menu.projectMilestones, path: "/project/milestones", group: TEXT.menu.currentProject, keywords: ["milestone"] },
@@ -896,7 +897,7 @@ const breadcrumbs = computed(() => {
}); });
return items; return items;
} }
} else if (study.currentStudy) { } else if (hasProjectContext.value && study.currentStudy) {
// 项目内不提供横向切换项目;需要切换时先回工作台重新选择。 // 项目内不提供横向切换项目;需要切换时先回工作台重新选择。
items.push({ items.push({
label: (study.currentStudy.name || "").replace(/^示例项目[:]/, ''), label: (study.currentStudy.name || "").replace(/^示例项目[:]/, ''),
+8
View File
@@ -242,6 +242,10 @@ export const TEXT = {
resultLabel: "操作结果", resultLabel: "操作结果",
reason: "拒绝原因", reason: "拒绝原因",
ip: "IP 地址", ip: "IP 地址",
ipLocation: "IP 位置",
clientSource: "请求来源",
clientType: "客户端标识",
userAgent: "User-Agent",
remark: "备注", remark: "备注",
}, },
eventDict: { eventDict: {
@@ -926,6 +930,9 @@ export const TEXT = {
filterEntityType: "对象类型", filterEntityType: "对象类型",
filterEvent: "操作类型", filterEvent: "操作类型",
filterOperator: "操作人", filterOperator: "操作人",
filterSource: "来源",
filterIpLocation: "IP / 位置",
filterKeyword: "账号、IP、位置或来源",
filterResult: "结果", filterResult: "结果",
rangeStart: "开始", rangeStart: "开始",
rangeEnd: "结束", rangeEnd: "结束",
@@ -941,6 +948,7 @@ export const TEXT = {
columns: { columns: {
time: "时间", time: "时间",
actor: "操作人", actor: "操作人",
source: "请求来源",
event: "操作类型", event: "操作类型",
action: "内容", action: "内容",
target: "对象", target: "对象",
+26
View File
@@ -402,6 +402,32 @@ export interface SecurityAccessLogsResponse {
items: SecurityAccessLogItem[]; items: SecurityAccessLogItem[];
} }
export interface AuditLogItem {
id: string;
study_id: string | null;
entity_type: string;
entity_id: string | null;
action: string;
detail: string | null;
operator_id: string;
operator_name: string | null;
operator_email: string | null;
operator_role: string;
client_ip: string | null;
ip_location: string;
ip_country: string;
ip_province: string;
ip_city: string;
ip_isp: string;
user_agent: string | null;
client_type: string | null;
client_version: string | null;
client_platform: string | null;
build_channel: string | null;
build_commit: string | null;
created_at: string;
}
// 系统监测 - 趋势数据 // 系统监测 - 趋势数据
export interface TrendDataPoint { export interface TrendDataPoint {
bucket_time: string; bucket_time: string;
@@ -67,6 +67,30 @@ describe("audit logs access", () => {
expect(source).not.toContain("limit: 2000"); expect(source).not.toContain("limit: 2000");
}); });
it("surfaces request source, IP and location filters for audit troubleshooting", () => {
const source = readAuditLogsView();
expect(source).toContain("filters.clientType");
expect(source).toContain("filters.ipKeyword");
expect(source).toContain("TEXT.modules.adminAuditLogs.filterSource");
expect(source).toContain("TEXT.modules.adminAuditLogs.filterIpLocation");
expect(source).toContain("TEXT.modules.adminAuditLogs.columns.source");
expect(source).toContain("formatClientIpLine(scope.row)");
expect(source).toContain("client_ip: ipKeyword && isIpSearchToken(ipKeyword) ? ipKeyword : undefined");
});
it("includes request source context in audit exports", () => {
const columns = readFileSync(resolve(__dirname, "../../audit/export/auditExportColumns.ts"), "utf8");
const formatter = readFileSync(resolve(__dirname, "../../audit/export/auditExportFormatter.ts"), "utf8");
expect(columns).toContain('key: "ipLocation"');
expect(columns).toContain('key: "clientSource"');
expect(columns).toContain('key: "userAgent"');
expect(formatter).toContain("ip: event.clientIp || \"\"");
expect(formatter).toContain("clientSource: event.clientSourceLabel || \"\"");
expect(formatter).toContain("userAgent: event.userAgent || \"\"");
});
it("keeps the audit table compact by removing the duplicate content column", () => { it("keeps the audit table compact by removing the duplicate content column", () => {
const source = readAuditLogsView(); const source = readAuditLogsView();
+198 -34
View File
@@ -35,8 +35,8 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div> </div>
<div class="stat-body"> <div class="stat-body">
<span class="stat-value">{{ operatorCount }}</span> <span class="stat-value">{{ uniqueIpCount }}</span>
<span class="stat-label">操作人数</span> <span class="stat-label">来源 IP</span>
</div> </div>
</div> </div>
</div> </div>
@@ -59,6 +59,22 @@
<el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" /> <el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
</el-select> </el-select>
</div> </div>
<div class="filter-item-form">
<el-select v-model="filters.clientType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterSource" @change="onServerFilterChange" class="filter-select-source">
<el-option v-for="opt in sourceTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</div>
<div class="filter-item-form">
<el-input
v-model="filters.ipKeyword"
clearable
:placeholder="TEXT.modules.adminAuditLogs.filterIpLocation"
class="filter-input-ip"
@input="onLocalFilterChange"
@clear="onServerFilterChange"
@change="onServerFilterChange"
/>
</div>
<div class="filter-item-form"> <div class="filter-item-form">
<el-select v-model="filters.result" clearable :placeholder="TEXT.modules.adminAuditLogs.filterResult" @change="onLocalFilterChange" class="filter-select-result"> <el-select v-model="filters.result" clearable :placeholder="TEXT.modules.adminAuditLogs.filterResult" @change="onLocalFilterChange" class="filter-select-result">
<el-option :label="TEXT.audit.resultSuccess" value="SUCCESS" /> <el-option :label="TEXT.audit.resultSuccess" value="SUCCESS" />
@@ -105,6 +121,16 @@
<template #default="scope"> <template #default="scope">
<div class="actor-cell"> <div class="actor-cell">
<span class="actor-name">{{ scope.row.actorName }}</span> <span class="actor-name">{{ scope.row.actorName }}</span>
<span v-if="scope.row.actorAccount" class="actor-account">{{ scope.row.actorAccount }}</span>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.source" width="230">
<template #default="scope">
<div class="source-cell">
<span class="source-main">{{ scope.row.clientSourceLabel }}</span>
<span class="source-meta">{{ formatClientMeta(scope.row) }}</span>
<span class="source-ip">{{ formatClientIpLine(scope.row) }}</span>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
@@ -211,6 +237,34 @@
<span class="meta-value">{{ selectedLog.actionText || TEXT.common.fallback }}</span> <span class="meta-value">{{ selectedLog.actionText || TEXT.common.fallback }}</span>
</div> </div>
</div> </div>
<div class="detail-meta-grid">
<div class="meta-card">
<span class="meta-label">请求来源</span>
<span class="meta-value">{{ selectedLog.clientSourceLabel || TEXT.common.fallback }}</span>
</div>
<div class="meta-card">
<span class="meta-label">来源 IP</span>
<span class="meta-value">{{ selectedLog.clientIp || "未知 IP" }}</span>
</div>
<div class="meta-card">
<span class="meta-label">IP 位置</span>
<span class="meta-value">{{ selectedLog.ipLocation || TEXT.common.fallback }}</span>
</div>
<div class="meta-card">
<span class="meta-label">客户端</span>
<span class="meta-value">{{ formatClientMeta(selectedLog) }}</span>
</div>
</div>
<div class="detail-section">
<div class="detail-section-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 0 20"/><path d="M12 2a15.3 15.3 0 0 0 0 20"/></svg>
访问上下文
</div>
<div class="context-lines">
<div><span>User-Agent</span><code>{{ selectedLog.userAgent || TEXT.common.fallback }}</code></div>
<div><span>构建信息</span><code>{{ formatBuildMeta(selectedLog) }}</code></div>
</div>
</div>
<!-- 变更明细 --> <!-- 变更明细 -->
<div class="detail-section"> <div class="detail-section">
@@ -265,7 +319,6 @@ import { listMembers } from "../../api/members";
import { auditDict, normalizeAuditEvent } from "../../audit"; import { auditDict, normalizeAuditEvent } from "../../audit";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { roleDict, getDictLabel } from "../../dictionaries";
import { exportAuditCsv } from "../../audit/export/auditExportService"; import { exportAuditCsv } from "../../audit/export/auditExportService";
import { displayDateTime } from "../../utils/display"; import { displayDateTime } from "../../utils/display";
import { getProjectRole, isSystemAdmin } from "../../utils/roles"; import { getProjectRole, isSystemAdmin } from "../../utils/roles";
@@ -296,13 +349,15 @@ const DIFF_PREVIEW_LIMIT = 2;
const filters = ref({ const filters = ref({
eventType: "", eventType: "",
operatorId: "", operatorId: "",
clientType: "",
ipKeyword: "",
result: "", result: "",
range: [], range: [],
}); });
const successCount = computed(() => allLogs.value.filter(l => (l.result || 'SUCCESS') === 'SUCCESS').length); const successCount = computed(() => allLogs.value.filter(l => (l.result || 'SUCCESS') === 'SUCCESS').length);
const failCount = computed(() => allLogs.value.filter(l => l.result === 'FAIL').length); const failCount = computed(() => allLogs.value.filter(l => l.result === 'FAIL').length);
const operatorCount = computed(() => new Set(allLogs.value.map(l => l.actorId || l.actorName)).size); const uniqueIpCount = computed(() => new Set(allLogs.value.map(l => l.clientIp || "未知 IP")).size);
const formatDiffLine = (line: any): string => { const formatDiffLine = (line: any): string => {
if (typeof line === 'string') return line; if (typeof line === 'string') return line;
@@ -375,6 +430,11 @@ const eventTypeOptions = Object.entries(auditDict).map(([value, cfg]) => ({
value, value,
label: cfg.label, label: cfg.label,
})); }));
const sourceTypeOptions = [
{ value: "web", label: "网页端" },
{ value: "desktop", label: "桌面端" },
{ value: "unknown", label: "未知/异常" },
];
const resolveUserDisplayName = (u: any): string => { const resolveUserDisplayName = (u: any): string => {
return u?.full_name || u?.display_name || u?.username || u?.email || u?.id || TEXT.common.fallback; return u?.full_name || u?.display_name || u?.username || u?.email || u?.id || TEXT.common.fallback;
}; };
@@ -470,6 +530,34 @@ const fetchAuditLogPages = async (baseParams: Record<string, any> = {}) => {
return allItems; return allItems;
}; };
const isIpSearchToken = (value: string) => /^[0-9a-f:.]+$/i.test(value.trim());
const buildAuditServerParams = () => {
const ipKeyword = String(filters.value.ipKeyword || "").trim();
return {
action: filters.value.eventType || undefined,
operator_id: filters.value.operatorId || undefined,
client_type: filters.value.clientType || undefined,
client_ip: ipKeyword && isIpSearchToken(ipKeyword) ? ipKeyword : undefined,
};
};
const formatClientMeta = (log: any) => {
const meta = [log.clientType, log.clientVersion, log.clientPlatform].filter(Boolean).join("/");
if (meta) return meta;
return log.userAgent ? "未标识客户端" : TEXT.common.fallback;
};
const formatBuildMeta = (log: any) => {
const meta = [log.buildChannel, log.buildCommit].filter(Boolean).join("/");
return meta || TEXT.common.fallback;
};
const formatClientIpLine = (log: any) => {
const parts = [log.clientIp || "未知 IP", log.ipLocation].filter(Boolean);
return parts.join(" / ");
};
const loadLogs = async () => { const loadLogs = async () => {
if (!selectedStudyId.value) return; if (!selectedStudyId.value) return;
loading.value = true; loading.value = true;
@@ -477,10 +565,7 @@ const loadLogs = async () => {
if (!permissionMatrix.value) { if (!permissionMatrix.value) {
await loadPermissionMatrix(); await loadPermissionMatrix();
} }
const allItems = await fetchAuditLogPages({ const allItems = await fetchAuditLogPages(buildAuditServerParams());
action: filters.value.eventType || undefined,
operator_id: filters.value.operatorId || undefined,
});
enrichLogs(allItems); enrichLogs(allItems);
refreshPagedLogs(); refreshPagedLogs();
} catch (e: any) { } catch (e: any) {
@@ -498,20 +583,36 @@ const enrichLogs = (items: any[]) => {
allLogs.value = items allLogs.value = items
.map((log) => normalizeAuditEvent(log, userMap)) .map((log) => normalizeAuditEvent(log, userMap))
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
if (users.value.length === 0) { const byActor = new Map(users.value.map((user) => [user.id, resolveUserDisplayName(user)]));
const byActor = new Map<string, string>(); allLogs.value.forEach((log) => {
allLogs.value.forEach((log) => { if (!byActor.has(log.actorId)) {
if (!byActor.has(log.actorId)) { byActor.set(log.actorId, log.actorName || log.actorAccount || log.actorId);
byActor.set(log.actorId, log.actorName || log.actorId); }
} });
}); users.value = Array.from(byActor.entries()).map(([id, full_name]) => ({ id, full_name }));
users.value = Array.from(byActor.entries()).map(([id, full_name]) => ({ id, full_name }));
}
}; };
const filterLogs = (items: any[]) => const filterLogs = (items: any[]) =>
items.filter((log) => { items.filter((log) => {
if (filters.value.result && (log.result || "SUCCESS") !== filters.value.result) return false; if (filters.value.result && (log.result || "SUCCESS") !== filters.value.result) return false;
const ipKeyword = String(filters.value.ipKeyword || "").trim().toLowerCase();
if (ipKeyword) {
const haystack = [
log.clientIp,
log.ipLocation,
log.ipCountry,
log.ipProvince,
log.ipCity,
log.ipIsp,
log.clientSourceLabel,
log.clientType,
log.clientPlatform,
log.userAgent,
log.actorName,
log.actorAccount,
];
if (!haystack.some((value) => String(value || "").toLowerCase().includes(ipKeyword))) return false;
}
if (filters.value.range?.length === 2) { if (filters.value.range?.length === 2) {
const ts = new Date(log.timestamp); const ts = new Date(log.timestamp);
const start = new Date(filters.value.range[0]); const start = new Date(filters.value.range[0]);
@@ -582,24 +683,12 @@ const fetchAllForExport = async () => {
if (!selectedStudyId.value) return []; if (!selectedStudyId.value) return [];
exportLoading.value = true; exportLoading.value = true;
try { try {
const items = await fetchAuditLogPages({ const items = await fetchAuditLogPages(buildAuditServerParams());
action: filters.value.eventType || undefined,
operator_id: filters.value.operatorId || undefined,
});
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => { const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
acc[cur.id] = resolveUserDisplayName(cur); acc[cur.id] = resolveUserDisplayName(cur);
return acc; return acc;
}, {}); }, {});
const filtered = items.filter((log: any) => { return filterLogs(items.map((log: any) => normalizeAuditEvent(log, userMap)));
if (filters.value.range?.length === 2) {
const ts = new Date(log.created_at);
const start = new Date(filters.value.range[0]);
const end = new Date(filters.value.range[1]);
if (ts < start || ts > end) return false;
}
return true;
});
return filtered.map((log: any) => normalizeAuditEvent(log, userMap));
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminAuditLogs.exportLoadFailed); ElMessage.error(e?.response?.data?.message || TEXT.modules.adminAuditLogs.exportLoadFailed);
return []; return [];
@@ -701,7 +790,7 @@ onMounted(async () => {
width: 100%; width: 100%;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
flex-wrap: nowrap; flex-wrap: wrap;
} }
.filter-item-form { .filter-item-form {
@@ -712,9 +801,11 @@ onMounted(async () => {
.filter-select-comp { width: 132px; } .filter-select-comp { width: 132px; }
.filter-select-project { width: 180px; } .filter-select-project { width: 180px; }
.filter-select-source { width: 112px; }
.filter-input-ip { width: 170px; }
.filter-select-result { width: 96px; } .filter-select-result { width: 96px; }
.date-range-picker-comp { width: 248px !important; } .date-range-picker-comp { width: 248px !important; }
.filter-spacer { flex: 1; } .filter-spacer { flex: 1; min-width: 16px; }
.audit-export-dropdown { flex-shrink: 0; } .audit-export-dropdown { flex-shrink: 0; }
.audit-export-btn { .audit-export-btn {
@@ -748,12 +839,56 @@ onMounted(async () => {
} }
.actor-cell { .actor-cell {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0; min-width: 0;
} }
.actor-name { .actor-name,
.actor-account {
display: block; display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.actor-name {
font-size: 13px; font-size: 13px;
}
.actor-account {
font-size: 11px;
color: var(--ctms-text-secondary);
}
.source-cell {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.source-main {
display: inline-flex;
align-self: flex-start;
max-width: 100%;
padding: 1px 7px;
border-radius: 6px;
font-size: 12px;
font-weight: 700;
color: #155e75;
background: #ecfeff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-meta,
.source-ip {
display: block;
font-size: 11px;
color: var(--ctms-text-secondary);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -923,6 +1058,35 @@ onMounted(async () => {
color: var(--ctms-primary); color: var(--ctms-primary);
} }
.context-lines {
display: flex;
flex-direction: column;
gap: 8px;
}
.context-lines div {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
gap: 10px;
align-items: start;
}
.context-lines span {
color: var(--ctms-text-secondary);
font-size: 12px;
}
.context-lines code {
display: block;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--ctms-border-color);
background: var(--ctms-neutral-100);
color: var(--ctms-text-main);
white-space: pre-wrap;
word-break: break-all;
}
.diff-count { .diff-count {
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;