dingdanquanliucheng/backend/app/services/file_service.py
taiyi ea0a4093e1 feat: 司机端增加图片附件上传和产品规格/业务员展示
- 照片先上传服务器获取URL再提交,不再用本地临时路径
- accepted状态增加现场照片上传区域(最多9张)
- 任务详情展示产品规格和业务员姓名
- 不再暴露客户姓名等敏感信息
- 新增 GET /api/files/attachments 附件查询接口
- picked_up/delivered 状态展示已保存的附件照片

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 19:57:26 +08:00

274 lines
11 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.

"""文件管理服务层。
负责文件上传凭证生成、附件元数据保存、文件类型检测和大小校验等业务逻辑。
依赖 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、importExcel/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()