attachment_binding_service.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624
  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 (
  8. AttachmentType,
  9. AuditAction,
  10. AuditTarget,
  11. DocumentType,
  12. )
  13. from dms.common.errors import (
  14. ConflictError,
  15. DocumentNotInMoveScopeError,
  16. InvalidArgumentError,
  17. ResourceNotFoundError,
  18. )
  19. from dms.common.response import serialize_id
  20. from dms.database.transaction import execute_with_deadlock_retry, transaction
  21. from dms.extensions import db
  22. from dms.models import LOCK_DEFER_LOB, AttachmentBinding, Document
  23. from dms.security.auth_context import get_auth_context
  24. from dms.services.audit_service import business_audit
  25. from dms.common.enums import AllowedAction
  26. from dms.services.authorization_service import (
  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. BIND_FIELDS = {"attachmentIds", "mainPlanRowVersion"}
  36. def _now() -> datetime:
  37. return datetime.now(timezone.utc).replace(tzinfo=None)
  38. def _version(value: Any, name: str) -> int:
  39. if type(value) is not int or value < 0:
  40. raise InvalidArgumentError(f"{name}必须是非负整数")
  41. return value
  42. def _string_id(value: Any, name: str) -> int:
  43. if not isinstance(value, str) or not value.isdecimal() or int(value) <= 0:
  44. raise InvalidArgumentError(f"{name}必须是正整数形式的字符串ID")
  45. return int(value)
  46. def _bind_payload(payload: Any) -> tuple[list[int], int]:
  47. if not isinstance(payload, dict) or set(payload) != BIND_FIELDS:
  48. raise InvalidArgumentError(
  49. "请求字段必须且只能包含attachmentIds和mainPlanRowVersion"
  50. )
  51. raw_ids = payload["attachmentIds"]
  52. if not isinstance(raw_ids, list) or not raw_ids:
  53. raise InvalidArgumentError("attachmentIds必须是非空数组")
  54. attachment_ids: list[int] = []
  55. seen: set[int] = set()
  56. for index, raw_id in enumerate(raw_ids):
  57. attachment_id = _string_id(raw_id, f"attachmentIds[{index}]")
  58. if attachment_id not in seen:
  59. attachment_ids.append(attachment_id)
  60. seen.add(attachment_id)
  61. return attachment_ids, _version(
  62. payload["mainPlanRowVersion"], "mainPlanRowVersion"
  63. )
  64. def _locked_main(session, main_document_id: int) -> Document:
  65. main = session.scalar(
  66. select(Document)
  67. .where(
  68. Document.id == main_document_id,
  69. Document.is_deleted.is_(False),
  70. )
  71. .options(*LOCK_DEFER_LOB)
  72. .with_for_update()
  73. )
  74. if main is None or main.document_type != DocumentType.MAIN.value:
  75. raise ResourceNotFoundError("主案不存在或类型不匹配")
  76. require_security_clearance(main, get_auth_context())
  77. return main
  78. def _check_version(main: Document, expected: int) -> None:
  79. if main.row_version != expected:
  80. raise ConflictError(
  81. "数据已被其他用户修改,请刷新后重试",
  82. details={"currentRowVersion": main.row_version},
  83. )
  84. def _locked_attachments(session, attachment_ids: list[int]) -> dict[int, Document]:
  85. documents = session.scalars(
  86. select(Document)
  87. .where(Document.id.in_(sorted(attachment_ids)))
  88. .order_by(Document.id.asc())
  89. .options(*LOCK_DEFER_LOB)
  90. .with_for_update()
  91. ).all()
  92. by_id = {document.id: document for document in documents}
  93. if any(
  94. attachment_id not in by_id
  95. or by_id[attachment_id].is_deleted
  96. or by_id[attachment_id].document_type != DocumentType.ATTACHMENT.value
  97. for attachment_id in attachment_ids
  98. ):
  99. raise ResourceNotFoundError("共享附件不存在或类型不匹配")
  100. return by_id
  101. def _active_count(session, main_document_id: int) -> int:
  102. return (
  103. session.scalar(
  104. select(func.count(AttachmentBinding.id)).where(
  105. AttachmentBinding.main_document_id == main_document_id,
  106. AttachmentBinding.is_deleted.is_(False),
  107. )
  108. )
  109. or 0
  110. )
  111. def _current_version(main_document_id: int) -> int | None:
  112. return db.session.scalar(
  113. select(Document.row_version).where(
  114. Document.id == main_document_id,
  115. Document.is_deleted.is_(False),
  116. )
  117. )
  118. def bind_attachments(main_document_id: int, payload: Any) -> dict[str, int]:
  119. """去重后批量挂载;同一主案上的写入由主案行锁串行化。"""
  120. attachment_ids, expected_version = _bind_payload(payload)
  121. context = get_auth_context()
  122. def operation() -> dict[str, int]:
  123. with transaction() as session:
  124. main = _locked_main(session, main_document_id)
  125. require_plan_action(main, context, AllowedAction.BIND_ATTACHMENT)
  126. require_document_password(
  127. main,
  128. DocumentPasswordAction.BIND_ATTACHMENT.value,
  129. get_request_password(),
  130. context,
  131. )
  132. _check_version(main, expected_version)
  133. _locked_attachments(session, attachment_ids)
  134. bindings = session.scalars(
  135. select(AttachmentBinding)
  136. .where(AttachmentBinding.main_document_id == main.id)
  137. .order_by(AttachmentBinding.id.asc())
  138. .with_for_update()
  139. ).all()
  140. active_by_attachment = {
  141. binding.attachment_document_id: binding
  142. for binding in bindings
  143. if not binding.is_deleted
  144. }
  145. existing_ids = [
  146. attachment_id
  147. for attachment_id in attachment_ids
  148. if attachment_id in active_by_attachment
  149. ]
  150. created_ids = [
  151. attachment_id
  152. for attachment_id in attachment_ids
  153. if attachment_id not in active_by_attachment
  154. ]
  155. if not created_ids:
  156. return {
  157. "createdCount": 0,
  158. "existingCount": len(existing_ids),
  159. "attachmentCount": _active_count(session, main.id),
  160. "mainPlanRowVersion": main.row_version,
  161. }
  162. max_sort_no = max(
  163. (
  164. binding.sort_no
  165. for binding in bindings
  166. if not binding.is_deleted
  167. ),
  168. default=0,
  169. )
  170. for offset, attachment_id in enumerate(created_ids, start=1):
  171. session.add(
  172. AttachmentBinding(
  173. main_document_id=main.id,
  174. attachment_document_id=attachment_id,
  175. sort_no=max_sort_no + offset * 10,
  176. created_by=context.user_id,
  177. updated_by=context.user_id,
  178. )
  179. )
  180. session.flush()
  181. main.attachment_count = _active_count(session, main.id)
  182. main.row_version += 1
  183. main.updated_by = context.user_id
  184. main.updated_by_name = context.real_name
  185. main.updated_at = _now()
  186. session.add(
  187. business_audit(
  188. action=AuditAction.BIND_ATTACHMENT,
  189. target=AuditTarget.ATTACHMENT_BINDING,
  190. target_id=main.id,
  191. target_name=main.document_name,
  192. detail={
  193. "mainPlanId": serialize_id(main.id),
  194. "requestedAttachmentIds": [
  195. serialize_id(value) for value in attachment_ids
  196. ],
  197. "createdAttachmentIds": [
  198. serialize_id(value) for value in created_ids
  199. ],
  200. "existingAttachmentIds": [
  201. serialize_id(value) for value in existing_ids
  202. ],
  203. "createdCount": len(created_ids),
  204. "existingCount": len(existing_ids),
  205. "attachmentCount": main.attachment_count,
  206. "mainPlanRowVersion": main.row_version,
  207. },
  208. )
  209. )
  210. return {
  211. "createdCount": len(created_ids),
  212. "existingCount": len(existing_ids),
  213. "attachmentCount": main.attachment_count,
  214. "mainPlanRowVersion": main.row_version,
  215. }
  216. try:
  217. return execute_with_deadlock_retry(operation)
  218. except IntegrityError as exc:
  219. db.session.rollback()
  220. raise ConflictError(
  221. "挂载关系已被并发修改,请刷新后重试",
  222. details={"currentRowVersion": _current_version(main_document_id)},
  223. ) from exc
  224. def unbind_attachment(
  225. main_document_id: int,
  226. attachment_document_id: int,
  227. expected_version: int,
  228. ) -> dict[str, int]:
  229. """逻辑删除一条有效挂载关系并重新计算主案计数。"""
  230. expected_version = _version(expected_version, "mainPlanRowVersion")
  231. context = get_auth_context()
  232. def operation() -> dict[str, int]:
  233. with transaction() as session:
  234. main = _locked_main(session, main_document_id)
  235. require_plan_action(main, context, AllowedAction.UNBIND_ATTACHMENT)
  236. require_document_password(
  237. main,
  238. DocumentPasswordAction.UNBIND_ATTACHMENT.value,
  239. get_request_password(),
  240. context,
  241. )
  242. _check_version(main, expected_version)
  243. _locked_attachments(session, [attachment_document_id])
  244. binding = session.scalar(
  245. select(AttachmentBinding)
  246. .where(
  247. AttachmentBinding.main_document_id == main.id,
  248. AttachmentBinding.attachment_document_id
  249. == attachment_document_id,
  250. AttachmentBinding.is_deleted.is_(False),
  251. )
  252. .with_for_update()
  253. )
  254. if binding is None:
  255. raise ResourceNotFoundError("有效挂载关系不存在")
  256. now = _now()
  257. binding.is_deleted = True
  258. binding.deleted_at = now
  259. binding.updated_at = now
  260. binding.updated_by = context.user_id
  261. binding.row_version += 1
  262. session.flush()
  263. main.attachment_count = _active_count(session, main.id)
  264. main.row_version += 1
  265. main.updated_by = context.user_id
  266. main.updated_by_name = context.real_name
  267. main.updated_at = now
  268. session.add(
  269. business_audit(
  270. action=AuditAction.UNBIND_ATTACHMENT,
  271. target=AuditTarget.ATTACHMENT_BINDING,
  272. target_id=binding.id,
  273. target_name=main.document_name,
  274. detail={
  275. "bindingId": serialize_id(binding.id),
  276. "mainPlanId": serialize_id(main.id),
  277. "attachmentId": serialize_id(attachment_document_id),
  278. "attachmentCount": main.attachment_count,
  279. "mainPlanRowVersion": main.row_version,
  280. },
  281. )
  282. )
  283. return {
  284. "attachmentCount": main.attachment_count,
  285. "mainPlanRowVersion": main.row_version,
  286. }
  287. return execute_with_deadlock_retry(operation)
  288. REORDER_BINDING_FIELDS = {"orderedBindingIds", "mainPlanRowVersion"}
  289. def _reorder_bindings_payload(payload: Any) -> tuple[list[int], int]:
  290. if not isinstance(payload, dict) or set(payload) != REORDER_BINDING_FIELDS:
  291. raise InvalidArgumentError(
  292. "请求字段必须且只能包含orderedBindingIds和mainPlanRowVersion"
  293. )
  294. raw_ids = payload["orderedBindingIds"]
  295. if not isinstance(raw_ids, list) or len(raw_ids) < 2:
  296. raise InvalidArgumentError("orderedBindingIds必须是至少包含2个ID的数组")
  297. binding_ids: list[int] = []
  298. seen: set[int] = set()
  299. for index, raw_id in enumerate(raw_ids):
  300. binding_id = _string_id(raw_id, f"orderedBindingIds[{index}]")
  301. if binding_id not in seen:
  302. binding_ids.append(binding_id)
  303. seen.add(binding_id)
  304. return binding_ids, _version(
  305. payload["mainPlanRowVersion"], "mainPlanRowVersion"
  306. )
  307. def reorder_main_plan_attachments(
  308. main_document_id: int,
  309. payload: Any,
  310. ) -> dict[str, int]:
  311. """调整主案下挂载附件的显示顺序;槽位重排语义与方案重排一致。
  312. 不同主案可对同一附件给出各自独立的顺序(顺序存于挂载关系而非附件本身)。
  313. """
  314. ordered_ids, expected_version = _reorder_bindings_payload(payload)
  315. context = get_auth_context()
  316. def operation() -> dict[str, int]:
  317. with transaction() as session:
  318. main = _locked_main(session, main_document_id)
  319. # 挂载重排=调整主案挂载区顺序:按主案 EDIT 判定(移动=编辑,契约1.18修订)
  320. require_plan_action(main, context, AllowedAction.EDIT)
  321. _check_version(main, expected_version)
  322. bindings = session.scalars(
  323. select(AttachmentBinding)
  324. .where(
  325. AttachmentBinding.main_document_id == main.id,
  326. AttachmentBinding.is_deleted.is_(False),
  327. )
  328. .order_by(
  329. AttachmentBinding.sort_no.asc(), AttachmentBinding.id.asc()
  330. )
  331. .with_for_update()
  332. ).all()
  333. all_ids = [binding.id for binding in bindings]
  334. active_ids = set(all_ids)
  335. if any(binding_id not in active_ids for binding_id in ordered_ids):
  336. raise ResourceNotFoundError("挂载关系不存在或已解除")
  337. slot_ids = sorted(ordered_ids, key=all_ids.index)
  338. new_order = list(all_ids)
  339. for position, binding_id in zip(
  340. (all_ids.index(bid) for bid in slot_ids), ordered_ids
  341. ):
  342. new_order[position] = binding_id
  343. target_sort_by_id = {
  344. binding_id: (index + 1) * 10
  345. for index, binding_id in enumerate(new_order)
  346. }
  347. now = _now()
  348. reordered_count = 0
  349. for binding in bindings:
  350. target = target_sort_by_id[binding.id]
  351. if binding.sort_no == target:
  352. continue
  353. binding.sort_no = target
  354. binding.updated_at = now
  355. binding.updated_by = context.user_id
  356. binding.row_version += 1
  357. reordered_count += 1
  358. if reordered_count:
  359. main.row_version += 1
  360. main.updated_by = context.user_id
  361. main.updated_by_name = context.real_name
  362. main.updated_at = now
  363. session.add(
  364. business_audit(
  365. action=AuditAction.REORDER_ATTACHMENT,
  366. target=AuditTarget.ATTACHMENT_BINDING,
  367. target_id=main.id,
  368. target_name=main.document_name,
  369. detail={
  370. "mainPlanId": serialize_id(main.id),
  371. "orderedBindingIds": [
  372. serialize_id(bid) for bid in ordered_ids
  373. ],
  374. "reorderedCount": reordered_count,
  375. "mainPlanRowVersion": main.row_version,
  376. },
  377. )
  378. )
  379. return {
  380. "reorderedCount": reordered_count,
  381. "mainPlanRowVersion": main.row_version,
  382. }
  383. return execute_with_deadlock_retry(operation)
  384. def _move_binding_payload(
  385. payload: Any,
  386. ) -> tuple[int | None, str | None, str | None, int]:
  387. """挂载附件移动请求解析:targetPosition/target 二选一 +
  388. 必填 mainPlanRowVersion + 可选 scopeAttachmentType(页签类型)。
  389. """
  390. if not isinstance(payload, dict):
  391. raise InvalidArgumentError("请求体必须是JSON对象")
  392. allowed = {
  393. "targetPosition",
  394. "target",
  395. "scopeAttachmentType",
  396. "mainPlanRowVersion",
  397. }
  398. if set(payload) - allowed:
  399. raise InvalidArgumentError(
  400. "请求字段只允许targetPosition、target、scopeAttachmentType或mainPlanRowVersion"
  401. )
  402. if "mainPlanRowVersion" not in payload:
  403. raise InvalidArgumentError("请求必须包含mainPlanRowVersion")
  404. if ("targetPosition" in payload) == ("target" in payload):
  405. raise InvalidArgumentError("targetPosition与target必须二选一")
  406. if "targetPosition" in payload:
  407. raw_position = payload["targetPosition"]
  408. if type(raw_position) is not int or raw_position < 1:
  409. raise InvalidArgumentError("targetPosition必须是大于等于1的整数")
  410. position: int | None = raw_position
  411. target: str | None = None
  412. else:
  413. target = payload["target"]
  414. if target not in {"TOP", "BOTTOM"}:
  415. raise InvalidArgumentError("target只允许TOP或BOTTOM")
  416. position = None
  417. scope_attachment_type: str | None = None
  418. if "scopeAttachmentType" in payload:
  419. raw_type = payload["scopeAttachmentType"]
  420. try:
  421. scope_attachment_type = AttachmentType(raw_type).value
  422. except ValueError:
  423. raise InvalidArgumentError("scopeAttachmentType不是有效附件类型")
  424. return (
  425. position,
  426. target,
  427. scope_attachment_type,
  428. _version(payload["mainPlanRowVersion"], "mainPlanRowVersion"),
  429. )
  430. def move_main_plan_attachment(
  431. main_document_id: int,
  432. binding_id: int,
  433. payload: Any,
  434. ) -> dict[str, int]:
  435. """跨页调整主案下挂载附件顺序:移动到页签类型范围内指定位置/置顶/末尾。
  436. - ``scopeAttachmentType`` 给定时位置口径为该类型挂载的 1-based 序号
  437. (与列表页签过滤口径一致),其他类型挂载保持原相对顺序;
  438. - 槽位移动语义与挂载重排一致,顺序存于挂载关系(各主案独立)。
  439. """
  440. position_input, target_input, scope_type, expected_version = (
  441. _move_binding_payload(payload)
  442. )
  443. context = get_auth_context()
  444. def operation() -> dict[str, int]:
  445. with transaction() as session:
  446. main = _locked_main(session, main_document_id)
  447. # 挂载移动与重排同权限口径:按主案 EDIT 判定(移动=编辑,契约1.18修订)
  448. require_plan_action(main, context, AllowedAction.EDIT)
  449. _check_version(main, expected_version)
  450. bindings = session.scalars(
  451. select(AttachmentBinding)
  452. .where(
  453. AttachmentBinding.main_document_id == main.id,
  454. AttachmentBinding.is_deleted.is_(False),
  455. )
  456. .order_by(
  457. AttachmentBinding.sort_no.asc(), AttachmentBinding.id.asc()
  458. )
  459. .with_for_update()
  460. ).all()
  461. moved = next(
  462. (item for item in bindings if item.id == binding_id), None
  463. )
  464. if moved is None:
  465. raise ResourceNotFoundError("挂载关系不存在或已解除")
  466. if scope_type is not None:
  467. attachment_ids = [
  468. item.attachment_document_id for item in bindings
  469. ]
  470. documents = session.scalars(
  471. select(Document).where(
  472. Document.id.in_(attachment_ids),
  473. Document.is_deleted.is_(False),
  474. )
  475. ).all()
  476. type_by_attachment = {
  477. document.id: document.attachment_type
  478. for document in documents
  479. }
  480. visible = [
  481. item
  482. for item in bindings
  483. if type_by_attachment.get(item.attachment_document_id)
  484. == scope_type
  485. ]
  486. else:
  487. visible = list(bindings)
  488. if not any(item.id == binding_id for item in visible):
  489. raise DocumentNotInMoveScopeError("挂载附件不在指定类型范围内")
  490. current = next(
  491. index for index, item in enumerate(visible)
  492. if item.id == binding_id
  493. ) + 1
  494. total = len(visible)
  495. if target_input == "TOP":
  496. position = 1
  497. elif target_input == "BOTTOM":
  498. position = total
  499. else:
  500. position = max(1, min(position_input, total))
  501. if position == current:
  502. return {
  503. "position": position,
  504. "total": total,
  505. "mainPlanRowVersion": main.row_version,
  506. }
  507. # 类型内重排、类型外挂载原位保持:可见槽位按新序列回填。
  508. rest = [item for item in visible if item.id != binding_id]
  509. insert_at = position - 1
  510. new_visible = rest[:insert_at] + [moved] + rest[insert_at:]
  511. visible_ids = {item.id for item in visible}
  512. fill = iter(new_visible)
  513. new_order = [
  514. next(fill) if item.id in visible_ids else item
  515. for item in bindings
  516. ]
  517. target_sort_by_id = {
  518. item.id: (index + 1) * 10
  519. for index, item in enumerate(new_order)
  520. }
  521. now = _now()
  522. moved_count = 0
  523. for item in bindings:
  524. target_no = target_sort_by_id[item.id]
  525. if item.sort_no == target_no:
  526. continue
  527. item.sort_no = target_no
  528. item.updated_at = now
  529. item.updated_by = context.user_id
  530. item.row_version += 1
  531. moved_count += 1
  532. if moved_count:
  533. main.row_version += 1
  534. main.updated_by = context.user_id
  535. main.updated_by_name = context.real_name
  536. main.updated_at = now
  537. detail: dict[str, Any] = {
  538. "mainPlanId": serialize_id(main.id),
  539. "bindingId": serialize_id(moved.id),
  540. }
  541. if scope_type is not None:
  542. detail["scopeAttachmentType"] = scope_type
  543. detail.update(
  544. {
  545. "fromPosition": current,
  546. "toPosition": position,
  547. "scopeTotal": total,
  548. "movedCount": moved_count,
  549. "mainPlanRowVersion": main.row_version,
  550. }
  551. )
  552. session.add(
  553. business_audit(
  554. action=AuditAction.MOVE_ATTACHMENT,
  555. target=AuditTarget.ATTACHMENT_BINDING,
  556. target_id=moved.id,
  557. target_name=main.document_name,
  558. detail=detail,
  559. )
  560. )
  561. return {
  562. "position": position,
  563. "total": total,
  564. "mainPlanRowVersion": main.row_version,
  565. }
  566. return execute_with_deadlock_retry(operation)
  567. __all__ = [
  568. "bind_attachments",
  569. "move_main_plan_attachment",
  570. "reorder_main_plan_attachments",
  571. "unbind_attachment",
  572. ]