| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398 |
- """方案关联关系:预置关系类型加载校验与关联关系读写。"""
- from __future__ import annotations
- import json
- from dataclasses import dataclass
- from datetime import datetime, timezone
- from pathlib import Path
- from typing import Any
- from sqlalchemy import select
- from sqlalchemy.exc import IntegrityError
- from dms.common.enums import AuditAction, AuditTarget, DocumentType
- from dms.common.errors import (
- ConflictError,
- DocumentViewForbiddenError,
- InvalidArgumentError,
- ResourceNotFoundError,
- SecurityLevelForbiddenError,
- )
- from dms.common.response import serialize_id
- from dms.database.transaction import execute_with_deadlock_retry, transaction
- from dms.extensions import db
- from dms.models import Document, DocumentRelation
- from dms.security.auth_context import get_auth_context
- from dms.services.audit_service import business_audit
- from dms.services.authorization_service import (
- evaluate_plan_access,
- require_security_clearance,
- )
- from dms.services.document_password_service import (
- DocumentPasswordAction,
- get_request_password,
- require_document_password,
- )
- from dms.services.document_query_service import PLAN_TYPES
- RELATION_FIELDS = {"relations", "rowVersion"}
- RELATION_ITEM_FIELDS = {"relatedDocumentId", "relationType"}
- MAX_RELATIONS = 50
- RELATION_TYPE_MAX_LENGTH = 64
- # ─── 预置关系类型:启动期加载与严格校验 ─────────────────────────────────────
- @dataclass(frozen=True, slots=True)
- class RelationPreset:
- code: str
- label: str
- sort_no: int
- def to_dict(self) -> dict[str, str | int]:
- return {
- "code": self.code,
- "label": self.label,
- "sortNo": self.sort_no,
- }
- class DocumentRelationPresetError(RuntimeError):
- """不包含文件路径或原始内容的预置关系配置错误。"""
- def _preset_invalid(reason: str) -> DocumentRelationPresetError:
- return DocumentRelationPresetError(f"方案关联关系预置配置无效:{reason}")
- def load_relation_presets(config_path: str | Path) -> tuple[RelationPreset, ...]:
- """读取并严格校验预置关系类型清单。"""
- path = Path(config_path)
- try:
- with path.open("r", encoding="utf-8") as source:
- raw = json.load(source)
- except FileNotFoundError:
- raise _preset_invalid("配置文件不存在") from None
- except UnicodeDecodeError:
- raise _preset_invalid("配置不是有效的UTF-8文件") from None
- except json.JSONDecodeError:
- raise _preset_invalid("配置不是有效的JSON") from None
- except OSError:
- raise _preset_invalid("配置文件无法读取") from None
- if not isinstance(raw, dict) or set(raw) != {"presets"}:
- raise _preset_invalid("根节点必须且只能包含presets")
- raw_presets = raw["presets"]
- if not isinstance(raw_presets, list) or not raw_presets:
- raise _preset_invalid("presets必须是非空数组")
- seen_codes: set[str] = set()
- seen_labels: set[str] = set()
- presets: list[RelationPreset] = []
- for index, raw_preset in enumerate(raw_presets):
- if not isinstance(raw_preset, dict) or set(raw_preset) != {
- "code",
- "label",
- "sortNo",
- }:
- raise _preset_invalid(f"presets[{index}]字段不合法")
- code = raw_preset["code"]
- label = raw_preset["label"]
- sort_no = raw_preset["sortNo"]
- if not isinstance(code, str) or not code:
- raise _preset_invalid(f"presets[{index}]包含非法code")
- if code in seen_codes:
- raise _preset_invalid(f"presets[{index}]包含重复code")
- if not isinstance(label, str) or not label.strip():
- raise _preset_invalid(f"presets[{index}]包含空label")
- if label.strip() in seen_labels:
- raise _preset_invalid(f"presets[{index}]包含重复label")
- if type(sort_no) is not int or sort_no < 0:
- raise _preset_invalid(f"presets[{index}]包含非法sortNo")
- seen_codes.add(code)
- seen_labels.add(label.strip())
- presets.append(
- RelationPreset(code=code, label=label.strip(), sort_no=sort_no)
- )
- return tuple(sorted(presets, key=lambda item: (item.sort_no, item.code)))
- # ─── 关联关系读取 ──────────────────────────────────────────────────────────
- def _now() -> datetime:
- return datetime.now(timezone.utc).replace(tzinfo=None)
- def _version(value: Any, name: str) -> int:
- if type(value) is not int or value < 0:
- raise InvalidArgumentError(f"{name}必须是非负整数")
- return value
- def _string_id(value: Any, name: str) -> int:
- if not isinstance(value, str) or not value.isdecimal() or int(value) <= 0:
- raise InvalidArgumentError(f"{name}必须是正整数形式的字符串ID")
- return int(value)
- def _relation_items(session, document_id: int) -> list[dict[str, str]]:
- rows = session.execute(
- select(DocumentRelation, Document)
- .join(Document, Document.id == DocumentRelation.related_document_id)
- .where(
- DocumentRelation.document_id == document_id,
- DocumentRelation.is_deleted.is_(False),
- Document.is_deleted.is_(False),
- )
- .order_by(
- DocumentRelation.sort_no.asc(), DocumentRelation.id.asc()
- )
- ).all()
- return [
- {
- "relatedDocumentId": serialize_id(relation.related_document_id),
- "relatedDocumentName": document.document_name,
- "relationType": relation.relation_type,
- }
- for relation, document in rows
- ]
- def _require_plan_access(document: Document, context) -> None:
- access = evaluate_plan_access(document, context)
- if access.allowed:
- return
- if access.reason == "SECURITY":
- raise SecurityLevelForbiddenError()
- raise DocumentViewForbiddenError()
- def list_document_relations(document_id: int) -> dict[str, object]:
- """查看方案声明的关联清单(与文档详情同套访问控制)。"""
- context = get_auth_context()
- 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("方案文档不存在")
- _require_plan_access(document, context)
- return {"items": _relation_items(db.session, document.id)}
- # ─── 关联关系整体替换保存 ──────────────────────────────────────────────────
- def _relations_payload(payload: Any) -> tuple[list[tuple[int, str]], int]:
- if not isinstance(payload, dict) or set(payload) != RELATION_FIELDS:
- raise InvalidArgumentError(
- "请求字段必须且只能包含relations和rowVersion"
- )
- raw_relations = payload["relations"]
- if not isinstance(raw_relations, list):
- raise InvalidArgumentError("relations必须是数组")
- if len(raw_relations) > MAX_RELATIONS:
- raise InvalidArgumentError(f"relations最多允许{MAX_RELATIONS}条")
- pairs: list[tuple[int, str]] = []
- seen: set[int] = set()
- for index, raw_item in enumerate(raw_relations):
- if not isinstance(raw_item, dict) or set(raw_item) != RELATION_ITEM_FIELDS:
- raise InvalidArgumentError(
- f"relations[{index}]必须且只能包含relatedDocumentId和relationType"
- )
- related_id = _string_id(
- raw_item["relatedDocumentId"],
- f"relations[{index}].relatedDocumentId",
- )
- relation_type = raw_item["relationType"]
- if not isinstance(relation_type, str):
- raise InvalidArgumentError(
- f"relations[{index}].relationType必须是字符串"
- )
- relation_type = relation_type.strip()
- if not relation_type or len(relation_type) > RELATION_TYPE_MAX_LENGTH:
- raise InvalidArgumentError(
- f"relations[{index}].relationType长度须为1~{RELATION_TYPE_MAX_LENGTH}"
- )
- if related_id in seen:
- raise InvalidArgumentError(
- f"relations[{index}].relatedDocumentId与其他条目重复"
- )
- seen.add(related_id)
- pairs.append((related_id, relation_type))
- return pairs, _version(payload["rowVersion"], "rowVersion")
- def _locked_related_plans(
- session,
- related_ids: list[int],
- self_document_id: int,
- ) -> None:
- """被关联方必须存在、未删且为主案;按ID升序加锁避免死锁。"""
- if not related_ids:
- return
- documents = session.scalars(
- select(Document)
- .where(Document.id.in_(sorted(related_ids)))
- .order_by(Document.id.asc())
- .with_for_update()
- ).all()
- by_id = {document.id: document for document in documents}
- for related_id in related_ids:
- if related_id == self_document_id:
- raise InvalidArgumentError("不允许关联方案自身")
- target = by_id.get(related_id)
- if (
- target is None
- or target.is_deleted
- or target.document_type != DocumentType.MAIN.value
- ):
- raise ResourceNotFoundError(
- f"被关联方案不存在或类型不匹配:{related_id}"
- )
- def _current_row_version(document_id: int) -> int | None:
- return db.session.scalar(
- select(Document.row_version).where(
- Document.id == document_id,
- Document.is_deleted.is_(False),
- )
- )
- def save_document_relations(
- document_id: int,
- payload: Any,
- ) -> dict[str, object]:
- """整体替换方案的关联清单;同一方案上的写入由方案行锁串行化。"""
- pairs, expected_version = _relations_payload(payload)
- context = get_auth_context()
- def operation() -> dict[str, object]:
- with transaction() as session:
- document = session.scalar(
- select(Document)
- .where(
- Document.id == document_id,
- Document.is_deleted.is_(False),
- Document.document_type.in_(PLAN_TYPES),
- )
- .with_for_update()
- )
- if document is None:
- raise ResourceNotFoundError("方案文档不存在")
- require_security_clearance(document, context)
- require_document_password(
- document,
- DocumentPasswordAction.EDIT.value,
- get_request_password(),
- context,
- )
- if document.row_version != expected_version:
- raise ConflictError(
- "数据已被其他用户修改,请刷新后重试",
- details={"currentRowVersion": document.row_version},
- )
- _locked_related_plans(
- session, [related_id for related_id, _ in pairs], document.id
- )
- existing = session.scalars(
- select(DocumentRelation)
- .where(
- DocumentRelation.document_id == document.id,
- DocumentRelation.is_deleted.is_(False),
- )
- .order_by(
- DocumentRelation.sort_no.asc(), DocumentRelation.id.asc()
- )
- .with_for_update()
- ).all()
- existing_pairs = [
- (relation.related_document_id, relation.relation_type)
- for relation in existing
- ]
- if existing_pairs == pairs:
- # 内容未变化:不产生写入、版本号与审计噪音
- return {
- "relations": _relation_items(session, document.id),
- "rowVersion": document.row_version,
- }
- now = _now()
- for relation in existing:
- relation.is_deleted = True
- relation.deleted_at = now
- relation.updated_at = now
- relation.updated_by = context.user_id
- relation.row_version += 1
- for offset, (related_id, relation_type) in enumerate(
- pairs, start=1
- ):
- session.add(
- DocumentRelation(
- document_id=document.id,
- related_document_id=related_id,
- relation_type=relation_type,
- sort_no=offset * 10,
- created_by=context.user_id,
- updated_by=context.user_id,
- )
- )
- session.flush()
- document.row_version += 1
- document.updated_by = context.user_id
- document.updated_by_name = context.real_name
- document.updated_at = now
- session.add(
- business_audit(
- action=AuditAction.EDIT_DOCUMENT,
- target=AuditTarget.DOCUMENT,
- target_id=document.id,
- target_name=document.document_name,
- detail={
- "scope": "relations",
- "documentType": document.document_type,
- "removedCount": len(existing_pairs),
- "relations": [
- {
- "relatedDocumentId": serialize_id(related_id),
- "relationType": relation_type,
- }
- for related_id, relation_type in pairs
- ],
- "rowVersion": document.row_version,
- },
- )
- )
- return {
- "relations": _relation_items(session, document.id),
- "rowVersion": document.row_version,
- }
- try:
- return execute_with_deadlock_retry(operation)
- except IntegrityError as exc:
- db.session.rollback()
- raise ConflictError(
- "关联关系已被并发修改,请刷新后重试",
- details={
- "currentRowVersion": _current_row_version(document_id)
- },
- ) from exc
- __all__ = [
- "DocumentRelationPresetError",
- "RelationPreset",
- "list_document_relations",
- "load_relation_presets",
- "save_document_relations",
- ]
|