dingdanquanliucheng/backend/app/services/storage_service.py

292 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
文件存储服务模块。
职责:
封装与阿里云 OSS 对象存储的交互逻辑,为上层业务提供统一的文件上传令牌生成、
本地文件发布到 OSS、对象键构建、公开访问 URL 拼装、签名上传地址生成等能力。
依赖:
- backend.app.core.config读取 OSS 相关配置endpoint、bucket、access key 等)
- backend.app.core.exceptions统一业务异常
- backend.app.core.error_codes错误码常量
- oss2阿里云 OSS SDK
被引用方:
- FileService生成上传令牌、解析文件 URL 中的对象键
- CustomerService导入文件时通过对象键解析本地路径
- ReportService发布导出文件到 OSS
"""
from __future__ import annotations
import base64
import hashlib
import hmac
from datetime import datetime, timedelta
from pathlib import Path
from urllib.parse import quote
import oss2
from backend.app.core.config import get_settings
from backend.app.core.error_codes import ErrorCode
from backend.app.core.exceptions import AppException
class StorageService:
"""阿里云 OSS 文件存储服务。
负责生成前端直传 OSS 的签名上传地址、将本地生成的文件发布到 OSS 目录、
以及提供对象键与公开访问 URL 之间的双向转换。
依赖:
- get_settings():获取 OSS endpoint、bucket、access key 等配置
- oss2阿里云 OSS SDK
- AppException / ErrorCode文件不存在等异常场景的统一抛出
"""
def __init__(self) -> None:
self.settings = get_settings()
self._bucket = None
@property
def bucket(self) -> oss2.Bucket:
"""懒加载 OSS Bucket 实例"""
if self._bucket is None:
auth = oss2.Auth(
self.settings.aliyun_oss_access_key_id,
self.settings.aliyun_oss_access_key_secret,
)
self._bucket = oss2.Bucket(
auth,
self.settings.aliyun_oss_endpoint,
self.settings.aliyun_oss_bucket,
)
return self._bucket
def create_upload_token(self, biz_type: str, biz_id: int, file_name: str, file_size: int) -> dict:
"""生成前端直传 OSS 的 POST 表单上传令牌。
根据业务类型、业务 ID、文件名等信息构建对象键并生成带签名的 POST policy
供前端使用 POST 表单方式直接上传文件到阿里云 OSS。
参数:
biz_type (str): 业务类型标识,用于构建对象键前缀
biz_id (int): 业务记录 ID用于构建对象键中间段
file_name (str): 原始文件名,用于提取扩展名和构建安全文件名
file_size (int): 文件大小上限(字节)
返回:
dict: 包含 upload_url、policy、access_key_id、signature、
object_key、bucket_name、public_base_url、expire_at、content_type 等字段
被调用方:
- FileService.create_upload_token() → API 文件上传令牌接口 /files/upload-token
"""
object_key = self.build_object_key(biz_type, biz_id, file_name)
expire_at = datetime.now() + timedelta(seconds=self.settings.oss_signed_url_expire_seconds)
content_type = self._guess_content_type(file_name)
policy_b64 = self._build_post_policy(object_key, expire_at, file_size)
signature = self._sign_post_policy(policy_b64)
upload_url = (
f"https://{self.settings.aliyun_oss_bucket}"
f".{self.settings.aliyun_oss_endpoint}/"
)
return {
"upload_url": upload_url,
"policy": policy_b64,
"access_key_id": self.settings.aliyun_oss_access_key_id,
"signature": signature,
"object_key": object_key,
"bucket_name": self.settings.aliyun_oss_bucket,
"public_base_url": self.settings.aliyun_oss_public_base_url,
"expire_at": expire_at.strftime("%Y-%m-%d %H:%M:%S"),
"content_type": content_type,
}
def publish_local_file(self, local_file_path: str, object_key: str) -> dict:
"""将本地文件上传到阿里云 OSS。
将后端生成的本地文件(如导出的报表文件)上传到阿里云 OSS
并返回发布结果元数据,包含公开访问 URL。
参数:
local_file_path (str): 本地文件的绝对路径
object_key (str): OSS 对象键,用于确定目标目录结构
返回:
dict: 包含 file_url、object_key、bucket_name、
storage_provider、published 等字段
异常:
- AppException当待发布文件不存在时抛出 FILE_UPLOAD_FAILED 错误
被调用方:
- ReportService.export_performance() → 业绩报表导出流程
"""
source = Path(local_file_path)
if not source.exists() or not source.is_file():
raise AppException(code=ErrorCode.FILE_UPLOAD_FAILED, message="待发布文件不存在", status_code=400)
# 确定 Content-Type
content_type = self._guess_content_type(source.name)
# 上传到阿里云 OSS
headers = {
"Content-Type": content_type,
"Content-Disposition": f'attachment; filename="{source.name}"',
}
with open(source, "rb") as f:
self.bucket.put_object(object_key, f, headers=headers)
return {
"file_url": self.build_public_file_url(object_key),
"object_key": object_key,
"bucket_name": self.settings.aliyun_oss_bucket,
"storage_provider": "aliyun_oss",
"published": True,
}
def build_object_key(self, biz_type: str, biz_id: int, file_name: str) -> str:
"""构建 OSS 对象键。
按照 "{业务类型}/{业务ID}/{时间戳}_{安全文件名}" 的格式生成对象键,
时间戳精确到秒,确保同一文件多次上传不会覆盖。
参数:
biz_type (str): 业务类型标识
biz_id (int): 业务记录 ID
file_name (str): 原始文件名
返回:
str: 格式化后的对象键字符串
被调用方:
- create_upload_token():生成上传令牌时构建对象键
"""
safe_name = Path(file_name).name
return f"{biz_type}/{biz_id}/{datetime.now().strftime('%Y%m%d%H%M%S')}_{safe_name}"
def build_public_file_url(self, object_key: str) -> str:
"""根据对象键拼装文件的公开访问 URL。
将对象键拼接到 OSS 公开基础 URL 之后,对特殊字符进行 URL 编码。
参数:
object_key (str): OSS 对象键
返回:
str: 完整的公开访问 URL
被调用方:
- create_upload_token():返回给前端的 file_url 字段
- publish_local_file():返回给调用方的 file_url 字段
"""
encoded_key = quote(object_key.replace("\\", "/"), safe="/-_.*()")
return f"{self.settings.aliyun_oss_public_base_url.rstrip('/')}/{encoded_key}"
def extract_object_key(self, file_url: str) -> str:
"""从文件 URL 中提取 OSS 对象键。
支持两种格式:以公开基础 URL 开头的完整 URL以及其他格式的路径
自动去除协议和域名前缀,返回纯对象键。
参数:
file_url (str): 文件 URL 字符串
返回:
str: 提取出的对象键
被调用方:
- FileService.save_attachment():解析附件 URL 获取对象键
- CustomerService._resolve_file_path():解析导入文件路径
"""
prefix = f"{self.settings.aliyun_oss_public_base_url.rstrip('/')}/"
if file_url.startswith(prefix):
return file_url[len(prefix) :]
return file_url.split("://", 1)[-1].split("/", 1)[-1] if "/" in file_url.split("://", 1)[-1] else file_url
def _build_post_policy(self, object_key: str, expire_at: datetime, file_size: int) -> str:
"""构建阿里云 OSS POST policy 并 base64 编码。
policy 包含过期时间、目标 bucket、文件 key 和 Content-Type 约束,
供前端 POST 表单直传时携带OSS 服务端会校验 policy 合法性。
参数:
object_key: OSS 对象键
expire_at: 过期时间
file_size: 文件大小上限(字节)
返回:
base64 编码的 policy 字符串
被调用方:
- create_upload_token():生成上传令牌时调用
"""
import json as _json
policy = {
"expiration": expire_at.strftime("%Y-%m-%dT%H:%M:%SZ"),
"conditions": [
{"bucket": self.settings.aliyun_oss_bucket},
["eq", "$key", object_key],
["content-length-range", 1, file_size],
],
}
return base64.b64encode(_json.dumps(policy).encode("utf-8")).decode("utf-8")
def _sign_post_policy(self, policy_b64: str) -> str:
"""使用 access_key_secret 对 base64 编码的 policy 进行 HMAC-SHA1 签名。
参数:
policy_b64: base64 编码的 policy 字符串
返回:
base64 编码的签名字符串
被调用方:
- create_upload_token():生成上传令牌时调用
"""
return base64.b64encode(
hmac.new(
self.settings.aliyun_oss_access_key_secret.encode("utf-8"),
policy_b64.encode("utf-8"),
hashlib.sha1,
).digest()
).decode("utf-8")
def _guess_content_type(self, file_name: str) -> str:
"""根据文件扩展名猜测 MIME 类型。
内置常见图片、视频、文档格式的映射关系,未知扩展名默认返回
application/octet-stream。
参数:
file_name (str): 文件名(含扩展名)
返回:
str: 对应的 MIME 类型字符串
被调用方:
- create_upload_token():确定上传文件的 Content-Type
- publish_local_file():确定上传文件的 Content-Type
"""
suffix = Path(file_name).suffix.lower()
mapping = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".mp4": "video/mp4",
".mov": "video/quicktime",
".avi": "video/x-msvideo",
".csv": "text/csv",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".xls": "application/vnd.ms-excel",
".pdf": "application/pdf",
}
return mapping.get(suffix, "application/octet-stream")
storage_service = StorageService()