recycle_bin_service.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840
  1. """B8回收站查询和单条文档恢复。"""
  2. from __future__ import annotations
  3. import hashlib
  4. import logging
  5. import os
  6. import stat
  7. from dataclasses import dataclass
  8. from datetime import datetime, timezone
  9. from pathlib import Path
  10. from typing import Any, BinaryIO, Mapping
  11. from flask import current_app
  12. from sqlalchemy import and_, func, or_, select
  13. from sqlalchemy.exc import IntegrityError
  14. from dms.common.enums import (
  15. AuditAction,
  16. AuditResult,
  17. AuditTarget,
  18. DocumentType,
  19. EnabledStatus,
  20. SecurityLevel,
  21. )
  22. from dms.common.errors import (
  23. ConflictError,
  24. DocumentNotDeletedError,
  25. FileIntegrityMismatchError,
  26. FileNotFoundError as DmsFileNotFoundError,
  27. FilePathInvalidError,
  28. InvalidArgumentError,
  29. ResourceNotFoundError,
  30. RestoreCategoryInvalidError,
  31. RestoreFileChangedError,
  32. RestoreParentInvalidError,
  33. RestoreRelationConflictError,
  34. UnsupportedFileTypeError,
  35. )
  36. from dms.common.pagination import PageRequest, page_result
  37. from dms.common.response import serialize_id
  38. from dms.database.transaction import execute_with_deadlock_retry, transaction
  39. from dms.extensions import db
  40. from dms.models import (
  41. LOCK_DEFER_LOB,
  42. AttachmentBinding,
  43. AuditLog,
  44. Category,
  45. Document,
  46. Permission,
  47. User,
  48. )
  49. from dms.security.auth_context import get_auth_context
  50. from dms.services.attachment_query_service import attachment_detail
  51. from dms.services.audit_service import business_audit
  52. from dms.services.authorization_service import evaluate_plan_access
  53. from dms.services.document_query_service import _iso, document_detail
  54. from dms.storage.paths import UnsafeStoragePathError, resolve_storage_path
  55. from dms.storage.uploads import ALLOWED_EXTENSIONS, _validate
  56. logger = logging.getLogger(__name__)
  57. QUERY_FIELDS = {
  58. "keyword",
  59. "documentType",
  60. "categoryId",
  61. "deletedFrom",
  62. "deletedTo",
  63. "page",
  64. "pageSize",
  65. "sortField",
  66. "sortDirection",
  67. }
  68. SORT_FIELDS = {
  69. "deletedAt": Document.deleted_at,
  70. "documentName": Document.document_name,
  71. "documentType": Document.document_type,
  72. "updatedAt": Document.updated_at,
  73. }
  74. SUPPORTED_TYPES = {
  75. DocumentType.MAIN.value,
  76. DocumentType.SUB_PLAN.value,
  77. DocumentType.ATTACHMENT.value,
  78. }
  79. RESTORE_FIELDS = {"rowVersion"}
  80. def _now() -> datetime:
  81. return datetime.now(timezone.utc).replace(tzinfo=None)
  82. def _strict_single_params(params: Any) -> None:
  83. unknown = set(params.keys()) - QUERY_FIELDS
  84. if unknown:
  85. raise InvalidArgumentError(
  86. "存在未知查询参数",
  87. details={"parameters": sorted(unknown)},
  88. )
  89. for name in params.keys():
  90. if len(params.getlist(name)) != 1:
  91. raise InvalidArgumentError(
  92. f"{name}不允许重复传入",
  93. details={"parameter": name},
  94. )
  95. def _positive_integer(value: str | None, name: str, default: int) -> int:
  96. if value is None:
  97. return default
  98. if not value.isdecimal() or int(value) < 1:
  99. raise InvalidArgumentError(f"{name}必须是正整数")
  100. return int(value)
  101. def _utc_z(value: str | None, name: str) -> datetime | None:
  102. if value is None or value == "":
  103. return None
  104. if not value.endswith("Z"):
  105. raise InvalidArgumentError(f"{name}必须是UTC Z时间")
  106. try:
  107. parsed = datetime.fromisoformat(value[:-1] + "+00:00")
  108. except ValueError as exc:
  109. raise InvalidArgumentError(f"{name}必须是有效UTC Z时间") from exc
  110. if parsed.utcoffset() != timezone.utc.utcoffset(parsed):
  111. raise InvalidArgumentError(f"{name}必须是UTC Z时间")
  112. return parsed.astimezone(timezone.utc).replace(tzinfo=None)
  113. def _escape_like(value: str) -> str:
  114. return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
  115. def _deleted_by(documents: list[Document]) -> dict[int, dict[str, str | None]]:
  116. """单次窗口查询获取当前页每个文档最近一次可靠删除审计快照。"""
  117. if not documents:
  118. return {}
  119. keys = [
  120. (
  121. AuditTarget.ATTACHMENT.value
  122. if item.document_type == DocumentType.ATTACHMENT.value
  123. else AuditTarget.DOCUMENT.value,
  124. item.id,
  125. )
  126. for item in documents
  127. ]
  128. predicates = [
  129. and_(AuditLog.target_type == target_type, AuditLog.target_id == target_id)
  130. for target_type, target_id in keys
  131. ]
  132. ranked = (
  133. select(
  134. AuditLog.target_type.label("target_type"),
  135. AuditLog.target_id.label("target_id"),
  136. AuditLog.user_id.label("user_id"),
  137. AuditLog.username.label("username"),
  138. AuditLog.real_name.label("real_name"),
  139. AuditLog.organization_name.label("organization_name"),
  140. func.row_number()
  141. .over(
  142. partition_by=(AuditLog.target_type, AuditLog.target_id),
  143. order_by=(AuditLog.created_at.desc(), AuditLog.id.desc()),
  144. )
  145. .label("row_number"),
  146. )
  147. .where(
  148. AuditLog.action_type == AuditAction.DELETE_DOCUMENT.value,
  149. AuditLog.operation_result == AuditResult.SUCCESS.value,
  150. or_(*predicates),
  151. )
  152. .subquery()
  153. )
  154. rows = db.session.execute(
  155. select(ranked).where(ranked.c.row_number == 1)
  156. ).mappings()
  157. result: dict[int, dict[str, str | None]] = {}
  158. for row in rows:
  159. result[int(row["target_id"])] = {
  160. "userId": serialize_id(row["user_id"]),
  161. "username": row["username"],
  162. "realName": row["real_name"],
  163. "organizationName": row["organization_name"],
  164. }
  165. return result
  166. def _recycle_summary(
  167. document: Document,
  168. deleted_by: dict[int, dict[str, str | None]],
  169. ) -> dict[str, object]:
  170. return {
  171. "id": serialize_id(document.id),
  172. "documentName": document.document_name,
  173. "documentType": document.document_type,
  174. "originalFileName": document.original_file_name,
  175. "categoryId": serialize_id(document.category_id),
  176. "categoryName": document.category_name,
  177. "parentDocumentId": serialize_id(document.parent_document_id),
  178. "securityLevel": document.security_level,
  179. "documentStatus": document.document_status,
  180. "deletedAt": _iso(document.deleted_at),
  181. "updatedBy": serialize_id(document.updated_by),
  182. "updatedByName": document.updated_by_name,
  183. "deletedBy": deleted_by.get(document.id),
  184. "rowVersion": document.row_version,
  185. }
  186. def list_recycle_bin_documents(params: Any) -> dict[str, object]:
  187. _strict_single_params(params)
  188. page_number = _positive_integer(params.get("page"), "page", 1)
  189. page_size = _positive_integer(params.get("pageSize"), "pageSize", 20)
  190. if page_size > 100:
  191. raise InvalidArgumentError("pageSize不能超过100")
  192. page = PageRequest(page=page_number, page_size=page_size)
  193. statement = select(Document).where(
  194. Document.is_deleted.is_(True),
  195. Document.deleted_at.is_not(None),
  196. Document.document_type.in_(sorted(SUPPORTED_TYPES)),
  197. )
  198. document_type = params.get("documentType")
  199. if document_type:
  200. if document_type not in SUPPORTED_TYPES:
  201. raise InvalidArgumentError("documentType不是有效枚举值")
  202. statement = statement.where(Document.document_type == document_type)
  203. category_id = params.get("categoryId")
  204. if category_id:
  205. if not category_id.isdecimal() or int(category_id) <= 0:
  206. raise InvalidArgumentError("categoryId必须是正整数形式的字符串ID")
  207. statement = statement.where(Document.category_id == int(category_id))
  208. keyword = params.get("keyword")
  209. if keyword and keyword.strip():
  210. pattern = f"%{_escape_like(keyword.strip())}%"
  211. statement = statement.where(
  212. or_(
  213. Document.document_name.like(pattern, escape="\\"),
  214. Document.summary.like(pattern, escape="\\"),
  215. Document.search_text.like(pattern, escape="\\"),
  216. Document.original_file_name.like(pattern, escape="\\"),
  217. )
  218. )
  219. deleted_from = _utc_z(params.get("deletedFrom"), "deletedFrom")
  220. deleted_to = _utc_z(params.get("deletedTo"), "deletedTo")
  221. if deleted_from and deleted_to and deleted_from >= deleted_to:
  222. raise InvalidArgumentError("deletedFrom必须早于deletedTo")
  223. if deleted_from:
  224. statement = statement.where(Document.deleted_at >= deleted_from)
  225. if deleted_to:
  226. statement = statement.where(Document.deleted_at < deleted_to)
  227. sort_field = params.get("sortField", "deletedAt")
  228. direction = params.get("sortDirection", "desc").lower()
  229. if sort_field not in SORT_FIELDS:
  230. raise InvalidArgumentError("sortField不是允许的排序字段")
  231. if direction not in {"asc", "desc"}:
  232. raise InvalidArgumentError("sortDirection必须是asc或desc")
  233. sort_column = SORT_FIELDS[sort_field]
  234. order = sort_column.asc() if direction == "asc" else sort_column.desc()
  235. id_order = Document.id.asc() if direction == "asc" else Document.id.desc()
  236. total = db.session.scalar(
  237. select(func.count()).select_from(statement.order_by(None).subquery())
  238. ) or 0
  239. documents = db.session.scalars(
  240. statement.order_by(order, id_order).offset(page.offset).limit(page.page_size)
  241. ).all()
  242. deleted_by = _deleted_by(documents)
  243. return page_result(
  244. [_recycle_summary(item, deleted_by) for item in documents],
  245. page=page.page,
  246. page_size=page.page_size,
  247. total=total,
  248. )
  249. @dataclass(slots=True)
  250. class VerifiedFile:
  251. path: Path
  252. stream: BinaryIO
  253. state: tuple[int, int, int, int]
  254. document_state: tuple[str, str, int, str]
  255. def close(self) -> None:
  256. self.stream.close()
  257. def _file_state(value: os.stat_result) -> tuple[int, int, int, int]:
  258. return (value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns)
  259. def _verify_file(document: Document) -> VerifiedFile:
  260. try:
  261. path = resolve_storage_path(
  262. document.file_relative_path,
  263. current_app.config["DMS_STORAGE_ROOT"],
  264. )
  265. except UnsafeStoragePathError as exc:
  266. logger.error("恢复文件路径校验失败:document_id=%s", document.id)
  267. raise FilePathInvalidError() from exc
  268. try:
  269. stream = path.open("rb")
  270. except FileNotFoundError as exc:
  271. raise DmsFileNotFoundError() from exc
  272. except OSError as exc:
  273. logger.error("恢复文件无法安全打开:document_id=%s", document.id)
  274. raise FilePathInvalidError() from exc
  275. try:
  276. handle_stat = os.fstat(stream.fileno())
  277. if not stat.S_ISREG(handle_stat.st_mode):
  278. raise DmsFileNotFoundError()
  279. extension = document.file_extension.lower().lstrip(".")
  280. if extension not in ALLOWED_EXTENSIONS:
  281. raise UnsupportedFileTypeError()
  282. if handle_stat.st_size != document.file_size:
  283. raise FileIntegrityMismatchError(details={"reason": "SIZE_MISMATCH"})
  284. digest = hashlib.sha256()
  285. while chunk := stream.read(1024 * 1024):
  286. digest.update(chunk)
  287. stream.seek(0)
  288. if digest.hexdigest() != document.file_hash:
  289. raise FileIntegrityMismatchError(details={"reason": "HASH_MISMATCH"})
  290. if path.suffix.lower() != f".{extension}":
  291. raise FileIntegrityMismatchError(details={"reason": "TYPE_MISMATCH"})
  292. try:
  293. _validate(path, extension)
  294. except UnsupportedFileTypeError as exc:
  295. raise FileIntegrityMismatchError(
  296. details={"reason": "TYPE_MISMATCH"}
  297. ) from exc
  298. path_stat = path.stat()
  299. if _file_state(path_stat) != _file_state(handle_stat):
  300. raise RestoreFileChangedError()
  301. return VerifiedFile(
  302. path=path,
  303. stream=stream,
  304. state=_file_state(handle_stat),
  305. document_state=(
  306. document.file_relative_path,
  307. document.file_extension,
  308. document.file_size,
  309. document.file_hash,
  310. ),
  311. )
  312. except Exception:
  313. stream.close()
  314. raise
  315. def _recheck_file(verified: VerifiedFile, document: Document) -> None:
  316. if verified.document_state != (
  317. document.file_relative_path,
  318. document.file_extension,
  319. document.file_size,
  320. document.file_hash,
  321. ):
  322. raise RestoreFileChangedError()
  323. try:
  324. handle_state = _file_state(os.fstat(verified.stream.fileno()))
  325. path_state = _file_state(verified.path.stat())
  326. except OSError as exc:
  327. raise RestoreFileChangedError() from exc
  328. if handle_state != verified.state or path_state != verified.state:
  329. raise RestoreFileChangedError()
  330. def _restore_payload(payload: Any) -> int:
  331. if not isinstance(payload, dict) or set(payload) != RESTORE_FIELDS:
  332. raise InvalidArgumentError("请求字段必须且只能包含rowVersion")
  333. value = payload["rowVersion"]
  334. if type(value) is not int or value < 0:
  335. raise InvalidArgumentError("rowVersion必须是非负整数")
  336. return value
  337. def _category_reason(category: Category | None) -> str | None:
  338. if category is None:
  339. return "NOT_FOUND"
  340. if category.is_deleted:
  341. return "DELETED"
  342. if category.status != EnabledStatus.ENABLED.value:
  343. return "DISABLED"
  344. return None
  345. def _lock_category(session, document: Document) -> Category:
  346. category = session.scalar(
  347. select(Category)
  348. .where(Category.id == document.category_id)
  349. .with_for_update()
  350. )
  351. reason = _category_reason(category)
  352. if reason:
  353. raise RestoreCategoryInvalidError(
  354. details={
  355. "categoryId": serialize_id(document.category_id),
  356. "reason": reason,
  357. }
  358. )
  359. return category
  360. def _active_permission_conflicts(
  361. session,
  362. document_id: int,
  363. permissions: list[Permission],
  364. ) -> int:
  365. if not permissions:
  366. return 0
  367. keys = {item.subject_id for item in permissions}
  368. active = session.scalars(
  369. select(Permission).where(
  370. Permission.document_id == document_id,
  371. Permission.is_deleted.is_(False),
  372. )
  373. ).all()
  374. return sum(item.subject_id in keys for item in active)
  375. def _restore_main_relations(
  376. session,
  377. document: Document,
  378. now: datetime,
  379. ) -> tuple[int, int, int, int]:
  380. context = get_auth_context()
  381. deleted_at = document.deleted_at
  382. deleted_by = document.updated_by
  383. permissions = session.scalars(
  384. select(Permission)
  385. .where(
  386. Permission.document_id == document.id,
  387. Permission.is_deleted.is_(True),
  388. Permission.deleted_at == deleted_at,
  389. Permission.updated_by == deleted_by,
  390. )
  391. .order_by(Permission.id.asc())
  392. .with_for_update()
  393. ).all()
  394. user_ids = sorted({item.subject_id for item in permissions})
  395. users = (
  396. session.scalars(
  397. select(User)
  398. .where(User.id.in_(user_ids))
  399. .order_by(User.id.asc())
  400. .with_for_update()
  401. ).all()
  402. if user_ids
  403. else []
  404. )
  405. valid_users = {
  406. item.id: item
  407. for item in users
  408. if not item.is_deleted and item.status == EnabledStatus.ENABLED.value
  409. }
  410. restorable_permissions: list[Permission] = []
  411. for permission in permissions:
  412. if permission.subject_id in valid_users:
  413. restorable_permissions.append(permission)
  414. skipped_permission_count = len(permissions) - len(restorable_permissions)
  415. binding_candidates = session.scalars(
  416. select(AttachmentBinding).where(
  417. AttachmentBinding.main_document_id == document.id,
  418. AttachmentBinding.is_deleted.is_(True),
  419. AttachmentBinding.deleted_at == deleted_at,
  420. AttachmentBinding.updated_by == deleted_by,
  421. )
  422. ).all()
  423. attachment_ids = sorted(
  424. {item.attachment_document_id for item in binding_candidates}
  425. )
  426. attachments = (
  427. session.scalars(
  428. select(Document)
  429. .where(Document.id.in_(attachment_ids))
  430. .order_by(Document.id.asc())
  431. .options(*LOCK_DEFER_LOB)
  432. .with_for_update()
  433. ).all()
  434. if attachment_ids
  435. else []
  436. )
  437. valid_attachment_ids = {
  438. item.id
  439. for item in attachments
  440. if not item.is_deleted and item.document_type == DocumentType.ATTACHMENT.value
  441. }
  442. bindings = session.scalars(
  443. select(AttachmentBinding)
  444. .where(
  445. AttachmentBinding.main_document_id == document.id,
  446. AttachmentBinding.is_deleted.is_(True),
  447. AttachmentBinding.deleted_at == deleted_at,
  448. AttachmentBinding.updated_by == deleted_by,
  449. )
  450. .order_by(AttachmentBinding.id.asc())
  451. .with_for_update()
  452. ).all()
  453. restorable_bindings = [
  454. item for item in bindings if item.attachment_document_id in valid_attachment_ids
  455. ]
  456. skipped_binding_count = len(bindings) - len(restorable_bindings)
  457. permission_conflicts = _active_permission_conflicts(
  458. session, document.id, restorable_permissions
  459. )
  460. binding_keys = {item.attachment_document_id for item in restorable_bindings}
  461. binding_conflicts = (
  462. session.scalar(
  463. select(func.count(AttachmentBinding.id)).where(
  464. AttachmentBinding.main_document_id == document.id,
  465. AttachmentBinding.attachment_document_id.in_(binding_keys),
  466. AttachmentBinding.is_deleted.is_(False),
  467. )
  468. )
  469. if binding_keys
  470. else 0
  471. ) or 0
  472. if permission_conflicts or binding_conflicts:
  473. raise RestoreRelationConflictError(
  474. details={
  475. "permissionConflictCount": permission_conflicts,
  476. "bindingConflictCount": binding_conflicts,
  477. }
  478. )
  479. # 借阅语义(0026):授权仅面向用户;还原后已过期条目由读路径惰性判定
  480. # 自行失效,无需还原期校验可见范围。
  481. for permission in restorable_permissions:
  482. permission.subject_name = valid_users[permission.subject_id].real_name
  483. permission.is_deleted = False
  484. permission.deleted_at = None
  485. permission.updated_at = now
  486. permission.updated_by = context.user_id
  487. permission.row_version += 1
  488. for binding in restorable_bindings:
  489. binding.is_deleted = False
  490. binding.deleted_at = None
  491. binding.updated_at = now
  492. binding.updated_by = context.user_id
  493. binding.row_version += 1
  494. return (
  495. len(restorable_permissions),
  496. skipped_permission_count,
  497. len(restorable_bindings),
  498. skipped_binding_count,
  499. )
  500. def _recount_category(session, category: Category, now: datetime) -> None:
  501. context = get_auth_context()
  502. count = session.scalar(
  503. select(func.count(Document.id)).where(
  504. Document.category_id == category.id,
  505. Document.document_type.in_(
  506. [DocumentType.MAIN.value, DocumentType.SUB_PLAN.value]
  507. ),
  508. Document.is_deleted.is_(False),
  509. )
  510. ) or 0
  511. if category.document_count != count:
  512. category.document_count = count
  513. category.row_version += 1
  514. category.updated_by = context.user_id
  515. category.updated_at = now
  516. def _restore_operation(
  517. document_id: int,
  518. expected_version: int,
  519. verified: VerifiedFile,
  520. ) -> tuple[Document, tuple[int, int, int, int]]:
  521. context = get_auth_context()
  522. with transaction() as session:
  523. identity = session.execute(
  524. select(
  525. Document.document_type,
  526. Document.parent_document_id,
  527. Document.root_document_id,
  528. ).where(Document.id == document_id)
  529. ).one_or_none()
  530. if identity is None:
  531. raise ResourceNotFoundError("文档不存在")
  532. document_type, parent_id, root_id = identity
  533. if document_type not in SUPPORTED_TYPES:
  534. raise ResourceNotFoundError("文档不存在或类型不支持")
  535. parent: Document | None = None
  536. if document_type == DocumentType.SUB_PLAN.value:
  537. lock_parent_id = root_id or parent_id
  538. parent = session.scalar(
  539. select(Document)
  540. .where(Document.id == lock_parent_id)
  541. .options(*LOCK_DEFER_LOB)
  542. .with_for_update()
  543. )
  544. document = session.scalar(
  545. select(Document)
  546. .where(Document.id == document_id)
  547. .options(*LOCK_DEFER_LOB)
  548. .with_for_update()
  549. )
  550. if document is None:
  551. raise ResourceNotFoundError("文档不存在")
  552. if not document.is_deleted or document.deleted_at is None:
  553. raise DocumentNotDeletedError(
  554. details={"documentId": serialize_id(document.id)}
  555. )
  556. if document.row_version != expected_version:
  557. raise ConflictError(
  558. "数据已被其他用户修改,请刷新后重试",
  559. details={"currentRowVersion": document.row_version},
  560. )
  561. category: Category | None = None
  562. if document.document_type == DocumentType.MAIN.value:
  563. category = _lock_category(session, document)
  564. active_child_count = session.scalar(
  565. select(func.count(Category.id)).where(
  566. Category.parent_id == category.id,
  567. Category.is_deleted.is_(False),
  568. Category.status == EnabledStatus.ENABLED.value,
  569. )
  570. ) or 0
  571. if active_child_count:
  572. raise RestoreCategoryInvalidError(
  573. details={
  574. "categoryId": serialize_id(category.id),
  575. "reason": "NOT_LEAF",
  576. "childCategoryCount": int(active_child_count),
  577. }
  578. )
  579. if document.document_type == DocumentType.SUB_PLAN.value:
  580. if (
  581. document.parent_document_id is None
  582. or document.root_document_id is None
  583. or document.parent_document_id != document.root_document_id
  584. ):
  585. raise RestoreParentInvalidError(
  586. details={
  587. "parentDocumentId": serialize_id(document.parent_document_id),
  588. "reason": "ROOT_MISMATCH",
  589. }
  590. )
  591. reason = None
  592. if parent is None:
  593. reason = "NOT_FOUND"
  594. elif parent.is_deleted:
  595. reason = "DELETED"
  596. elif parent.document_type != DocumentType.MAIN.value:
  597. reason = "WRONG_TYPE"
  598. if reason:
  599. raise RestoreParentInvalidError(
  600. details={
  601. "parentDocumentId": serialize_id(document.parent_document_id),
  602. "reason": reason,
  603. }
  604. )
  605. category = session.scalar(
  606. select(Category)
  607. .where(Category.id == parent.category_id)
  608. .with_for_update()
  609. )
  610. category_reason = _category_reason(category)
  611. if category_reason:
  612. raise RestoreCategoryInvalidError(
  613. details={
  614. "categoryId": serialize_id(parent.category_id),
  615. "reason": category_reason,
  616. }
  617. )
  618. document.category_id = parent.category_id
  619. document.category_name = parent.category_name
  620. document.category_path = parent.category_path
  621. counts = (0, 0, 0, 0)
  622. now = _now()
  623. deleted_at_before = document.deleted_at
  624. row_version_before = document.row_version
  625. if document.document_type == DocumentType.MAIN.value:
  626. counts = _restore_main_relations(session, document, now)
  627. elif document.document_type == DocumentType.ATTACHMENT.value:
  628. shape_valid = (
  629. document.security_level == SecurityLevel.PUBLIC.value
  630. and document.category_id is None
  631. and document.parent_document_id is None
  632. and document.root_document_id is None
  633. )
  634. if not shape_valid:
  635. raise RestoreRelationConflictError(
  636. details={
  637. "permissionConflictCount": 0,
  638. "bindingConflictCount": 0,
  639. }
  640. )
  641. active_bindings = session.scalars(
  642. select(AttachmentBinding)
  643. .where(
  644. AttachmentBinding.attachment_document_id == document.id,
  645. AttachmentBinding.is_deleted.is_(False),
  646. )
  647. .order_by(AttachmentBinding.id.asc())
  648. .with_for_update()
  649. ).all()
  650. if active_bindings:
  651. raise RestoreRelationConflictError(
  652. details={
  653. "permissionConflictCount": 0,
  654. "bindingConflictCount": len(active_bindings),
  655. }
  656. )
  657. document.is_deleted = False
  658. document.deleted_at = None
  659. document.updated_at = now
  660. document.updated_by = context.user_id
  661. document.updated_by_name = context.real_name
  662. document.row_version += 1
  663. session.flush()
  664. if document.document_type == DocumentType.MAIN.value:
  665. document.child_count = session.scalar(
  666. select(func.count(Document.id)).where(
  667. Document.document_type == DocumentType.SUB_PLAN.value,
  668. Document.parent_document_id == document.id,
  669. Document.root_document_id == document.id,
  670. Document.is_deleted.is_(False),
  671. )
  672. ) or 0
  673. document.attachment_count = session.scalar(
  674. select(func.count(AttachmentBinding.id)).where(
  675. AttachmentBinding.main_document_id == document.id,
  676. AttachmentBinding.is_deleted.is_(False),
  677. )
  678. ) or 0
  679. elif document.document_type == DocumentType.SUB_PLAN.value:
  680. assert parent is not None
  681. parent.child_count = session.scalar(
  682. select(func.count(Document.id)).where(
  683. Document.document_type == DocumentType.SUB_PLAN.value,
  684. Document.parent_document_id == parent.id,
  685. Document.root_document_id == parent.id,
  686. Document.is_deleted.is_(False),
  687. )
  688. ) or 0
  689. parent.row_version += 1
  690. parent.updated_at = now
  691. parent.updated_by = context.user_id
  692. parent.updated_by_name = context.real_name
  693. if category is not None:
  694. _recount_category(session, category, now)
  695. _recheck_file(verified, document)
  696. session.add(
  697. business_audit(
  698. action=AuditAction.RESTORE_DOCUMENT,
  699. target=(
  700. AuditTarget.ATTACHMENT
  701. if document.document_type == DocumentType.ATTACHMENT.value
  702. else AuditTarget.DOCUMENT
  703. ),
  704. target_id=document.id,
  705. target_name=document.document_name,
  706. detail={
  707. "documentType": document.document_type,
  708. "rowVersionBefore": row_version_before,
  709. "rowVersionAfter": document.row_version,
  710. "deletedAtBefore": _iso(deleted_at_before),
  711. "restoredPermissionCount": counts[0],
  712. "skippedPermissionCount": counts[1],
  713. "restoredBindingCount": counts[2],
  714. "skippedBindingCount": counts[3],
  715. "parentDocumentId": serialize_id(document.parent_document_id),
  716. "categoryId": serialize_id(document.category_id),
  717. },
  718. )
  719. )
  720. return document, counts
  721. def _pure_detail(document: Document) -> dict[str, object]:
  722. context = get_auth_context()
  723. if document.document_type == DocumentType.ATTACHMENT.value:
  724. result = attachment_detail(document, context)
  725. else:
  726. result = document_detail(
  727. document,
  728. context,
  729. evaluate_plan_access(document, context),
  730. )
  731. result.pop("fileHash", None)
  732. return result
  733. def restore_document(document_id: int, payload: Any) -> dict[str, object]:
  734. expected_version = _restore_payload(payload)
  735. document = db.session.get(Document, document_id)
  736. if document is None:
  737. raise ResourceNotFoundError("文档不存在")
  738. if document.document_type not in SUPPORTED_TYPES:
  739. raise ResourceNotFoundError("文档不存在或类型不支持")
  740. if not document.is_deleted or document.deleted_at is None:
  741. raise DocumentNotDeletedError(
  742. details={"documentId": serialize_id(document.id)}
  743. )
  744. if document.row_version != expected_version:
  745. raise ConflictError(
  746. "数据已被其他用户修改,请刷新后重试",
  747. details={"currentRowVersion": document.row_version},
  748. )
  749. verified = _verify_file(document)
  750. db.session.rollback()
  751. try:
  752. try:
  753. restored, counts = execute_with_deadlock_retry(
  754. lambda: _restore_operation(document_id, expected_version, verified)
  755. )
  756. except IntegrityError as exc:
  757. db.session.rollback()
  758. raise RestoreRelationConflictError(
  759. details={
  760. "permissionConflictCount": 0,
  761. "bindingConflictCount": 0,
  762. }
  763. ) from exc
  764. result = {
  765. "document": _pure_detail(restored),
  766. "restoredPermissionCount": counts[0],
  767. "skippedPermissionCount": counts[1],
  768. "restoredBindingCount": counts[2],
  769. "skippedBindingCount": counts[3],
  770. }
  771. return result
  772. finally:
  773. verified.close()
  774. __all__ = ["list_recycle_bin_documents", "restore_document"]