refactor: P3c 服务层迁入 src/modules/ → service.py(29 服务)
- 22 个单服务 + 3 组合并(config+config_item、dict+dict_item、login_log+operation_log) - 特殊命名:job_executor→executor、scheduler→scheduler_service、 mail_svc→mail_service、sms_svc→sms_service(含使用处标识符改写) - 重写引用:跨服务(module 别名/子模块)、models/forms→src.modules、 db/extensions→src.core、utils→src.common|core、R→src.core.response - application/services/ 全部替换为 shim 【验收】全部服务文件编译通过;应用启动;pytest 5 passed;核心服务导入正常 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
2586c0aace
commit
87aec4e26e
@@ -1,396 +1,8 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
|
||||
# +----------------------------------------------------------------------
|
||||
# | 开源协议: Apache-2.0
|
||||
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
# +----------------------------------------------------------------------
|
||||
# | 官方网站: https://www.djangoadmin.cn
|
||||
# +----------------------------------------------------------------------
|
||||
# | 研发团队: 一米阳光团队 荣誉出品
|
||||
# +======================================================================
|
||||
# | 版权和免责声明:
|
||||
# | 一、知识产权声明
|
||||
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
|
||||
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||||
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||||
# | 二、使用限制与禁止条款
|
||||
# | 您理解并同意,禁止将本产品用于以下任何行为:
|
||||
# | 1. 任何违反中华人民共和国法律法规的用途;
|
||||
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||||
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||||
# | 三、责任豁免
|
||||
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||||
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||||
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||||
# | 四、违约与法律责任
|
||||
# | 如发现任何违反本声明的行为,本团队有权:
|
||||
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||||
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||||
# | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||||
# | 五、许可范围说明
|
||||
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||||
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||||
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||||
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||||
# | 成的一切经济损失及商誉损害。
|
||||
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||||
# | 许可协议》附件为准。
|
||||
# +======================================================================
|
||||
|
||||
"""
|
||||
文件上传服务模块
|
||||
兼容 shim(绞杀者迁移期间保留)
|
||||
=============================================================
|
||||
|
||||
该模块实现了文件上传相关的业务逻辑。
|
||||
支持图片和各类文件的上传,自动进行文件类型验证、存储路径生成、
|
||||
文件重命名等操作,并返回标准化的文件信息。
|
||||
服务已迁移至 src.modules.upload.service。
|
||||
本文件仅作兼容转发,最终在 P7(切尾)阶段删除。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import random
|
||||
import time
|
||||
import mimetypes
|
||||
|
||||
from flask import request
|
||||
|
||||
from config import FLASK_TEMP_PATH, FLASK_UPLOAD_DIR, UPLOAD_ALLOWED_EXTS, UPLOAD_MAX_SIZE_MB
|
||||
from utils import R
|
||||
from utils.file import mkdir, verify_file_magic
|
||||
from utils.rich_text import get_file_url
|
||||
|
||||
# 获取日志记录器
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 文件上传
|
||||
# ============================================================
|
||||
|
||||
def upload_file(file_exts=UPLOAD_ALLOWED_EXTS):
|
||||
"""
|
||||
上传文件(兼容图片和各类文件)
|
||||
|
||||
Args:
|
||||
file_exts: 允许的文件扩展名列表或字符串,如 ['.jpg', '.png'] 或 ".jpg|.png|.gif"
|
||||
默认使用 UPLOAD_ALLOWED_EXTS 全局配置
|
||||
|
||||
Returns:
|
||||
dict: 包含文件信息的字典,格式为:
|
||||
{
|
||||
'originalName': 原始文件名,
|
||||
'fileExtension': 文件扩展名,
|
||||
'fileType': 文件MIME类型,
|
||||
'fileSize': 文件大小,
|
||||
'fileName': 生成的文件名,
|
||||
'filePath': 文件相对路径,
|
||||
'fileUrl': 文件访问URL
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# 步骤1:获取上传的文件对象
|
||||
file = request.files.get('file')
|
||||
if not file:
|
||||
logger.warning("未选择上传文件")
|
||||
return R.failed(msg="请选择要上传的文件")
|
||||
|
||||
# 步骤2:获取文件基本信息
|
||||
original_name = file.filename
|
||||
file_size = _get_file_size(file)
|
||||
|
||||
# 步骤2.5:校验文件大小
|
||||
max_size_bytes = UPLOAD_MAX_SIZE_MB * 1024 * 1024
|
||||
if file_size and file_size > max_size_bytes:
|
||||
logger.warning(f"文件大小超限: {file_size} bytes, 上限: {max_size_bytes} bytes")
|
||||
return R.failed(msg=f"文件大小超过限制,最大允许 {UPLOAD_MAX_SIZE_MB}MB")
|
||||
|
||||
# 步骤3:获取并处理文件扩展名
|
||||
file_ext_with_dot = os.path.splitext(original_name)[1].lower()
|
||||
file_ext = file_ext_with_dot[1:] if file_ext_with_dot.startswith('.') else ''
|
||||
|
||||
# 步骤4:验证文件扩展名是否在允许的范围内
|
||||
if file_exts:
|
||||
allowed_exts = _parse_allowed_extensions(file_exts)
|
||||
if allowed_exts and file_ext_with_dot not in allowed_exts:
|
||||
logger.warning(f"文件格式不支持,允许: {allowed_exts},实际: {file_ext_with_dot}")
|
||||
return R.failed(msg=f"文件格式不支持,仅支持 {', '.join(allowed_exts)} 格式")
|
||||
|
||||
# 步骤4.5:校验文件魔术字节,防止扩展名伪造
|
||||
is_valid, magic_msg = verify_file_magic(file, file_ext_with_dot)
|
||||
if not is_valid:
|
||||
logger.warning(f"文件魔术字节校验失败: {magic_msg},文件名: {original_name}")
|
||||
return R.failed(msg=magic_msg)
|
||||
|
||||
# 步骤5:获取文件的MIME类型
|
||||
mime_type = _get_mime_type(original_name, file_ext_with_dot)
|
||||
|
||||
# 步骤6:生成新的文件名(避免文件名冲突)
|
||||
generated_name = _generate_filename(file_ext_with_dot)
|
||||
|
||||
# 步骤7:生成存储路径(按日期分目录存储)
|
||||
date_dir = time.strftime('%Y%m%d')
|
||||
save_dir = os.path.join(FLASK_TEMP_PATH, date_dir)
|
||||
|
||||
# 步骤8:创建存放目录(如果目录不存在)
|
||||
mkdir(save_dir)
|
||||
|
||||
# 步骤9:生成完整的文件保存路径
|
||||
file_path = os.path.join(save_dir, generated_name)
|
||||
|
||||
# 步骤10:保存文件
|
||||
file.save(file_path)
|
||||
|
||||
# 步骤10.5:SVG文件清洗,移除可执行脚本防止XSS攻击
|
||||
if file_ext_with_dot == '.svg':
|
||||
_sanitize_svg(file_path)
|
||||
|
||||
# 步骤11:获取相对路径(用于存储和返回)
|
||||
relative_path = file_path.replace(FLASK_UPLOAD_DIR, "").replace("\\", "/")
|
||||
|
||||
# 步骤12:生成文件的访问URL
|
||||
file_url = get_file_url(relative_path)
|
||||
|
||||
# 步骤13:构建标准化的返回数据
|
||||
result = {
|
||||
'originalName': original_name,
|
||||
'fileExtension': file_ext,
|
||||
'fileType': mime_type,
|
||||
'fileSize': file_size,
|
||||
'fileName': generated_name,
|
||||
'filePath': relative_path,
|
||||
'fileUrl': file_url,
|
||||
}
|
||||
|
||||
return R.ok(msg="上传成功", data=result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"文件上传异常: {str(e)}")
|
||||
return R.failed(msg=f"上传失败: {str(e)}")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 内部辅助函数
|
||||
# ============================================================
|
||||
|
||||
def _parse_allowed_extensions(exts):
|
||||
"""
|
||||
解析允许的扩展名参数(内部工具函数)
|
||||
|
||||
将各种格式的扩展名参数统一转换为带点的列表格式。
|
||||
支持的输入格式:
|
||||
- 列表:['.jpg', '.png'] 或 ['jpg', 'png']
|
||||
- 字符串:".jpg|.png|.gif" 或 "jpg,png,gif" 或 "jpg"
|
||||
|
||||
Args:
|
||||
exts: 扩展名参数,可以是字符串或列表
|
||||
|
||||
Returns:
|
||||
list: 允许的扩展名列表,格式为 ['.jpg', '.png']
|
||||
"""
|
||||
if not exts:
|
||||
return []
|
||||
|
||||
if isinstance(exts, list):
|
||||
return [ext if ext.startswith('.') else f'.{ext}' for ext in exts]
|
||||
|
||||
if isinstance(exts, str):
|
||||
if '|' in exts:
|
||||
parts = exts.split('|')
|
||||
elif ',' in exts:
|
||||
parts = exts.split(',')
|
||||
else:
|
||||
parts = [exts]
|
||||
|
||||
result = []
|
||||
for part in parts:
|
||||
part = part.strip().lower()
|
||||
if part:
|
||||
if not part.startswith('.'):
|
||||
part = f'.{part}'
|
||||
result.append(part)
|
||||
return result
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _get_mime_type(filename, ext_with_dot):
|
||||
"""
|
||||
获取文件的MIME类型(内部工具函数)
|
||||
|
||||
Args:
|
||||
filename: 原始文件名
|
||||
ext_with_dot: 带点的扩展名,如 '.jpg'
|
||||
|
||||
Returns:
|
||||
str: MIME类型
|
||||
"""
|
||||
mime_type, _ = mimetypes.guess_type(filename)
|
||||
if not mime_type:
|
||||
mime_type = _guess_mime_type(ext_with_dot)
|
||||
return mime_type
|
||||
|
||||
|
||||
def _guess_mime_type(ext_with_dot):
|
||||
"""
|
||||
根据扩展名推断MIME类型(内部工具函数)
|
||||
|
||||
当mimetypes.guess_type无法识别时,使用此函数手动推断MIME类型。
|
||||
|
||||
Args:
|
||||
ext_with_dot: 带点的扩展名,如 '.jpg'
|
||||
|
||||
Returns:
|
||||
str: MIME类型,如果无法识别则返回默认的'application/octet-stream'
|
||||
"""
|
||||
mime_map = {
|
||||
# 图片类型
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.bmp': 'image/bmp',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
|
||||
# 文本类型
|
||||
'.txt': 'text/plain',
|
||||
'.html': 'text/html',
|
||||
'.htm': 'text/html',
|
||||
'.css': 'text/css',
|
||||
'.js': 'application/javascript',
|
||||
'.json': 'application/json',
|
||||
'.xml': 'application/xml',
|
||||
|
||||
# 文档类型
|
||||
'.pdf': 'application/pdf',
|
||||
'.doc': 'application/msword',
|
||||
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'.xls': 'application/vnd.ms-excel',
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'.ppt': 'application/vnd.ms-powerpoint',
|
||||
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
|
||||
# 音频视频类型
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.mp4': 'video/mp4',
|
||||
'.avi': 'video/x-msvideo',
|
||||
'.mov': 'video/quicktime',
|
||||
|
||||
# 压缩包类型
|
||||
'.zip': 'application/zip',
|
||||
'.rar': 'application/x-rar-compressed',
|
||||
'.7z': 'application/x-7z-compressed',
|
||||
'.tar': 'application/x-tar',
|
||||
'.gz': 'application/gzip',
|
||||
}
|
||||
|
||||
return mime_map.get(ext_with_dot, 'application/octet-stream')
|
||||
|
||||
|
||||
def _generate_filename(ext_with_dot):
|
||||
"""
|
||||
生成唯一的文件名(内部工具函数)
|
||||
|
||||
格式:年月日时分秒 + 5位随机数 + 扩展名
|
||||
|
||||
Args:
|
||||
ext_with_dot: 带点的扩展名,如 '.jpg'
|
||||
|
||||
Returns:
|
||||
str: 生成的文件名
|
||||
"""
|
||||
timestamp = time.strftime('%Y%m%d%H%M%S')
|
||||
random_num = '%05d' % random.randint(0, 99999)
|
||||
return f"{timestamp}{random_num}{ext_with_dot}"
|
||||
|
||||
|
||||
def _get_file_size(file):
|
||||
"""
|
||||
获取文件大小(内部工具函数)
|
||||
|
||||
Flask中可以通过多种方式获取文件大小:
|
||||
- file.content_length: 内容长度
|
||||
- 保存后使用 os.path.getsize
|
||||
|
||||
Args:
|
||||
file: Flask文件对象
|
||||
|
||||
Returns:
|
||||
int: 文件大小(字节),如果无法获取则返回0
|
||||
"""
|
||||
try:
|
||||
# 尝试获取 content_length
|
||||
if hasattr(file, 'content_length') and file.content_length:
|
||||
return file.content_length
|
||||
|
||||
# 保存文件位置后获取大小
|
||||
if hasattr(file, 'filename') and file.filename:
|
||||
# 临时保存到内存流中获取大小
|
||||
file.seek(0, 2) # 移动到文件末尾
|
||||
size = file.tell() # 获取当前位置(文件大小)
|
||||
file.seek(0) # 重置到文件开头
|
||||
return size
|
||||
except Exception as e:
|
||||
logger.warning(f"获取文件大小异常: {str(e)}")
|
||||
return 0
|
||||
|
||||
|
||||
# SVG中需要移除的危险标签
|
||||
_SVG_DANGEROUS_TAGS = re.compile(
|
||||
r'<\s*/?(?:script|iframe|object|embed|applet|form|input|button|textarea|select|link|meta|base)'
|
||||
r'(?:\s[^>]*)?\s*/?>',
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
# SVG中需要移除的危险事件属性(on*)
|
||||
_SVG_EVENT_ATTRS = re.compile(
|
||||
r'\s+on\w+\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# SVG中需要移除的危险href(javascript: / data: 协议)
|
||||
_SVG_DANGEROUS_HREF = re.compile(
|
||||
r'(\s+(?:href|xlink:href)\s*=\s*)(?:"javascript:[^"]*"|\'javascript:[^\']*\'|javascript:[^\s>]+'
|
||||
r'|"data:[^"]*"|\'data:[^\']*\'|data:[^\s>]+)',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_svg(file_path):
|
||||
"""
|
||||
清洗SVG文件,移除可执行脚本内容防止XSS攻击
|
||||
|
||||
SVG是文本格式的XML,可内嵌<script>标签和on*事件属性,
|
||||
浏览器直接访问SVG URL时会执行这些脚本。此函数在文件保存后
|
||||
对SVG内容进行清洗,移除所有危险元素和属性。
|
||||
|
||||
Args:
|
||||
file_path: SVG文件的绝对路径
|
||||
"""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read()
|
||||
|
||||
if not content:
|
||||
return
|
||||
|
||||
original_len = len(content)
|
||||
|
||||
# 移除危险标签
|
||||
content = _SVG_DANGEROUS_TAGS.sub('', content)
|
||||
# 移除事件属性(onclick, onload等)
|
||||
content = _SVG_EVENT_ATTRS.sub('', content)
|
||||
# 移除javascript:/data:协议的href
|
||||
content = _SVG_DANGEROUS_HREF.sub(r'\1""', content)
|
||||
|
||||
if len(content) != original_len:
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
logger.info(f"SVG文件已清洗,移除了危险内容: {file_path}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"SVG文件清洗异常: {e}")
|
||||
from src.modules.upload.service import * # noqa: F401,F403
|
||||
|
||||
Reference in New Issue
Block a user