attachment_binding_service.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  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.common.enums import AllowedAction
  20. from dms.services.authorization_service import (
  21. require_plan_action,
  22. require_security_clearance,
  23. )
  24. from dms.services.document_password_service import (
  25. DocumentPasswordAction,
  26. get_request_password,
  27. require_document_password,
  28. )
  29. BIND_FIELDS = {"attachmentIds", "mainPlanRowVersion"}
  30. def _now() -> datetime:
  31. return datetime.now(timezone.utc).replace(tzinfo=None)
  32. def _version(value: Any, name: str) -> int:
  33. if type(value) is not int or value < 0:
  34. raise InvalidArgumentError(f"{name}必须是非负整数")
  35. return value
  36. def _string_id(value: Any, name: str) -> int:
  37. if not isinstance(value, str) or not value.isdecimal() or int(value) <= 0:
  38. raise InvalidArgumentError(f"{name}必须是正整数形式的字符串ID")
  39. return int(value)
  40. def _bind_payload(payload: Any) -> tuple[list[int], int]:
  41. if not isinstance(payload, dict) or set(payload) != BIND_FIELDS:
  42. raise InvalidArgumentError(
  43. "请求字段必须且只能包含attachmentIds和mainPlanRowVersion"
  44. )
  45. raw_ids = payload["attachmentIds"]
  46. if not isinstance(raw_ids, list) or not raw_ids:
  47. raise InvalidArgumentError("attachmentIds必须是非空数组")
  48. attachment_ids: list[int] = []
  49. seen: set[int] = set()
  50. for index, raw_id in enumerate(raw_ids):
  51. attachment_id = _string_id(raw_id, f"attachmentIds[{index}]")
  52. if attachment_id not in seen:
  53. attachment_ids.append(attachment_id)
  54. seen.add(attachment_id)
  55. return attachment_ids, _version(
  56. payload["mainPlanRowVersion"], "mainPlanRowVersion"
  57. )
  58. def _locked_main(session, main_document_id: int) -> Document:
  59. main = session.scalar(
  60. select(Document)
  61. .where(
  62. Document.id == main_document_id,
  63. Document.is_deleted.is_(False),
  64. )
  65. .with_for_update()
  66. )
  67. if main is None or main.document_type != DocumentType.MAIN.value:
  68. raise ResourceNotFoundError("主案不存在或类型不匹配")
  69. require_security_clearance(main, get_auth_context())
  70. return main
  71. def _check_version(main: Document, expected: int) -> None:
  72. if main.row_version != expected:
  73. raise ConflictError(
  74. "数据已被其他用户修改,请刷新后重试",
  75. details={"currentRowVersion": main.row_version},
  76. )
  77. def _locked_attachments(session, attachment_ids: list[int]) -> dict[int, Document]:
  78. documents = session.scalars(
  79. select(Document)
  80. .where(Document.id.in_(sorted(attachment_ids)))
  81. .order_by(Document.id.asc())
  82. .with_for_update()
  83. ).all()
  84. by_id = {document.id: document for document in documents}
  85. if any(
  86. attachment_id not in by_id
  87. or by_id[attachment_id].is_deleted
  88. or by_id[attachment_id].document_type != DocumentType.ATTACHMENT.value
  89. for attachment_id in attachment_ids
  90. ):
  91. raise ResourceNotFoundError("共享附件不存在或类型不匹配")
  92. return by_id
  93. def _active_count(session, main_document_id: int) -> int:
  94. return (
  95. session.scalar(
  96. select(func.count(AttachmentBinding.id)).where(
  97. AttachmentBinding.main_document_id == main_document_id,
  98. AttachmentBinding.is_deleted.is_(False),
  99. )
  100. )
  101. or 0
  102. )
  103. def _current_version(main_document_id: int) -> int | None:
  104. return db.session.scalar(
  105. select(Document.row_version).where(
  106. Document.id == main_document_id,
  107. Document.is_deleted.is_(False),
  108. )
  109. )
  110. def bind_attachments(main_document_id: int, payload: Any) -> dict[str, int]:
  111. """去重后批量挂载;同一主案上的写入由主案行锁串行化。"""
  112. attachment_ids, expected_version = _bind_payload(payload)
  113. context = get_auth_context()
  114. def operation() -> dict[str, int]:
  115. with transaction() as session:
  116. main = _locked_main(session, main_document_id)
  117. require_plan_action(main, context, AllowedAction.BIND_ATTACHMENT)
  118. require_document_password(
  119. main,
  120. DocumentPasswordAction.BIND_ATTACHMENT.value,
  121. get_request_password(),
  122. context,
  123. )
  124. _check_version(main, expected_version)
  125. _locked_attachments(session, attachment_ids)
  126. bindings = session.scalars(
  127. select(AttachmentBinding)
  128. .where(AttachmentBinding.main_document_id == main.id)
  129. .order_by(AttachmentBinding.id.asc())
  130. .with_for_update()
  131. ).all()
  132. active_by_attachment = {
  133. binding.attachment_document_id: binding
  134. for binding in bindings
  135. if not binding.is_deleted
  136. }
  137. existing_ids = [
  138. attachment_id
  139. for attachment_id in attachment_ids
  140. if attachment_id in active_by_attachment
  141. ]
  142. created_ids = [
  143. attachment_id
  144. for attachment_id in attachment_ids
  145. if attachment_id not in active_by_attachment
  146. ]
  147. if not created_ids:
  148. return {
  149. "createdCount": 0,
  150. "existingCount": len(existing_ids),
  151. "attachmentCount": _active_count(session, main.id),
  152. "mainPlanRowVersion": main.row_version,
  153. }
  154. max_sort_no = max(
  155. (
  156. binding.sort_no
  157. for binding in bindings
  158. if not binding.is_deleted
  159. ),
  160. default=0,
  161. )
  162. for offset, attachment_id in enumerate(created_ids, start=1):
  163. session.add(
  164. AttachmentBinding(
  165. main_document_id=main.id,
  166. attachment_document_id=attachment_id,
  167. sort_no=max_sort_no + offset * 10,
  168. created_by=context.user_id,
  169. updated_by=context.user_id,
  170. )
  171. )
  172. session.flush()
  173. main.attachment_count = _active_count(session, main.id)
  174. main.row_version += 1
  175. main.updated_by = context.user_id
  176. main.updated_by_name = context.real_name
  177. main.updated_at = _now()
  178. session.add(
  179. business_audit(
  180. action=AuditAction.BIND_ATTACHMENT,
  181. target=AuditTarget.ATTACHMENT_BINDING,
  182. target_id=main.id,
  183. target_name=main.document_name,
  184. detail={
  185. "mainPlanId": serialize_id(main.id),
  186. "requestedAttachmentIds": [
  187. serialize_id(value) for value in attachment_ids
  188. ],
  189. "createdAttachmentIds": [
  190. serialize_id(value) for value in created_ids
  191. ],
  192. "existingAttachmentIds": [
  193. serialize_id(value) for value in existing_ids
  194. ],
  195. "createdCount": len(created_ids),
  196. "existingCount": len(existing_ids),
  197. "attachmentCount": main.attachment_count,
  198. "mainPlanRowVersion": main.row_version,
  199. },
  200. )
  201. )
  202. return {
  203. "createdCount": len(created_ids),
  204. "existingCount": len(existing_ids),
  205. "attachmentCount": main.attachment_count,
  206. "mainPlanRowVersion": main.row_version,
  207. }
  208. try:
  209. return execute_with_deadlock_retry(operation)
  210. except IntegrityError as exc:
  211. db.session.rollback()
  212. raise ConflictError(
  213. "挂载关系已被并发修改,请刷新后重试",
  214. details={"currentRowVersion": _current_version(main_document_id)},
  215. ) from exc
  216. def unbind_attachment(
  217. main_document_id: int,
  218. attachment_document_id: int,
  219. expected_version: int,
  220. ) -> dict[str, int]:
  221. """逻辑删除一条有效挂载关系并重新计算主案计数。"""
  222. expected_version = _version(expected_version, "mainPlanRowVersion")
  223. context = get_auth_context()
  224. def operation() -> dict[str, int]:
  225. with transaction() as session:
  226. main = _locked_main(session, main_document_id)
  227. require_plan_action(main, context, AllowedAction.UNBIND_ATTACHMENT)
  228. require_document_password(
  229. main,
  230. DocumentPasswordAction.UNBIND_ATTACHMENT.value,
  231. get_request_password(),
  232. context,
  233. )
  234. _check_version(main, expected_version)
  235. _locked_attachments(session, [attachment_document_id])
  236. binding = session.scalar(
  237. select(AttachmentBinding)
  238. .where(
  239. AttachmentBinding.main_document_id == main.id,
  240. AttachmentBinding.attachment_document_id
  241. == attachment_document_id,
  242. AttachmentBinding.is_deleted.is_(False),
  243. )
  244. .with_for_update()
  245. )
  246. if binding is None:
  247. raise ResourceNotFoundError("有效挂载关系不存在")
  248. now = _now()
  249. binding.is_deleted = True
  250. binding.deleted_at = now
  251. binding.updated_at = now
  252. binding.updated_by = context.user_id
  253. binding.row_version += 1
  254. session.flush()
  255. main.attachment_count = _active_count(session, main.id)
  256. main.row_version += 1
  257. main.updated_by = context.user_id
  258. main.updated_by_name = context.real_name
  259. main.updated_at = now
  260. session.add(
  261. business_audit(
  262. action=AuditAction.UNBIND_ATTACHMENT,
  263. target=AuditTarget.ATTACHMENT_BINDING,
  264. target_id=binding.id,
  265. target_name=main.document_name,
  266. detail={
  267. "bindingId": serialize_id(binding.id),
  268. "mainPlanId": serialize_id(main.id),
  269. "attachmentId": serialize_id(attachment_document_id),
  270. "attachmentCount": main.attachment_count,
  271. "mainPlanRowVersion": main.row_version,
  272. },
  273. )
  274. )
  275. return {
  276. "attachmentCount": main.attachment_count,
  277. "mainPlanRowVersion": main.row_version,
  278. }
  279. return execute_with_deadlock_retry(operation)
  280. REORDER_BINDING_FIELDS = {"orderedBindingIds", "mainPlanRowVersion"}
  281. def _reorder_bindings_payload(payload: Any) -> tuple[list[int], int]:
  282. if not isinstance(payload, dict) or set(payload) != REORDER_BINDING_FIELDS:
  283. raise InvalidArgumentError(
  284. "请求字段必须且只能包含orderedBindingIds和mainPlanRowVersion"
  285. )
  286. raw_ids = payload["orderedBindingIds"]
  287. if not isinstance(raw_ids, list) or len(raw_ids) < 2:
  288. raise InvalidArgumentError("orderedBindingIds必须是至少包含2个ID的数组")
  289. binding_ids: list[int] = []
  290. seen: set[int] = set()
  291. for index, raw_id in enumerate(raw_ids):
  292. binding_id = _string_id(raw_id, f"orderedBindingIds[{index}]")
  293. if binding_id not in seen:
  294. binding_ids.append(binding_id)
  295. seen.add(binding_id)
  296. return binding_ids, _version(
  297. payload["mainPlanRowVersion"], "mainPlanRowVersion"
  298. )
  299. def reorder_main_plan_attachments(
  300. main_document_id: int,
  301. payload: Any,
  302. ) -> dict[str, int]:
  303. """调整主案下挂载附件的显示顺序;槽位重排语义与方案重排一致。
  304. 不同主案可对同一附件给出各自独立的顺序(顺序存于挂载关系而非附件本身)。
  305. """
  306. ordered_ids, expected_version = _reorder_bindings_payload(payload)
  307. context = get_auth_context()
  308. def operation() -> dict[str, int]:
  309. with transaction() as session:
  310. main = _locked_main(session, main_document_id)
  311. require_plan_action(main, context, AllowedAction.BIND_ATTACHMENT)
  312. _check_version(main, expected_version)
  313. bindings = session.scalars(
  314. select(AttachmentBinding)
  315. .where(
  316. AttachmentBinding.main_document_id == main.id,
  317. AttachmentBinding.is_deleted.is_(False),
  318. )
  319. .order_by(
  320. AttachmentBinding.sort_no.asc(), AttachmentBinding.id.asc()
  321. )
  322. .with_for_update()
  323. ).all()
  324. all_ids = [binding.id for binding in bindings]
  325. active_ids = set(all_ids)
  326. if any(binding_id not in active_ids for binding_id in ordered_ids):
  327. raise ResourceNotFoundError("挂载关系不存在或已解除")
  328. slot_ids = sorted(ordered_ids, key=all_ids.index)
  329. new_order = list(all_ids)
  330. for position, binding_id in zip(
  331. (all_ids.index(bid) for bid in slot_ids), ordered_ids
  332. ):
  333. new_order[position] = binding_id
  334. target_sort_by_id = {
  335. binding_id: (index + 1) * 10
  336. for index, binding_id in enumerate(new_order)
  337. }
  338. now = _now()
  339. reordered_count = 0
  340. for binding in bindings:
  341. target = target_sort_by_id[binding.id]
  342. if binding.sort_no == target:
  343. continue
  344. binding.sort_no = target
  345. binding.updated_at = now
  346. binding.updated_by = context.user_id
  347. binding.row_version += 1
  348. reordered_count += 1
  349. if reordered_count:
  350. main.row_version += 1
  351. main.updated_by = context.user_id
  352. main.updated_by_name = context.real_name
  353. main.updated_at = now
  354. session.add(
  355. business_audit(
  356. action=AuditAction.REORDER_ATTACHMENT,
  357. target=AuditTarget.ATTACHMENT_BINDING,
  358. target_id=main.id,
  359. target_name=main.document_name,
  360. detail={
  361. "mainPlanId": serialize_id(main.id),
  362. "orderedBindingIds": [
  363. serialize_id(bid) for bid in ordered_ids
  364. ],
  365. "reorderedCount": reordered_count,
  366. "mainPlanRowVersion": main.row_version,
  367. },
  368. )
  369. )
  370. return {
  371. "reorderedCount": reordered_count,
  372. "mainPlanRowVersion": main.row_version,
  373. }
  374. return execute_with_deadlock_retry(operation)
  375. __all__ = [
  376. "bind_attachments",
  377. "reorder_main_plan_attachments",
  378. "unbind_attachment",
  379. ]