2026-05-30 07:23:33 +08:00
|
|
|
|
"""
|
|
|
|
|
|
文件存储服务模块。
|
|
|
|
|
|
|
|
|
|
|
|
职责:
|
|
|
|
|
|
封装与阿里云 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
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-05-15 13:17:45 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-05-15 13:47:08 +08:00
|
|
|
|
import base64
|
|
|
|
|
|
import hashlib
|
|
|
|
|
|
import hmac
|
2026-05-15 13:17:45 +08:00
|
|
|
|
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:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""阿里云 OSS 文件存储服务。
|
|
|
|
|
|
|
|
|
|
|
|
负责生成前端直传 OSS 的签名上传地址、将本地生成的文件发布到 OSS 目录、
|
|
|
|
|
|
以及提供对象键与公开访问 URL 之间的双向转换。
|
|
|
|
|
|
|
|
|
|
|
|
依赖:
|
|
|
|
|
|
- get_settings():获取 OSS endpoint、bucket、access key 等配置
|
|
|
|
|
|
- AppException / ErrorCode:文件不存在等异常场景的统一抛出
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-05-15 13:17:45 +08:00
|
|
|
|
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:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""生成前端直传 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
|
|
|
|
|
|
"""
|
2026-05-15 13:17:45 +08:00
|
|
|
|
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)
|
|
|
|
|
|
signed_meta = self._build_signed_upload_url(object_key, expire_at, content_type)
|
2026-05-15 13:17:45 +08:00
|
|
|
|
return {
|
2026-05-15 13:47:08 +08:00
|
|
|
|
"upload_url": signed_meta["upload_url"],
|
2026-05-15 13:17:45 +08:00
|
|
|
|
"file_url": self.build_public_file_url(object_key),
|
2026-05-15 13:47:08 +08:00
|
|
|
|
"headers": signed_meta["headers"],
|
2026-05-15 13:17:45 +08:00
|
|
|
|
"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,
|
2026-05-15 13:47:08 +08:00
|
|
|
|
"content_type": content_type,
|
2026-05-15 13:17:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def publish_local_file(self, local_file_path: str, object_key: str) -> dict:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""将本地文件发布到 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() → 业绩报表导出流程
|
|
|
|
|
|
"""
|
2026-05-15 13:17:45 +08:00
|
|
|
|
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:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""构建 OSS 对象键。
|
|
|
|
|
|
|
|
|
|
|
|
按照 "{业务类型}/{业务ID}/{时间戳}_{安全文件名}" 的格式生成对象键,
|
|
|
|
|
|
时间戳精确到秒,确保同一文件多次上传不会覆盖。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
biz_type (str): 业务类型标识
|
|
|
|
|
|
biz_id (int): 业务记录 ID
|
|
|
|
|
|
file_name (str): 原始文件名
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
str: 格式化后的对象键字符串
|
|
|
|
|
|
|
|
|
|
|
|
被调用方:
|
|
|
|
|
|
- create_upload_token():生成上传令牌时构建对象键
|
|
|
|
|
|
"""
|
2026-05-15 13:17:45 +08:00
|
|
|
|
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:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""根据对象键拼装文件的公开访问 URL。
|
|
|
|
|
|
|
|
|
|
|
|
将对象键拼接到 OSS 公开基础 URL 之后,对特殊字符进行 URL 编码。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
object_key (str): OSS 对象键
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
str: 完整的公开访问 URL
|
|
|
|
|
|
|
|
|
|
|
|
被调用方:
|
|
|
|
|
|
- create_upload_token():返回给前端的 file_url 字段
|
|
|
|
|
|
- publish_local_file():返回给调用方的 file_url 字段
|
|
|
|
|
|
"""
|
2026-05-15 13:17:45 +08:00
|
|
|
|
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:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""从文件 URL 中提取 OSS 对象键。
|
|
|
|
|
|
|
|
|
|
|
|
支持两种格式:以公开基础 URL 开头的完整 URL,以及其他格式的路径,
|
|
|
|
|
|
自动去除协议和域名前缀,返回纯对象键。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
file_url (str): 文件 URL 字符串
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
str: 提取出的对象键
|
|
|
|
|
|
|
|
|
|
|
|
被调用方:
|
|
|
|
|
|
- FileService.save_attachment():解析附件 URL 获取对象键
|
|
|
|
|
|
- CustomerService._resolve_file_path():解析导入文件路径
|
|
|
|
|
|
"""
|
2026-05-15 13:17:45 +08:00
|
|
|
|
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
|
2026-05-15 13:17:45 +08:00
|
|
|
|
|
2026-05-15 13:47:08 +08:00
|
|
|
|
def _build_signed_upload_url(self, object_key: str, expire_at: datetime, content_type: str) -> dict:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""构建阿里云 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():生成上传令牌时调用
|
|
|
|
|
|
"""
|
2026-05-15 13:47:08 +08:00
|
|
|
|
# 当前按 OSS V1 预签名 URL 规则生成上传地址,便于前端直接联调真实 PUT 上传口径。
|
|
|
|
|
|
expires = str(int(expire_at.timestamp()))
|
2026-05-15 13:17:45 +08:00
|
|
|
|
encoded_key = quote(object_key.replace("\\", "/"), safe="/-_.*()")
|
2026-05-15 13:47:08 +08:00
|
|
|
|
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 = (
|
2026-05-15 13:17:45 +08:00
|
|
|
|
f"https://{self.settings.aliyun_oss_bucket}.{self.settings.aliyun_oss_endpoint}/{encoded_key}"
|
2026-05-15 13:47:08 +08:00
|
|
|
|
f"?OSSAccessKeyId={quote(self.settings.aliyun_oss_access_key_id, safe='')}"
|
|
|
|
|
|
f"&Expires={expires}"
|
|
|
|
|
|
f"&Signature={query_signature}"
|
2026-05-15 13:17:45 +08:00
|
|
|
|
)
|
2026-05-15 13:47:08 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"upload_url": upload_url,
|
|
|
|
|
|
"headers": {
|
|
|
|
|
|
"Content-Type": content_type,
|
|
|
|
|
|
"x-oss-object-acl": "private",
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def _guess_content_type(self, file_name: str) -> str:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""根据文件扩展名猜测 MIME 类型。
|
|
|
|
|
|
|
|
|
|
|
|
内置常见图片、视频、文档格式的映射关系,未知扩展名默认返回
|
|
|
|
|
|
application/octet-stream。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
file_name (str): 文件名(含扩展名)
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
str: 对应的 MIME 类型字符串
|
|
|
|
|
|
|
|
|
|
|
|
被调用方:
|
|
|
|
|
|
- create_upload_token():确定上传文件的 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")
|
2026-05-15 13:17:45 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
storage_service = StorageService()
|