document_mutation_service.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
  1. """B5文档与共享附件写操作及文件事务补偿。"""
  2. from __future__ import annotations
  3. from datetime import datetime, timezone
  4. from typing import Any
  5. from sqlalchemy import func, or_, select
  6. from werkzeug.datastructures import FileStorage
  7. from dms.common.enums import (
  8. AttachmentType,
  9. AuditAction,
  10. AuditTarget,
  11. DocumentStatus,
  12. DocumentType,
  13. EnabledStatus,
  14. SecurityLevel,
  15. VisibilityType,
  16. )
  17. from dms.common.errors import (
  18. AttachmentInUseError,
  19. ConflictError,
  20. InvalidArgumentError,
  21. MainPlanHasChildrenError,
  22. ResourceNotFoundError,
  23. )
  24. from dms.common.response import serialize_id
  25. from dms.database.transaction import transaction
  26. from dms.extensions import db
  27. from dms.models import AttachmentBinding, Category, Document, Permission
  28. from dms.security.auth_context import get_auth_context
  29. from dms.services.attachment_query_service import attachment_detail
  30. from dms.services.audit_service import business_audit
  31. from dms.services.authorization_service import evaluate_plan_access
  32. from dms.services.document_query_service import document_detail
  33. from dms.storage.uploads import StagedUpload, stage_upload
  34. PLAN_CREATE_FIELDS = {
  35. "documentName",
  36. "documentType",
  37. "summary",
  38. "categoryId",
  39. "securityLevel",
  40. "visibilityType",
  41. "status",
  42. "tags",
  43. }
  44. PLAN_EDIT_FIELDS = {
  45. "documentName",
  46. "summary",
  47. "categoryId",
  48. "securityLevel",
  49. "visibilityType",
  50. "status",
  51. "tags",
  52. "rowVersion",
  53. }
  54. ATTACHMENT_CREATE_FIELDS = {"documentName", "attachmentType", "summary", "tags"}
  55. ATTACHMENT_EDIT_FIELDS = ATTACHMENT_CREATE_FIELDS | {"rowVersion"}
  56. def _now() -> datetime:
  57. return datetime.now(timezone.utc).replace(tzinfo=None)
  58. def _exact(payload: Any, fields: set[str]) -> dict[str, Any]:
  59. if not isinstance(payload, dict) or set(payload) != fields:
  60. raise InvalidArgumentError(
  61. "metadata字段必须且只能包含:" + "、".join(sorted(fields))
  62. )
  63. return payload
  64. def _text(value: Any, name: str, limit: int, *, nullable: bool = False):
  65. if value is None and nullable:
  66. return None
  67. if not isinstance(value, str) or not value.strip():
  68. raise InvalidArgumentError(f"{name}不能为空")
  69. result = value.strip()
  70. if len(result) > limit:
  71. raise InvalidArgumentError(f"{name}长度不能超过{limit}")
  72. return result
  73. def _tags(value: Any) -> list[str]:
  74. if not isinstance(value, list) or len(value) > 50:
  75. raise InvalidArgumentError("tags必须是最多50项的字符串数组")
  76. result: list[str] = []
  77. for item in value:
  78. normalized = _text(item, "tags元素", 64)
  79. if normalized not in result:
  80. result.append(normalized)
  81. return result
  82. def _enum(value: Any, enum_type, name: str) -> str:
  83. if not isinstance(value, str):
  84. raise InvalidArgumentError(f"{name}必须是字符串枚举")
  85. try:
  86. return enum_type(value).value
  87. except ValueError as exc:
  88. raise InvalidArgumentError(f"{name}不是有效枚举值") from exc
  89. def _string_id(value: Any, name: str) -> int:
  90. if not isinstance(value, str) or not value.isdecimal() or int(value) <= 0:
  91. raise InvalidArgumentError(f"{name}必须是正整数形式的字符串ID")
  92. return int(value)
  93. def _version(value: Any) -> int:
  94. if type(value) is not int or value < 0:
  95. raise InvalidArgumentError("rowVersion必须是非负整数")
  96. return value
  97. def _category(category_id: int) -> Category:
  98. category = db.session.scalar(
  99. select(Category).where(
  100. Category.id == category_id,
  101. Category.is_deleted.is_(False),
  102. Category.status == EnabledStatus.ENABLED.value,
  103. )
  104. )
  105. if category is None:
  106. raise ResourceNotFoundError("方案分类不存在或不可用")
  107. return category
  108. def _search(name: str, summary: str | None, tags: list[str]) -> str:
  109. return " ".join([name, summary or "", *tags]).strip()
  110. def _serialize(document: Document) -> dict[str, object]:
  111. context = get_auth_context()
  112. if document.document_type == DocumentType.ATTACHMENT.value:
  113. return attachment_detail(document, context)
  114. return document_detail(document, context, evaluate_plan_access(document, context))
  115. def _create_record(
  116. upload: StagedUpload,
  117. payload: dict[str, Any],
  118. *,
  119. attachment: bool,
  120. batch: bool,
  121. ) -> dict[str, object]:
  122. if not isinstance(payload, dict):
  123. raise InvalidArgumentError("metadata必须是JSON对象")
  124. context = get_auth_context()
  125. actor_id = context.user_id
  126. document_type = DocumentType.ATTACHMENT.value
  127. category = None
  128. parent = None
  129. attachment_type = None
  130. if attachment:
  131. _exact(payload, ATTACHMENT_CREATE_FIELDS)
  132. attachment_type = _enum(
  133. payload["attachmentType"], AttachmentType, "attachmentType"
  134. )
  135. security = SecurityLevel.PUBLIC.value
  136. visibility = VisibilityType.ALL_AUTHENTICATED.value
  137. status = DocumentStatus.PUBLISHED.value
  138. else:
  139. raw_type = payload.get("documentType")
  140. try:
  141. document_type = DocumentType(raw_type).value
  142. except (ValueError, TypeError) as exc:
  143. raise InvalidArgumentError("documentType只允许MAIN或SUB_PLAN") from exc
  144. if document_type not in {
  145. DocumentType.MAIN.value,
  146. DocumentType.SUB_PLAN.value,
  147. }:
  148. raise InvalidArgumentError("documentType只允许MAIN或SUB_PLAN")
  149. fields = PLAN_CREATE_FIELDS | (
  150. {"parentDocumentId"}
  151. if document_type == DocumentType.SUB_PLAN.value
  152. else set()
  153. )
  154. _exact(payload, fields)
  155. category = _category(_string_id(payload["categoryId"], "categoryId"))
  156. security = _enum(payload["securityLevel"], SecurityLevel, "securityLevel")
  157. visibility = _enum(
  158. payload["visibilityType"], VisibilityType, "visibilityType"
  159. )
  160. status = _enum(payload["status"], DocumentStatus, "status")
  161. if document_type == DocumentType.SUB_PLAN.value:
  162. parent_id = _string_id(payload["parentDocumentId"], "parentDocumentId")
  163. parent = db.session.scalar(
  164. select(Document).where(
  165. Document.id == parent_id,
  166. Document.document_type == DocumentType.MAIN.value,
  167. Document.is_deleted.is_(False),
  168. )
  169. )
  170. if parent is None:
  171. raise ResourceNotFoundError("父文档必须是有效主案")
  172. visibility = parent.visibility_type
  173. name = _text(payload["documentName"], "documentName", 255)
  174. summary = _text(payload["summary"], "summary", 20000, nullable=True)
  175. tags = _tags(payload["tags"])
  176. final_created = False
  177. committed = False
  178. try:
  179. with transaction() as session:
  180. document = Document(
  181. document_name=name,
  182. summary=summary,
  183. document_type=document_type,
  184. document_status=status,
  185. security_level=security,
  186. visibility_type=visibility,
  187. attachment_type=attachment_type,
  188. category_id=category.id if category else None,
  189. category_name=category.category_name if category else None,
  190. category_path=category.category_path if category else None,
  191. parent_document_id=parent.id if parent else None,
  192. root_document_id=parent.id if parent else None,
  193. tags=tags,
  194. original_file_name=upload.original_file_name,
  195. file_relative_path=upload.relative_path,
  196. file_extension=upload.extension,
  197. mime_type=upload.mime_type,
  198. file_size=upload.file_size,
  199. file_hash=upload.file_hash,
  200. search_text=_search(name, summary, tags),
  201. created_by=actor_id,
  202. updated_by=actor_id,
  203. created_by_name=context.real_name,
  204. updated_by_name=context.real_name,
  205. )
  206. session.add(document)
  207. session.flush()
  208. if category is not None:
  209. category.document_count += 1
  210. category.row_version += 1
  211. category.updated_by = actor_id
  212. if parent is not None:
  213. parent.child_count += 1
  214. parent.row_version += 1
  215. parent.updated_by = actor_id
  216. parent.updated_by_name = context.real_name
  217. session.add(
  218. business_audit(
  219. action=(
  220. AuditAction.BATCH_IMPORT if batch else AuditAction.UPLOAD_DOCUMENT
  221. ),
  222. target=(
  223. AuditTarget.ATTACHMENT
  224. if attachment
  225. else AuditTarget.DOCUMENT
  226. ),
  227. target_id=document.id,
  228. target_name=document.document_name,
  229. detail={
  230. "documentType": document.document_type,
  231. "originalFileName": document.original_file_name,
  232. "fileSize": document.file_size,
  233. },
  234. )
  235. )
  236. upload.promote()
  237. final_created = True
  238. committed = True
  239. except Exception:
  240. if final_created and not committed:
  241. upload.final_path.unlink(missing_ok=True)
  242. raise
  243. finally:
  244. upload.cleanup()
  245. return _serialize(document)
  246. def create_document(file: FileStorage, payload: dict[str, Any], *, batch=False):
  247. upload = stage_upload(file)
  248. return _create_record(upload, payload, attachment=False, batch=batch)
  249. def create_attachment(file: FileStorage, payload: dict[str, Any], *, batch=False):
  250. upload = stage_upload(file)
  251. return _create_record(upload, payload, attachment=True, batch=batch)
  252. def _active(document_id: int, types: set[str]) -> Document:
  253. document = db.session.scalar(
  254. select(Document).where(
  255. Document.id == document_id,
  256. Document.document_type.in_(types),
  257. Document.is_deleted.is_(False),
  258. )
  259. )
  260. if document is None:
  261. raise ResourceNotFoundError("文档不存在或类型不匹配")
  262. return document
  263. def _check_version(document: Document, expected: int) -> None:
  264. if document.row_version != expected:
  265. raise ConflictError(
  266. "数据已被其他用户修改,请刷新后重试",
  267. details={"currentRowVersion": document.row_version},
  268. )
  269. def update_document(document_id: int, payload: dict[str, Any]):
  270. _exact(payload, PLAN_EDIT_FIELDS)
  271. context = get_auth_context()
  272. with transaction() as session:
  273. document = session.scalar(
  274. select(Document)
  275. .where(
  276. Document.id == document_id,
  277. Document.document_type.in_(
  278. [DocumentType.MAIN.value, DocumentType.SUB_PLAN.value]
  279. ),
  280. Document.is_deleted.is_(False),
  281. )
  282. .with_for_update()
  283. )
  284. if document is None:
  285. raise ResourceNotFoundError("方案文档不存在或类型不匹配")
  286. _check_version(document, _version(payload["rowVersion"]))
  287. category = _category(_string_id(payload["categoryId"], "categoryId"))
  288. before = {
  289. "documentName": document.document_name,
  290. "categoryId": serialize_id(document.category_id),
  291. "securityLevel": document.security_level,
  292. "visibilityType": document.visibility_type,
  293. "status": document.document_status,
  294. }
  295. if document.category_id != category.id:
  296. old = session.get(Category, document.category_id)
  297. if old is not None:
  298. old.document_count = max(0, old.document_count - 1)
  299. old.row_version += 1
  300. category.document_count += 1
  301. category.row_version += 1
  302. document.document_name = _text(
  303. payload["documentName"], "documentName", 255
  304. )
  305. document.summary = _text(
  306. payload["summary"], "summary", 20000, nullable=True
  307. )
  308. document.tags = _tags(payload["tags"])
  309. document.category_id = category.id
  310. document.category_name = category.category_name
  311. document.category_path = category.category_path
  312. document.security_level = _enum(
  313. payload["securityLevel"], SecurityLevel, "securityLevel"
  314. )
  315. requested_visibility = _enum(
  316. payload["visibilityType"], VisibilityType, "visibilityType"
  317. )
  318. if document.document_type == DocumentType.MAIN.value:
  319. document.visibility_type = requested_visibility
  320. document.document_status = _enum(
  321. payload["status"], DocumentStatus, "status"
  322. )
  323. document.search_text = _search(
  324. document.document_name, document.summary, document.tags
  325. )
  326. document.updated_by = context.user_id
  327. document.updated_by_name = context.real_name
  328. document.updated_at = _now()
  329. document.row_version += 1
  330. session.add(
  331. business_audit(
  332. action=AuditAction.EDIT_DOCUMENT,
  333. target=AuditTarget.DOCUMENT,
  334. target_id=document.id,
  335. target_name=document.document_name,
  336. detail={"before": before, "rowVersion": document.row_version},
  337. )
  338. )
  339. return _serialize(document)
  340. def update_attachment(document_id: int, payload: dict[str, Any]):
  341. _exact(payload, ATTACHMENT_EDIT_FIELDS)
  342. context = get_auth_context()
  343. with transaction() as session:
  344. document = session.scalar(
  345. select(Document)
  346. .where(
  347. Document.id == document_id,
  348. Document.document_type == DocumentType.ATTACHMENT.value,
  349. Document.is_deleted.is_(False),
  350. )
  351. .with_for_update()
  352. )
  353. if document is None:
  354. raise ResourceNotFoundError("共享附件不存在或类型不匹配")
  355. _check_version(document, _version(payload["rowVersion"]))
  356. before = {
  357. "documentName": document.document_name,
  358. "attachmentType": document.attachment_type,
  359. }
  360. document.document_name = _text(
  361. payload["documentName"], "documentName", 255
  362. )
  363. document.attachment_type = _enum(
  364. payload["attachmentType"], AttachmentType, "attachmentType"
  365. )
  366. document.summary = _text(
  367. payload["summary"], "summary", 20000, nullable=True
  368. )
  369. document.tags = _tags(payload["tags"])
  370. document.search_text = _search(
  371. document.document_name, document.summary, document.tags
  372. )
  373. document.updated_by = context.user_id
  374. document.updated_by_name = context.real_name
  375. document.updated_at = _now()
  376. document.row_version += 1
  377. session.add(
  378. business_audit(
  379. action=AuditAction.EDIT_DOCUMENT,
  380. target=AuditTarget.ATTACHMENT,
  381. target_id=document.id,
  382. target_name=document.document_name,
  383. detail={"before": before, "rowVersion": document.row_version},
  384. )
  385. )
  386. return _serialize(document)
  387. def delete_document(document_id: int, row_version: int):
  388. context = get_auth_context()
  389. now = _now()
  390. with transaction() as session:
  391. document = session.scalar(
  392. select(Document)
  393. .where(
  394. Document.id == document_id,
  395. Document.document_type.in_(
  396. [DocumentType.MAIN.value, DocumentType.SUB_PLAN.value]
  397. ),
  398. Document.is_deleted.is_(False),
  399. )
  400. .with_for_update()
  401. )
  402. if document is None:
  403. raise ResourceNotFoundError("方案文档不存在或类型不匹配")
  404. _check_version(document, row_version)
  405. if document.document_type == DocumentType.MAIN.value:
  406. child_count = session.scalar(
  407. select(func.count(Document.id)).where(
  408. or_(
  409. Document.parent_document_id == document.id,
  410. Document.root_document_id == document.id,
  411. ),
  412. Document.document_type == DocumentType.SUB_PLAN.value,
  413. Document.is_deleted.is_(False),
  414. )
  415. )
  416. if child_count:
  417. raise MainPlanHasChildrenError(
  418. details={"childCount": child_count}
  419. )
  420. for relation in session.scalars(
  421. select(Permission).where(
  422. Permission.document_id == document.id,
  423. Permission.is_deleted.is_(False),
  424. )
  425. ):
  426. relation.is_deleted = True
  427. relation.deleted_at = now
  428. relation.updated_at = now
  429. relation.updated_by = context.user_id
  430. relation.row_version += 1
  431. for binding in session.scalars(
  432. select(AttachmentBinding).where(
  433. AttachmentBinding.main_document_id == document.id,
  434. AttachmentBinding.is_deleted.is_(False),
  435. )
  436. ):
  437. binding.is_deleted = True
  438. binding.deleted_at = now
  439. binding.updated_at = now
  440. binding.updated_by = context.user_id
  441. binding.row_version += 1
  442. document.attachment_count = 0
  443. else:
  444. parent = session.get(Document, document.root_document_id)
  445. if parent is not None and not parent.is_deleted:
  446. parent.child_count = max(0, parent.child_count - 1)
  447. parent.updated_at = now
  448. parent.updated_by = context.user_id
  449. parent.updated_by_name = context.real_name
  450. parent.row_version += 1
  451. category = session.get(Category, document.category_id)
  452. if category is not None:
  453. category.document_count = max(0, category.document_count - 1)
  454. category.row_version += 1
  455. category.updated_by = context.user_id
  456. document.is_deleted = True
  457. document.deleted_at = now
  458. document.updated_at = now
  459. document.updated_by = context.user_id
  460. document.updated_by_name = context.real_name
  461. document.row_version += 1
  462. session.add(
  463. business_audit(
  464. action=AuditAction.DELETE_DOCUMENT,
  465. target=AuditTarget.DOCUMENT,
  466. target_id=document.id,
  467. target_name=document.document_name,
  468. detail={"documentType": document.document_type},
  469. )
  470. )
  471. return {"id": serialize_id(document.id), "deleted": True}
  472. def delete_attachment(document_id: int, row_version: int):
  473. context = get_auth_context()
  474. now = _now()
  475. with transaction() as session:
  476. document = session.scalar(
  477. select(Document)
  478. .where(
  479. Document.id == document_id,
  480. Document.document_type == DocumentType.ATTACHMENT.value,
  481. Document.is_deleted.is_(False),
  482. )
  483. .with_for_update()
  484. )
  485. if document is None:
  486. raise ResourceNotFoundError("共享附件不存在或类型不匹配")
  487. _check_version(document, row_version)
  488. rows = session.execute(
  489. select(AttachmentBinding, Document)
  490. .join(Document, Document.id == AttachmentBinding.main_document_id)
  491. .where(
  492. AttachmentBinding.attachment_document_id == document.id,
  493. AttachmentBinding.is_deleted.is_(False),
  494. Document.document_type == DocumentType.MAIN.value,
  495. Document.is_deleted.is_(False),
  496. )
  497. ).all()
  498. if rows:
  499. raise AttachmentInUseError(
  500. details={
  501. "mountedPlanCount": len(rows),
  502. "mainPlans": [
  503. {
  504. "id": serialize_id(main.id),
  505. "documentName": main.document_name,
  506. }
  507. for _, main in rows
  508. ],
  509. }
  510. )
  511. document.is_deleted = True
  512. document.deleted_at = now
  513. document.updated_at = now
  514. document.updated_by = context.user_id
  515. document.updated_by_name = context.real_name
  516. document.row_version += 1
  517. session.add(
  518. business_audit(
  519. action=AuditAction.DELETE_DOCUMENT,
  520. target=AuditTarget.ATTACHMENT,
  521. target_id=document.id,
  522. target_name=document.document_name,
  523. detail={"attachmentType": document.attachment_type},
  524. )
  525. )
  526. return {"id": serialize_id(document.id), "deleted": True}