voucher_service.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. """B6借阅授权审批单(PDF凭证)的上传、下载与清除。
  2. 凭证与授权条目一对一(0026迁移,契约1.19),读写均要求授权配置位
  3. (CONFIG_PERMISSION)并通过密级与密码锁校验;上传为替换语义,旧凭证
  4. 文件在数据库事务成功后清理。仅主案可操作(与save_permissions同口径)。
  5. """
  6. from __future__ import annotations
  7. import hashlib
  8. import logging
  9. import uuid
  10. from pathlib import Path
  11. from flask import current_app, send_file
  12. from sqlalchemy import select
  13. from werkzeug.datastructures import FileStorage
  14. from dms.common.enums import (
  15. AllowedAction,
  16. AuditAction,
  17. AuditTarget,
  18. DocumentType,
  19. )
  20. from dms.common.errors import (
  21. AttachmentHasNoAclError,
  22. PayloadTooLargeError,
  23. ResourceNotFoundError,
  24. SubPlanPermissionInheritedError,
  25. UnsupportedFileTypeError,
  26. )
  27. from dms.common.response import serialize_id
  28. from dms.database.transaction import execute_with_deadlock_retry, transaction
  29. from dms.extensions import db
  30. from dms.models import LOCK_DEFER_LOB, Document, Permission
  31. from dms.security.auth_context import get_auth_context
  32. from dms.services.audit_service import business_audit
  33. from dms.services.document_password_service import (
  34. DocumentPasswordAction,
  35. get_request_password,
  36. require_document_password,
  37. )
  38. from dms.services.authorization_service import (
  39. require_plan_action,
  40. require_security_clearance,
  41. )
  42. from dms.services.permission_service import _document, _iso, _now
  43. from dms.storage.paths import (
  44. ensure_storage_directories,
  45. resolve_storage_path,
  46. )
  47. from dms.storage.uploads import StagedUpload, _validate
  48. logger = logging.getLogger(__name__)
  49. def _stage_voucher(file: FileStorage) -> StagedUpload:
  50. """暂存审批单并校验:仅允许PDF,大小受DMS_MAX_FILE_SIZE约束。"""
  51. original_name = Path(file.filename or "").name
  52. if not original_name or "." not in original_name:
  53. raise UnsupportedFileTypeError("审批单文件名缺少扩展名")
  54. extension = original_name.rsplit(".", 1)[1].lower()
  55. if extension != "pdf":
  56. raise UnsupportedFileTypeError("审批单凭证只允许PDF文件")
  57. root = ensure_storage_directories(current_app.config["DMS_STORAGE_ROOT"])
  58. temporary = root / "temporary" / f"{uuid.uuid4()}.upload"
  59. final_name = f"{uuid.uuid4()}.pdf"
  60. final = root / "voucher" / final_name
  61. digest = hashlib.sha256()
  62. size = 0
  63. try:
  64. with temporary.open("xb") as stream:
  65. while chunk := file.stream.read(1024 * 1024):
  66. size += len(chunk)
  67. if size > current_app.config["DMS_MAX_FILE_SIZE"]:
  68. raise PayloadTooLargeError("审批单超过允许大小")
  69. digest.update(chunk)
  70. stream.write(chunk)
  71. if size == 0:
  72. raise UnsupportedFileTypeError("不允许上传空文件")
  73. _validate(temporary, "pdf")
  74. return StagedUpload(
  75. temporary_path=temporary,
  76. final_path=final,
  77. relative_path=f"voucher/{final_name}",
  78. original_file_name=original_name,
  79. extension=extension,
  80. mime_type="application/pdf",
  81. file_size=size,
  82. file_hash=digest.hexdigest(),
  83. )
  84. except Exception:
  85. temporary.unlink(missing_ok=True)
  86. raise
  87. def _checked_main(
  88. document_id: int,
  89. *,
  90. lock: bool = False,
  91. ) -> Document:
  92. """定位主案并执行与save_permissions同口径的授权校验。"""
  93. document = _document(document_id, lock=lock)
  94. if document.document_type == DocumentType.ATTACHMENT.value:
  95. raise AttachmentHasNoAclError()
  96. if document.document_type == DocumentType.SUB_PLAN.value:
  97. raise SubPlanPermissionInheritedError()
  98. context = get_auth_context()
  99. require_security_clearance(document, context)
  100. require_plan_action(document, context, AllowedAction.CONFIG_PERMISSION)
  101. require_document_password(
  102. document,
  103. DocumentPasswordAction.CONFIG_PERMISSION.value,
  104. get_request_password(),
  105. context,
  106. )
  107. return document
  108. def _locked_entry(source_id: int, entry_id: int) -> Permission:
  109. statement = (
  110. select(Permission)
  111. .where(
  112. Permission.id == entry_id,
  113. Permission.document_id == source_id,
  114. Permission.is_deleted.is_(False),
  115. )
  116. .with_for_update()
  117. )
  118. entry = db.session.scalar(statement)
  119. if entry is None:
  120. raise ResourceNotFoundError("授权条目不存在")
  121. return entry
  122. def _remove_storage_file(relative_path: str) -> None:
  123. """清理存储文件;失败仅记录日志,不影响业务结果。"""
  124. try:
  125. path = resolve_storage_path(
  126. relative_path, current_app.config["DMS_STORAGE_ROOT"]
  127. )
  128. path.unlink(missing_ok=True)
  129. except OSError:
  130. logger.exception("清理审批单文件失败:%s", relative_path)
  131. def upload_voucher(
  132. document_id: int,
  133. entry_id: int,
  134. file: FileStorage,
  135. ) -> dict[str, object]:
  136. staged = _stage_voucher(file)
  137. context = get_auth_context()
  138. replaced: list[str] = []
  139. def operation() -> dict[str, object]:
  140. with transaction() as session:
  141. document = _checked_main(document_id, lock=True)
  142. entry = _locked_entry(document.id, entry_id)
  143. if entry.voucher_storage_path:
  144. replaced.append(entry.voucher_storage_path)
  145. now = _now()
  146. entry.voucher_file_name = staged.original_file_name
  147. entry.voucher_storage_path = staged.relative_path
  148. entry.voucher_file_size = staged.file_size
  149. entry.voucher_uploaded_at = now
  150. entry.updated_at = now
  151. entry.updated_by = context.user_id
  152. entry.row_version += 1
  153. session.add(
  154. business_audit(
  155. action=AuditAction.UPLOAD_PERMISSION_VOUCHER,
  156. target=AuditTarget.PERMISSION,
  157. target_id=document.id,
  158. target_name=document.document_name,
  159. detail={
  160. "mainPlanId": serialize_id(document.id),
  161. "entryId": serialize_id(entry.id),
  162. "subjectId": serialize_id(entry.subject_id),
  163. "subjectName": entry.subject_name,
  164. "voucherFileName": staged.original_file_name,
  165. "voucherFileSize": staged.file_size,
  166. },
  167. )
  168. )
  169. return {
  170. "documentId": serialize_id(document.id),
  171. "entryId": serialize_id(entry.id),
  172. "voucherFileName": staged.original_file_name,
  173. "voucherFileSize": staged.file_size,
  174. "voucherUploadedAt": _iso(now),
  175. }
  176. try:
  177. # 文件提升放数据库事务成功之后:死锁重试只重放纯DB操作,
  178. # 避免promote重入;提交后未及提升的极端窗口可通过重新上传修复。
  179. result = execute_with_deadlock_retry(operation)
  180. staged.promote()
  181. except Exception:
  182. staged.cleanup_all()
  183. raise
  184. for relative_path in set(replaced):
  185. if relative_path != staged.relative_path:
  186. _remove_storage_file(relative_path)
  187. return result
  188. def download_voucher(document_id: int, entry_id: int):
  189. document = _checked_main(document_id)
  190. statement = select(Permission).where(
  191. Permission.id == entry_id,
  192. Permission.document_id == document.id,
  193. Permission.is_deleted.is_(False),
  194. )
  195. entry = db.session.scalar(statement)
  196. if entry is None:
  197. raise ResourceNotFoundError("授权条目不存在")
  198. if not entry.voucher_storage_path:
  199. raise ResourceNotFoundError("该授权未上传审批单")
  200. path = resolve_storage_path(
  201. entry.voucher_storage_path, current_app.config["DMS_STORAGE_ROOT"]
  202. )
  203. if not path.is_file():
  204. raise ResourceNotFoundError("审批单文件不存在或已被清理")
  205. response = send_file(
  206. path,
  207. mimetype="application/pdf",
  208. as_attachment=True,
  209. download_name=entry.voucher_file_name or "voucher.pdf",
  210. conditional=True,
  211. )
  212. response.headers["X-Content-Type-Options"] = "nosniff"
  213. return response
  214. def delete_voucher(document_id: int, entry_id: int) -> dict[str, object]:
  215. context = get_auth_context()
  216. removed: list[str] = []
  217. def operation() -> dict[str, object]:
  218. with transaction() as session:
  219. document = _checked_main(document_id, lock=True)
  220. entry = _locked_entry(document.id, entry_id)
  221. if not entry.voucher_storage_path:
  222. raise ResourceNotFoundError("该授权未上传审批单")
  223. removed.append(entry.voucher_storage_path)
  224. now = _now()
  225. cleared_file_name = entry.voucher_file_name
  226. entry.voucher_file_name = None
  227. entry.voucher_storage_path = None
  228. entry.voucher_file_size = None
  229. entry.voucher_uploaded_at = None
  230. entry.updated_at = now
  231. entry.updated_by = context.user_id
  232. entry.row_version += 1
  233. session.add(
  234. business_audit(
  235. action=AuditAction.DELETE_PERMISSION_VOUCHER,
  236. target=AuditTarget.PERMISSION,
  237. target_id=document.id,
  238. target_name=document.document_name,
  239. detail={
  240. "mainPlanId": serialize_id(document.id),
  241. "entryId": serialize_id(entry.id),
  242. "subjectId": serialize_id(entry.subject_id),
  243. "subjectName": entry.subject_name,
  244. "voucherFileName": cleared_file_name,
  245. },
  246. )
  247. )
  248. return {
  249. "documentId": serialize_id(document.id),
  250. "entryId": serialize_id(entry.id),
  251. }
  252. result = execute_with_deadlock_retry(operation)
  253. for relative_path in set(removed):
  254. _remove_storage_file(relative_path)
  255. return result
  256. __all__ = ["delete_voucher", "download_voucher", "upload_voucher"]