document_mutation_service.py 56 KB

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