| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282 |
- """B6借阅授权审批单(PDF凭证)的上传、下载与清除。
- 凭证与授权条目一对一(0026迁移,契约1.19),读写均要求授权配置位
- (CONFIG_PERMISSION)并通过密级与密码锁校验;上传为替换语义,旧凭证
- 文件在数据库事务成功后清理。仅主案可操作(与save_permissions同口径)。
- """
- from __future__ import annotations
- import hashlib
- import logging
- import uuid
- from pathlib import Path
- from flask import current_app, send_file
- from sqlalchemy import select
- from werkzeug.datastructures import FileStorage
- from dms.common.enums import (
- AllowedAction,
- AuditAction,
- AuditTarget,
- DocumentType,
- )
- from dms.common.errors import (
- AttachmentHasNoAclError,
- PayloadTooLargeError,
- ResourceNotFoundError,
- SubPlanPermissionInheritedError,
- UnsupportedFileTypeError,
- )
- from dms.common.response import serialize_id
- from dms.database.transaction import execute_with_deadlock_retry, transaction
- from dms.extensions import db
- from dms.models import LOCK_DEFER_LOB, Document, Permission
- from dms.security.auth_context import get_auth_context
- from dms.services.audit_service import business_audit
- from dms.services.document_password_service import (
- DocumentPasswordAction,
- get_request_password,
- require_document_password,
- )
- from dms.services.authorization_service import (
- require_plan_action,
- require_security_clearance,
- )
- from dms.services.permission_service import _document, _iso, _now
- from dms.storage.paths import (
- ensure_storage_directories,
- resolve_storage_path,
- )
- from dms.storage.uploads import StagedUpload, _validate
- logger = logging.getLogger(__name__)
- def _stage_voucher(file: FileStorage) -> StagedUpload:
- """暂存审批单并校验:仅允许PDF,大小受DMS_MAX_FILE_SIZE约束。"""
- original_name = Path(file.filename or "").name
- if not original_name or "." not in original_name:
- raise UnsupportedFileTypeError("审批单文件名缺少扩展名")
- extension = original_name.rsplit(".", 1)[1].lower()
- if extension != "pdf":
- raise UnsupportedFileTypeError("审批单凭证只允许PDF文件")
- root = ensure_storage_directories(current_app.config["DMS_STORAGE_ROOT"])
- temporary = root / "temporary" / f"{uuid.uuid4()}.upload"
- final_name = f"{uuid.uuid4()}.pdf"
- final = root / "voucher" / final_name
- digest = hashlib.sha256()
- size = 0
- try:
- with temporary.open("xb") as stream:
- while chunk := file.stream.read(1024 * 1024):
- size += len(chunk)
- if size > current_app.config["DMS_MAX_FILE_SIZE"]:
- raise PayloadTooLargeError("审批单超过允许大小")
- digest.update(chunk)
- stream.write(chunk)
- if size == 0:
- raise UnsupportedFileTypeError("不允许上传空文件")
- _validate(temporary, "pdf")
- return StagedUpload(
- temporary_path=temporary,
- final_path=final,
- relative_path=f"voucher/{final_name}",
- original_file_name=original_name,
- extension=extension,
- mime_type="application/pdf",
- file_size=size,
- file_hash=digest.hexdigest(),
- )
- except Exception:
- temporary.unlink(missing_ok=True)
- raise
- def _checked_main(
- document_id: int,
- *,
- lock: bool = False,
- ) -> Document:
- """定位主案并执行与save_permissions同口径的授权校验。"""
- document = _document(document_id, lock=lock)
- if document.document_type == DocumentType.ATTACHMENT.value:
- raise AttachmentHasNoAclError()
- if document.document_type == DocumentType.SUB_PLAN.value:
- raise SubPlanPermissionInheritedError()
- context = get_auth_context()
- require_security_clearance(document, context)
- require_plan_action(document, context, AllowedAction.CONFIG_PERMISSION)
- require_document_password(
- document,
- DocumentPasswordAction.CONFIG_PERMISSION.value,
- get_request_password(),
- context,
- )
- return document
- def _locked_entry(source_id: int, entry_id: int) -> Permission:
- statement = (
- select(Permission)
- .where(
- Permission.id == entry_id,
- Permission.document_id == source_id,
- Permission.is_deleted.is_(False),
- )
- .with_for_update()
- )
- entry = db.session.scalar(statement)
- if entry is None:
- raise ResourceNotFoundError("授权条目不存在")
- return entry
- def _remove_storage_file(relative_path: str) -> None:
- """清理存储文件;失败仅记录日志,不影响业务结果。"""
- try:
- path = resolve_storage_path(
- relative_path, current_app.config["DMS_STORAGE_ROOT"]
- )
- path.unlink(missing_ok=True)
- except OSError:
- logger.exception("清理审批单文件失败:%s", relative_path)
- def upload_voucher(
- document_id: int,
- entry_id: int,
- file: FileStorage,
- ) -> dict[str, object]:
- staged = _stage_voucher(file)
- context = get_auth_context()
- replaced: list[str] = []
- def operation() -> dict[str, object]:
- with transaction() as session:
- document = _checked_main(document_id, lock=True)
- entry = _locked_entry(document.id, entry_id)
- if entry.voucher_storage_path:
- replaced.append(entry.voucher_storage_path)
- now = _now()
- entry.voucher_file_name = staged.original_file_name
- entry.voucher_storage_path = staged.relative_path
- entry.voucher_file_size = staged.file_size
- entry.voucher_uploaded_at = now
- entry.updated_at = now
- entry.updated_by = context.user_id
- entry.row_version += 1
- session.add(
- business_audit(
- action=AuditAction.UPLOAD_PERMISSION_VOUCHER,
- target=AuditTarget.PERMISSION,
- target_id=document.id,
- target_name=document.document_name,
- detail={
- "mainPlanId": serialize_id(document.id),
- "entryId": serialize_id(entry.id),
- "subjectId": serialize_id(entry.subject_id),
- "subjectName": entry.subject_name,
- "voucherFileName": staged.original_file_name,
- "voucherFileSize": staged.file_size,
- },
- )
- )
- return {
- "documentId": serialize_id(document.id),
- "entryId": serialize_id(entry.id),
- "voucherFileName": staged.original_file_name,
- "voucherFileSize": staged.file_size,
- "voucherUploadedAt": _iso(now),
- }
- try:
- # 文件提升放数据库事务成功之后:死锁重试只重放纯DB操作,
- # 避免promote重入;提交后未及提升的极端窗口可通过重新上传修复。
- result = execute_with_deadlock_retry(operation)
- staged.promote()
- except Exception:
- staged.cleanup_all()
- raise
- for relative_path in set(replaced):
- if relative_path != staged.relative_path:
- _remove_storage_file(relative_path)
- return result
- def download_voucher(document_id: int, entry_id: int):
- document = _checked_main(document_id)
- statement = select(Permission).where(
- Permission.id == entry_id,
- Permission.document_id == document.id,
- Permission.is_deleted.is_(False),
- )
- entry = db.session.scalar(statement)
- if entry is None:
- raise ResourceNotFoundError("授权条目不存在")
- if not entry.voucher_storage_path:
- raise ResourceNotFoundError("该授权未上传审批单")
- path = resolve_storage_path(
- entry.voucher_storage_path, current_app.config["DMS_STORAGE_ROOT"]
- )
- if not path.is_file():
- raise ResourceNotFoundError("审批单文件不存在或已被清理")
- response = send_file(
- path,
- mimetype="application/pdf",
- as_attachment=True,
- download_name=entry.voucher_file_name or "voucher.pdf",
- conditional=True,
- )
- response.headers["X-Content-Type-Options"] = "nosniff"
- return response
- def delete_voucher(document_id: int, entry_id: int) -> dict[str, object]:
- context = get_auth_context()
- removed: list[str] = []
- def operation() -> dict[str, object]:
- with transaction() as session:
- document = _checked_main(document_id, lock=True)
- entry = _locked_entry(document.id, entry_id)
- if not entry.voucher_storage_path:
- raise ResourceNotFoundError("该授权未上传审批单")
- removed.append(entry.voucher_storage_path)
- now = _now()
- cleared_file_name = entry.voucher_file_name
- entry.voucher_file_name = None
- entry.voucher_storage_path = None
- entry.voucher_file_size = None
- entry.voucher_uploaded_at = None
- entry.updated_at = now
- entry.updated_by = context.user_id
- entry.row_version += 1
- session.add(
- business_audit(
- action=AuditAction.DELETE_PERMISSION_VOUCHER,
- target=AuditTarget.PERMISSION,
- target_id=document.id,
- target_name=document.document_name,
- detail={
- "mainPlanId": serialize_id(document.id),
- "entryId": serialize_id(entry.id),
- "subjectId": serialize_id(entry.subject_id),
- "subjectName": entry.subject_name,
- "voucherFileName": cleared_file_name,
- },
- )
- )
- return {
- "documentId": serialize_id(document.id),
- "entryId": serialize_id(entry.id),
- }
- result = execute_with_deadlock_retry(operation)
- for relative_path in set(removed):
- _remove_storage_file(relative_path)
- return result
- __all__ = ["delete_voucher", "download_voucher", "upload_voucher"]
|