document_relation_service.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. """方案关联关系:预置关系类型加载校验与关联关系读写。"""
  2. from __future__ import annotations
  3. import json
  4. from dataclasses import dataclass
  5. from datetime import datetime, timezone
  6. from pathlib import Path
  7. from typing import Any
  8. from sqlalchemy import select
  9. from sqlalchemy.exc import IntegrityError
  10. from dms.common.enums import AuditAction, AuditTarget, DocumentType
  11. from dms.common.errors import (
  12. ConflictError,
  13. DocumentViewForbiddenError,
  14. InvalidArgumentError,
  15. ResourceNotFoundError,
  16. SecurityLevelForbiddenError,
  17. )
  18. from dms.common.response import serialize_id
  19. from dms.database.transaction import execute_with_deadlock_retry, transaction
  20. from dms.extensions import db
  21. from dms.models import LOCK_DEFER_LOB, Document, DocumentRelation
  22. from dms.security.auth_context import get_auth_context
  23. from dms.services.audit_service import business_audit
  24. from dms.common.enums import AllowedAction
  25. from dms.services.authorization_service import (
  26. evaluate_plan_access,
  27. require_plan_action,
  28. require_security_clearance,
  29. )
  30. from dms.services.document_password_service import (
  31. DocumentPasswordAction,
  32. get_request_password,
  33. require_document_password,
  34. )
  35. from dms.services.document_query_service import PLAN_TYPES
  36. RELATION_FIELDS = {"relations", "rowVersion"}
  37. RELATION_ITEM_FIELDS = {"relatedDocumentId", "relationType"}
  38. MAX_RELATIONS = 50
  39. RELATION_TYPE_MAX_LENGTH = 64
  40. # ─── 预置关系类型:启动期加载与严格校验 ─────────────────────────────────────
  41. @dataclass(frozen=True, slots=True)
  42. class RelationPreset:
  43. code: str
  44. label: str
  45. sort_no: int
  46. def to_dict(self) -> dict[str, str | int]:
  47. return {
  48. "code": self.code,
  49. "label": self.label,
  50. "sortNo": self.sort_no,
  51. }
  52. class DocumentRelationPresetError(RuntimeError):
  53. """不包含文件路径或原始内容的预置关系配置错误。"""
  54. def _preset_invalid(reason: str) -> DocumentRelationPresetError:
  55. return DocumentRelationPresetError(f"方案关联关系预置配置无效:{reason}")
  56. def load_relation_presets(config_path: str | Path) -> tuple[RelationPreset, ...]:
  57. """读取并严格校验预置关系类型清单。"""
  58. path = Path(config_path)
  59. try:
  60. with path.open("r", encoding="utf-8") as source:
  61. raw = json.load(source)
  62. except FileNotFoundError:
  63. raise _preset_invalid("配置文件不存在") from None
  64. except UnicodeDecodeError:
  65. raise _preset_invalid("配置不是有效的UTF-8文件") from None
  66. except json.JSONDecodeError:
  67. raise _preset_invalid("配置不是有效的JSON") from None
  68. except OSError:
  69. raise _preset_invalid("配置文件无法读取") from None
  70. if not isinstance(raw, dict) or set(raw) != {"presets"}:
  71. raise _preset_invalid("根节点必须且只能包含presets")
  72. raw_presets = raw["presets"]
  73. if not isinstance(raw_presets, list) or not raw_presets:
  74. raise _preset_invalid("presets必须是非空数组")
  75. seen_codes: set[str] = set()
  76. seen_labels: set[str] = set()
  77. presets: list[RelationPreset] = []
  78. for index, raw_preset in enumerate(raw_presets):
  79. if not isinstance(raw_preset, dict) or set(raw_preset) != {
  80. "code",
  81. "label",
  82. "sortNo",
  83. }:
  84. raise _preset_invalid(f"presets[{index}]字段不合法")
  85. code = raw_preset["code"]
  86. label = raw_preset["label"]
  87. sort_no = raw_preset["sortNo"]
  88. if not isinstance(code, str) or not code:
  89. raise _preset_invalid(f"presets[{index}]包含非法code")
  90. if code in seen_codes:
  91. raise _preset_invalid(f"presets[{index}]包含重复code")
  92. if not isinstance(label, str) or not label.strip():
  93. raise _preset_invalid(f"presets[{index}]包含空label")
  94. if label.strip() in seen_labels:
  95. raise _preset_invalid(f"presets[{index}]包含重复label")
  96. if type(sort_no) is not int or sort_no < 0:
  97. raise _preset_invalid(f"presets[{index}]包含非法sortNo")
  98. seen_codes.add(code)
  99. seen_labels.add(label.strip())
  100. presets.append(
  101. RelationPreset(code=code, label=label.strip(), sort_no=sort_no)
  102. )
  103. return tuple(sorted(presets, key=lambda item: (item.sort_no, item.code)))
  104. # ─── 关联关系读取 ──────────────────────────────────────────────────────────
  105. def _now() -> datetime:
  106. return datetime.now(timezone.utc).replace(tzinfo=None)
  107. def _version(value: Any, name: str) -> int:
  108. if type(value) is not int or value < 0:
  109. raise InvalidArgumentError(f"{name}必须是非负整数")
  110. return value
  111. def _string_id(value: Any, name: str) -> int:
  112. if not isinstance(value, str) or not value.isdecimal() or int(value) <= 0:
  113. raise InvalidArgumentError(f"{name}必须是正整数形式的字符串ID")
  114. return int(value)
  115. def _relation_items(session, document_id: int) -> list[dict[str, str]]:
  116. rows = session.execute(
  117. select(DocumentRelation, Document)
  118. .join(Document, Document.id == DocumentRelation.related_document_id)
  119. .where(
  120. DocumentRelation.document_id == document_id,
  121. DocumentRelation.is_deleted.is_(False),
  122. Document.is_deleted.is_(False),
  123. )
  124. .order_by(
  125. DocumentRelation.sort_no.asc(), DocumentRelation.id.asc()
  126. )
  127. ).all()
  128. return [
  129. {
  130. "relatedDocumentId": serialize_id(relation.related_document_id),
  131. "relatedDocumentName": document.document_name,
  132. "relationType": relation.relation_type,
  133. }
  134. for relation, document in rows
  135. ]
  136. def _require_plan_access(document: Document, context) -> None:
  137. access = evaluate_plan_access(document, context)
  138. if access.allowed:
  139. return
  140. if access.reason == "SECURITY":
  141. raise SecurityLevelForbiddenError()
  142. raise DocumentViewForbiddenError()
  143. def list_document_relations(document_id: int) -> dict[str, object]:
  144. """查看方案声明的关联清单(与文档详情同套访问控制)。"""
  145. context = get_auth_context()
  146. document = db.session.scalar(
  147. select(Document).where(
  148. Document.id == document_id,
  149. Document.is_deleted.is_(False),
  150. Document.document_type.in_(PLAN_TYPES),
  151. )
  152. )
  153. if document is None:
  154. raise ResourceNotFoundError("方案文档不存在")
  155. _require_plan_access(document, context)
  156. return {"items": _relation_items(db.session, document.id)}
  157. # ─── 关联关系整体替换保存 ──────────────────────────────────────────────────
  158. def _relations_payload(payload: Any) -> tuple[list[tuple[int, str]], int]:
  159. if not isinstance(payload, dict) or set(payload) != RELATION_FIELDS:
  160. raise InvalidArgumentError(
  161. "请求字段必须且只能包含relations和rowVersion"
  162. )
  163. raw_relations = payload["relations"]
  164. if not isinstance(raw_relations, list):
  165. raise InvalidArgumentError("relations必须是数组")
  166. if len(raw_relations) > MAX_RELATIONS:
  167. raise InvalidArgumentError(f"relations最多允许{MAX_RELATIONS}条")
  168. pairs: list[tuple[int, str]] = []
  169. seen: set[int] = set()
  170. for index, raw_item in enumerate(raw_relations):
  171. if not isinstance(raw_item, dict) or set(raw_item) != RELATION_ITEM_FIELDS:
  172. raise InvalidArgumentError(
  173. f"relations[{index}]必须且只能包含relatedDocumentId和relationType"
  174. )
  175. related_id = _string_id(
  176. raw_item["relatedDocumentId"],
  177. f"relations[{index}].relatedDocumentId",
  178. )
  179. relation_type = raw_item["relationType"]
  180. if not isinstance(relation_type, str):
  181. raise InvalidArgumentError(
  182. f"relations[{index}].relationType必须是字符串"
  183. )
  184. relation_type = relation_type.strip()
  185. if not relation_type or len(relation_type) > RELATION_TYPE_MAX_LENGTH:
  186. raise InvalidArgumentError(
  187. f"relations[{index}].relationType长度须为1~{RELATION_TYPE_MAX_LENGTH}"
  188. )
  189. if related_id in seen:
  190. raise InvalidArgumentError(
  191. f"relations[{index}].relatedDocumentId与其他条目重复"
  192. )
  193. seen.add(related_id)
  194. pairs.append((related_id, relation_type))
  195. return pairs, _version(payload["rowVersion"], "rowVersion")
  196. def _locked_related_plans(
  197. session,
  198. related_ids: list[int],
  199. self_document_id: int,
  200. ) -> None:
  201. """被关联方必须存在、未删且为主案;按ID升序加锁避免死锁。"""
  202. if not related_ids:
  203. return
  204. documents = session.scalars(
  205. select(Document)
  206. .where(Document.id.in_(sorted(related_ids)))
  207. .order_by(Document.id.asc())
  208. .options(*LOCK_DEFER_LOB)
  209. .with_for_update()
  210. ).all()
  211. by_id = {document.id: document for document in documents}
  212. for related_id in related_ids:
  213. if related_id == self_document_id:
  214. raise InvalidArgumentError("不允许关联方案自身")
  215. target = by_id.get(related_id)
  216. if (
  217. target is None
  218. or target.is_deleted
  219. or target.document_type != DocumentType.MAIN.value
  220. ):
  221. raise ResourceNotFoundError(
  222. f"被关联方案不存在或类型不匹配:{related_id}"
  223. )
  224. def _current_row_version(document_id: int) -> int | None:
  225. return db.session.scalar(
  226. select(Document.row_version).where(
  227. Document.id == document_id,
  228. Document.is_deleted.is_(False),
  229. )
  230. )
  231. def save_document_relations(
  232. document_id: int,
  233. payload: Any,
  234. ) -> dict[str, object]:
  235. """整体替换方案的关联清单;同一方案上的写入由方案行锁串行化。"""
  236. pairs, expected_version = _relations_payload(payload)
  237. context = get_auth_context()
  238. def operation() -> dict[str, object]:
  239. with transaction() as session:
  240. document = session.scalar(
  241. select(Document)
  242. .where(
  243. Document.id == document_id,
  244. Document.is_deleted.is_(False),
  245. Document.document_type.in_(PLAN_TYPES),
  246. )
  247. .options(*LOCK_DEFER_LOB)
  248. .with_for_update()
  249. )
  250. if document is None:
  251. raise ResourceNotFoundError("方案文档不存在")
  252. require_plan_action(document, context, AllowedAction.EDIT)
  253. require_security_clearance(document, context)
  254. require_document_password(
  255. document,
  256. DocumentPasswordAction.EDIT.value,
  257. get_request_password(),
  258. context,
  259. )
  260. if document.row_version != expected_version:
  261. raise ConflictError(
  262. "数据已被其他用户修改,请刷新后重试",
  263. details={"currentRowVersion": document.row_version},
  264. )
  265. _locked_related_plans(
  266. session, [related_id for related_id, _ in pairs], document.id
  267. )
  268. existing = session.scalars(
  269. select(DocumentRelation)
  270. .where(
  271. DocumentRelation.document_id == document.id,
  272. DocumentRelation.is_deleted.is_(False),
  273. )
  274. .order_by(
  275. DocumentRelation.sort_no.asc(), DocumentRelation.id.asc()
  276. )
  277. .with_for_update()
  278. ).all()
  279. existing_pairs = [
  280. (relation.related_document_id, relation.relation_type)
  281. for relation in existing
  282. ]
  283. if existing_pairs == pairs:
  284. # 内容未变化:不产生写入、版本号与审计噪音
  285. return {
  286. "relations": _relation_items(session, document.id),
  287. "rowVersion": document.row_version,
  288. }
  289. now = _now()
  290. for relation in existing:
  291. relation.is_deleted = True
  292. relation.deleted_at = now
  293. relation.updated_at = now
  294. relation.updated_by = context.user_id
  295. relation.row_version += 1
  296. for offset, (related_id, relation_type) in enumerate(
  297. pairs, start=1
  298. ):
  299. session.add(
  300. DocumentRelation(
  301. document_id=document.id,
  302. related_document_id=related_id,
  303. relation_type=relation_type,
  304. sort_no=offset * 10,
  305. created_by=context.user_id,
  306. updated_by=context.user_id,
  307. )
  308. )
  309. session.flush()
  310. document.row_version += 1
  311. document.updated_by = context.user_id
  312. document.updated_by_name = context.real_name
  313. document.updated_at = now
  314. session.add(
  315. business_audit(
  316. action=AuditAction.EDIT_DOCUMENT,
  317. target=AuditTarget.DOCUMENT,
  318. target_id=document.id,
  319. target_name=document.document_name,
  320. detail={
  321. "scope": "relations",
  322. "documentType": document.document_type,
  323. "removedCount": len(existing_pairs),
  324. "relations": [
  325. {
  326. "relatedDocumentId": serialize_id(related_id),
  327. "relationType": relation_type,
  328. }
  329. for related_id, relation_type in pairs
  330. ],
  331. "rowVersion": document.row_version,
  332. },
  333. )
  334. )
  335. return {
  336. "relations": _relation_items(session, document.id),
  337. "rowVersion": document.row_version,
  338. }
  339. try:
  340. return execute_with_deadlock_retry(operation)
  341. except IntegrityError as exc:
  342. db.session.rollback()
  343. raise ConflictError(
  344. "关联关系已被并发修改,请刷新后重试",
  345. details={
  346. "currentRowVersion": _current_row_version(document_id)
  347. },
  348. ) from exc
  349. __all__ = [
  350. "DocumentRelationPresetError",
  351. "RelationPreset",
  352. "list_document_relations",
  353. "load_relation_presets",
  354. "save_document_relations",
  355. ]