dingdanquanliucheng/backend/app/services/storage_service.py

292 lines
11 KiB
Python
Raw Normal View History

"""
文件存储服务模块
职责
封装与阿里云 OSS 对象存储的交互逻辑为上层业务提供统一的文件上传令牌生成
2026-06-15 13:18:43 +08:00
本地文件发布到 OSS对象键构建公开访问 URL 拼装签名上传地址生成等能力
依赖
- backend.app.core.config读取 OSS 相关配置endpointbucketaccess key
- backend.app.core.exceptions统一业务异常
- backend.app.core.error_codes错误码常量
2026-06-15 13:18:43 +08:00
- oss2阿里云 OSS SDK
被引用方
- FileService生成上传令牌解析文件 URL 中的对象键
- CustomerService导入文件时通过对象键解析本地路径
- ReportService发布导出文件到 OSS
"""
from __future__ import annotations
2026-05-15 13:47:08 +08:00
import base64
import hashlib
import hmac
from datetime import datetime, timedelta
from pathlib import Path
from urllib.parse import quote
2026-06-15 13:18:43 +08:00
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 endpointbucketaccess key 等配置
2026-06-15 13:18:43 +08:00
- oss2阿里云 OSS SDK
- AppException / ErrorCode文件不存在等异常场景的统一抛出
"""
def __init__(self) -> None:
self.settings = get_settings()
2026-06-15 13:18:43 +08:00
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_urlpolicyaccess_key_idsignature
object_keybucket_namepublic_base_urlexpire_atcontent_type 等字段
被调用方
- FileService.create_upload_token() API 文件上传令牌接口 /files/upload-token
"""
object_key = self.build_object_key(biz_type, biz_id, file_name)
2026-05-15 13:47:08 +08:00
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"),
2026-05-15 13:47:08 +08:00
"content_type": content_type,
}
def publish_local_file(self, local_file_path: str, object_key: str) -> dict:
2026-06-15 13:18:43 +08:00
"""将本地文件上传到阿里云 OSS。
2026-06-15 13:18:43 +08:00
将后端生成的本地文件如导出的报表文件上传到阿里云 OSS
并返回发布结果元数据包含公开访问 URL
参数
local_file_path (str): 本地文件的绝对路径
object_key (str): OSS 对象键用于确定目标目录结构
返回
2026-06-15 13:18:43 +08:00
dict: 包含 file_urlobject_keybucket_name
storage_providerpublished 等字段
异常
- 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)
2026-06-15 13:18:43 +08:00
# 确定 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) :]
2026-05-15 13:47:08 +08:00
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(
2026-05-15 13:47:08 +08:00
hmac.new(
self.settings.aliyun_oss_access_key_secret.encode("utf-8"),
policy_b64.encode("utf-8"),
2026-05-15 13:47:08 +08:00
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
2026-06-15 13:18:43 +08:00
- publish_local_file()确定上传文件的 Content-Type
"""
2026-05-15 13:47:08 +08:00
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()