document_mutation_service.py 48 KB

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