recycle_bin_service.py 31 KB

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