document_mutation_service.py 31 KB

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