recycle_bin_service.py 30 KB

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