sub_plan_binding_service.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. """子案跨主案挂载与解除挂载事务。
  2. 与附件挂载(attachment_binding_service)同范式:主案行锁 + 密码动作 +
  3. 乐观锁 + 步长10排序 + 审计(复用 BIND_ATTACHMENT / UNBIND_ATTACHMENT
  4. 动作,detail 标注 scope=subPlanMount,不扩展 sys_audit_log 的 CHECK)。
  5. 子案权限仍继承其原主案(root_document_id 不变),挂载仅建立展示关系。
  6. """
  7. from __future__ import annotations
  8. from datetime import datetime, timezone
  9. from typing import Any
  10. from sqlalchemy import func, select
  11. from sqlalchemy.exc import IntegrityError
  12. from dms.common.enums import AuditAction, AuditTarget, DocumentType
  13. from dms.common.errors import (
  14. ConflictError,
  15. InvalidArgumentError,
  16. ResourceNotFoundError,
  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 Document, SubPlanBinding
  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. require_plan_action,
  27. require_security_clearance,
  28. )
  29. from dms.services.document_password_service import (
  30. DocumentPasswordAction,
  31. get_request_password,
  32. require_document_password,
  33. )
  34. BIND_SUB_FIELDS = {"subPlanIds", "mainPlanRowVersion"}
  35. def _now() -> datetime:
  36. return datetime.now(timezone.utc).replace(tzinfo=None)
  37. def _version(value: Any, name: str) -> int:
  38. if type(value) is not int or value < 0:
  39. raise InvalidArgumentError(f"{name}必须是非负整数")
  40. return value
  41. def _string_id(value: Any, name: str) -> int:
  42. if not isinstance(value, str) or not value.isdecimal() or int(value) <= 0:
  43. raise InvalidArgumentError(f"{name}必须是正整数形式的字符串ID")
  44. return int(value)
  45. def _bind_payload(payload: Any) -> tuple[list[int], int]:
  46. if not isinstance(payload, dict) or set(payload) != BIND_SUB_FIELDS:
  47. raise InvalidArgumentError(
  48. "请求字段必须且只能包含subPlanIds和mainPlanRowVersion"
  49. )
  50. raw_ids = payload["subPlanIds"]
  51. if not isinstance(raw_ids, list) or not raw_ids:
  52. raise InvalidArgumentError("subPlanIds必须是非空数组")
  53. sub_plan_ids: list[int] = []
  54. seen: set[int] = set()
  55. for index, raw_id in enumerate(raw_ids):
  56. sub_plan_id = _string_id(raw_id, f"subPlanIds[{index}]")
  57. if sub_plan_id not in seen:
  58. sub_plan_ids.append(sub_plan_id)
  59. seen.add(sub_plan_id)
  60. return sub_plan_ids, _version(
  61. payload["mainPlanRowVersion"], "mainPlanRowVersion"
  62. )
  63. def _locked_main(session, main_document_id: int) -> Document:
  64. main = session.scalar(
  65. select(Document)
  66. .where(
  67. Document.id == main_document_id,
  68. Document.is_deleted.is_(False),
  69. )
  70. .with_for_update()
  71. )
  72. if main is None or main.document_type != DocumentType.MAIN.value:
  73. raise ResourceNotFoundError("主案不存在或类型不匹配")
  74. require_security_clearance(main, get_auth_context())
  75. return main
  76. def _locked_sub_plans(
  77. session,
  78. main_id: int,
  79. sub_plan_ids: list[int],
  80. ) -> dict[int, Document]:
  81. """校验目标均为有效子案;属于本主案的自有子案直接剔除(无需挂载)。"""
  82. documents = session.scalars(
  83. select(Document)
  84. .where(Document.id.in_(sorted(sub_plan_ids)))
  85. .order_by(Document.id.asc())
  86. .with_for_update()
  87. ).all()
  88. by_id = {document.id: document for document in documents}
  89. if any(
  90. sub_plan_id not in by_id
  91. or by_id[sub_plan_id].is_deleted
  92. or by_id[sub_plan_id].document_type != DocumentType.SUB_PLAN.value
  93. for sub_plan_id in sub_plan_ids
  94. ):
  95. raise ResourceNotFoundError("子案不存在或类型不匹配")
  96. return {
  97. sub_plan_id: by_id[sub_plan_id]
  98. for sub_plan_id in sub_plan_ids
  99. if by_id[sub_plan_id].parent_document_id != main_id
  100. }
  101. def _current_version(main_document_id: int) -> int | None:
  102. return db.session.scalar(
  103. select(Document.row_version).where(
  104. Document.id == main_document_id,
  105. Document.is_deleted.is_(False),
  106. )
  107. )
  108. def bind_sub_plans(main_document_id: int, payload: Any) -> dict[str, int]:
  109. """去重后批量挂载其他主案的子案;同一主案上的写入由主案行锁串行化。"""
  110. sub_plan_ids, expected_version = _bind_payload(payload)
  111. context = get_auth_context()
  112. def operation() -> dict[str, int]:
  113. with transaction() as session:
  114. main = _locked_main(session, main_document_id)
  115. require_plan_action(main, context, AllowedAction.BIND_ATTACHMENT)
  116. require_document_password(
  117. main,
  118. DocumentPasswordAction.BIND_ATTACHMENT.value,
  119. get_request_password(),
  120. context,
  121. )
  122. if main.row_version != expected_version:
  123. raise ConflictError(
  124. "数据已被其他用户修改,请刷新后重试",
  125. details={"currentRowVersion": main.row_version},
  126. )
  127. mountable_by_id = _locked_sub_plans(session, main.id, sub_plan_ids)
  128. bindings = session.scalars(
  129. select(SubPlanBinding)
  130. .where(SubPlanBinding.main_document_id == main.id)
  131. .order_by(SubPlanBinding.id.asc())
  132. .with_for_update()
  133. ).all()
  134. active_by_sub = {
  135. binding.sub_document_id: binding
  136. for binding in bindings
  137. if not binding.is_deleted
  138. }
  139. existing_ids = [
  140. sub_plan_id
  141. for sub_plan_id in mountable_by_id
  142. if sub_plan_id in active_by_sub
  143. ]
  144. created_ids = [
  145. sub_plan_id
  146. for sub_plan_id in mountable_by_id
  147. if sub_plan_id not in active_by_sub
  148. ]
  149. if not created_ids:
  150. return {
  151. "createdCount": 0,
  152. "existingCount": len(existing_ids),
  153. "subPlanCount": len(active_by_sub),
  154. "mainPlanRowVersion": main.row_version,
  155. }
  156. max_sort_no = max(
  157. (
  158. binding.sort_no
  159. for binding in bindings
  160. if not binding.is_deleted
  161. ),
  162. default=0,
  163. )
  164. for offset, sub_plan_id in enumerate(created_ids, start=1):
  165. session.add(
  166. SubPlanBinding(
  167. main_document_id=main.id,
  168. sub_document_id=sub_plan_id,
  169. sort_no=max_sort_no + offset * 10,
  170. created_by=context.user_id,
  171. updated_by=context.user_id,
  172. )
  173. )
  174. session.flush()
  175. main.row_version += 1
  176. main.updated_by = context.user_id
  177. main.updated_by_name = context.real_name
  178. main.updated_at = _now()
  179. session.add(
  180. business_audit(
  181. action=AuditAction.BIND_ATTACHMENT,
  182. target=AuditTarget.ATTACHMENT_BINDING,
  183. target_id=main.id,
  184. target_name=main.document_name,
  185. detail={
  186. "scope": "subPlanMount",
  187. "mainPlanId": serialize_id(main.id),
  188. "requestedSubPlanIds": [
  189. serialize_id(value) for value in sub_plan_ids
  190. ],
  191. "createdSubPlanIds": [
  192. serialize_id(value) for value in created_ids
  193. ],
  194. "existingSubPlanIds": [
  195. serialize_id(value) for value in existing_ids
  196. ],
  197. "createdCount": len(created_ids),
  198. "existingCount": len(existing_ids),
  199. "mainPlanRowVersion": main.row_version,
  200. },
  201. )
  202. )
  203. return {
  204. "createdCount": len(created_ids),
  205. "existingCount": len(existing_ids),
  206. "subPlanCount": len(active_by_sub) + len(created_ids),
  207. "mainPlanRowVersion": main.row_version,
  208. }
  209. try:
  210. return execute_with_deadlock_retry(operation)
  211. except IntegrityError as exc:
  212. db.session.rollback()
  213. raise ConflictError(
  214. "子案挂载关系已被并发修改,请刷新后重试",
  215. details={"currentRowVersion": _current_version(main_document_id)},
  216. ) from exc
  217. def unbind_sub_plan(
  218. main_document_id: int,
  219. sub_document_id: int,
  220. expected_version: int,
  221. ) -> dict[str, int]:
  222. """逻辑删除一条有效子案挂载关系。"""
  223. expected_version = _version(expected_version, "mainPlanRowVersion")
  224. context = get_auth_context()
  225. def operation() -> dict[str, int]:
  226. with transaction() as session:
  227. main = _locked_main(session, main_document_id)
  228. require_plan_action(main, context, AllowedAction.UNBIND_ATTACHMENT)
  229. require_document_password(
  230. main,
  231. DocumentPasswordAction.UNBIND_ATTACHMENT.value,
  232. get_request_password(),
  233. context,
  234. )
  235. if main.row_version != expected_version:
  236. raise ConflictError(
  237. "数据已被其他用户修改,请刷新后重试",
  238. details={"currentRowVersion": main.row_version},
  239. )
  240. binding = session.scalar(
  241. select(SubPlanBinding)
  242. .where(
  243. SubPlanBinding.main_document_id == main.id,
  244. SubPlanBinding.sub_document_id == sub_document_id,
  245. SubPlanBinding.is_deleted.is_(False),
  246. )
  247. .with_for_update()
  248. )
  249. if binding is None:
  250. raise ResourceNotFoundError("有效子案挂载关系不存在")
  251. now = _now()
  252. binding.is_deleted = True
  253. binding.deleted_at = now
  254. binding.updated_at = now
  255. binding.updated_by = context.user_id
  256. binding.row_version += 1
  257. remaining = (
  258. session.scalar(
  259. select(func.count(SubPlanBinding.id)).where(
  260. SubPlanBinding.main_document_id == main.id,
  261. SubPlanBinding.is_deleted.is_(False),
  262. )
  263. )
  264. or 0
  265. )
  266. main.row_version += 1
  267. main.updated_by = context.user_id
  268. main.updated_by_name = context.real_name
  269. main.updated_at = now
  270. session.add(
  271. business_audit(
  272. action=AuditAction.UNBIND_ATTACHMENT,
  273. target=AuditTarget.ATTACHMENT_BINDING,
  274. target_id=binding.id,
  275. target_name=main.document_name,
  276. detail={
  277. "scope": "subPlanMount",
  278. "bindingId": serialize_id(binding.id),
  279. "mainPlanId": serialize_id(main.id),
  280. "subPlanId": serialize_id(sub_document_id),
  281. "subPlanCount": remaining,
  282. "mainPlanRowVersion": main.row_version,
  283. },
  284. )
  285. )
  286. return {
  287. "subPlanCount": remaining,
  288. "mainPlanRowVersion": main.row_version,
  289. }
  290. return execute_with_deadlock_retry(operation)
  291. __all__ = [
  292. "bind_sub_plans",
  293. "unbind_sub_plan",
  294. ]