document_mutation_service.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768
  1. """B5文档与共享附件写操作及文件事务补偿。"""
  2. from __future__ import annotations
  3. from datetime import datetime, timezone
  4. import logging
  5. from typing import Any
  6. from flask import current_app
  7. from sqlalchemy import func, or_, select
  8. from werkzeug.datastructures import FileStorage
  9. from dms.common.enums import (
  10. AttachmentType,
  11. AuditAction,
  12. AuditTarget,
  13. DocumentStatus,
  14. DocumentType,
  15. EnabledStatus,
  16. SecurityLevel,
  17. VisibilityType,
  18. )
  19. from dms.common.errors import (
  20. AttachmentInUseError,
  21. CategoryNotLeafError,
  22. ConflictError,
  23. DocumentNameConflictError,
  24. InvalidArgumentError,
  25. MainPlanHasChildrenError,
  26. ResourceNotFoundError,
  27. )
  28. from dms.common.response import serialize_id
  29. from dms.database.transaction import transaction
  30. from dms.extensions import db
  31. from dms.models import AttachmentBinding, Category, Document, Permission
  32. from dms.security.auth_context import get_auth_context
  33. from dms.services.attachment_query_service import attachment_detail
  34. from dms.services.audit_service import business_audit
  35. from dms.services.authorization_service import evaluate_plan_access
  36. from dms.services.document_query_service import document_detail
  37. from dms.services.document_content_service import (
  38. build_search_text,
  39. extract_document_content,
  40. )
  41. from dms.storage.uploads import StagedUpload, stage_upload
  42. from dms.storage.paths import UnsafeStoragePathError, resolve_storage_path
  43. logger = logging.getLogger(__name__)
  44. MAIN_CREATE_FIELDS = {
  45. "documentName",
  46. "documentType",
  47. "summary",
  48. "categoryId",
  49. "securityLevel",
  50. "tags",
  51. }
  52. OVERWRITE_FIELDS = {"overwriteDocumentId", "rowVersion"}
  53. SUB_PLAN_CREATE_FIELDS = {
  54. "documentName",
  55. "documentType",
  56. "summary",
  57. "parentDocumentId",
  58. "securityLevel",
  59. "tags",
  60. }
  61. MAIN_EDIT_FIELDS = {
  62. "documentName",
  63. "summary",
  64. "categoryId",
  65. "securityLevel",
  66. "tags",
  67. "rowVersion",
  68. }
  69. SUB_PLAN_EDIT_FIELDS = {
  70. "documentName",
  71. "summary",
  72. "securityLevel",
  73. "tags",
  74. "rowVersion",
  75. }
  76. ATTACHMENT_CREATE_FIELDS = {"documentName", "attachmentType", "summary", "tags"}
  77. ATTACHMENT_EDIT_FIELDS = ATTACHMENT_CREATE_FIELDS | {"rowVersion"}
  78. def _now() -> datetime:
  79. return datetime.now(timezone.utc).replace(tzinfo=None)
  80. def _exact(payload: Any, fields: set[str]) -> dict[str, Any]:
  81. if not isinstance(payload, dict) or set(payload) != fields:
  82. raise InvalidArgumentError(
  83. "metadata字段必须且只能包含:" + "、".join(sorted(fields))
  84. )
  85. return payload
  86. def _text(value: Any, name: str, limit: int, *, nullable: bool = False):
  87. if value is None and nullable:
  88. return None
  89. if not isinstance(value, str) or not value.strip():
  90. raise InvalidArgumentError(f"{name}不能为空")
  91. result = value.strip()
  92. if len(result) > limit:
  93. raise InvalidArgumentError(f"{name}长度不能超过{limit}")
  94. return result
  95. def _tags(value: Any) -> list[str]:
  96. if not isinstance(value, list) or len(value) > 50:
  97. raise InvalidArgumentError("tags必须是最多50项的字符串数组")
  98. result: list[str] = []
  99. for item in value:
  100. normalized = _text(item, "tags元素", 64)
  101. if normalized not in result:
  102. result.append(normalized)
  103. return result
  104. def _enum(value: Any, enum_type, name: str) -> str:
  105. if not isinstance(value, str):
  106. raise InvalidArgumentError(f"{name}必须是字符串枚举")
  107. try:
  108. return enum_type(value).value
  109. except ValueError as exc:
  110. raise InvalidArgumentError(f"{name}不是有效枚举值") from exc
  111. def _string_id(value: Any, name: str) -> int:
  112. if not isinstance(value, str) or not value.isdecimal() or int(value) <= 0:
  113. raise InvalidArgumentError(f"{name}必须是正整数形式的字符串ID")
  114. return int(value)
  115. def _version(value: Any) -> int:
  116. if type(value) is not int or value < 0:
  117. raise InvalidArgumentError("rowVersion必须是非负整数")
  118. return value
  119. def _category(category_id: int) -> Category:
  120. category = db.session.scalar(
  121. select(Category).where(
  122. Category.id == category_id,
  123. Category.is_deleted.is_(False),
  124. Category.status == EnabledStatus.ENABLED.value,
  125. )
  126. )
  127. if category is None:
  128. raise ResourceNotFoundError("方案分类不存在或不可用")
  129. return category
  130. def _leaf_category(category_id: int) -> Category:
  131. category = _category(category_id)
  132. child_count = db.session.scalar(
  133. select(func.count(Category.id)).where(
  134. Category.parent_id == category.id,
  135. Category.is_deleted.is_(False),
  136. Category.status == EnabledStatus.ENABLED.value,
  137. )
  138. ) or 0
  139. if child_count:
  140. raise CategoryNotLeafError(
  141. details={
  142. "categoryId": serialize_id(category.id),
  143. "childCategoryCount": int(child_count),
  144. }
  145. )
  146. return category
  147. def _serialize(document: Document) -> dict[str, object]:
  148. context = get_auth_context()
  149. if document.document_type == DocumentType.ATTACHMENT.value:
  150. return attachment_detail(document, context)
  151. return document_detail(document, context, evaluate_plan_access(document, context))
  152. def _create_record(
  153. upload: StagedUpload,
  154. payload: dict[str, Any],
  155. *,
  156. attachment: bool,
  157. batch: bool,
  158. ) -> dict[str, object]:
  159. if not isinstance(payload, dict):
  160. raise InvalidArgumentError("metadata必须是JSON对象")
  161. context = get_auth_context()
  162. actor_id = context.user_id
  163. document_type = DocumentType.ATTACHMENT.value
  164. category = None
  165. parent = None
  166. attachment_type = None
  167. if attachment:
  168. _exact(payload, ATTACHMENT_CREATE_FIELDS)
  169. attachment_type = _enum(
  170. payload["attachmentType"], AttachmentType, "attachmentType"
  171. )
  172. security = SecurityLevel.PUBLIC.value
  173. visibility = VisibilityType.ALL_AUTHENTICATED.value
  174. status = DocumentStatus.PUBLISHED.value
  175. else:
  176. raw_type = payload.get("documentType")
  177. try:
  178. document_type = DocumentType(raw_type).value
  179. except (ValueError, TypeError) as exc:
  180. raise InvalidArgumentError("documentType只允许MAIN或SUB_PLAN") from exc
  181. if document_type not in {
  182. DocumentType.MAIN.value,
  183. DocumentType.SUB_PLAN.value,
  184. }:
  185. raise InvalidArgumentError("documentType只允许MAIN或SUB_PLAN")
  186. create_fields = (
  187. SUB_PLAN_CREATE_FIELDS
  188. if document_type == DocumentType.SUB_PLAN.value
  189. else MAIN_CREATE_FIELDS
  190. )
  191. if frozenset(payload) not in {
  192. frozenset(create_fields),
  193. frozenset(create_fields | OVERWRITE_FIELDS),
  194. }:
  195. raise InvalidArgumentError(
  196. "metadata字段必须为新增字段全集,或额外同时包含overwriteDocumentId和rowVersion"
  197. )
  198. security = _enum(payload["securityLevel"], SecurityLevel, "securityLevel")
  199. visibility = VisibilityType.CUSTOM.value
  200. status = DocumentStatus.PUBLISHED.value
  201. if document_type == DocumentType.SUB_PLAN.value:
  202. parent_id = _string_id(payload["parentDocumentId"], "parentDocumentId")
  203. parent = db.session.scalar(
  204. select(Document).where(
  205. Document.id == parent_id,
  206. Document.document_type == DocumentType.MAIN.value,
  207. Document.is_deleted.is_(False),
  208. )
  209. )
  210. if parent is None:
  211. raise ResourceNotFoundError("父文档必须是有效主案")
  212. visibility = parent.visibility_type
  213. category = _category(parent.category_id)
  214. else:
  215. category = _leaf_category(
  216. _string_id(payload["categoryId"], "categoryId")
  217. )
  218. name = _text(payload["documentName"], "documentName", 255)
  219. summary = _text(payload["summary"], "summary", 20000, nullable=True)
  220. tags = _tags(payload["tags"])
  221. extraction = extract_document_content(upload.temporary_path, upload.extension)
  222. final_created = False
  223. committed = False
  224. old_file_path = None
  225. try:
  226. with transaction() as session:
  227. duplicate = None
  228. if not attachment:
  229. duplicate_conditions = [
  230. Document.document_name == name,
  231. Document.document_type == document_type,
  232. Document.is_deleted.is_(False),
  233. ]
  234. if document_type == DocumentType.SUB_PLAN.value:
  235. duplicate_conditions.append(Document.parent_document_id == parent.id)
  236. duplicate = session.scalar(
  237. select(Document).where(*duplicate_conditions).with_for_update()
  238. )
  239. overwrite_id = payload.get("overwriteDocumentId")
  240. if duplicate is not None and overwrite_id is None:
  241. raise DocumentNameConflictError(
  242. details={
  243. "existingDocumentId": serialize_id(duplicate.id),
  244. "existingDocumentName": duplicate.document_name,
  245. "existingRowVersion": duplicate.row_version,
  246. "documentType": duplicate.document_type,
  247. "parentDocumentId": serialize_id(duplicate.parent_document_id),
  248. }
  249. )
  250. if overwrite_id is not None:
  251. expected_id = _string_id(overwrite_id, "overwriteDocumentId")
  252. expected_version = _version(payload["rowVersion"])
  253. if duplicate is None or duplicate.id != expected_id:
  254. raise ConflictError(
  255. "同名方案已发生变化,请重新确认",
  256. details={"currentRowVersion": duplicate.row_version if duplicate else None},
  257. )
  258. _check_version(duplicate, expected_version)
  259. document = duplicate
  260. old_category_id = document.category_id
  261. if document_type == DocumentType.MAIN.value and old_category_id != category.id:
  262. old_category = session.get(Category, old_category_id)
  263. if old_category is not None:
  264. old_category.document_count = max(0, old_category.document_count - 1)
  265. old_category.row_version += 1
  266. category.document_count += 1
  267. category.row_version += 1
  268. try:
  269. old_file_path = resolve_storage_path(
  270. document.file_relative_path,
  271. current_app.config["DMS_STORAGE_ROOT"],
  272. )
  273. except UnsafeStoragePathError:
  274. logger.error("覆盖方案时检测到异常旧文件路径:document_id=%s", document.id)
  275. old_file_path = None
  276. document.summary = summary
  277. document.security_level = security
  278. document.category_id = category.id if category else None
  279. document.category_name = category.category_name if category else None
  280. document.category_path = category.category_path if category else None
  281. document.tags = tags
  282. document.original_file_name = upload.original_file_name
  283. document.file_relative_path = upload.relative_path
  284. document.file_extension = upload.extension
  285. document.mime_type = upload.mime_type
  286. document.file_size = upload.file_size
  287. document.file_hash = upload.file_hash
  288. document.content_text = extraction.text
  289. document.content_extract_status = extraction.status
  290. document.content_extracted_at = extraction.extracted_at
  291. document.search_text = build_search_text(name, summary, tags, extraction.text)
  292. document.updated_by = actor_id
  293. document.updated_by_name = context.real_name
  294. document.updated_at = _now()
  295. document.row_version += 1
  296. if document_type == DocumentType.MAIN.value and old_category_id != category.id:
  297. session.query(Document).filter(
  298. Document.parent_document_id == document.id,
  299. Document.document_type == DocumentType.SUB_PLAN.value,
  300. Document.is_deleted.is_(False),
  301. ).update(
  302. {
  303. Document.category_id: category.id,
  304. Document.category_name: category.category_name,
  305. Document.category_path: category.category_path,
  306. Document.updated_by: actor_id,
  307. Document.updated_by_name: context.real_name,
  308. Document.updated_at: _now(),
  309. Document.row_version: Document.row_version + 1,
  310. },
  311. synchronize_session=False,
  312. )
  313. session.add(
  314. business_audit(
  315. action=AuditAction.UPLOAD_DOCUMENT,
  316. target=AuditTarget.DOCUMENT,
  317. target_id=document.id,
  318. target_name=document.document_name,
  319. detail={
  320. "overwrite": True,
  321. "originalFileName": document.original_file_name,
  322. "fileSize": document.file_size,
  323. "rowVersion": document.row_version,
  324. },
  325. )
  326. )
  327. upload.promote()
  328. final_created = True
  329. else:
  330. document = Document(
  331. document_name=name,
  332. summary=summary,
  333. document_type=document_type,
  334. document_status=status,
  335. security_level=security,
  336. visibility_type=visibility,
  337. attachment_type=attachment_type,
  338. category_id=category.id if category else None,
  339. category_name=category.category_name if category else None,
  340. category_path=category.category_path if category else None,
  341. parent_document_id=parent.id if parent else None,
  342. root_document_id=parent.id if parent else None,
  343. tags=tags,
  344. original_file_name=upload.original_file_name,
  345. file_relative_path=upload.relative_path,
  346. file_extension=upload.extension,
  347. mime_type=upload.mime_type,
  348. file_size=upload.file_size,
  349. file_hash=upload.file_hash,
  350. content_text=extraction.text,
  351. content_extract_status=extraction.status,
  352. content_extracted_at=extraction.extracted_at,
  353. search_text=build_search_text(
  354. name, summary, tags, extraction.text
  355. ),
  356. created_by=actor_id,
  357. updated_by=actor_id,
  358. created_by_name=context.real_name,
  359. updated_by_name=context.real_name,
  360. )
  361. session.add(document)
  362. session.flush()
  363. if category is not None and document_type == DocumentType.MAIN.value:
  364. category.document_count += 1
  365. category.row_version += 1
  366. category.updated_by = actor_id
  367. if parent is not None:
  368. parent.child_count += 1
  369. parent.row_version += 1
  370. parent.updated_by = actor_id
  371. parent.updated_by_name = context.real_name
  372. session.add(
  373. business_audit(
  374. action=(
  375. AuditAction.BATCH_IMPORT if batch else AuditAction.UPLOAD_DOCUMENT
  376. ),
  377. target=(
  378. AuditTarget.ATTACHMENT
  379. if attachment
  380. else AuditTarget.DOCUMENT
  381. ),
  382. target_id=document.id,
  383. target_name=document.document_name,
  384. detail={
  385. "documentType": document.document_type,
  386. "originalFileName": document.original_file_name,
  387. "fileSize": document.file_size,
  388. },
  389. )
  390. )
  391. upload.promote()
  392. final_created = True
  393. committed = True
  394. if old_file_path is not None and old_file_path != upload.final_path:
  395. try:
  396. old_file_path.unlink(missing_ok=True)
  397. except OSError:
  398. logger.exception("覆盖成功后清理旧文件失败:document_id=%s", document.id)
  399. except Exception:
  400. if final_created and not committed:
  401. upload.final_path.unlink(missing_ok=True)
  402. raise
  403. finally:
  404. upload.cleanup()
  405. return _serialize(document)
  406. def create_document(file: FileStorage, payload: dict[str, Any], *, batch=False):
  407. upload = stage_upload(file)
  408. return _create_record(upload, payload, attachment=False, batch=batch)
  409. def create_attachment(file: FileStorage, payload: dict[str, Any], *, batch=False):
  410. upload = stage_upload(file)
  411. return _create_record(upload, payload, attachment=True, batch=batch)
  412. def _active(document_id: int, types: set[str]) -> Document:
  413. document = db.session.scalar(
  414. select(Document).where(
  415. Document.id == document_id,
  416. Document.document_type.in_(types),
  417. Document.is_deleted.is_(False),
  418. )
  419. )
  420. if document is None:
  421. raise ResourceNotFoundError("文档不存在或类型不匹配")
  422. return document
  423. def _check_version(document: Document, expected: int) -> None:
  424. if document.row_version != expected:
  425. raise ConflictError(
  426. "数据已被其他用户修改,请刷新后重试",
  427. details={"currentRowVersion": document.row_version},
  428. )
  429. def update_document(document_id: int, payload: dict[str, Any]):
  430. context = get_auth_context()
  431. with transaction() as session:
  432. document = session.scalar(
  433. select(Document)
  434. .where(
  435. Document.id == document_id,
  436. Document.document_type.in_(
  437. [DocumentType.MAIN.value, DocumentType.SUB_PLAN.value]
  438. ),
  439. Document.is_deleted.is_(False),
  440. )
  441. .with_for_update()
  442. )
  443. if document is None:
  444. raise ResourceNotFoundError("方案文档不存在或类型不匹配")
  445. _exact(
  446. payload,
  447. MAIN_EDIT_FIELDS
  448. if document.document_type == DocumentType.MAIN.value
  449. else SUB_PLAN_EDIT_FIELDS,
  450. )
  451. _check_version(document, _version(payload["rowVersion"]))
  452. if document.document_type == DocumentType.MAIN.value:
  453. category = _leaf_category(
  454. _string_id(payload["categoryId"], "categoryId")
  455. )
  456. else:
  457. parent = session.scalar(
  458. select(Document).where(
  459. Document.id == document.parent_document_id,
  460. Document.document_type == DocumentType.MAIN.value,
  461. Document.is_deleted.is_(False),
  462. )
  463. )
  464. if parent is None:
  465. raise ResourceNotFoundError("父文档必须是有效主案")
  466. category = _category(parent.category_id)
  467. before = {
  468. "documentName": document.document_name,
  469. "categoryId": serialize_id(document.category_id),
  470. "securityLevel": document.security_level,
  471. "visibilityType": document.visibility_type,
  472. "status": document.document_status,
  473. }
  474. category_changed = document.category_id != category.id
  475. if document.document_type == DocumentType.MAIN.value and category_changed:
  476. old = session.get(Category, document.category_id)
  477. if old is not None:
  478. old.document_count = max(0, old.document_count - 1)
  479. old.row_version += 1
  480. category.document_count += 1
  481. category.row_version += 1
  482. document.document_name = _text(
  483. payload["documentName"], "documentName", 255
  484. )
  485. document.summary = _text(
  486. payload["summary"], "summary", 20000, nullable=True
  487. )
  488. document.tags = _tags(payload["tags"])
  489. document.category_id = category.id
  490. document.category_name = category.category_name
  491. document.category_path = category.category_path
  492. document.security_level = _enum(
  493. payload["securityLevel"], SecurityLevel, "securityLevel"
  494. )
  495. if document.document_type == DocumentType.SUB_PLAN.value:
  496. document.visibility_type = parent.visibility_type
  497. document.search_text = build_search_text(
  498. document.document_name,
  499. document.summary,
  500. document.tags,
  501. document.content_text,
  502. )
  503. document.updated_by = context.user_id
  504. document.updated_by_name = context.real_name
  505. document.updated_at = _now()
  506. document.row_version += 1
  507. if document.document_type == DocumentType.MAIN.value and category_changed:
  508. session.query(Document).filter(
  509. Document.parent_document_id == document.id,
  510. Document.document_type == DocumentType.SUB_PLAN.value,
  511. Document.is_deleted.is_(False),
  512. ).update(
  513. {
  514. Document.category_id: category.id,
  515. Document.category_name: category.category_name,
  516. Document.category_path: category.category_path,
  517. Document.updated_by: context.user_id,
  518. Document.updated_by_name: context.real_name,
  519. Document.updated_at: _now(),
  520. Document.row_version: Document.row_version + 1,
  521. },
  522. synchronize_session=False,
  523. )
  524. session.add(
  525. business_audit(
  526. action=AuditAction.EDIT_DOCUMENT,
  527. target=AuditTarget.DOCUMENT,
  528. target_id=document.id,
  529. target_name=document.document_name,
  530. detail={"before": before, "rowVersion": document.row_version},
  531. )
  532. )
  533. return _serialize(document)
  534. def update_attachment(document_id: int, payload: dict[str, Any]):
  535. _exact(payload, ATTACHMENT_EDIT_FIELDS)
  536. context = get_auth_context()
  537. with transaction() as session:
  538. document = session.scalar(
  539. select(Document)
  540. .where(
  541. Document.id == document_id,
  542. Document.document_type == DocumentType.ATTACHMENT.value,
  543. Document.is_deleted.is_(False),
  544. )
  545. .with_for_update()
  546. )
  547. if document is None:
  548. raise ResourceNotFoundError("共享附件不存在或类型不匹配")
  549. _check_version(document, _version(payload["rowVersion"]))
  550. before = {
  551. "documentName": document.document_name,
  552. "attachmentType": document.attachment_type,
  553. }
  554. document.document_name = _text(
  555. payload["documentName"], "documentName", 255
  556. )
  557. document.attachment_type = _enum(
  558. payload["attachmentType"], AttachmentType, "attachmentType"
  559. )
  560. document.summary = _text(
  561. payload["summary"], "summary", 20000, nullable=True
  562. )
  563. document.tags = _tags(payload["tags"])
  564. document.search_text = build_search_text(
  565. document.document_name,
  566. document.summary,
  567. document.tags,
  568. document.content_text,
  569. )
  570. document.updated_by = context.user_id
  571. document.updated_by_name = context.real_name
  572. document.updated_at = _now()
  573. document.row_version += 1
  574. session.add(
  575. business_audit(
  576. action=AuditAction.EDIT_DOCUMENT,
  577. target=AuditTarget.ATTACHMENT,
  578. target_id=document.id,
  579. target_name=document.document_name,
  580. detail={"before": before, "rowVersion": document.row_version},
  581. )
  582. )
  583. return _serialize(document)
  584. def delete_document(document_id: int, row_version: int):
  585. context = get_auth_context()
  586. now = _now()
  587. with transaction() as session:
  588. document = session.scalar(
  589. select(Document)
  590. .where(
  591. Document.id == document_id,
  592. Document.document_type.in_(
  593. [DocumentType.MAIN.value, DocumentType.SUB_PLAN.value]
  594. ),
  595. Document.is_deleted.is_(False),
  596. )
  597. .with_for_update()
  598. )
  599. if document is None:
  600. raise ResourceNotFoundError("方案文档不存在或类型不匹配")
  601. _check_version(document, row_version)
  602. if document.document_type == DocumentType.MAIN.value:
  603. child_count = session.scalar(
  604. select(func.count(Document.id)).where(
  605. or_(
  606. Document.parent_document_id == document.id,
  607. Document.root_document_id == document.id,
  608. ),
  609. Document.document_type == DocumentType.SUB_PLAN.value,
  610. Document.is_deleted.is_(False),
  611. )
  612. )
  613. if child_count:
  614. raise MainPlanHasChildrenError(
  615. details={"childCount": child_count}
  616. )
  617. for relation in session.scalars(
  618. select(Permission).where(
  619. Permission.document_id == document.id,
  620. Permission.is_deleted.is_(False),
  621. )
  622. ):
  623. relation.is_deleted = True
  624. relation.deleted_at = now
  625. relation.updated_at = now
  626. relation.updated_by = context.user_id
  627. relation.row_version += 1
  628. for binding in session.scalars(
  629. select(AttachmentBinding).where(
  630. AttachmentBinding.main_document_id == document.id,
  631. AttachmentBinding.is_deleted.is_(False),
  632. )
  633. ):
  634. binding.is_deleted = True
  635. binding.deleted_at = now
  636. binding.updated_at = now
  637. binding.updated_by = context.user_id
  638. binding.row_version += 1
  639. document.attachment_count = 0
  640. else:
  641. parent = session.get(Document, document.root_document_id)
  642. if parent is not None and not parent.is_deleted:
  643. parent.child_count = max(0, parent.child_count - 1)
  644. parent.updated_at = now
  645. parent.updated_by = context.user_id
  646. parent.updated_by_name = context.real_name
  647. parent.row_version += 1
  648. category = session.get(Category, document.category_id)
  649. if category is not None and document.document_type == DocumentType.MAIN.value:
  650. category.document_count = max(0, category.document_count - 1)
  651. category.row_version += 1
  652. category.updated_by = context.user_id
  653. document.is_deleted = True
  654. document.deleted_at = now
  655. document.updated_at = now
  656. document.updated_by = context.user_id
  657. document.updated_by_name = context.real_name
  658. document.row_version += 1
  659. session.add(
  660. business_audit(
  661. action=AuditAction.DELETE_DOCUMENT,
  662. target=AuditTarget.DOCUMENT,
  663. target_id=document.id,
  664. target_name=document.document_name,
  665. detail={"documentType": document.document_type},
  666. )
  667. )
  668. return {"id": serialize_id(document.id), "deleted": True}
  669. def delete_attachment(document_id: int, row_version: int):
  670. context = get_auth_context()
  671. now = _now()
  672. with transaction() as session:
  673. document = session.scalar(
  674. select(Document)
  675. .where(
  676. Document.id == document_id,
  677. Document.document_type == DocumentType.ATTACHMENT.value,
  678. Document.is_deleted.is_(False),
  679. )
  680. .with_for_update()
  681. )
  682. if document is None:
  683. raise ResourceNotFoundError("共享附件不存在或类型不匹配")
  684. _check_version(document, row_version)
  685. rows = session.execute(
  686. select(AttachmentBinding, Document)
  687. .join(Document, Document.id == AttachmentBinding.main_document_id)
  688. .where(
  689. AttachmentBinding.attachment_document_id == document.id,
  690. AttachmentBinding.is_deleted.is_(False),
  691. Document.document_type == DocumentType.MAIN.value,
  692. Document.is_deleted.is_(False),
  693. )
  694. ).all()
  695. if rows:
  696. raise AttachmentInUseError(
  697. details={
  698. "mountedPlanCount": len(rows),
  699. "mainPlans": [
  700. {
  701. "id": serialize_id(main.id),
  702. "documentName": main.document_name,
  703. }
  704. for _, main in rows
  705. ],
  706. }
  707. )
  708. document.is_deleted = True
  709. document.deleted_at = now
  710. document.updated_at = now
  711. document.updated_by = context.user_id
  712. document.updated_by_name = context.real_name
  713. document.row_version += 1
  714. session.add(
  715. business_audit(
  716. action=AuditAction.DELETE_DOCUMENT,
  717. target=AuditTarget.ATTACHMENT,
  718. target_id=document.id,
  719. target_name=document.document_name,
  720. detail={"attachmentType": document.attachment_type},
  721. )
  722. )
  723. return {"id": serialize_id(document.id), "deleted": True}