| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546 |
- """主案、子方案只读查询、DTO和查看审计。"""
- from __future__ import annotations
- import json
- import logging
- from datetime import datetime, timezone
- from typing import Any, Mapping
- from sqlalchemy import String, and_, cast, func, or_, select
- from dms.common.enums import (
- AuditAction,
- AuditTarget,
- DocumentStatus,
- DocumentType,
- RoleCode,
- SecurityLevel,
- VisibilityType,
- )
- from dms.common.errors import (
- DocumentViewForbiddenError,
- InvalidArgumentError,
- MainPlanNotFoundError,
- ResourceNotFoundError,
- SecurityLevelForbiddenError,
- )
- from dms.common.pagination import PageRequest, page_result
- from dms.common.response import serialize_id
- from dms.common.time_range import parse_datetime, parse_updated_range
- from dms.database.transaction import transaction
- from dms.extensions import db
- from dms.models import Category, Document, Permission
- from dms.security.auth_context import AuthContext, get_auth_context
- from dms.services.audit_service import business_audit
- from dms.services.authorization_service import (
- PlanAccess,
- evaluate_plan_access,
- plan_allowed_actions,
- )
- logger = logging.getLogger(__name__)
- PLAN_TYPES = (DocumentType.MAIN.value, DocumentType.SUB_PLAN.value)
- DOCUMENT_SORTS = {
- "documentName": Document.document_name,
- "createdAt": Document.created_at,
- "updatedAt": Document.updated_at,
- "viewCount": Document.view_count,
- "downloadCount": Document.download_count,
- }
- def parse_string_id(value: str, *, field: str = "id") -> int:
- if not value.isdecimal() or int(value) <= 0:
- raise InvalidArgumentError(f"{field}必须是正整数形式的字符串ID")
- return int(value)
- def _integer(
- params: Mapping[str, str],
- name: str,
- default: int,
- ) -> int:
- raw = params.get(name)
- if raw is None:
- return default
- if not raw.isdecimal():
- raise InvalidArgumentError(f"{name}必须是正整数")
- return int(raw)
- def _page(params: Mapping[str, str]) -> PageRequest:
- return PageRequest(
- page=_integer(params, "page", 1),
- page_size=_integer(params, "pageSize", 20),
- )
- def _enum(value: str | None, enum_type, name: str) -> str | None:
- if value is None or value == "":
- return None
- try:
- return enum_type(value).value
- except ValueError as exc:
- raise InvalidArgumentError(f"{name}不是有效枚举值") from exc
- def _date(value: str | None, name: str) -> datetime | None:
- return parse_datetime(value, name=name)
- def _sort(
- params: Mapping[str, str],
- *,
- allowed: dict[str, Any] = DOCUMENT_SORTS,
- default: str = "updatedAt",
- ) -> tuple[Any, str]:
- sort_by = params.get("sortBy", default)
- direction = params.get("sortDirection", "desc").lower()
- if sort_by not in allowed:
- raise InvalidArgumentError("sortBy不是允许的排序字段")
- if direction not in {"asc", "desc"}:
- raise InvalidArgumentError("sortDirection必须是asc或desc")
- column = allowed[sort_by]
- return (column.asc() if direction == "asc" else column.desc()), direction
- def _iso(value: datetime) -> str:
- return value.replace(tzinfo=timezone.utc).isoformat(timespec="milliseconds").replace(
- "+00:00", "Z"
- )
- def _permission_summary(
- access: PlanAccess,
- document: Document,
- ) -> tuple[dict[str, object], str]:
- source = access.source
- if source is None:
- return {
- "organizationCount": 0,
- "userCount": 0,
- "inheritedFromMainPlan": False,
- }, "未配置"
- permissions = db.session.scalars(
- select(Permission).where(
- Permission.document_id == source.id,
- Permission.is_deleted.is_(False),
- )
- ).all()
- organization_count = sum(item.subject_type == "ORG" for item in permissions)
- user_count = sum(item.subject_type == "USER" for item in permissions)
- visibility = VisibilityType(source.visibility_type)
- if visibility == VisibilityType.ALL_AUTHENTICATED:
- visibility_summary = "全部已登录用户"
- else:
- names = [item.subject_name for item in permissions if item.can_view]
- visibility_summary = "、".join(names) if names else "未配置"
- return {
- "organizationCount": organization_count,
- "userCount": user_count,
- "inheritedFromMainPlan": document.document_type
- == DocumentType.SUB_PLAN.value,
- }, visibility_summary
- _SNIPPET_BEFORE = 40
- _SNIPPET_AFTER = 80
- def _content_snippet(content_text: str | None, keyword: str | None) -> str | None:
- """当 keyword 命中正文时,截取包含命中区域的片段。
- 前后各保留若干字符作为上下文;若未触达正文首尾则以"……"标识截断。
- 未命中或任一参数为空时返回 None。
- """
- if not content_text or not keyword or not keyword.strip():
- return None
- kw = keyword.strip()
- pos = content_text.lower().find(kw.lower())
- if pos < 0:
- return None
- start = max(pos - _SNIPPET_BEFORE, 0)
- end = min(pos + len(kw) + _SNIPPET_AFTER, len(content_text))
- snippet = content_text[start:end]
- if start > 0:
- snippet = "……" + snippet
- if end < len(content_text):
- snippet += "……"
- return snippet
- def document_summary(
- document: Document,
- context: AuthContext,
- access: PlanAccess,
- keyword: str | None = None,
- ) -> dict[str, object]:
- _, visibility_summary = _permission_summary(access, document)
- source = access.source or document
- result: dict[str, object] = {
- "id": serialize_id(document.id),
- "documentName": document.document_name,
- "summary": document.summary,
- "documentType": document.document_type,
- "status": document.document_status,
- "securityLevel": document.security_level,
- "visibilityType": source.visibility_type,
- "visibilitySummary": visibility_summary,
- "attachmentType": document.attachment_type,
- "categoryId": serialize_id(document.category_id),
- "categoryName": document.category_name,
- "categoryPath": document.category_path,
- "parentDocumentId": serialize_id(document.parent_document_id),
- "rootDocumentId": serialize_id(
- document.id
- if document.document_type == DocumentType.MAIN.value
- else document.root_document_id
- ),
- "tags": document.tags or [],
- "fileExtension": document.file_extension,
- "childCount": document.child_count,
- "attachmentCount": document.attachment_count,
- "viewCount": document.view_count,
- "downloadCount": document.download_count,
- "createdByName": document.created_by_name,
- "createdAt": _iso(document.created_at),
- "updatedAt": _iso(document.updated_at),
- "rowVersion": document.row_version,
- "allowedActions": plan_allowed_actions(document, context, access),
- }
- snippet = _content_snippet(document.content_text, keyword)
- if snippet is not None:
- result["contentSnippet"] = snippet
- return result
- def document_detail(
- document: Document,
- context: AuthContext,
- access: PlanAccess,
- ) -> dict[str, object]:
- result = document_summary(document, context, access)
- permission_summary, _ = _permission_summary(access, document)
- result.update(
- {
- "originalFileName": document.original_file_name,
- "mimeType": document.mime_type,
- "fileSize": document.file_size,
- "fileHash": document.file_hash,
- "permissionSummary": permission_summary,
- "contentText": document.content_text,
- }
- )
- return result
- def _require_plan_access(document: Document, context: AuthContext) -> PlanAccess:
- access = evaluate_plan_access(document, context)
- if access.allowed:
- return access
- if access.reason == "SECURITY":
- raise SecurityLevelForbiddenError()
- raise DocumentViewForbiddenError()
- def _keyword(statement, keyword: str | None):
- if not keyword or not keyword.strip():
- return statement
- escaped = (
- keyword.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
- )
- pattern = f"%{escaped}%"
- return statement.where(
- or_(
- Document.document_name.like(pattern, escape="\\"),
- Document.summary.like(pattern, escape="\\"),
- Document.search_text.like(pattern, escape="\\"),
- cast(Document.tags, String).like(pattern, escape="\\"),
- )
- )
- def _tags_filter(
- statement,
- tags_value: str | None,
- match_value: str | None,
- ):
- """按 ``tags`` JSON 数组精确匹配;``tagsMatch=any|all`` 决定 OR/AND 语义。"""
- if not tags_value or not tags_value.strip():
- return statement
- tags = [item.strip() for item in tags_value.split(",") if item.strip()]
- if not tags:
- return statement
- match = (match_value or "any").strip().lower()
- if match not in {"any", "all"}:
- raise InvalidArgumentError("tagsMatch只允许any或all")
- # 使用 JSON_CONTAINS 做数组元素的精确匹配;json.dumps 保证特殊字符安全转义。
- conditions = [func.json_contains(Document.tags, json.dumps(tag)) for tag in tags]
- combiner = and_ if match == "all" else or_
- return statement.where(combiner(*conditions))
- def _boolean(value: str | None, name: str, default: bool = False) -> bool:
- if value is None:
- return default
- if value not in {"true", "false"}:
- raise InvalidArgumentError(f"{name}必须是true或false")
- return value == "true"
- def _category_scope(category_id: int, include_descendants: bool) -> set[int]:
- categories = db.session.scalars(
- select(Category).where(
- Category.is_deleted.is_(False),
- Category.status == "ENABLED",
- )
- ).all()
- if not any(category.id == category_id for category in categories):
- raise ResourceNotFoundError("方案分类不存在或不可用")
- if not include_descendants:
- return {category_id}
- children: dict[int | None, list[int]] = {}
- for category in categories:
- children.setdefault(category.parent_id, []).append(category.id)
- result = {category_id}
- pending = [category_id]
- while pending:
- current = pending.pop()
- for child_id in children.get(current, []):
- if child_id not in result:
- result.add(child_id)
- pending.append(child_id)
- return result
- def list_documents(params: Mapping[str, str]) -> dict[str, object]:
- context = get_auth_context()
- page = _page(params)
- raw_types = params.get("documentType", "MAIN")
- document_types = [item.strip() for item in raw_types.split(",") if item.strip()]
- if (
- not document_types
- or any(item not in PLAN_TYPES for item in document_types)
- or len(set(document_types)) != len(document_types)
- ):
- raise InvalidArgumentError("documentType只允许MAIN、SUB_PLAN或二者组合")
- statement = select(Document).where(
- Document.is_deleted.is_(False),
- Document.document_type.in_(document_types),
- )
- include_descendants = _boolean(
- params.get("includeDescendants"), "includeDescendants"
- )
- category_id = params.get("categoryId")
- if category_id:
- parsed_category_id = parse_string_id(category_id, field="categoryId")
- statement = statement.where(
- Document.category_id.in_(
- _category_scope(parsed_category_id, include_descendants)
- )
- )
- statement = _keyword(statement, params.get("keyword"))
- statement = _tags_filter(
- statement, params.get("tags"), params.get("tagsMatch")
- )
- visibility_filter = _enum(
- params.get("visibilityType"), VisibilityType, "visibilityType"
- )
- for name, enum_type, column in (
- ("securityLevel", SecurityLevel, Document.security_level),
- ("status", DocumentStatus, Document.document_status),
- ):
- value = _enum(params.get(name), enum_type, name)
- if value:
- statement = statement.where(column == value)
- updated_from, updated_to = parse_updated_range(params)
- if updated_from:
- statement = statement.where(Document.updated_at >= updated_from)
- if updated_to:
- statement = statement.where(Document.updated_at <= updated_to)
- order, _ = _sort(params)
- documents = db.session.scalars(statement.order_by(order, Document.id.asc())).all()
- visible: list[tuple[Document, PlanAccess]] = []
- for document in documents:
- access = evaluate_plan_access(document, context)
- if (
- access.allowed
- and (
- visibility_filter is None
- or (
- access.source is not None
- and access.source.visibility_type == visibility_filter
- )
- )
- ):
- visible.append((document, access))
- total = len(visible)
- selected = visible[page.offset : page.offset + page.page_size]
- return page_result(
- [
- document_summary(document, context, access, keyword=params.get("keyword"))
- for document, access in selected
- ],
- page=page.page,
- page_size=page.page_size,
- total=total,
- )
- def _active_plan(document_id: int) -> Document:
- document = db.session.scalar(
- select(Document).where(
- Document.id == document_id,
- Document.is_deleted.is_(False),
- Document.document_type.in_(PLAN_TYPES),
- )
- )
- if document is None:
- raise ResourceNotFoundError("方案文档不存在")
- return document
- def _record_view(document: Document) -> int:
- old_count = document.view_count
- document_id = document.id
- document_name = document.document_name
- target = (
- AuditTarget.ATTACHMENT
- if document.document_type == DocumentType.ATTACHMENT.value
- else AuditTarget.DOCUMENT
- )
- db.session.rollback()
- try:
- with transaction() as session:
- current = session.scalar(
- select(Document)
- .where(
- Document.id == document_id,
- Document.is_deleted.is_(False),
- )
- .with_for_update()
- )
- if current is None:
- raise ResourceNotFoundError("文档不存在")
- current.view_count += 1
- session.add(
- business_audit(
- action=AuditAction.VIEW_DOCUMENT,
- target=target,
- target_id=document_id,
- target_name=document_name,
- detail={"documentType": current.document_type},
- )
- )
- new_count = current.view_count
- return new_count
- except Exception:
- db.session.rollback()
- logger.exception("文档查看计数或审计写入失败:document_id=%s", document_id)
- return old_count
- def get_document(document_id: int) -> dict[str, object]:
- context = get_auth_context()
- document = _active_plan(document_id)
- access = _require_plan_access(document, context)
- result = document_detail(document, context, access)
- result["viewCount"] = _record_view(document)
- return result
- def list_sub_plans(
- main_document_id: int,
- params: Mapping[str, str],
- ) -> dict[str, object]:
- context = get_auth_context()
- main = db.session.scalar(
- select(Document).where(
- Document.id == main_document_id,
- Document.document_type == DocumentType.MAIN.value,
- Document.is_deleted.is_(False),
- )
- )
- if main is None:
- raise MainPlanNotFoundError()
- _require_plan_access(main, context)
- page = _page(params)
- statement = select(Document).where(
- Document.parent_document_id == main.id,
- Document.document_type == DocumentType.SUB_PLAN.value,
- Document.is_deleted.is_(False),
- )
- statement = _keyword(statement, params.get("keyword"))
- status = _enum(params.get("status"), DocumentStatus, "status")
- if status:
- statement = statement.where(Document.document_status == status)
- updated_from, updated_to = parse_updated_range(params)
- if updated_from:
- statement = statement.where(Document.updated_at >= updated_from)
- if updated_to:
- statement = statement.where(Document.updated_at <= updated_to)
- order, _ = _sort(params)
- children = db.session.scalars(
- statement.order_by(order, Document.id.asc())
- ).all()
- visible: list[tuple[Document, PlanAccess]] = []
- for child in children:
- access = evaluate_plan_access(child, context)
- if access.allowed:
- visible.append((child, access))
- total = len(visible)
- selected = visible[page.offset : page.offset + page.page_size]
- return page_result(
- [document_summary(child, context, access, keyword=params.get("keyword")) for child, access in selected],
- page=page.page,
- page_size=page.page_size,
- total=total,
- )
- def list_main_plan_tags() -> dict[str, object]:
- """聚合当前用户可见的所有主案标签。
- - 全局视角:标签集合不随分类、关键词等其他筛选变化;
- - 权限:通过 ``evaluate_plan_access`` 在 Python 层逐条过滤,
- 与 ``list_documents`` 保持一致的可见性语义,避免泄露被
- ACL/密级隔离的主案标签;
- - 顺序:按 Unicode 升序,便于前端稳定渲染。
- """
- context = get_auth_context()
- statement = select(Document).where(
- Document.is_deleted.is_(False),
- Document.document_type == DocumentType.MAIN.value,
- )
- documents = db.session.scalars(statement).all()
- tags_set: set[str] = set()
- for document in documents:
- access = evaluate_plan_access(document, context)
- if not access.allowed:
- continue
- if document.tags:
- tags_set.update(document.tags)
- tags = sorted(tags_set)
- return {"items": tags, "total": len(tags)}
- __all__ = [
- "DOCUMENT_SORTS",
- "_date",
- "_enum",
- "_iso",
- "_keyword",
- "_page",
- "_record_view",
- "_sort",
- "_tags_filter",
- "document_detail",
- "document_summary",
- "get_document",
- "list_documents",
- "list_main_plan_tags",
- "list_sub_plans",
- "parse_string_id",
- ]
|