| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370 |
- """B7审计日志和统计查询;本模块不写入审计表。"""
- from __future__ import annotations
- from datetime import UTC, datetime, time, timedelta
- from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
- from flask import current_app
- from sqlalchemy import case, func, or_, select
- from werkzeug.datastructures import MultiDict
- from dms.common.enums import AuditAction, AuditResult, AuditTarget
- from dms.common.errors import InternalError, InvalidArgumentError
- from dms.common.pagination import PageRequest, page_result
- from dms.common.response import serialize_id
- from dms.extensions import db
- from dms.models import AuditLog, Document
- AUDIT_LIST_PARAMETERS = {
- "keyword",
- "createdFrom",
- "createdTo",
- "userId",
- "organizationId",
- "actionType",
- "operationResult",
- "targetType",
- "targetId",
- "page",
- "pageSize",
- "sortDirection",
- }
- TIME_PARAMETERS = {"createdFrom", "createdTo"}
- def _validate_parameters(params: MultiDict, allowed: set[str]) -> None:
- unknown = set(params) - allowed
- if unknown:
- raise InvalidArgumentError(f"不支持的查询参数:{sorted(unknown)[0]}")
- duplicate = next(
- (key for key in params if len(params.getlist(key)) != 1),
- None,
- )
- if duplicate is not None:
- raise InvalidArgumentError(f"查询参数{duplicate}不能重复")
- def _positive_integer(
- params: MultiDict,
- name: str,
- *,
- default: int | None = None,
- maximum: int | None = None,
- ) -> int | None:
- raw = params.get(name)
- if raw is None:
- return default
- if not raw.isdecimal() or int(raw) < 1:
- raise InvalidArgumentError(f"{name}必须是正整数字符串")
- value = int(raw)
- if maximum is not None and value > maximum:
- raise InvalidArgumentError(f"{name}不得大于{maximum}")
- return value
- def _enum_value(params: MultiDict, name: str, enum_type) -> str | None:
- raw = params.get(name)
- if raw is None:
- return None
- try:
- return enum_type(raw).value
- except ValueError as exc:
- raise InvalidArgumentError(f"{name}不是有效枚举值") from exc
- def _utc_parameter(params: MultiDict, name: str) -> datetime | None:
- raw = params.get(name)
- if raw is None:
- return None
- if not raw or not raw.endswith("Z"):
- raise InvalidArgumentError(f"{name}必须是带Z的ISO 8601 UTC时间")
- try:
- parsed = datetime.fromisoformat(f"{raw[:-1]}+00:00")
- except ValueError as exc:
- raise InvalidArgumentError(f"{name}必须是带Z的ISO 8601 UTC时间") from exc
- if parsed.tzinfo is None or parsed.utcoffset() != timedelta(0):
- raise InvalidArgumentError(f"{name}必须是带Z的ISO 8601 UTC时间")
- return parsed.astimezone(UTC).replace(tzinfo=None)
- def _time_filters(params: MultiDict) -> list:
- created_from = _utc_parameter(params, "createdFrom")
- created_to = _utc_parameter(params, "createdTo")
- if (
- created_from is not None
- and created_to is not None
- and created_from >= created_to
- ):
- raise InvalidArgumentError("createdFrom必须早于createdTo")
- filters = []
- if created_from is not None:
- filters.append(AuditLog.created_at >= created_from)
- if created_to is not None:
- filters.append(AuditLog.created_at < created_to)
- return filters
- def _format_utc(value: datetime) -> str:
- return (
- value.replace(tzinfo=UTC)
- .isoformat(timespec="milliseconds")
- .replace("+00:00", "Z")
- )
- def _escape_like(value: str) -> str:
- return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
- def _audit_summary(item: AuditLog) -> dict:
- return {
- "id": serialize_id(item.id),
- "username": item.username,
- "realName": item.real_name,
- "organizationName": item.organization_name,
- "actionType": item.action_type,
- "targetType": item.target_type,
- "targetId": serialize_id(item.target_id),
- "targetName": item.target_name,
- "operationResult": item.operation_result,
- "failureReason": item.failure_reason,
- "clientIp": item.client_ip,
- "requestId": item.request_id,
- "createdAt": _format_utc(item.created_at),
- }
- def audit_log_page(params: MultiDict) -> dict:
- _validate_parameters(params, AUDIT_LIST_PARAMETERS)
- page = _positive_integer(params, "page", default=1)
- page_size = _positive_integer(
- params, "pageSize", default=20, maximum=100
- )
- assert page is not None and page_size is not None
- page_request = PageRequest(page=page, page_size=page_size)
- sort_direction = params.get("sortDirection", "desc")
- if sort_direction not in {"asc", "desc"}:
- raise InvalidArgumentError("sortDirection只允许asc或desc")
- filters = _time_filters(params)
- for parameter, column in (
- ("userId", AuditLog.user_id),
- ("organizationId", AuditLog.organization_id),
- ("targetId", AuditLog.target_id),
- ):
- value = _positive_integer(params, parameter)
- if value is not None:
- filters.append(column == value)
- for parameter, enum_type, column in (
- ("actionType", AuditAction, AuditLog.action_type),
- ("operationResult", AuditResult, AuditLog.operation_result),
- ("targetType", AuditTarget, AuditLog.target_type),
- ):
- value = _enum_value(params, parameter, enum_type)
- if value is not None:
- filters.append(column == value)
- keyword = (params.get("keyword") or "").strip()
- if keyword:
- pattern = f"%{_escape_like(keyword)}%"
- filters.append(
- or_(
- *[
- column.like(pattern, escape="\\")
- for column in (
- AuditLog.username,
- AuditLog.real_name,
- AuditLog.organization_name,
- AuditLog.target_name,
- AuditLog.failure_reason,
- AuditLog.request_id,
- )
- ]
- )
- )
- total = db.session.scalar(
- select(func.count(AuditLog.id)).where(*filters)
- ) or 0
- direction = (
- (AuditLog.created_at.asc(), AuditLog.id.asc())
- if sort_direction == "asc"
- else (AuditLog.created_at.desc(), AuditLog.id.desc())
- )
- items = db.session.scalars(
- select(AuditLog)
- .where(*filters)
- .order_by(*direction)
- .offset(page_request.offset)
- .limit(page_request.page_size)
- ).all()
- return page_result(
- [_audit_summary(item) for item in items],
- page=page_request.page,
- page_size=page_request.page_size,
- total=int(total),
- )
- def _utc_now() -> datetime:
- return datetime.now(UTC)
- def _business_day_utc_range() -> tuple[datetime, datetime]:
- timezone_name = current_app.config["DMS_BUSINESS_TIMEZONE"]
- try:
- business_timezone = ZoneInfo(timezone_name)
- except ZoneInfoNotFoundError as exc:
- raise InternalError("业务时区配置无效") from exc
- local_now = _utc_now().astimezone(business_timezone)
- start_local = datetime.combine(
- local_now.date(), time.min, tzinfo=business_timezone
- )
- end_local = start_local + timedelta(days=1)
- return (
- start_local.astimezone(UTC).replace(tzinfo=None),
- end_local.astimezone(UTC).replace(tzinfo=None),
- )
- def document_statistics(params: MultiDict) -> dict:
- _validate_parameters(params, set())
- counts = db.session.execute(
- select(
- func.coalesce(
- func.sum(case((Document.document_type == "MAIN", 1), else_=0)),
- 0,
- ),
- func.coalesce(
- func.sum(
- case((Document.document_type == "SUB_PLAN", 1), else_=0)
- ),
- 0,
- ),
- func.coalesce(
- func.sum(
- case((Document.document_type == "ATTACHMENT", 1), else_=0)
- ),
- 0,
- ),
- ).where(Document.is_deleted.is_(False))
- ).one()
- start_utc, end_utc = _business_day_utc_range()
- today_viewers = db.session.scalar(
- select(func.count(func.distinct(AuditLog.user_id))).where(
- AuditLog.action_type == AuditAction.VIEW_DOCUMENT.value,
- AuditLog.operation_result == AuditResult.SUCCESS.value,
- AuditLog.user_id.is_not(None),
- AuditLog.created_at >= start_utc,
- AuditLog.created_at < end_utc,
- )
- ) or 0
- main_count, sub_count, attachment_count = map(int, counts)
- return {
- "planDocumentCount": main_count + sub_count,
- "mainPlanCount": main_count,
- "subPlanCount": sub_count,
- "attachmentCount": attachment_count,
- "todayViewerCount": int(today_viewers),
- }
- def _period_expression(granularity: str):
- if granularity == "DAY":
- return func.date_format(AuditLog.created_at, "%Y-%m-%d")
- if granularity == "MONTH":
- return func.date_format(AuditLog.created_at, "%Y-%m")
- monday = func.from_days(
- func.to_days(AuditLog.created_at) - func.weekday(AuditLog.created_at)
- )
- return func.date_format(monday, "%Y-%m-%d")
- def trend_statistics(params: MultiDict) -> dict:
- _validate_parameters(params, TIME_PARAMETERS | {"granularity"})
- granularity = params.get("granularity")
- if granularity not in {"DAY", "WEEK", "MONTH"}:
- raise InvalidArgumentError("granularity只允许DAY、WEEK或MONTH且为必填")
- filters = _time_filters(params)
- period = _period_expression(granularity).label("period")
- operation_count = func.count(AuditLog.id).label("operation_count")
- rows = db.session.execute(
- select(period, operation_count)
- .where(*filters)
- .group_by(period)
- .order_by(period.asc())
- ).all()
- return {
- "items": [
- {"period": row.period, "operationCount": int(row.operation_count)}
- for row in rows
- ]
- }
- def action_statistics(params: MultiDict) -> dict:
- _validate_parameters(params, TIME_PARAMETERS)
- filters = _time_filters(params)
- count = func.count(AuditLog.id).label("action_count")
- rows = db.session.execute(
- select(AuditLog.action_type, count)
- .where(*filters)
- .group_by(AuditLog.action_type)
- .order_by(count.desc(), AuditLog.action_type.asc())
- ).all()
- return {
- "items": [
- {"actionType": row.action_type, "count": int(row.action_count)}
- for row in rows
- ]
- }
- def active_user_statistics(params: MultiDict) -> dict:
- _validate_parameters(params, TIME_PARAMETERS | {"limit"})
- limit = _positive_integer(params, "limit", default=10, maximum=100)
- assert limit is not None
- filters = _time_filters(params)
- ranked = (
- select(
- AuditLog.user_id.label("user_id"),
- AuditLog.real_name.label("real_name"),
- AuditLog.organization_name.label("organization_name"),
- func.count()
- .over(partition_by=AuditLog.user_id)
- .label("operation_count"),
- func.row_number()
- .over(
- partition_by=AuditLog.user_id,
- order_by=(AuditLog.created_at.desc(), AuditLog.id.desc()),
- )
- .label("snapshot_rank"),
- )
- .where(AuditLog.user_id.is_not(None), *filters)
- .subquery()
- )
- rows = db.session.execute(
- select(
- ranked.c.user_id,
- ranked.c.real_name,
- ranked.c.organization_name,
- ranked.c.operation_count,
- )
- .where(ranked.c.snapshot_rank == 1)
- .order_by(ranked.c.operation_count.desc(), ranked.c.user_id.asc())
- .limit(limit)
- ).all()
- return {
- "items": [
- {
- "userId": serialize_id(row.user_id),
- "realName": row.real_name,
- "organizationName": row.organization_name,
- "operationCount": int(row.operation_count),
- }
- for row in rows
- ]
- }
|