dingdanquanliucheng/backend/app/services/file_service.py
taiyi f7dfc5d282 清除全系统模拟数据和演示数据,全面对接真实后端接口
- 删除 demo_store.py、web-admin/data.js、web-sales/data.js 三个模拟数据源
- 后端14个 service 文件:去除 session 为 None 时返回演示数据的 fallback 模式,统一改为抛出 AppException
- 前端 web-admin/web-sales 的 mockApi.js 改为通过 fetch() 调用真实后端 API
- 19个 Vue 组件:移除 isMock 标识、硬编码演示数据和模拟延迟逻辑
- 小程序 login.js 清除硬编码测试账号密码
- order_flow.sql 删除全部 INSERT 演示数据,仅保留建表结构

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 11:38:53 +08:00

162 lines
7.4 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.

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:
def __init__(self) -> None:
self.settings = get_settings()
self.repository = FileRepository()
def create_upload_token(self, payload: dict, session: Session | None = None) -> dict:
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:
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:
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:
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:
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:
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:
parts = file_name.rsplit(".", 1)
return parts[1] if len(parts) == 2 else ""
def _map_attachment(self, attachment) -> dict:
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()