""" 文件存储服务模块。 职责: 封装与阿里云 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()