storage.py 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. """报销附件存储适配 — 复用门户 app.core.storage,对外保持 bx 协议。"""
  2. from __future__ import annotations
  3. from datetime import timedelta
  4. from io import BytesIO
  5. from pathlib import Path
  6. from app.core.config import settings
  7. from app.core.storage import LocalStorage, MinIOStorage, get_storage, storage
  8. def normalize_storage_path(file_url: str) -> str:
  9. """将 upload 返回的完整 URL 归一化为存储相对路径(与 admin 附件 upload 一致)。"""
  10. u = (file_url or "").strip()
  11. if not u:
  12. return ""
  13. if "?" in u:
  14. u = u.split("?", 1)[0]
  15. base_url = (settings.MINIO_BASE_URL or "").rstrip("/")
  16. if base_url and u.startswith(base_url + "/"):
  17. return u[len(base_url) + 1 :]
  18. local_base = settings.LOCAL_STORAGE_URL.rstrip("/")
  19. if u.startswith(local_base + "/"):
  20. return u[len(local_base) + 1 :]
  21. if u.startswith("http://") or u.startswith("https://"):
  22. store = get_storage()
  23. if isinstance(store, MinIOStorage):
  24. return store._extract_object_key(u)
  25. return u.lstrip("/")
  26. def upload_bx_file(file_content: bytes, filename: str, *, biz_type: str = "") -> tuple[str, str]:
  27. """
  28. 上传文件到对象存储。
  29. 返回 (relative_path, access_url)。
  30. """
  31. folder = "bx"
  32. if biz_type:
  33. folder = f"bx/{biz_type.strip('/')}"
  34. full_url = storage.upload_file(file_content, filename, folder)
  35. relative_path = normalize_storage_path(full_url)
  36. return relative_path, full_url
  37. def get_access_url(file_path: str) -> str:
  38. """获取可访问 URL(本地/MinIO 公开地址)。"""
  39. store = get_storage()
  40. key = normalize_storage_path(file_path)
  41. if not key:
  42. return ""
  43. return store.get_url(key)
  44. def get_preview_url(file_path: str, *, expires_seconds: int = 3600) -> str:
  45. """图片/附件预览 URL:MinIO 预签名 inline,本地/其他存储用 get_url。"""
  46. store = get_storage()
  47. key = normalize_storage_path(file_path)
  48. if not key:
  49. return ""
  50. if isinstance(store, MinIOStorage):
  51. return store.client.presigned_get_object(
  52. bucket_name=store.bucket_name,
  53. object_name=key,
  54. expires=timedelta(seconds=expires_seconds),
  55. )
  56. return store.get_url(key)
  57. def get_download_url(file_path: str, file_name: str, *, expires_seconds: int = 3600) -> str:
  58. """带文件名的下载 URL(对齐门户 attachment/download-url)。"""
  59. store = get_storage()
  60. key = normalize_storage_path(file_path)
  61. return store.presigned_download_url(key, file_name, expires_seconds)
  62. def download_file_bytes(file_path: str) -> bytes:
  63. """读取文件二进制,供 StreamingResponse 下载。"""
  64. store = get_storage()
  65. key = normalize_storage_path(file_path)
  66. if not key:
  67. raise ValueError("无效的文件路径")
  68. if isinstance(store, MinIOStorage):
  69. response = store.client.get_object(store.bucket_name, key)
  70. try:
  71. return response.read()
  72. finally:
  73. response.close()
  74. response.release_conn()
  75. if isinstance(store, LocalStorage):
  76. file_full_path = Path(store.upload_dir) / key
  77. if not file_full_path.is_file():
  78. raise FileNotFoundError(f"文件不存在: {key}")
  79. return file_full_path.read_bytes()
  80. # 七牛/OSS 等:回退 HTTP 拉取(开发环境较少用到)
  81. import urllib.request
  82. url = store.get_url(key)
  83. with urllib.request.urlopen(url, timeout=60) as resp:
  84. return resp.read()
  85. def file_stream(file_path: str) -> BytesIO:
  86. return BytesIO(download_file_bytes(file_path))