attachment_binding_service.py 15 KB

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