SpringSunYY 4 сар өмнө
parent
commit
0c0ed47859

+ 31 - 9
ruoyi_admin/controller/common/common.py

@@ -43,7 +43,7 @@ def common_download(
     return response
 
 
-@reg.api.route('/common/upload')
+@reg.api.route('/common/upload', methods=['POST'])
 @FileValidator()
 @JsonSerializer()
 def common_upload(file: MultiFile):
@@ -53,14 +53,15 @@ def common_upload(file: MultiFile):
     new_file_name = FileUploadUtil.get_filename(file_name)
     original_filename = file.filename
     ajax_response = AjaxResponse.from_success()
+    # 为了兼容若依 Vue 前端,这里的字段名与 Java 版保持一致(驼峰命名)
     setattr(ajax_response, "url", url)
-    setattr(ajax_response, "file_name", file_name)
-    setattr(ajax_response, "new_file_name", new_file_name)
-    setattr(ajax_response, "original_filename", original_filename)
+    setattr(ajax_response, "fileName", file_name)
+    setattr(ajax_response, "newFileName", new_file_name)
+    setattr(ajax_response, "originalFilename", original_filename)
     return ajax_response
 
 
-@reg.api.route('/common/uploads')
+@reg.api.route('/common/uploads', methods=['POST'])
 @FileValidator()
 @JsonSerializer()
 def common_uploads(files: MultiFile):
@@ -78,10 +79,11 @@ def common_uploads(files: MultiFile):
         original_filename = file.filename
         original_filenames.append(original_filename)
     ajax_response = AjaxResponse.from_success()
-    setattr(ajax_response, "urls", urls.join(","))
-    setattr(ajax_response, "file_names", file_names.join(","))
-    setattr(ajax_response, "new_file_names", new_file_names.join(","))
-    setattr(ajax_response, "original_filenames", original_filenames.join(","))
+    # 多文件上传字段命名也与若依保持一致
+    setattr(ajax_response, "urls", ",".join(urls))
+    setattr(ajax_response, "fileNames", ",".join(file_names))
+    setattr(ajax_response, "newFileNames", ",".join(new_file_names))
+    setattr(ajax_response, "originalFilenames", ",".join(original_filenames))
     return ajax_response
 
 
@@ -105,3 +107,23 @@ def common_download_resource(
     except Exception as e:
         return AjaxResponse.from_error("下载失败")
     return response
+
+
+@reg.api.route(f"{Constants.RESOURCE_PREFIX}/<path:resource>")
+def common_profile_resource(resource: str):
+    """
+    静态资源访问:
+    将 /profile/** 映射到配置的 profile 物理目录下,与 Java 版若依保持一致。
+
+    例如:
+    ruoyi.profile = G:/ruoyi/uploadPath
+    URL:  /profile/upload/2025/11/18/xxx.jpg
+    实际: G:/ruoyi/uploadPath/upload/2025/11/18/xxx.jpg
+    """
+    try:
+        return send_from_directory(
+            directory=RuoYiConfig.profile,
+            path=resource,
+        )
+    except NotFound:
+        return AjaxResponse.from_error("文件不存在")

+ 12 - 2
ruoyi_common/base/model.py

@@ -367,8 +367,18 @@ class MultiFile(ImmutableMultiDict[str, FileStorage]):
         return next(self.values())
 
     @classmethod
-    def from_obj(cls,obj:ImmutableMultiDict):
-        return cls(**obj.to_dict())
+    def from_obj(cls, obj: ImmutableMultiDict):
+        """
+        从 ImmutableMultiDict 构造 MultiFile
+
+        Args:
+            obj (ImmutableMultiDict): Flask/Werkzeug 提供的 files 对象
+
+        Returns:
+            MultiFile: 包装后的文件字典
+        """
+        # 直接用原始对象初始化,避免使用 **kwargs 造成参数不匹配
+        return cls(obj)
 
 
 class VoModel(BaseModel):

+ 40 - 17
ruoyi_common/config.py

@@ -1,49 +1,72 @@
 # -*- coding: utf-8 -*-
 # @Author  : YY
 
+import os
+
 from ruoyi_common.ruoyi.config import CONFIG_CACHE
+from ruoyi_common.base.snippet import classproperty
 
 
 class RuoYiConfig:
+    """
+    系统相关配置
+
+    设计为类属性访问方式,与 Java 版 RuoYi 一致:
+        RuoYiConfig.profile
+        RuoYiConfig.upload_path
+        RuoYiConfig.download_path
+    """
 
-    profile = CONFIG_CACHE.get("ruoyi.profile")
+    @classproperty
+    def profile(cls) -> str:
+        """
+        基础配置路径,从配置缓存中实时读取,避免在模块导入时
+        CONFIG_CACHE 还未初始化导致为 None 的问题。
 
-    @property
-    def upload_path(self) -> str:
+        对应 app.yml 中的 ruoyi.profile,通常是一个绝对路径,
+        与 Java 版 RuoYi 保持一致,例如:G:/ruoyi/uploadPath
+        """
+        return CONFIG_CACHE.get("ruoyi.profile", "")
+
+    @classproperty
+    def upload_path(cls) -> str:
         """
         获取上传路径
 
         Returns:
-            str: 上传路径
+            str: 上传路径(profile/upload),与 Java 版 RuoYi 行为一致
         """
-        return f"uploads/{self.profile}/upload"
+        # profile 一般为绝对路径,这里直接在其下拼接 upload 目录
+        return os.path.join(cls.profile, "upload")
 
-    @property
-    def download_path(self) -> str:
+    @classproperty
+    def download_path(cls) -> str:
         """
         获取下载路径
 
         Returns:
-            str: 下载路径
+            str: 下载路径(profile/download),与 Java 版 RuoYi 行为一致
         """
-        return f"uploads/{self.profile}/download/"
+        return os.path.join(cls.profile, "download")
 
-    @property
-    def avatar_path(self) -> str:
+    @classproperty
+    def avatar_path(cls) -> str:
         """
         获取头像路径
 
         Returns:
-            str: 头像路径
+            str: 头像路径(profile/avatar),与 Java 版 RuoYi 行为一致
         """
-        return f"uploads/{self.profile}/avatar"
+        return os.path.join(cls.profile, "avatar")
 
-    @property
-    def import_path(self) -> str:
+    @classproperty
+    def import_path(cls) -> str:
         """
         获取导入路径
 
         Returns:
-            str: 导入路径
+            str: 导入路径(profile/import),与 Java 版 RuoYi 行为一致
         """
-        return f"uploads/{self.profile}/import"
+        return os.path.join(cls.profile, "import")
+
+

+ 16 - 2
ruoyi_common/descriptor/validator.py

@@ -3,13 +3,14 @@ import inspect
 from abc import ABC, abstractmethod
 from functools import wraps
 from dataclasses import dataclass, field
-from typing import Annotated, Any, Callable,  Dict, Tuple, Type, ClassVar, \
+from typing import Annotated, Any, Callable, Dict, Tuple, Type, ClassVar, \
     Optional, Set
 from werkzeug.exceptions import BadRequest, InternalServerError
-from flask import has_request_context
+from flask import has_request_context, request
 from pydantic import BaseModel, ValidationError, validate_call
 from pydantic.fields import FieldInfo
 
+from ruoyi_common.base.model import MultiFile
 from ruoyi_common.base.reqparser import BaseReqParser, BodyReqParser, \
     DownloadFileQueryReqParser, UploadFileFormReqParser, PathReqParser, \
     QueryReqParser, VoValidatorContext
@@ -164,6 +165,19 @@ class ValidatorViewFunction(AbcValidatorFunction):
             obj = self._data_parser.cast_model(bo_model)
             kwargs[key] = obj
         else:
+            # 特殊处理文件上传参数(MultiFile),直接从 request.files 构造
+            has_multi_file_param = any(
+                param.annotation is MultiFile
+                for param in self.sig.parameters.values()
+            )
+            if has_multi_file_param:
+                files = MultiFile.from_obj(request.files)
+                kwargs.clear()
+                for name, param in self.sig.parameters.items():
+                    if param.annotation is MultiFile:
+                        kwargs[name] = files
+                return
+
             data = self._data_parser.data()
             kwargs.clear()
             kwargs.update(data)

+ 48 - 10
ruoyi_common/utils/base.py

@@ -159,6 +159,20 @@ class StringUtil:
         return str(value).zfill(length)
 
     @classmethod
+    def left_pad(cls, value, length:int) -> str:
+        """
+        兼容方法:left_pad 等同于 pad_left
+
+        Args:
+            value (_type_): 输入字符串
+            length (int): 目标长度
+
+        Returns:
+            str: 填充后的字符串
+        """
+        return cls.pad_left(value, length)
+
+    @classmethod
     def substring_after(cls, string:str, separator:str) -> str:
         """
         获取字符串string中第一个分隔符separator之后的字符串
@@ -612,7 +626,8 @@ class MimeTypeUtil:
         # pdf
         "pdf" ]
 
-    def get_extension(cls, mime_type:str):
+    @classmethod
+    def get_extension(cls, mime_type: str) -> str:
         '''
         根据mime_type获取文件扩展名
 
@@ -724,27 +739,49 @@ class FileUploadUtil:
             raise Exception("文件名长度超过限制")
         cls.check_allowed(file, MimeTypeUtil.DEFAULT_ALLOWED_EXTENSION)
         filename = cls.extract_file_name(file)
+        # 物理磁盘完整路径,例如:G:/ruoyi/uploadPath/upload/2025/11/18/xxx.jpg
         filepath = os.path.join(base_path, filename)
         file_parpath = os.path.dirname(filepath)
         if not os.path.exists(file_parpath):
             os.makedirs(file_parpath)
         file.save(filepath)
-        resource_path = Constants.RESOURCE_PREFIX + "/" + filepath
+        # 将物理路径转换为相对于 profile 的路径,生成浏览器可访问的 URL:
+        # /profile/upload/2025/11/18/xxx.jpg
+        # 为避免 utils.base 与 config 之间的循环依赖,这里在函数内部延迟导入 RuoYiConfig
+        from ruoyi_common.config import RuoYiConfig
+        relpath = os.path.relpath(filepath, RuoYiConfig.profile)
+        relpath = relpath.replace(os.sep, "/")
+        resource_path = Constants.RESOURCE_PREFIX + "/" + relpath
         return resource_path
 
     @classmethod
     def check_allowed(cls, file:FileStorage, allowed_extensions:List[str]):
         '''
-        文件大小校验
-
+        文件大小、类型校验(对标若依的 FileUploadUtils.assertAllowed)
+       
         Args:
             file(FileStorage): 文件对象
             allowed_extensions(List[str]): 允许的扩展名列表
         '''
+        # 1. 文件大小校验
+        # FileStorage 默认游标在起始位置,这里需要先 seek 到末尾再计算大小,
+        # 然后把游标复位,避免影响后续 file.save 调用。
+        current_pos = file.stream.tell()
+        file.stream.seek(0, os.SEEK_END)
         file_size = file.stream.tell()
+        file.stream.seek(current_pos, os.SEEK_SET)
         if file_size > cls.DEFAULT_MAX_SIZE:
             raise Exception("文件大小超过限制")
-        extension = MimeTypeUtil.get_extension(file.content_type.lower())
+
+        # 2. 扩展名校验
+        # 优先根据原始文件名获取扩展名,与若依 Java 版保持一致,
+        # 只有当文件名没有扩展名时,才回退到根据 content_type 推断。
+        extension = os.path.splitext(file.filename)[1]
+        if extension.startswith("."):
+            extension = extension[1:]
+        extension = extension.lower()
+        if not extension:
+            extension = MimeTypeUtil.get_extension(file.content_type.lower())
         if extension not in allowed_extensions:
             if allowed_extensions == MimeTypeUtil.IMAGE_EXTENSION:
                 raise Exception("图片格式不支持")
@@ -760,18 +797,19 @@ class FileUploadUtil:
     @classmethod
     def extract_file_name(cls, file:FileStorage) -> str:
         '''
-        提取文件名
-
+        提取文件名,仿照若依:
+        日期路径/原文件名_序列号.扩展名
+        
         Args:
             file(FileStorage): 文件对象
-
+        
         Returns:
             str: 文件名
         '''
-        "{}/{}_{}.{}".format(
+        return "{}/{}_{}.{}".format(
             DateUtil.get_date_path(),
             os.path.basename(file.filename),
-            Seq.get_seq_id(cls.upload_seq_type),
+            Seq.get_seq_id(Seq.upload_seq_type),
             cls.get_extension(file)
         )