document_mutation_service.py 56 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387
  1. """B5文档与共享附件写操作及文件事务补偿。"""
  2. from __future__ import annotations
  3. from datetime import datetime, timezone
  4. import logging
  5. from pathlib import Path
  6. import tempfile
  7. from typing import Any
  8. from flask import current_app
  9. from sqlalchemy import func, or_, select
  10. from werkzeug.datastructures import FileStorage
  11. from dms.common.enums import (
  12. AllowedAction,
  13. AttachmentType,
  14. AuditAction,
  15. AuditTarget,
  16. DocumentStatus,
  17. DocumentType,
  18. EnabledStatus,
  19. RoleCode,
  20. SecurityLevel,
  21. )
  22. from dms.common.errors import (
  23. AttachmentInUseError,
  24. CategoryNotLeafError,
  25. ConflictError,
  26. DocumentNameConflictError,
  27. DocumentNotInMoveScopeError,
  28. FileNotFoundError as DmsFileNotFoundError,
  29. FilePathInvalidError,
  30. InvalidArgumentError,
  31. MainPlanHasChildrenError,
  32. ResourceNotFoundError,
  33. )
  34. from dms.common.response import serialize_id
  35. from dms.database.transaction import execute_with_deadlock_retry, transaction
  36. from dms.extensions import db
  37. from dms.models import AttachmentBinding, Category, Document, Organization, Permission
  38. from dms.security.auth_context import get_auth_context
  39. from dms.services.attachment_query_service import attachment_detail
  40. from dms.services.audit_service import business_audit
  41. from dms.services.authorization_service import (
  42. evaluate_plan_access,
  43. require_plan_action,
  44. )
  45. from dms.services.document_password_service import (
  46. DocumentPasswordAction,
  47. _pop_password_spec,
  48. apply_password_lock,
  49. get_request_password,
  50. require_document_password,
  51. )
  52. from dms.services.document_query_service import (
  53. LOCK_DEFER_LOB,
  54. _category_scope,
  55. document_detail,
  56. )
  57. from dms.services.document_content_service import (
  58. ContentExtraction,
  59. build_search_text,
  60. extract_document_content,
  61. sanitize_extract_error,
  62. )
  63. from dms.services.preview_service import extract_doc_content_via_pdf
  64. from dms.storage.uploads import StagedUpload, stage_upload
  65. from dms.storage.paths import UnsafeStoragePathError, resolve_storage_path
  66. logger = logging.getLogger(__name__)
  67. MAIN_CREATE_FIELDS = {
  68. "documentName",
  69. "documentType",
  70. "summary",
  71. "categoryId",
  72. "securityLevel",
  73. "tags",
  74. }
  75. OVERWRITE_FIELDS = {"overwriteDocumentId", "rowVersion"}
  76. SUB_PLAN_CREATE_FIELDS = {
  77. "documentName",
  78. "documentType",
  79. "summary",
  80. "parentDocumentId",
  81. "securityLevel",
  82. "tags",
  83. }
  84. MAIN_EDIT_FIELDS = {
  85. "documentName",
  86. "summary",
  87. "categoryId",
  88. "securityLevel",
  89. "tags",
  90. "rowVersion",
  91. }
  92. SUB_PLAN_EDIT_FIELDS = {
  93. "documentName",
  94. "summary",
  95. "securityLevel",
  96. "tags",
  97. "rowVersion",
  98. }
  99. ATTACHMENT_CREATE_FIELDS = {"documentName", "attachmentType", "summary", "tags"}
  100. ATTACHMENT_EDIT_FIELDS = ATTACHMENT_CREATE_FIELDS | {"rowVersion"}
  101. # 详细说明:独立于元数据编辑的备注字段(主案/子案/附件通用),
  102. # 仅 {"description"},不参与 rowVersion 乐观锁(备注型轻量更新)
  103. DESCRIPTION_EDIT_FIELDS = {"description"}
  104. def _now() -> datetime:
  105. return datetime.now(timezone.utc).replace(tzinfo=None)
  106. def _exact(payload: Any, fields: set[str]) -> dict[str, Any]:
  107. if not isinstance(payload, dict) or set(payload) != fields:
  108. raise InvalidArgumentError(
  109. "metadata字段必须且只能包含:" + "、".join(sorted(fields))
  110. )
  111. return payload
  112. def _text(value: Any, name: str, limit: int, *, nullable: bool = False):
  113. if value is None and nullable:
  114. return None
  115. if not isinstance(value, str) or not value.strip():
  116. raise InvalidArgumentError(f"{name}不能为空")
  117. result = value.strip()
  118. if len(result) > limit:
  119. raise InvalidArgumentError(f"{name}长度不能超过{limit}")
  120. return result
  121. def _tags(value: Any) -> list[str]:
  122. if not isinstance(value, list) or len(value) > 50:
  123. raise InvalidArgumentError("tags必须是最多50项的字符串数组")
  124. result: list[str] = []
  125. for item in value:
  126. normalized = _text(item, "tags元素", 64)
  127. if normalized not in result:
  128. result.append(normalized)
  129. return result
  130. def _enum(value: Any, enum_type, name: str) -> str:
  131. if not isinstance(value, str):
  132. raise InvalidArgumentError(f"{name}必须是字符串枚举")
  133. try:
  134. return enum_type(value).value
  135. except ValueError as exc:
  136. raise InvalidArgumentError(f"{name}不是有效枚举值") from exc
  137. def _string_id(value: Any, name: str) -> int:
  138. if not isinstance(value, str) or not value.isdecimal() or int(value) <= 0:
  139. raise InvalidArgumentError(f"{name}必须是正整数形式的字符串ID")
  140. return int(value)
  141. def _version(value: Any) -> int:
  142. if type(value) is not int or value < 0:
  143. raise InvalidArgumentError("rowVersion必须是非负整数")
  144. return value
  145. def _category(category_id: int) -> Category:
  146. category = db.session.scalar(
  147. select(Category).where(
  148. Category.id == category_id,
  149. Category.is_deleted.is_(False),
  150. Category.status == EnabledStatus.ENABLED.value,
  151. )
  152. )
  153. if category is None:
  154. raise ResourceNotFoundError("方案分类不存在或不可用")
  155. return category
  156. def _leaf_category(category_id: int) -> Category:
  157. category = _category(category_id)
  158. child_count = db.session.scalar(
  159. select(func.count(Category.id)).where(
  160. Category.parent_id == category.id,
  161. Category.is_deleted.is_(False),
  162. Category.status == EnabledStatus.ENABLED.value,
  163. )
  164. ) or 0
  165. if child_count:
  166. raise CategoryNotLeafError(
  167. details={
  168. "categoryId": serialize_id(category.id),
  169. "childCategoryCount": int(child_count),
  170. }
  171. )
  172. return category
  173. def _resolve_organization(
  174. session,
  175. organization_id_input: int | None,
  176. context,
  177. current: tuple[int | None, str | None] | None = None,
  178. ) -> tuple[int | None, str | None]:
  179. """解析文档归属部门。
  180. - 输入为空:新建(current为None)取当前用户的部门(与创建者当前部门一致);
  181. 编辑则沿用文档现有归属,不会被编辑人部门静默覆盖。
  182. - 输入与当前用户部门相同:直接复用上下文冗余字段,免一次查询。
  183. - 输入与文档现有归属相同:原样回传视为未变更,免一次查询
  184. (允许非管理员原样提交其他部门文档的归属)。
  185. - 输入为其他部门:仅管理员可调整,按 ID 校验部门可用性并取最新名称
  186. (防止借编辑调整文档的权限范围)。
  187. """
  188. if organization_id_input is None:
  189. if current is not None:
  190. return current
  191. return context.organization_id, context.organization_name
  192. if (
  193. context.organization_id is not None
  194. and organization_id_input == context.organization_id
  195. ):
  196. return context.organization_id, context.organization_name
  197. if current is not None and organization_id_input == current[0]:
  198. return current
  199. if context.role_code != RoleCode.ADMIN:
  200. raise InvalidArgumentError("仅管理员可将文档调整到其他部门")
  201. organization = session.scalar(
  202. select(Organization).where(
  203. Organization.id == organization_id_input,
  204. Organization.is_deleted.is_(False),
  205. Organization.status == EnabledStatus.ENABLED.value,
  206. )
  207. )
  208. if organization is None:
  209. raise ResourceNotFoundError("所属部门不存在或不可用")
  210. return organization.id, organization.org_name
  211. def _serialize(document: Document) -> dict[str, object]:
  212. context = get_auth_context()
  213. if document.document_type == DocumentType.ATTACHMENT.value:
  214. return attachment_detail(document, context)
  215. return document_detail(document, context, evaluate_plan_access(document, context))
  216. _OFFICE_CONVERT_EXTENSIONS = {"doc", "wps", "xls", "xlsx", "et"}
  217. def _extract_file_content(
  218. file_path: Path, extension: str, file_hash: str
  219. ) -> ContentExtraction:
  220. """对落盘文件提取正文;Office 类型先经 LibreOffice 转 PDF 再提取。
  221. 上传(_create_record)与重新解析(re_extract_content)共用,保证口径一致。
  222. 任何异常都收敛为 FAILED 并携带脱敏错误信息,不向上抛出。
  223. """
  224. if extension not in _OFFICE_CONVERT_EXTENSIONS:
  225. return extract_document_content(file_path, extension)
  226. temp_pdf: Path | None = None
  227. try:
  228. from dms.services.document_converter import create_converter
  229. converter = create_converter(current_app.config)
  230. if not converter.available:
  231. return ContentExtraction(
  232. text=None,
  233. status="FAILED",
  234. extracted_at=_now(),
  235. error="LibreOffice转换器不可用,无法解析Office文档",
  236. )
  237. pdf_bytes = converter.convert_to_pdf(file_path, file_hash)
  238. with tempfile.NamedTemporaryFile(
  239. suffix=".pdf", delete=False
  240. ) as temp_file:
  241. temp_file.write(pdf_bytes)
  242. temp_pdf = Path(temp_file.name)
  243. return extract_document_content(temp_pdf, "pdf")
  244. except Exception as exc:
  245. logger.exception("DOC/WPS/XLS/XLSX/ET转换PDF提取正文失败")
  246. return ContentExtraction(
  247. text=None,
  248. status="FAILED",
  249. extracted_at=_now(),
  250. error=sanitize_extract_error(exc),
  251. )
  252. finally:
  253. if temp_pdf is not None:
  254. temp_pdf.unlink(missing_ok=True)
  255. def _create_record(
  256. upload: StagedUpload,
  257. payload: dict[str, Any],
  258. *,
  259. attachment: bool,
  260. batch: bool,
  261. ) -> dict[str, object]:
  262. if not isinstance(payload, dict):
  263. raise InvalidArgumentError("metadata必须是JSON对象")
  264. context = get_auth_context()
  265. actor_id = context.user_id
  266. document_type = DocumentType.ATTACHMENT.value
  267. category = None
  268. parent = None
  269. attachment_type = None
  270. organization_id_input: int | None = None
  271. if "organizationId" in payload:
  272. organization_id_input = _string_id(
  273. payload.pop("organizationId"), "organizationId"
  274. )
  275. password, protected_actions = _pop_password_spec(
  276. payload, attachment=attachment
  277. )
  278. if attachment:
  279. _exact(payload, ATTACHMENT_CREATE_FIELDS)
  280. attachment_type = _enum(
  281. payload["attachmentType"], AttachmentType, "attachmentType"
  282. )
  283. security = SecurityLevel.PUBLIC.value
  284. status = DocumentStatus.PUBLISHED.value
  285. else:
  286. raw_type = payload.get("documentType")
  287. # 阶段11:配套方案并入共享资料库(0020),停止新建SUB_PLAN文档;
  288. # 历史子案已迁移为ACCOMPANYING_PLAN附件,编辑/挂载走附件链路。
  289. if raw_type == DocumentType.SUB_PLAN.value:
  290. raise InvalidArgumentError(
  291. "配套方案已并入共享资料库,请以附件类型ACCOMPANYING_PLAN上传"
  292. )
  293. try:
  294. document_type = DocumentType(raw_type).value
  295. except (ValueError, TypeError) as exc:
  296. raise InvalidArgumentError("documentType必须是MAIN") from exc
  297. if document_type not in {
  298. DocumentType.MAIN.value,
  299. }:
  300. raise InvalidArgumentError("documentType必须是MAIN")
  301. create_fields = (
  302. SUB_PLAN_CREATE_FIELDS
  303. if document_type == DocumentType.SUB_PLAN.value
  304. else MAIN_CREATE_FIELDS
  305. )
  306. if frozenset(payload) not in {
  307. frozenset(create_fields),
  308. frozenset(create_fields | OVERWRITE_FIELDS),
  309. }:
  310. raise InvalidArgumentError(
  311. "metadata字段必须为新增字段全集,或额外同时包含overwriteDocumentId和rowVersion"
  312. )
  313. security = _enum(payload["securityLevel"], SecurityLevel, "securityLevel")
  314. status = DocumentStatus.PUBLISHED.value
  315. if document_type == DocumentType.SUB_PLAN.value:
  316. parent_id = _string_id(payload["parentDocumentId"], "parentDocumentId")
  317. parent = db.session.scalar(
  318. select(Document).where(
  319. Document.id == parent_id,
  320. Document.document_type == DocumentType.MAIN.value,
  321. Document.is_deleted.is_(False),
  322. )
  323. )
  324. if parent is None:
  325. raise ResourceNotFoundError("父文档必须是有效主案")
  326. category = _category(parent.category_id)
  327. else:
  328. category = _leaf_category(
  329. _string_id(payload["categoryId"], "categoryId")
  330. )
  331. name = _text(payload["documentName"], "documentName", 255)
  332. summary = _text(payload["summary"], "summary", 20000, nullable=True)
  333. tags = _tags(payload["tags"])
  334. extraction = _extract_file_content(
  335. upload.temporary_path, upload.extension, upload.file_hash
  336. )
  337. final_created = False
  338. committed = False
  339. old_file_path = None
  340. try:
  341. with transaction() as session:
  342. organization_id, organization_name = _resolve_organization(
  343. session, organization_id_input, context
  344. )
  345. duplicate = None
  346. if not attachment:
  347. duplicate_conditions = [
  348. Document.document_name == name,
  349. Document.document_type == document_type,
  350. Document.is_deleted.is_(False),
  351. ]
  352. if document_type == DocumentType.SUB_PLAN.value:
  353. duplicate_conditions.append(Document.parent_document_id == parent.id)
  354. duplicate = session.scalar(
  355. select(Document)
  356. .where(*duplicate_conditions)
  357. .options(*LOCK_DEFER_LOB)
  358. .with_for_update()
  359. )
  360. overwrite_id = payload.get("overwriteDocumentId")
  361. if duplicate is not None and overwrite_id is None:
  362. raise DocumentNameConflictError(
  363. details={
  364. "existingDocumentId": serialize_id(duplicate.id),
  365. "existingDocumentName": duplicate.document_name,
  366. "existingRowVersion": duplicate.row_version,
  367. "documentType": duplicate.document_type,
  368. "parentDocumentId": serialize_id(duplicate.parent_document_id),
  369. }
  370. )
  371. if overwrite_id is not None:
  372. expected_id = _string_id(overwrite_id, "overwriteDocumentId")
  373. expected_version = _version(payload["rowVersion"])
  374. if duplicate is None or duplicate.id != expected_id:
  375. raise ConflictError(
  376. "同名方案已发生变化,请重新确认",
  377. details={"currentRowVersion": duplicate.row_version if duplicate else None},
  378. )
  379. _check_version(duplicate, expected_version)
  380. document = duplicate
  381. old_category_id = document.category_id
  382. if document_type == DocumentType.MAIN.value and old_category_id != category.id:
  383. old_category = session.get(Category, old_category_id)
  384. if old_category is not None:
  385. old_category.document_count = max(0, old_category.document_count - 1)
  386. old_category.row_version += 1
  387. category.document_count += 1
  388. category.row_version += 1
  389. try:
  390. old_file_path = resolve_storage_path(
  391. document.file_relative_path,
  392. current_app.config["DMS_STORAGE_ROOT"],
  393. )
  394. except UnsafeStoragePathError:
  395. logger.error("覆盖方案时检测到异常旧文件路径:document_id=%s", document.id)
  396. old_file_path = None
  397. document.summary = summary
  398. document.security_level = security
  399. document.category_id = category.id if category else None
  400. document.category_name = category.category_name if category else None
  401. document.category_path = category.category_path if category else None
  402. document.organization_id = organization_id
  403. document.organization_name = organization_name
  404. document.tags = tags
  405. document.original_file_name = upload.original_file_name
  406. document.file_relative_path = upload.relative_path
  407. document.file_extension = upload.extension
  408. document.mime_type = upload.mime_type
  409. document.file_size = upload.file_size
  410. document.file_hash = upload.file_hash
  411. document.content_text = extraction.text
  412. document.content_extract_status = extraction.status
  413. document.content_extract_error = extraction.error
  414. document.content_extracted_at = extraction.extracted_at
  415. document.search_text = build_search_text(name, summary, tags, extraction.text)
  416. document.updated_by = actor_id
  417. document.updated_by_name = context.real_name
  418. document.updated_at = _now()
  419. document.row_version += 1
  420. if document_type == DocumentType.MAIN.value and old_category_id != category.id:
  421. session.query(Document).filter(
  422. Document.parent_document_id == document.id,
  423. Document.document_type == DocumentType.SUB_PLAN.value,
  424. Document.is_deleted.is_(False),
  425. ).update(
  426. {
  427. Document.category_id: category.id,
  428. Document.category_name: category.category_name,
  429. Document.category_path: category.category_path,
  430. Document.updated_by: actor_id,
  431. Document.updated_by_name: context.real_name,
  432. Document.updated_at: _now(),
  433. Document.row_version: Document.row_version + 1,
  434. },
  435. synchronize_session=False,
  436. )
  437. session.add(
  438. business_audit(
  439. action=AuditAction.UPLOAD_DOCUMENT,
  440. target=AuditTarget.DOCUMENT,
  441. target_id=document.id,
  442. target_name=document.document_name,
  443. detail={
  444. "overwrite": True,
  445. "originalFileName": document.original_file_name,
  446. "fileSize": document.file_size,
  447. "rowVersion": document.row_version,
  448. },
  449. )
  450. )
  451. upload.promote()
  452. final_created = True
  453. else:
  454. # 新建文档追加到同范围排序末尾:主案全局排序、子方案在所属主案内排序;共享附件不参与。
  455. if document_type == DocumentType.MAIN.value:
  456. next_sort_no = (session.scalar(
  457. select(func.max(Document.sort_no)).where(
  458. Document.document_type == DocumentType.MAIN.value,
  459. Document.parent_document_id.is_(None),
  460. Document.is_deleted.is_(False),
  461. )
  462. ) or 0) + 10
  463. elif document_type == DocumentType.SUB_PLAN.value:
  464. next_sort_no = (session.scalar(
  465. select(func.max(Document.sort_no)).where(
  466. Document.parent_document_id == parent.id,
  467. Document.document_type == DocumentType.SUB_PLAN.value,
  468. Document.is_deleted.is_(False),
  469. )
  470. ) or 0) + 10
  471. else:
  472. next_sort_no = 0
  473. document = Document(
  474. document_name=name,
  475. summary=summary,
  476. document_type=document_type,
  477. document_status=status,
  478. security_level=security,
  479. attachment_type=attachment_type,
  480. category_id=category.id if category else None,
  481. category_name=category.category_name if category else None,
  482. category_path=category.category_path if category else None,
  483. organization_id=organization_id,
  484. organization_name=organization_name,
  485. parent_document_id=parent.id if parent else None,
  486. root_document_id=parent.id if parent else None,
  487. tags=tags,
  488. original_file_name=upload.original_file_name,
  489. file_relative_path=upload.relative_path,
  490. file_extension=upload.extension,
  491. mime_type=upload.mime_type,
  492. file_size=upload.file_size,
  493. file_hash=upload.file_hash,
  494. sort_no=next_sort_no,
  495. content_text=extraction.text,
  496. content_extract_status=extraction.status,
  497. content_extract_error=extraction.error,
  498. content_extracted_at=extraction.extracted_at,
  499. search_text=build_search_text(
  500. name, summary, tags, extraction.text
  501. ),
  502. created_by=actor_id,
  503. updated_by=actor_id,
  504. created_by_name=context.real_name,
  505. updated_by_name=context.real_name,
  506. )
  507. session.add(document)
  508. session.flush()
  509. if password is not None:
  510. # 上传主案时同步启用密码锁(覆盖上传已在入口拒绝携带密码字段)
  511. apply_password_lock(document, password, protected_actions or [])
  512. session.add(
  513. business_audit(
  514. action=AuditAction.DOCUMENT_PASSWORD_SET,
  515. target=AuditTarget.DOCUMENT,
  516. target_id=document.id,
  517. target_name=document.document_name,
  518. detail={
  519. "onUpload": True,
  520. "protectedActions": protected_actions or [],
  521. },
  522. )
  523. )
  524. if category is not None and document_type == DocumentType.MAIN.value:
  525. category.document_count += 1
  526. category.row_version += 1
  527. category.updated_by = actor_id
  528. if parent is not None:
  529. parent.child_count += 1
  530. parent.row_version += 1
  531. parent.updated_by = actor_id
  532. parent.updated_by_name = context.real_name
  533. session.add(
  534. business_audit(
  535. action=(
  536. AuditAction.BATCH_IMPORT if batch else AuditAction.UPLOAD_DOCUMENT
  537. ),
  538. target=(
  539. AuditTarget.ATTACHMENT
  540. if attachment
  541. else AuditTarget.DOCUMENT
  542. ),
  543. target_id=document.id,
  544. target_name=document.document_name,
  545. detail={
  546. "documentType": document.document_type,
  547. "originalFileName": document.original_file_name,
  548. "fileSize": document.file_size,
  549. },
  550. )
  551. )
  552. upload.promote()
  553. final_created = True
  554. committed = True
  555. if old_file_path is not None and old_file_path != upload.final_path:
  556. try:
  557. old_file_path.unlink(missing_ok=True)
  558. except OSError:
  559. logger.exception("覆盖成功后清理旧文件失败:document_id=%s", document.id)
  560. except Exception:
  561. if final_created and not committed:
  562. upload.final_path.unlink(missing_ok=True)
  563. raise
  564. finally:
  565. upload.cleanup()
  566. return _serialize(document)
  567. def create_document(file: FileStorage, payload: dict[str, Any], *, batch=False):
  568. upload = stage_upload(file)
  569. return _create_record(upload, payload, attachment=False, batch=batch)
  570. def create_attachment(file: FileStorage, payload: dict[str, Any], *, batch=False):
  571. upload = stage_upload(file)
  572. return _create_record(upload, payload, attachment=True, batch=batch)
  573. def _active(document_id: int, types: set[str]) -> Document:
  574. document = db.session.scalar(
  575. select(Document).where(
  576. Document.id == document_id,
  577. Document.document_type.in_(types),
  578. Document.is_deleted.is_(False),
  579. )
  580. )
  581. if document is None:
  582. raise ResourceNotFoundError("文档不存在或类型不匹配")
  583. return document
  584. def _check_version(document: Document, expected: int) -> None:
  585. if document.row_version != expected:
  586. raise ConflictError(
  587. "数据已被其他用户修改,请刷新后重试",
  588. details={"currentRowVersion": document.row_version},
  589. )
  590. REORDER_FIELDS = {"orderedIds"}
  591. def _reorder_payload(payload: Any) -> list[int]:
  592. if not isinstance(payload, dict) or set(payload) != REORDER_FIELDS:
  593. raise InvalidArgumentError("请求字段必须且只能包含orderedIds")
  594. raw_ids = payload["orderedIds"]
  595. if not isinstance(raw_ids, list) or len(raw_ids) < 2:
  596. raise InvalidArgumentError("orderedIds必须是至少包含2个ID的数组")
  597. ordered_ids: list[int] = []
  598. seen: set[int] = set()
  599. for index, raw_id in enumerate(raw_ids):
  600. document_id = _string_id(raw_id, f"orderedIds[{index}]")
  601. if document_id not in seen:
  602. ordered_ids.append(document_id)
  603. seen.add(document_id)
  604. return ordered_ids
  605. def reorder_documents(payload: Any) -> dict[str, int]:
  606. """管理员拖动重排方案:提交ID确定排序范围,槽位整体重排并按步长10重编号。
  607. - 主案在全局范围排序,子方案在所属主案内排序,共享附件不支持;
  608. - ``orderedIds`` 为拖动后的目标顺序(允许只提交范围内的部分ID,
  609. 未提交的槽位保持相对顺序,仅被提交ID"置换"填充)。
  610. """
  611. ordered_ids = _reorder_payload(payload)
  612. context = get_auth_context()
  613. def operation() -> dict[str, int]:
  614. with transaction() as session:
  615. submitted = session.scalars(
  616. select(Document)
  617. .where(Document.id.in_(sorted(ordered_ids)))
  618. .order_by(Document.id.asc())
  619. .options(*LOCK_DEFER_LOB)
  620. .with_for_update()
  621. ).all()
  622. by_id = {document.id: document for document in submitted}
  623. if any(
  624. document_id not in by_id or by_id[document_id].is_deleted
  625. for document_id in ordered_ids
  626. ):
  627. raise ResourceNotFoundError("待排序方案不存在或已删除")
  628. # 重排=改排序字段:对每个提交文档要求EDIT(密级+矩阵/ACL同源校验)
  629. for document in submitted:
  630. require_plan_action(document, context, AllowedAction.EDIT)
  631. first = by_id[ordered_ids[0]]
  632. document_type = first.document_type
  633. if document_type == DocumentType.MAIN.value:
  634. scope = (
  635. Document.document_type == DocumentType.MAIN.value,
  636. Document.parent_document_id.is_(None),
  637. )
  638. scope_key = "GLOBAL"
  639. elif document_type == DocumentType.SUB_PLAN.value:
  640. scope = (
  641. Document.document_type == DocumentType.SUB_PLAN.value,
  642. Document.parent_document_id == first.parent_document_id,
  643. )
  644. scope_key = serialize_id(first.parent_document_id)
  645. else:
  646. raise InvalidArgumentError("共享附件不支持拖动排序")
  647. if any(
  648. by_id[document_id].document_type != document_type
  649. for document_id in ordered_ids
  650. ):
  651. raise InvalidArgumentError("orderedIds包含不同范围的方案")
  652. siblings = session.scalars(
  653. select(Document)
  654. .where(Document.is_deleted.is_(False), *scope)
  655. .order_by(Document.sort_no.asc(), Document.id.asc())
  656. .options(*LOCK_DEFER_LOB)
  657. .with_for_update()
  658. ).all()
  659. all_ids = [document.id for document in siblings]
  660. sibling_ids = set(all_ids)
  661. if any(document_id not in sibling_ids for document_id in ordered_ids):
  662. raise ResourceNotFoundError("待排序方案不存在或已删除")
  663. slot_ids = sorted(ordered_ids, key=all_ids.index)
  664. new_order = list(all_ids)
  665. for position, document_id in zip(
  666. (all_ids.index(did) for did in slot_ids), ordered_ids
  667. ):
  668. new_order[position] = document_id
  669. target_sort_by_id = {
  670. document_id: (index + 1) * 10
  671. for index, document_id in enumerate(new_order)
  672. }
  673. now = _now()
  674. reordered_count = 0
  675. for document in siblings:
  676. target = target_sort_by_id[document.id]
  677. if document.sort_no == target:
  678. continue
  679. document.sort_no = target
  680. document.updated_by = context.user_id
  681. document.updated_by_name = context.real_name
  682. document.updated_at = now
  683. document.row_version += 1
  684. reordered_count += 1
  685. session.add(
  686. business_audit(
  687. action=AuditAction.REORDER_DOCUMENT,
  688. target=AuditTarget.DOCUMENT,
  689. target_id=first.id,
  690. target_name=first.document_name,
  691. detail={
  692. "documentType": document_type,
  693. "scope": scope_key,
  694. "orderedIds": [serialize_id(did) for did in ordered_ids],
  695. "reorderedCount": reordered_count,
  696. },
  697. )
  698. )
  699. return {"reorderedCount": reordered_count}
  700. return execute_with_deadlock_retry(operation)
  701. MOVE_TARGET_VALUES = {"TOP", "BOTTOM"}
  702. def _move_target_position(
  703. payload: Any,
  704. *,
  705. extra_fields: set[str] = frozenset(),
  706. required_fields: set[str] = frozenset(),
  707. ) -> tuple[int | None, str | None]:
  708. """解析跨页移动请求:targetPosition/target 二选一,返回(数值位置, 枚举目标)。"""
  709. if not isinstance(payload, dict):
  710. raise InvalidArgumentError("请求体必须是JSON对象")
  711. allowed = {"targetPosition", "target"} | extra_fields | required_fields
  712. unknown = set(payload) - allowed
  713. if unknown:
  714. raise InvalidArgumentError(
  715. "请求字段只允许targetPosition、target" + "".join(
  716. f"、{field}" for field in sorted(extra_fields | required_fields)
  717. )
  718. )
  719. for field in sorted(required_fields):
  720. if field not in payload:
  721. raise InvalidArgumentError(f"请求必须包含{field}")
  722. if ("targetPosition" in payload) == ("target" in payload):
  723. raise InvalidArgumentError("targetPosition与target必须二选一")
  724. if "targetPosition" in payload:
  725. raw = payload["targetPosition"]
  726. if type(raw) is not int or raw < 1:
  727. raise InvalidArgumentError("targetPosition必须是大于等于1的整数")
  728. return raw, None
  729. target = payload["target"]
  730. if target not in MOVE_TARGET_VALUES:
  731. raise InvalidArgumentError("target只允许TOP或BOTTOM")
  732. return None, target
  733. def move_document(document_id: int, payload: Any) -> dict[str, int]:
  734. """管理员跨页调整方案顺序:移动到浏览范围内第 N 位(或置顶/末尾)。
  735. - 范围与拖动重排一致:主案全局、子方案在所属主案内,共享附件不支持;
  736. - ``scopeCategoryId`` 仅主案生效:按分类子树过滤可见范围(与列表页口径
  737. 一致),位置为范围内 1-based 序号,范围外方案保持原相对顺序;
  738. - 位置计算在 ``FOR UPDATE`` 锁内快照上完成,随后按步长10重编号并仅写
  739. 变化行,与拖动重排共享同一套并发控制。
  740. """
  741. position_input, target_input, scope_category_id = _move_document_payload(payload)
  742. context = get_auth_context()
  743. def operation() -> dict[str, int]:
  744. with transaction() as session:
  745. document = session.scalar(
  746. select(Document)
  747. .where(
  748. Document.id == document_id,
  749. Document.document_type.in_(
  750. [DocumentType.MAIN.value, DocumentType.SUB_PLAN.value]
  751. ),
  752. Document.is_deleted.is_(False),
  753. )
  754. .options(*LOCK_DEFER_LOB)
  755. .with_for_update()
  756. )
  757. if document is None:
  758. raise ResourceNotFoundError("方案文档不存在或类型不匹配")
  759. require_plan_action(document, context, AllowedAction.EDIT)
  760. if document.document_type == DocumentType.MAIN.value:
  761. scope = (
  762. Document.document_type == DocumentType.MAIN.value,
  763. Document.parent_document_id.is_(None),
  764. )
  765. scope_key = "GLOBAL"
  766. else:
  767. scope = (
  768. Document.document_type == DocumentType.SUB_PLAN.value,
  769. Document.parent_document_id == document.parent_document_id,
  770. )
  771. scope_key = serialize_id(document.parent_document_id)
  772. siblings = session.scalars(
  773. select(Document)
  774. .where(Document.is_deleted.is_(False), *scope)
  775. .order_by(Document.sort_no.asc(), Document.id.asc())
  776. .options(*LOCK_DEFER_LOB)
  777. .with_for_update()
  778. ).all()
  779. if not any(item.id == document.id for item in siblings):
  780. raise ResourceNotFoundError("待排序方案不存在或已删除")
  781. if scope_category_id is not None:
  782. if document.document_type != DocumentType.MAIN.value:
  783. raise InvalidArgumentError("scopeCategoryId仅支持主案范围")
  784. category_ids = _category_scope(scope_category_id, True)
  785. visible = [
  786. item for item in siblings if item.category_id in category_ids
  787. ]
  788. else:
  789. visible = list(siblings)
  790. if not any(item.id == document.id for item in visible):
  791. raise DocumentNotInMoveScopeError("方案不在指定分类范围内")
  792. current = next(
  793. index for index, item in enumerate(visible)
  794. if item.id == document.id
  795. ) + 1
  796. total = len(visible)
  797. if target_input == "TOP":
  798. position = 1
  799. elif target_input == "BOTTOM":
  800. position = total
  801. else:
  802. position = max(1, min(position_input, total))
  803. if position == current:
  804. return {"position": position, "total": total}
  805. # 可见子序列内重排、范围外方案原位保持:可见槽位按新序列回填。
  806. rest = [item for item in visible if item.id != document.id]
  807. insert_at = position - 1
  808. new_visible = rest[:insert_at] + [document] + rest[insert_at:]
  809. visible_ids = {item.id for item in visible}
  810. fill = iter(new_visible)
  811. new_order = [
  812. next(fill) if item.id in visible_ids else item for item in siblings
  813. ]
  814. target_sort_by_id = {
  815. item.id: (index + 1) * 10 for index, item in enumerate(new_order)
  816. }
  817. now = _now()
  818. moved_count = 0
  819. for item in siblings:
  820. target_no = target_sort_by_id[item.id]
  821. if item.sort_no == target_no:
  822. continue
  823. item.sort_no = target_no
  824. item.updated_by = context.user_id
  825. item.updated_by_name = context.real_name
  826. item.updated_at = now
  827. item.row_version += 1
  828. moved_count += 1
  829. detail: dict[str, Any] = {
  830. "documentType": document.document_type,
  831. "scope": scope_key,
  832. }
  833. if scope_category_id is not None:
  834. detail["scopeCategoryId"] = serialize_id(scope_category_id)
  835. detail.update(
  836. {
  837. "fromPosition": current,
  838. "toPosition": position,
  839. "scopeTotal": total,
  840. "movedCount": moved_count,
  841. }
  842. )
  843. session.add(
  844. business_audit(
  845. action=AuditAction.MOVE_DOCUMENT,
  846. target=AuditTarget.DOCUMENT,
  847. target_id=document.id,
  848. target_name=document.document_name,
  849. detail=detail,
  850. )
  851. )
  852. return {"position": position, "total": total}
  853. return execute_with_deadlock_retry(operation)
  854. def _move_document_payload(payload: Any) -> tuple[int | None, str | None, int | None]:
  855. """文档移动请求解析:targetPosition/target 二选一 + 可选 scopeCategoryId。"""
  856. position, target = _move_target_position(
  857. payload, extra_fields={"scopeCategoryId"}
  858. )
  859. scope_category_id: int | None = None
  860. if isinstance(payload, dict) and "scopeCategoryId" in payload:
  861. scope_category_id = _string_id(
  862. payload["scopeCategoryId"], "scopeCategoryId"
  863. )
  864. return position, target, scope_category_id
  865. def update_document(document_id: int, payload: dict[str, Any]):
  866. context = get_auth_context()
  867. organization_id_input: int | None = None
  868. if "organizationId" in payload:
  869. organization_id_input = _string_id(
  870. payload.pop("organizationId"), "organizationId"
  871. )
  872. with transaction() as session:
  873. document = session.scalar(
  874. select(Document)
  875. .where(
  876. Document.id == document_id,
  877. Document.document_type.in_(
  878. [DocumentType.MAIN.value, DocumentType.SUB_PLAN.value]
  879. ),
  880. Document.is_deleted.is_(False),
  881. )
  882. .options(*LOCK_DEFER_LOB)
  883. .with_for_update()
  884. )
  885. if document is None:
  886. raise ResourceNotFoundError("方案文档不存在或类型不匹配")
  887. require_plan_action(document, context, AllowedAction.EDIT)
  888. require_document_password(
  889. document,
  890. DocumentPasswordAction.EDIT.value,
  891. get_request_password(),
  892. context,
  893. )
  894. _exact(
  895. payload,
  896. MAIN_EDIT_FIELDS
  897. if document.document_type == DocumentType.MAIN.value
  898. else SUB_PLAN_EDIT_FIELDS,
  899. )
  900. _check_version(document, _version(payload["rowVersion"]))
  901. organization_id, organization_name = _resolve_organization(
  902. session,
  903. organization_id_input,
  904. context,
  905. current=(document.organization_id, document.organization_name),
  906. )
  907. if document.document_type == DocumentType.MAIN.value:
  908. category = _leaf_category(
  909. _string_id(payload["categoryId"], "categoryId")
  910. )
  911. else:
  912. parent = session.scalar(
  913. select(Document).where(
  914. Document.id == document.parent_document_id,
  915. Document.document_type == DocumentType.MAIN.value,
  916. Document.is_deleted.is_(False),
  917. )
  918. )
  919. if parent is None:
  920. raise ResourceNotFoundError("父文档必须是有效主案")
  921. category = _category(parent.category_id)
  922. before = {
  923. "documentName": document.document_name,
  924. "categoryId": serialize_id(document.category_id),
  925. "organizationId": serialize_id(document.organization_id),
  926. "securityLevel": document.security_level,
  927. "status": document.document_status,
  928. }
  929. category_changed = document.category_id != category.id
  930. if document.document_type == DocumentType.MAIN.value and category_changed:
  931. old = session.get(Category, document.category_id)
  932. if old is not None:
  933. old.document_count = max(0, old.document_count - 1)
  934. old.row_version += 1
  935. category.document_count += 1
  936. category.row_version += 1
  937. document.document_name = _text(
  938. payload["documentName"], "documentName", 255
  939. )
  940. document.summary = _text(
  941. payload["summary"], "summary", 20000, nullable=True
  942. )
  943. document.tags = _tags(payload["tags"])
  944. document.category_id = category.id
  945. document.category_name = category.category_name
  946. document.category_path = category.category_path
  947. document.organization_id = organization_id
  948. document.organization_name = organization_name
  949. document.security_level = _enum(
  950. payload["securityLevel"], SecurityLevel, "securityLevel"
  951. )
  952. document.search_text = build_search_text(
  953. document.document_name,
  954. document.summary,
  955. document.tags,
  956. document.content_text,
  957. )
  958. document.updated_by = context.user_id
  959. document.updated_by_name = context.real_name
  960. document.updated_at = _now()
  961. document.row_version += 1
  962. if document.document_type == DocumentType.MAIN.value and category_changed:
  963. session.query(Document).filter(
  964. Document.parent_document_id == document.id,
  965. Document.document_type == DocumentType.SUB_PLAN.value,
  966. Document.is_deleted.is_(False),
  967. ).update(
  968. {
  969. Document.category_id: category.id,
  970. Document.category_name: category.category_name,
  971. Document.category_path: category.category_path,
  972. Document.updated_by: context.user_id,
  973. Document.updated_by_name: context.real_name,
  974. Document.updated_at: _now(),
  975. Document.row_version: Document.row_version + 1,
  976. },
  977. synchronize_session=False,
  978. )
  979. session.add(
  980. business_audit(
  981. action=AuditAction.EDIT_DOCUMENT,
  982. target=AuditTarget.DOCUMENT,
  983. target_id=document.id,
  984. target_name=document.document_name,
  985. detail={"before": before, "rowVersion": document.row_version},
  986. )
  987. )
  988. return _serialize(document)
  989. def update_document_description(document_id: int, payload: dict[str, Any]):
  990. """维护文档详细说明("查看"对话框右侧备注区,主案/子案/附件通用)。"""
  991. context = get_auth_context()
  992. _exact(payload, DESCRIPTION_EDIT_FIELDS)
  993. description = _text(
  994. payload["description"], "description", 20000, nullable=True
  995. )
  996. with transaction() as session:
  997. document = session.scalar(
  998. select(Document)
  999. .where(
  1000. Document.id == document_id,
  1001. Document.is_deleted.is_(False),
  1002. )
  1003. .options(*LOCK_DEFER_LOB)
  1004. .with_for_update()
  1005. )
  1006. if document is None:
  1007. raise ResourceNotFoundError("文档不存在或已删除")
  1008. require_plan_action(document, context, AllowedAction.EDIT)
  1009. require_document_password(
  1010. document,
  1011. DocumentPasswordAction.EDIT.value,
  1012. get_request_password(),
  1013. context,
  1014. )
  1015. before = {"description": document.description}
  1016. document.description = description
  1017. document.updated_by = context.user_id
  1018. document.updated_by_name = context.real_name
  1019. document.updated_at = _now()
  1020. document.row_version += 1
  1021. session.add(
  1022. business_audit(
  1023. action=AuditAction.EDIT_DOCUMENT,
  1024. target=AuditTarget.DOCUMENT,
  1025. target_id=document.id,
  1026. target_name=document.document_name,
  1027. detail={"field": "description", "before": before, "rowVersion": document.row_version},
  1028. )
  1029. )
  1030. return _serialize(document)
  1031. def update_attachment(document_id: int, payload: dict[str, Any]):
  1032. context = get_auth_context()
  1033. organization_id_input: int | None = None
  1034. if "organizationId" in payload:
  1035. organization_id_input = _string_id(
  1036. payload.pop("organizationId"), "organizationId"
  1037. )
  1038. with transaction() as session:
  1039. document = session.scalar(
  1040. select(Document)
  1041. .where(
  1042. Document.id == document_id,
  1043. Document.document_type == DocumentType.ATTACHMENT.value,
  1044. Document.is_deleted.is_(False),
  1045. )
  1046. .options(*LOCK_DEFER_LOB)
  1047. .with_for_update()
  1048. )
  1049. if document is None:
  1050. raise ResourceNotFoundError("共享附件不存在或类型不匹配")
  1051. require_plan_action(document, context, AllowedAction.EDIT)
  1052. _exact(payload, ATTACHMENT_EDIT_FIELDS)
  1053. _check_version(document, _version(payload["rowVersion"]))
  1054. organization_id, organization_name = _resolve_organization(
  1055. session,
  1056. organization_id_input,
  1057. context,
  1058. current=(document.organization_id, document.organization_name),
  1059. )
  1060. before = {
  1061. "documentName": document.document_name,
  1062. "attachmentType": document.attachment_type,
  1063. "organizationId": serialize_id(document.organization_id),
  1064. }
  1065. document.document_name = _text(
  1066. payload["documentName"], "documentName", 255
  1067. )
  1068. document.attachment_type = _enum(
  1069. payload["attachmentType"], AttachmentType, "attachmentType"
  1070. )
  1071. document.summary = _text(
  1072. payload["summary"], "summary", 20000, nullable=True
  1073. )
  1074. document.tags = _tags(payload["tags"])
  1075. document.organization_id = organization_id
  1076. document.organization_name = organization_name
  1077. document.search_text = build_search_text(
  1078. document.document_name,
  1079. document.summary,
  1080. document.tags,
  1081. document.content_text,
  1082. )
  1083. document.updated_by = context.user_id
  1084. document.updated_by_name = context.real_name
  1085. document.updated_at = _now()
  1086. document.row_version += 1
  1087. session.add(
  1088. business_audit(
  1089. action=AuditAction.EDIT_DOCUMENT,
  1090. target=AuditTarget.ATTACHMENT,
  1091. target_id=document.id,
  1092. target_name=document.document_name,
  1093. detail={"before": before, "rowVersion": document.row_version},
  1094. )
  1095. )
  1096. return _serialize(document)
  1097. def re_extract_content(document_id: int):
  1098. """管理员对存量文档重新提取正文:按当前存储文件重新解析并回写提取结果。
  1099. 主案/子案/共享附件通用;提取结果(含失败原因)覆盖写回,
  1100. search_text 同步重建,审计复用 EDIT_DOCUMENT(scope=reExtractContent)。
  1101. """
  1102. context = get_auth_context()
  1103. with transaction() as session:
  1104. document = session.scalar(
  1105. select(Document)
  1106. .where(
  1107. Document.id == document_id,
  1108. Document.is_deleted.is_(False),
  1109. )
  1110. .options(*LOCK_DEFER_LOB)
  1111. .with_for_update()
  1112. )
  1113. if document is None:
  1114. raise ResourceNotFoundError("文档不存在")
  1115. try:
  1116. file_path = resolve_storage_path(
  1117. document.file_relative_path,
  1118. current_app.config["DMS_STORAGE_ROOT"],
  1119. )
  1120. except UnsafeStoragePathError as exc:
  1121. raise FilePathInvalidError() from exc
  1122. if not file_path.is_file():
  1123. raise DmsFileNotFoundError()
  1124. extraction = _extract_file_content(
  1125. file_path, document.file_extension, document.file_hash
  1126. )
  1127. document.content_text = extraction.text
  1128. document.content_extract_status = extraction.status
  1129. document.content_extract_error = extraction.error
  1130. document.content_extracted_at = extraction.extracted_at
  1131. document.search_text = build_search_text(
  1132. document.document_name,
  1133. document.summary,
  1134. document.tags,
  1135. extraction.text,
  1136. )
  1137. document.updated_by = context.user_id
  1138. document.updated_by_name = context.real_name
  1139. document.updated_at = _now()
  1140. document.row_version += 1
  1141. session.add(
  1142. business_audit(
  1143. action=AuditAction.EDIT_DOCUMENT,
  1144. target=(
  1145. AuditTarget.ATTACHMENT
  1146. if document.document_type == DocumentType.ATTACHMENT.value
  1147. else AuditTarget.DOCUMENT
  1148. ),
  1149. target_id=document.id,
  1150. target_name=document.document_name,
  1151. detail={
  1152. "scope": "reExtractContent",
  1153. "contentExtractStatus": extraction.status,
  1154. "rowVersion": document.row_version,
  1155. },
  1156. )
  1157. )
  1158. return _serialize(document)
  1159. def delete_document(document_id: int, row_version: int):
  1160. context = get_auth_context()
  1161. now = _now()
  1162. with transaction() as session:
  1163. document = session.scalar(
  1164. select(Document)
  1165. .where(
  1166. Document.id == document_id,
  1167. Document.document_type.in_(
  1168. [DocumentType.MAIN.value, DocumentType.SUB_PLAN.value]
  1169. ),
  1170. Document.is_deleted.is_(False),
  1171. )
  1172. .options(*LOCK_DEFER_LOB)
  1173. .with_for_update()
  1174. )
  1175. if document is None:
  1176. raise ResourceNotFoundError("方案文档不存在或类型不匹配")
  1177. require_plan_action(document, context, AllowedAction.DELETE)
  1178. require_document_password(
  1179. document,
  1180. DocumentPasswordAction.DELETE.value,
  1181. get_request_password(),
  1182. context,
  1183. )
  1184. _check_version(document, row_version)
  1185. if document.document_type == DocumentType.MAIN.value:
  1186. child_count = session.scalar(
  1187. select(func.count(Document.id)).where(
  1188. or_(
  1189. Document.parent_document_id == document.id,
  1190. Document.root_document_id == document.id,
  1191. ),
  1192. Document.document_type == DocumentType.SUB_PLAN.value,
  1193. Document.is_deleted.is_(False),
  1194. )
  1195. )
  1196. if child_count:
  1197. raise MainPlanHasChildrenError(
  1198. details={"childCount": child_count}
  1199. )
  1200. for relation in session.scalars(
  1201. select(Permission).where(
  1202. Permission.document_id == document.id,
  1203. Permission.is_deleted.is_(False),
  1204. )
  1205. ):
  1206. relation.is_deleted = True
  1207. relation.deleted_at = now
  1208. relation.updated_at = now
  1209. relation.updated_by = context.user_id
  1210. relation.row_version += 1
  1211. for binding in session.scalars(
  1212. select(AttachmentBinding).where(
  1213. AttachmentBinding.main_document_id == document.id,
  1214. AttachmentBinding.is_deleted.is_(False),
  1215. )
  1216. ):
  1217. binding.is_deleted = True
  1218. binding.deleted_at = now
  1219. binding.updated_at = now
  1220. binding.updated_by = context.user_id
  1221. binding.row_version += 1
  1222. document.attachment_count = 0
  1223. else:
  1224. parent = session.get(Document, document.root_document_id)
  1225. if parent is not None and not parent.is_deleted:
  1226. parent.child_count = max(0, parent.child_count - 1)
  1227. parent.updated_at = now
  1228. parent.updated_by = context.user_id
  1229. parent.updated_by_name = context.real_name
  1230. parent.row_version += 1
  1231. category = session.get(Category, document.category_id)
  1232. if category is not None and document.document_type == DocumentType.MAIN.value:
  1233. category.document_count = max(0, category.document_count - 1)
  1234. category.row_version += 1
  1235. category.updated_by = context.user_id
  1236. document.is_deleted = True
  1237. document.deleted_at = now
  1238. document.updated_at = now
  1239. document.updated_by = context.user_id
  1240. document.updated_by_name = context.real_name
  1241. document.row_version += 1
  1242. session.add(
  1243. business_audit(
  1244. action=AuditAction.DELETE_DOCUMENT,
  1245. target=AuditTarget.DOCUMENT,
  1246. target_id=document.id,
  1247. target_name=document.document_name,
  1248. detail={"documentType": document.document_type},
  1249. )
  1250. )
  1251. return {"id": serialize_id(document.id), "deleted": True}
  1252. def delete_attachment(document_id: int, row_version: int):
  1253. context = get_auth_context()
  1254. now = _now()
  1255. with transaction() as session:
  1256. document = session.scalar(
  1257. select(Document)
  1258. .where(
  1259. Document.id == document_id,
  1260. Document.document_type == DocumentType.ATTACHMENT.value,
  1261. Document.is_deleted.is_(False),
  1262. )
  1263. .options(*LOCK_DEFER_LOB)
  1264. .with_for_update()
  1265. )
  1266. if document is None:
  1267. raise ResourceNotFoundError("共享附件不存在或类型不匹配")
  1268. require_plan_action(document, context, AllowedAction.DELETE)
  1269. _check_version(document, row_version)
  1270. rows = session.execute(
  1271. select(AttachmentBinding, Document)
  1272. .join(Document, Document.id == AttachmentBinding.main_document_id)
  1273. .where(
  1274. AttachmentBinding.attachment_document_id == document.id,
  1275. AttachmentBinding.is_deleted.is_(False),
  1276. Document.document_type == DocumentType.MAIN.value,
  1277. Document.is_deleted.is_(False),
  1278. )
  1279. ).all()
  1280. if rows:
  1281. raise AttachmentInUseError(
  1282. details={
  1283. "mountedPlanCount": len(rows),
  1284. "mainPlans": [
  1285. {
  1286. "id": serialize_id(main.id),
  1287. "documentName": main.document_name,
  1288. }
  1289. for _, main in rows
  1290. ],
  1291. }
  1292. )
  1293. document.is_deleted = True
  1294. document.deleted_at = now
  1295. document.updated_at = now
  1296. document.updated_by = context.user_id
  1297. document.updated_by_name = context.real_name
  1298. document.row_version += 1
  1299. session.add(
  1300. business_audit(
  1301. action=AuditAction.DELETE_DOCUMENT,
  1302. target=AuditTarget.ATTACHMENT,
  1303. target_id=document.id,
  1304. target_name=document.document_name,
  1305. detail={"attachmentType": document.attachment_type},
  1306. )
  1307. )
  1308. return {"id": serialize_id(document.id), "deleted": True}