dingdanquanliucheng/backend/app/services/storage_service.py
taiyi 9b32afdbb4 为后端全部 86 个 Python 文件添加中文注释
覆盖所有模块:
- api 层:17 个路由文件,每个接口标注用途、参数、返回值、权限
- services 层:18 个服务文件,每个方法标注作用、参数、返回值、调用方
- repositories 层:13 个仓储文件,每个方法标注查询逻辑和被调用方
- schemas 层:11 个请求/响应体文件,每个字段标注业务含义
- core 层:config、security、exceptions、responses、error_codes
- models 层:19 个 ORM 模型类,每个表标注业务含义和关联关系
- scripts:bootstrap_data、smoke_check
- migrations:env.py 和版本迁移文件

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 07:23:33 +08:00

259 lines
10 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 对象存储的交互逻辑,为上层业务提供统一的文件上传令牌生成、
本地文件发布、对象键构建、公开访问 URL 拼装、签名上传地址生成等能力。
依赖:
- backend.app.core.config读取 OSS 相关配置endpoint、bucket、access key 等)
- backend.app.core.exceptions统一业务异常
- backend.app.core.error_codes错误码常量
被引用方:
- 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
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 等配置
- AppException / ErrorCode文件不存在等异常场景的统一抛出
"""
def __init__(self) -> None:
self.settings = get_settings()
self.export_publish_root = Path("D:/tmp/order-flow-oss")
self.export_publish_root.mkdir(parents=True, exist_ok=True)
def create_upload_token(self, biz_type: str, biz_id: int, file_name: str, file_size: int) -> dict:
"""生成前端直传 OSS 的上传令牌。
根据业务类型、业务 ID、文件名等信息构建对象键并生成带签名的上传地址
供前端使用 PUT 方法直接上传文件到阿里云 OSS。
参数:
biz_type (str): 业务类型标识,用于构建对象键前缀(如 "file""smoke"
biz_id (int): 业务记录 ID用于构建对象键中间段
file_name (str): 原始文件名,用于提取扩展名和构建安全文件名
file_size (int): 文件大小上限(字节),返回给前端做校验
返回:
dict: 包含 upload_url、file_url、headers、method、object_key、
bucket_name、expire_at、max_file_size、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)
signed_meta = self._build_signed_upload_url(object_key, expire_at, content_type)
return {
"upload_url": signed_meta["upload_url"],
"file_url": self.build_public_file_url(object_key),
"headers": signed_meta["headers"],
"method": "PUT",
"object_key": object_key,
"bucket_name": self.settings.aliyun_oss_bucket,
"expire_at": expire_at.strftime("%Y-%m-%d %H:%M:%S"),
"max_file_size": file_size,
"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_path、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)
target = self.export_publish_root / Path(object_key)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(source.read_bytes())
return {
"file_path": str(target),
"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_signed_upload_url(self, object_key: str, expire_at: datetime, content_type: str) -> dict:
"""构建阿里云 OSS V1 预签名上传地址。
按照 OSS V1 签名规则,使用 HMAC-SHA1 对请求信息进行签名,
生成带过期时间的上传 URL前端可直接使用 PUT 方法上传文件。
参数:
object_key (str): OSS 对象键
expire_at (datetime): 签名过期时间
content_type (str): 文件 MIME 类型
返回:
dict: 包含 upload_url签名后的完整上传地址和 headers请求头字段
被调用方:
- create_upload_token():生成上传令牌时调用
"""
# 当前按 OSS V1 预签名 URL 规则生成上传地址,便于前端直接联调真实 PUT 上传口径。
expires = str(int(expire_at.timestamp()))
encoded_key = quote(object_key.replace("\\", "/"), safe="/-_.*()")
canonical_headers = "x-oss-object-acl:private\n"
canonical_resource = f"/{self.settings.aliyun_oss_bucket}/{object_key}"
string_to_sign = f"PUT\n\n{content_type}\n{expires}\n{canonical_headers}{canonical_resource}"
signature = base64.b64encode(
hmac.new(
self.settings.aliyun_oss_access_key_secret.encode("utf-8"),
string_to_sign.encode("utf-8"),
hashlib.sha1,
).digest()
).decode("utf-8")
query_signature = quote(signature, safe="")
upload_url = (
f"https://{self.settings.aliyun_oss_bucket}.{self.settings.aliyun_oss_endpoint}/{encoded_key}"
f"?OSSAccessKeyId={quote(self.settings.aliyun_oss_access_key_id, safe='')}"
f"&Expires={expires}"
f"&Signature={query_signature}"
)
return {
"upload_url": upload_url,
"headers": {
"Content-Type": content_type,
"x-oss-object-acl": "private",
},
}
def _guess_content_type(self, file_name: str) -> str:
"""根据文件扩展名猜测 MIME 类型。
内置常见图片、视频、文档格式的映射关系,未知扩展名默认返回
application/octet-stream。
参数:
file_name (str): 文件名(含扩展名)
返回:
str: 对应的 MIME 类型字符串
被调用方:
- create_upload_token():确定上传文件的 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()