document_mutation_service.py 43 KB

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