attachment_binding_service.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. """B6共享附件挂载和解除挂载事务。"""
  2. from __future__ import annotations
  3. from datetime import datetime, timezone
  4. from typing import Any
  5. from sqlalchemy import func, select
  6. from sqlalchemy.exc import IntegrityError
  7. from dms.common.enums import AuditAction, AuditTarget, DocumentType
  8. from dms.common.errors import (
  9. ConflictError,
  10. InvalidArgumentError,
  11. ResourceNotFoundError,
  12. )
  13. from dms.common.response import serialize_id
  14. from dms.database.transaction import execute_with_deadlock_retry, transaction
  15. from dms.extensions import db
  16. from dms.models import AttachmentBinding, Document
  17. from dms.security.auth_context import get_auth_context
  18. from dms.services.audit_service import business_audit
  19. from dms.services.authorization_service import require_security_clearance
  20. BIND_FIELDS = {"attachmentIds", "mainPlanRowVersion"}
  21. def _now() -> datetime:
  22. return datetime.now(timezone.utc).replace(tzinfo=None)
  23. def _version(value: Any, name: str) -> int:
  24. if type(value) is not int or value < 0:
  25. raise InvalidArgumentError(f"{name}必须是非负整数")
  26. return value
  27. def _string_id(value: Any, name: str) -> int:
  28. if not isinstance(value, str) or not value.isdecimal() or int(value) <= 0:
  29. raise InvalidArgumentError(f"{name}必须是正整数形式的字符串ID")
  30. return int(value)
  31. def _bind_payload(payload: Any) -> tuple[list[int], int]:
  32. if not isinstance(payload, dict) or set(payload) != BIND_FIELDS:
  33. raise InvalidArgumentError(
  34. "请求字段必须且只能包含attachmentIds和mainPlanRowVersion"
  35. )
  36. raw_ids = payload["attachmentIds"]
  37. if not isinstance(raw_ids, list) or not raw_ids:
  38. raise InvalidArgumentError("attachmentIds必须是非空数组")
  39. attachment_ids: list[int] = []
  40. seen: set[int] = set()
  41. for index, raw_id in enumerate(raw_ids):
  42. attachment_id = _string_id(raw_id, f"attachmentIds[{index}]")
  43. if attachment_id not in seen:
  44. attachment_ids.append(attachment_id)
  45. seen.add(attachment_id)
  46. return attachment_ids, _version(
  47. payload["mainPlanRowVersion"], "mainPlanRowVersion"
  48. )
  49. def _locked_main(session, main_document_id: int) -> Document:
  50. main = session.scalar(
  51. select(Document)
  52. .where(
  53. Document.id == main_document_id,
  54. Document.is_deleted.is_(False),
  55. )
  56. .with_for_update()
  57. )
  58. if main is None or main.document_type != DocumentType.MAIN.value:
  59. raise ResourceNotFoundError("主案不存在或类型不匹配")
  60. require_security_clearance(main, get_auth_context())
  61. return main
  62. def _check_version(main: Document, expected: int) -> None:
  63. if main.row_version != expected:
  64. raise ConflictError(
  65. "数据已被其他用户修改,请刷新后重试",
  66. details={"currentRowVersion": main.row_version},
  67. )
  68. def _locked_attachments(session, attachment_ids: list[int]) -> dict[int, Document]:
  69. documents = session.scalars(
  70. select(Document)
  71. .where(Document.id.in_(sorted(attachment_ids)))
  72. .order_by(Document.id.asc())
  73. .with_for_update()
  74. ).all()
  75. by_id = {document.id: document for document in documents}
  76. if any(
  77. attachment_id not in by_id
  78. or by_id[attachment_id].is_deleted
  79. or by_id[attachment_id].document_type != DocumentType.ATTACHMENT.value
  80. for attachment_id in attachment_ids
  81. ):
  82. raise ResourceNotFoundError("共享附件不存在或类型不匹配")
  83. return by_id
  84. def _active_count(session, main_document_id: int) -> int:
  85. return (
  86. session.scalar(
  87. select(func.count(AttachmentBinding.id)).where(
  88. AttachmentBinding.main_document_id == main_document_id,
  89. AttachmentBinding.is_deleted.is_(False),
  90. )
  91. )
  92. or 0
  93. )
  94. def _current_version(main_document_id: int) -> int | None:
  95. return db.session.scalar(
  96. select(Document.row_version).where(
  97. Document.id == main_document_id,
  98. Document.is_deleted.is_(False),
  99. )
  100. )
  101. def bind_attachments(main_document_id: int, payload: Any) -> dict[str, int]:
  102. """去重后批量挂载;同一主案上的写入由主案行锁串行化。"""
  103. attachment_ids, expected_version = _bind_payload(payload)
  104. context = get_auth_context()
  105. def operation() -> dict[str, int]:
  106. with transaction() as session:
  107. main = _locked_main(session, main_document_id)
  108. _check_version(main, expected_version)
  109. _locked_attachments(session, attachment_ids)
  110. bindings = session.scalars(
  111. select(AttachmentBinding)
  112. .where(AttachmentBinding.main_document_id == main.id)
  113. .order_by(AttachmentBinding.id.asc())
  114. .with_for_update()
  115. ).all()
  116. active_by_attachment = {
  117. binding.attachment_document_id: binding
  118. for binding in bindings
  119. if not binding.is_deleted
  120. }
  121. existing_ids = [
  122. attachment_id
  123. for attachment_id in attachment_ids
  124. if attachment_id in active_by_attachment
  125. ]
  126. created_ids = [
  127. attachment_id
  128. for attachment_id in attachment_ids
  129. if attachment_id not in active_by_attachment
  130. ]
  131. if not created_ids:
  132. return {
  133. "createdCount": 0,
  134. "existingCount": len(existing_ids),
  135. "attachmentCount": _active_count(session, main.id),
  136. "mainPlanRowVersion": main.row_version,
  137. }
  138. max_sort_no = max(
  139. (
  140. binding.sort_no
  141. for binding in bindings
  142. if not binding.is_deleted
  143. ),
  144. default=0,
  145. )
  146. for offset, attachment_id in enumerate(created_ids, start=1):
  147. session.add(
  148. AttachmentBinding(
  149. main_document_id=main.id,
  150. attachment_document_id=attachment_id,
  151. sort_no=max_sort_no + offset * 10,
  152. created_by=context.user_id,
  153. updated_by=context.user_id,
  154. )
  155. )
  156. session.flush()
  157. main.attachment_count = _active_count(session, main.id)
  158. main.row_version += 1
  159. main.updated_by = context.user_id
  160. main.updated_by_name = context.real_name
  161. main.updated_at = _now()
  162. session.add(
  163. business_audit(
  164. action=AuditAction.BIND_ATTACHMENT,
  165. target=AuditTarget.ATTACHMENT_BINDING,
  166. target_id=main.id,
  167. target_name=main.document_name,
  168. detail={
  169. "mainPlanId": serialize_id(main.id),
  170. "requestedAttachmentIds": [
  171. serialize_id(value) for value in attachment_ids
  172. ],
  173. "createdAttachmentIds": [
  174. serialize_id(value) for value in created_ids
  175. ],
  176. "existingAttachmentIds": [
  177. serialize_id(value) for value in existing_ids
  178. ],
  179. "createdCount": len(created_ids),
  180. "existingCount": len(existing_ids),
  181. "attachmentCount": main.attachment_count,
  182. "mainPlanRowVersion": main.row_version,
  183. },
  184. )
  185. )
  186. return {
  187. "createdCount": len(created_ids),
  188. "existingCount": len(existing_ids),
  189. "attachmentCount": main.attachment_count,
  190. "mainPlanRowVersion": main.row_version,
  191. }
  192. try:
  193. return execute_with_deadlock_retry(operation)
  194. except IntegrityError as exc:
  195. db.session.rollback()
  196. raise ConflictError(
  197. "挂载关系已被并发修改,请刷新后重试",
  198. details={"currentRowVersion": _current_version(main_document_id)},
  199. ) from exc
  200. def unbind_attachment(
  201. main_document_id: int,
  202. attachment_document_id: int,
  203. expected_version: int,
  204. ) -> dict[str, int]:
  205. """逻辑删除一条有效挂载关系并重新计算主案计数。"""
  206. expected_version = _version(expected_version, "mainPlanRowVersion")
  207. context = get_auth_context()
  208. def operation() -> dict[str, int]:
  209. with transaction() as session:
  210. main = _locked_main(session, main_document_id)
  211. _check_version(main, expected_version)
  212. _locked_attachments(session, [attachment_document_id])
  213. binding = session.scalar(
  214. select(AttachmentBinding)
  215. .where(
  216. AttachmentBinding.main_document_id == main.id,
  217. AttachmentBinding.attachment_document_id
  218. == attachment_document_id,
  219. AttachmentBinding.is_deleted.is_(False),
  220. )
  221. .with_for_update()
  222. )
  223. if binding is None:
  224. raise ResourceNotFoundError("有效挂载关系不存在")
  225. now = _now()
  226. binding.is_deleted = True
  227. binding.deleted_at = now
  228. binding.updated_at = now
  229. binding.updated_by = context.user_id
  230. binding.row_version += 1
  231. session.flush()
  232. main.attachment_count = _active_count(session, main.id)
  233. main.row_version += 1
  234. main.updated_by = context.user_id
  235. main.updated_by_name = context.real_name
  236. main.updated_at = now
  237. session.add(
  238. business_audit(
  239. action=AuditAction.UNBIND_ATTACHMENT,
  240. target=AuditTarget.ATTACHMENT_BINDING,
  241. target_id=binding.id,
  242. target_name=main.document_name,
  243. detail={
  244. "bindingId": serialize_id(binding.id),
  245. "mainPlanId": serialize_id(main.id),
  246. "attachmentId": serialize_id(attachment_document_id),
  247. "attachmentCount": main.attachment_count,
  248. "mainPlanRowVersion": main.row_version,
  249. },
  250. )
  251. )
  252. return {
  253. "attachmentCount": main.attachment_count,
  254. "mainPlanRowVersion": main.row_version,
  255. }
  256. return execute_with_deadlock_retry(operation)
  257. __all__ = ["bind_attachments", "unbind_attachment"]