- 照片先上传服务器获取URL再提交,不再用本地临时路径 - accepted状态增加现场照片上传区域(最多9张) - 任务详情展示产品规格和业务员姓名 - 不再暴露客户姓名等敏感信息 - 新增 GET /api/files/attachments 附件查询接口 - picked_up/delivered 状态展示已保存的附件照片 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
274 lines
11 KiB
Python
274 lines
11 KiB
Python
"""文件管理服务层。
|
||
|
||
负责文件上传凭证生成、附件元数据保存、文件类型检测和大小校验等业务逻辑。
|
||
依赖 storage_service 进行对象存储交互,依赖 FileRepository 进行附件数据持久化,
|
||
依赖 audit_service 记录操作审计日志。
|
||
"""
|
||
|
||
from datetime import datetime
|
||
|
||
from sqlalchemy.exc import SQLAlchemyError
|
||
from sqlalchemy.orm import Session
|
||
|
||
from backend.app.core.config import get_settings
|
||
from backend.app.core.error_codes import ErrorCode
|
||
from backend.app.core.exceptions import AppException
|
||
from backend.app.repositories.file_repository import FileRepository
|
||
from backend.app.services.audit_service import audit_service
|
||
from backend.app.services.storage_service import storage_service
|
||
|
||
|
||
class FileService:
|
||
"""文件服务,封装上传凭证、附件元数据和文件校验逻辑。
|
||
|
||
依赖:
|
||
FileRepository - 附件数据访问
|
||
storage_service - 阿里云 OSS 存储交互
|
||
audit_service - 操作审计日志
|
||
settings - OSS 上传大小限制等配置
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
self.settings = get_settings()
|
||
self.repository = FileRepository()
|
||
|
||
def create_upload_token(self, payload: dict, session: Session | None = None) -> dict:
|
||
"""生成文件上传凭证。
|
||
|
||
校验文件名和业务类型非空,自动检测文件分类和校验文件大小,
|
||
调用 storage_service 生成 OSS 上传凭证。
|
||
|
||
Args:
|
||
payload: 包含 file_name、biz_type、file_type、file_size 等的请求体
|
||
session: 数据库会话
|
||
|
||
Returns:
|
||
包含上传凭证和 file_category 的字典
|
||
|
||
被调用路由: files.py - POST /files/upload-token
|
||
"""
|
||
if not payload["file_name"].strip():
|
||
raise AppException(code=ErrorCode.PARAM_ERROR, message="文件名不能为空", status_code=400)
|
||
if not payload["biz_type"].strip():
|
||
raise AppException(code=ErrorCode.PARAM_ERROR, message="业务类型不能为空", status_code=400)
|
||
|
||
file_category = self._detect_file_category(payload["file_type"], payload["file_name"])
|
||
self._validate_file_size(file_category, payload["file_size"])
|
||
token_payload = storage_service.create_upload_token(
|
||
payload["biz_type"],
|
||
payload["biz_id"],
|
||
payload["file_name"],
|
||
payload["file_size"],
|
||
)
|
||
token_payload["file_category"] = file_category
|
||
return token_payload
|
||
|
||
def save_attachment(self, payload: dict, session: Session | None = None) -> dict:
|
||
"""保存附件元数据。
|
||
|
||
在文件上传至 OSS 后调用,将文件元信息持久化到数据库。
|
||
自动记录审计日志。
|
||
|
||
Args:
|
||
payload: 包含 biz_type、biz_id、file_name、file_url 等的请求体
|
||
session: 数据库会话
|
||
|
||
Returns:
|
||
附件元数据字典
|
||
|
||
被调用路由: files.py - POST /files/attachment
|
||
"""
|
||
if session is None:
|
||
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库连接不可用", status_code=500)
|
||
attachment_payload = self.build_attachment_payload(payload, created_by=payload.get("created_by"))
|
||
try:
|
||
attachment = self.repository.create_attachment(session, attachment_payload)
|
||
audit_service.write_log(
|
||
session,
|
||
{
|
||
"operate_type": "attachment_create",
|
||
"biz_type": payload["biz_type"],
|
||
"biz_id": payload["biz_id"],
|
||
"before_value": None,
|
||
"after_value": self._map_attachment(attachment),
|
||
"remark": f"保存附件 {payload['file_name']}",
|
||
},
|
||
)
|
||
session.commit()
|
||
return self._map_attachment(attachment)
|
||
except AppException:
|
||
session.rollback()
|
||
raise
|
||
except SQLAlchemyError as exc:
|
||
session.rollback()
|
||
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc
|
||
|
||
def build_attachment_payload(
|
||
self,
|
||
payload: dict,
|
||
created_by: int | None = None,
|
||
require_file_size: bool = True,
|
||
) -> dict:
|
||
"""构建附件入库的数据载荷。
|
||
|
||
校验必填字段,检测文件分类,提取 object_key,验证文件名与地址匹配性。
|
||
供 save_attachment 和 logistics_service._persist_task_attachments 共用。
|
||
|
||
Args:
|
||
payload: 包含 biz_type、biz_id、file_name、file_url 等的原始请求体
|
||
created_by: 创建人用户 ID
|
||
require_file_size: 是否要求 file_size 非空(司机任务附件可为 False)
|
||
|
||
Returns:
|
||
标准化后的附件入库数据字典
|
||
"""
|
||
if not payload["biz_type"].strip():
|
||
raise AppException(code=ErrorCode.PARAM_ERROR, message="业务类型不能为空", status_code=400)
|
||
if not payload["file_name"].strip():
|
||
raise AppException(code=ErrorCode.PARAM_ERROR, message="文件名不能为空", status_code=400)
|
||
if not payload["file_url"].strip():
|
||
raise AppException(code=ErrorCode.PARAM_ERROR, message="文件地址不能为空", status_code=400)
|
||
|
||
file_name = payload["file_name"]
|
||
file_url = payload["file_url"]
|
||
file_type = payload.get("file_type") or None
|
||
file_size = payload.get("file_size")
|
||
if require_file_size and (file_size is None or int(file_size) <= 0):
|
||
raise AppException(code=ErrorCode.PARAM_ERROR, message="文件大小不能为空", status_code=400)
|
||
|
||
file_category = self._detect_file_category(file_type or "", file_name)
|
||
if file_size is not None and int(file_size) > 0:
|
||
self._validate_file_size(file_category, int(file_size))
|
||
|
||
object_key = storage_service.extract_object_key(file_url)
|
||
file_ext = self._extract_file_ext(file_name)
|
||
self._validate_attachment_file(file_name, file_url, object_key)
|
||
|
||
# 司机任务操作里可能只有文件名和 URL,这里允许 MIME 和大小为空,但仍统一沉淀附件元数据。
|
||
return {
|
||
"biz_type": payload["biz_type"],
|
||
"biz_id": payload["biz_id"],
|
||
"file_name": file_name,
|
||
"file_url": file_url,
|
||
"file_type": file_type,
|
||
"file_size": int(file_size) if file_size is not None else None,
|
||
"created_by": created_by,
|
||
"storage_provider": "aliyun_oss",
|
||
"bucket_name": self.settings.aliyun_oss_bucket,
|
||
"object_key": object_key,
|
||
"content_type": file_type,
|
||
"file_ext": file_ext,
|
||
"file_category": file_category,
|
||
}
|
||
|
||
def _validate_attachment_file(self, file_name: str, file_url: str, object_key: str) -> None:
|
||
"""校验附件文件名与对象地址的匹配性和 URL 格式。
|
||
|
||
防止前端绕过上传凭证直接塞入不匹配的文件。
|
||
|
||
Args:
|
||
file_name: 文件名
|
||
file_url: 文件 URL
|
||
object_key: OSS 对象 key
|
||
|
||
Raises:
|
||
AppException: 不匹配或格式错误时抛出 FILE_UPLOAD_FAILED
|
||
"""
|
||
expected_name = file_name.strip()
|
||
object_name = object_key.rsplit("/", 1)[-1]
|
||
# 保存附件时再次核对 object key 与文件名,避免前端绕过上传凭证直接塞入不匹配文件。
|
||
if expected_name and expected_name not in object_name:
|
||
raise AppException(code=ErrorCode.FILE_UPLOAD_FAILED, message="附件文件名与对象地址不匹配", status_code=400)
|
||
if not file_url.startswith(("http://", "https://")):
|
||
raise AppException(code=ErrorCode.FILE_UPLOAD_FAILED, message="附件地址格式不正确", status_code=400)
|
||
|
||
def _detect_file_category(self, file_type: str, file_name: str) -> str:
|
||
"""根据 MIME 类型和文件扩展名检测文件分类。
|
||
|
||
分类包括: image、video、import(Excel/CSV)、document。
|
||
|
||
Args:
|
||
file_type: MIME 类型
|
||
file_name: 文件名
|
||
|
||
Returns:
|
||
文件分类字符串
|
||
"""
|
||
content_type = (file_type or "").lower()
|
||
suffix = self._extract_file_ext(file_name).lower()
|
||
if content_type.startswith("image/") or suffix in {"jpg", "jpeg", "png", "gif", "webp"}:
|
||
return "image"
|
||
if content_type.startswith("video/") or suffix in {"mp4", "mov", "avi"}:
|
||
return "video"
|
||
if suffix in {"xlsx", "xls", "csv"}:
|
||
return "import"
|
||
return "document"
|
||
|
||
def _validate_file_size(self, file_category: str, file_size: int) -> None:
|
||
"""校验文件大小是否超出分类对应的限制。
|
||
|
||
Args:
|
||
file_category: 文件分类
|
||
file_size: 文件大小(字节)
|
||
|
||
Raises:
|
||
AppException: 超出限制时抛出 FILE_UPLOAD_FAILED
|
||
"""
|
||
max_mb = (
|
||
self.settings.oss_upload_max_image_mb
|
||
if file_category == "image"
|
||
else self.settings.oss_upload_max_video_mb
|
||
if file_category == "video"
|
||
else max(self.settings.oss_upload_max_image_mb, self.settings.oss_upload_max_video_mb)
|
||
)
|
||
if file_size > max_mb * 1024 * 1024:
|
||
raise AppException(code=ErrorCode.FILE_UPLOAD_FAILED, message="文件大小超出限制", status_code=400)
|
||
|
||
def _extract_file_ext(self, file_name: str) -> str:
|
||
"""从文件名中提取扩展名。
|
||
|
||
Args:
|
||
file_name: 文件名
|
||
|
||
Returns:
|
||
扩展名字符串(不含点号),无扩展名时返回空字符串
|
||
"""
|
||
parts = file_name.rsplit(".", 1)
|
||
return parts[1] if len(parts) == 2 else ""
|
||
|
||
def list_attachments(self, biz_type: str, biz_id: int, session: Session | None = None) -> list[dict]:
|
||
"""按业务类型和 ID 查询附件列表。"""
|
||
if session is None:
|
||
return []
|
||
attachments = self.repository.list_attachments(session, biz_type, int(biz_id))
|
||
return [self._map_attachment(a) for a in attachments]
|
||
|
||
def _map_attachment(self, attachment) -> dict:
|
||
"""将附件 ORM 对象映射为字典格式。
|
||
|
||
Args:
|
||
attachment: 附件 ORM 对象
|
||
|
||
Returns:
|
||
附件摘要字典
|
||
"""
|
||
return {
|
||
"attachment_id": attachment.id,
|
||
"biz_type": attachment.biz_type,
|
||
"biz_id": attachment.biz_id,
|
||
"file_name": attachment.file_name,
|
||
"file_url": attachment.file_url,
|
||
"file_type": attachment.file_type,
|
||
"file_size": attachment.file_size,
|
||
"storage_provider": attachment.storage_provider,
|
||
"bucket_name": attachment.bucket_name,
|
||
"object_key": attachment.object_key,
|
||
"content_type": attachment.content_type,
|
||
"file_ext": attachment.file_ext,
|
||
"file_category": attachment.file_category,
|
||
"created_at": attachment.created_at.strftime("%Y-%m-%d %H:%M:%S") if attachment.created_at else "",
|
||
}
|
||
|
||
|
||
file_service = FileService()
|