[
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: 'Blank Issue'\ndescription: 请使用 https://issue.zfile.vip 创建新的问题.\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        **注意:**\n        不要通过此页面创建问题, 请使用 https://issue.zfile.vip 创建新的问题.\n        如果不是通过此链接创建的问题, 将会被直接关闭.\n  - type: textarea\n    id: add-a-description\n    attributes:\n      label: Add a description"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: true\ncontact_links:\n  - name: 创建 Issue\n    url: https://issue.zfile.vip/\n    about: 未通过 https://issue.zfile.vip/ 创建的问题可能会被立即关闭。"
  },
  {
    "path": ".gitignore",
    "content": "HELP.md\ntarget/\n!.mvn/wrapper/maven-wrapper.jar\n!**/src/main/**/target/\n!**/src/test/**/target/\n\n### STS ###\n.apt_generated\n.classpath\n.factorypath\n.project\n.settings\n.springBeans\n.sts4-cache\n\n### IntelliJ IDEA ###\n.idea\n*.iws\n*.iml\n*.ipr\n.fastRequest\n.murphy.yml\n\n### NetBeans ###\n/nbproject/private/\n/nbbuild/\n/dist/\n/nbdist/\n/.nb-gradle/\nbuild/\n!**/src/main/**/build/\n!**/src/test/**/build/\n\n### VS Code ###\n.vscode/\n/.mvn/wrapper/\n/mvnw\n/mvnw.cmd\n/result/"
  },
  {
    "path": ".package/script/log.sh",
    "content": "#!/bin/bash\ntail -fn100 ~/.zfile-v4/logs/zfile.log"
  },
  {
    "path": ".package/script/restart.sh",
    "content": "#!/bin/bash\n\nDIR=\"$( cd \"$( dirname \"${BASH_SOURCE[0]}\" )\" && pwd )\"\n\n$DIR/stop.sh\n$DIR/start.sh"
  },
  {
    "path": ".package/script/start.sh",
    "content": "#!/bin/bash\n\n# 检测是否已启动\npid=`ps -ef | grep -n zfile | grep -v grep | grep -v launch | grep -v .sh | awk '{print $2}'`\nif [ -n \"${pid}\" ]\nthen\n   echo \"已运行在 pid：${pid}，无需重复启动！\"\n   exit 0\nfi\n\n# 获取当前脚本所在路径\nDIR=\"$( cd \"$( dirname \"${BASH_SOURCE[0]}\" )\" && pwd )\"\nZFILE_DIR=$(dirname \"$DIR\")\n\n# 启动 zfile\nnohup $ZFILE_DIR/zfile/zfile --spring.config.location=$ZFILE_DIR/application.properties --spring.web.resources.static-locations=file:$ZFILE_DIR/static/  >/dev/null 2>&1 &\necho '启动中...'\nsleep 3s\n\n# 输出 pid\npid=`ps -ef | grep -n zfile | grep -v grep | grep -v .sh | awk '{print $2}'`\necho \"目前 PID 为: ${pid}\"\n"
  },
  {
    "path": ".package/script/status.sh",
    "content": "#!/bin/bash\n\necho \"------------------ 检测状态 START --------------\"\npid=`ps -ef | grep -n zfile | grep -v grep | grep -v launch | grep -v .sh | awk '{print $2}'`\nif [ -z \"${pid}\" ]\nthen\n   echo \"未运行, 无需停止!\"\nelse\n   echo \"运行pid：${pid}\"\nfi\n\necho \"------------------ 检测状态  END  --------------\""
  },
  {
    "path": ".package/script/stop.sh",
    "content": "#!/bin/bash\n\necho \"------------------ 检测状态 START --------------\"\npid=`ps -ef | grep -n zfile | grep -v grep | grep -v .sh | awk '{print $2}'`\nif [ -z \"${pid}\" ]\nthen\n   echo \"未运行, 无需停止!\"\nelse\n   echo \"运行pid：${pid}\"\n   kill -9 ${pid}\n   echo \"已停止进程: ${pid}\"\nfi\n\necho \"------------------ 检测状态  END  --------------\""
  },
  {
    "path": ".package/script/双击我启动.bat",
    "content": "@echo off\nif not exist %windir%\\system32\\cmd.exe (\n    \"%CD%\\zfile\\zfile.exe\"\n) else (\n    cmd /k \"%CD%\\zfile\\zfile.exe\"\n    exit\n)"
  },
  {
    "path": "Dockerfile",
    "content": "# 此文件仅作为示例使用，与 ZFile 实际打包的 Dockerfile 不同（采用 Graal Native 打包，这部分不开源）\nFROM maven:3.9.9-eclipse-temurin-21-alpine AS builder\n\nWORKDIR /root\n\nADD ./pom.xml pom.xml\nADD ./src src\n\nRUN mvn clean package -Dmaven.test.skip=true\n\nFROM ibm-semeru-runtimes:open-21-jre-jammy\n\nWORKDIR /root\nEXPOSE 8080\n\nENV LANG=C.UTF-8\nENV LC_ALL=C.UTF-8\n\nRUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime\nRUN echo 'Asia/Shanghai' >/etc/timezone\n\nRUN apt update -y && apt install --no-install-recommends fontconfig -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*\n\nCOPY --from=builder /root/target/*.jar /root/app.jar\n\nCMD [\"java\", \"-jar\", \"app.jar\"]"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2019 ZhaoJun\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n    <a href=\"https://zfile.vip\" target=\"_blank\" rel=\"noopener noreferrer\">\n        <img style=\"margin: auto; width: 100px; display: block\" src=\"/img/logo-zfile.png\" alt=\"ZFile\" />\n    </a>\n    <p>ZFile 是一个适用于个人或小团队的在线网盘程序，可以将多种存储类型统一管理，再也不用登录各种网站管理文件，现在你只需要在 ZFile 中畅快使用！</p>\n<div>\n    <img alt=\"last commit\"      src=\"https://shields.io/github/last-commit/zfile-dev/zfile.svg?style=flat-square\"/>\n    <img alt=\"downloads\"        src=\"https://shields.io/github/downloads/zfile-dev/zfile/total?style=flat-square\"/>\n    <img alt=\"release version\"  src=\"https://shields.io/github/v/release/zfile-dev/zfile?style=flat-square\"/>\n    <img alt=\"commit activity\"  src=\"https://shields.io/github/commit-activity/y/zfile-dev/zfile?style=flat-square\"/>\n    <img alt=\"open issues\"      src=\"https://shields.io/github/issues/zfile-dev/zfile?style=flat-square\"/>\n    <img alt=\"closed issues\"    src=\"https://shields.io/github/issues-closed-raw/zfile-dev/zfile?style=flat-square\"/>\n    <img alt=\"forks\"            src=\"https://shields.io/github/forks/zfile-dev/zfile?style=flat-square\"/>\n    <img alt=\"stars\"            src=\"https://shields.io/github/stars/zfile-dev/zfile?style=flat-square\"/>\n    <img alt=\"watchers\"         src=\"https://shields.io/github/watchers/zfile-dev/zfile?style=flat-square\"/>\n    <img alt=\"gitcode\"          src=\"https://gitcode.com/zfile-dev/zfile/star/badge.svg\"/>\n</div>\n    <span>\n        <a href=\"https://zfile.vip\">官网</a>\n        <span> | </span>\n        <a href=\"https://docs.zfile.vip\">文档</a>\n        <span> | </span>\n        <a href=\"https://demo.zfile.vip\">预览地址</a>\n    </span>\n</div>\n\n## 系统特色\n\n- Docker、Docker Compose 支持(amd64, arm64)。\n- 支持对文件生成直链、短链(可设过期时间)。\n- 响应式设计，支持手机、平板、电脑等多种设备访问。\n- 支持多用户功能，可分配给指定用户指定存储源或目录。\n- 支持在线浏览图片、播放音视频，文本文件、Office、Obj（3d）等文件类型。\n- 支持对接 S3、OneDrive、SharePoint、Google Drive、多吉云、又拍云、本地存储、FTP、SFTP 等存储源。\n- 支持常用快捷键，`Ctrl + A` 全选，`Ctrl + 左键` 多选，`Shift + 左键` 范围选择，`Esc` 取消全选等。\n- 支持限速下载(捐赠版)\n- 支持限制指定用户可查看、上传的文件类型(捐赠版)\n\n## 快速开始\n\n一键脚本安装：\n\n```bash\ncurl -sSL https://docs.zfile.vip/install.sh -o install.sh && chmod +x install.sh && ./install.sh\n```\n\n更多安装方式请参考 [安装文档](https://docs.zfile.vip/install/)\n\n\n## 功能预览\n\n### 文件列表\n![文件列表](/img/file-list.png)\n### 画廊模式\n![图片预览](/img/gallery.png)\n### 视频预览\n![视频预览](/img/preview-video.png)\n### 文本预览\n![文本预览](/img/preview-text.png)\n### 音频预览\n![音频预览](/img/preview-audio.png)\n### PDF 预览\n![PDF 预览](/img/preview-pdf.png)\n### Office 预览\n![Office 预览](/img/preview-office.png)\n### 3d 文件预览\n![3d 文件预览](/img/preview-3d.png)\n### 生成直链\n![生成直链](/img/generate-link.jpeg)\n### 页面设置\n![页面设置](/img/page-setting.png)\n### 后台设置-登录\n![后台设置-登录](/img/login.png)\n### 后台设置-存储源列表\n![后台设置-存储源列表](/img/storage-list.png)\n### 后台设置-添加存储源(本地存储)\n![后台设置-添加存储源（本地存储）](/img/storage-edit-local.png)\n### 后台设置-用户管理\n![后台设置-存储源权限控制](/img/user-edit.png)\n### 后台设置-显示设置\n![后台设置-显示设置](/img/view-setting.png)\n\n## 支持作者\n\n如果本项目对你有帮助，请作者喝杯咖啡吧。\n\n<img src=\"https://cdn.jun6.net/2021/03/27/152704e91f13d.png\" width=\"400\" alt=\"赞助我\">\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=zfile-dev/zfile&type=Date)](https://star-history.com/#zfile-dev/zfile&Date)\n"
  },
  {
    "path": "pom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd\">\n    <modelVersion>4.0.0</modelVersion>\n\n    <groupId>im.zhaojun</groupId>\n    <artifactId>zfile</artifactId>\n    <version>4.5.0</version>\n    <name>zfile</name>\n    <packaging>jar</packaging>\n    <description>一个在线的文件浏览系统</description>\n\n    <parent>\n        <groupId>org.springframework.boot</groupId>\n        <artifactId>spring-boot-starter-parent</artifactId>\n        <version>3.3.2</version>\n        <relativePath/> <!-- lookup parent from repository -->\n    </parent>\n\n    <properties>\n        <skipTests>true</skipTests>\n\n        <java.version>21</java.version>\n        <maven.compiler.source>21</maven.compiler.source>\n        <maven.compiler.target>21</maven.compiler.target>\n\n        <maven.compiler.encoding>UTF-8</maven.compiler.encoding>\n        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>\n        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>\n\n        <org.mapstruct.version>1.5.3.Final</org.mapstruct.version>\n        <snakeyaml.version>2.0</snakeyaml.version>\n        <jackson-bom.version>2.14.1</jackson-bom.version>\n        <sqlite-jdbc.version>3.46.0.1</sqlite-jdbc.version>\n        <flyway.version>10.12.0</flyway.version>\n\n        <lombok.version>1.18.32</lombok.version>\n    </properties>\n\n    <dependencyManagement>\n        <dependencies>\n            <dependency>\n                <groupId>software.amazon.awssdk</groupId>\n                <artifactId>bom</artifactId>\n                <version>2.24.3</version>\n                <type>pom</type>\n                <scope>import</scope>\n            </dependency>\n        </dependencies>\n    </dependencyManagement>\n\n    <dependencies>\n        <!--         spring boot 官方相关-->\n        <dependency>\n            <groupId>org.graalvm.sdk</groupId>\n            <artifactId>graal-sdk</artifactId>\n            <version>24.1.0</version>\n            <scope>provided</scope>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-web</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-aop</artifactId>\n        </dependency>\n\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-configuration-processor</artifactId>\n            <optional>true</optional>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-data-redis</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-cache</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-thymeleaf</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-validation</artifactId>\n        </dependency>\n\n        <!-- 数据库相关 -->\n        <dependency>\n            <groupId>com.mysql</groupId>\n            <artifactId>mysql-connector-j</artifactId>\n            <scope>runtime</scope>\n        </dependency>\n        <dependency>\n            <groupId>org.xerial</groupId>\n            <artifactId>sqlite-jdbc</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.flywaydb</groupId>\n            <artifactId>flyway-core</artifactId>\n            <version>${flyway.version}</version>\n        </dependency>\n        <dependency>\n            <groupId>org.flywaydb</groupId>\n            <artifactId>flyway-mysql</artifactId>\n            <version>${flyway.version}</version>\n        </dependency>\n\n        <dependency>\n            <groupId>com.baomidou</groupId>\n            <artifactId>mybatis-plus-spring-boot3-starter</artifactId>\n            <version>3.5.6</version>\n        </dependency>\n\n\n        <!-- 存储策略相关 API, 对象存储、FTP、 Rest API-->\n        <dependency>\n            <groupId>com.upyun</groupId>\n            <artifactId>java-sdk</artifactId>\n            <version>4.2.3</version>\n        </dependency>\n        <!-- https://mvnrepository.com/artifact/software.amazon.awssdk/s3 -->\n        <dependency>\n            <groupId>software.amazon.awssdk</groupId>\n            <artifactId>s3</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>com.qiniu</groupId>\n            <artifactId>qiniu-java-sdk</artifactId>\n            <version>7.12.1</version>\n        </dependency>\n        <dependency>\n            <groupId>com.github.mwiede</groupId>\n            <artifactId>jsch</artifactId>\n            <version>0.2.20</version>\n        </dependency>\n        <dependency>\n            <groupId>com.github.lookfirst</groupId>\n            <artifactId>sardine</artifactId>\n            <version>5.12</version>\n            <exclusions>\n                <exclusion>\n                    <groupId>org.slf4j</groupId>\n                    <artifactId>slf4j-simple</artifactId>\n                </exclusion>\n            </exclusions>\n        </dependency>\n\n        <!-- 登陆/权限相关 -->\n        <dependency>\n            <groupId>cn.dev33</groupId>\n            <artifactId>sa-token-spring-boot3-starter</artifactId>\n            <version>1.38.0</version>\n        </dependency>\n\n\n        <!-- 文档相关 -->\n        <dependency>\n            <groupId>com.github.xiaoymin</groupId>\n            <artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>\n            <version>4.5.0</version>\n        </dependency>\n\n        <!-- 工具类 -->\n        <dependency>\n            <groupId>cn.hutool</groupId>\n            <artifactId>hutool-all</artifactId>\n            <version>5.8.31</version>\n        </dependency>\n        <dependency>\n            <groupId>org.apache.poi</groupId>\n            <artifactId>poi-ooxml</artifactId>\n            <version>5.4.0</version>\n            <exclusions>\n                <exclusion>\n                    <groupId>org.apache.commons</groupId>\n                    <artifactId>commons-compress</artifactId>\n                </exclusion>\n            </exclusions>\n        </dependency>\n\n        <dependency>\n            <groupId>org.apache.commons</groupId>\n            <artifactId>commons-compress</artifactId>\n            <version>1.26.2</version>\n            <scope>compile</scope>\n        </dependency>\n\n        <dependency>\n            <groupId>org.projectlombok</groupId>\n            <artifactId>lombok</artifactId>\n            <scope>provided</scope>\n        </dependency>\n\n        <dependency>\n            <groupId>commons-net</groupId>\n            <artifactId>commons-net</artifactId>\n            <version>3.11.0</version>\n        </dependency>\n        <dependency>\n            <groupId>org.apache.commons</groupId>\n            <artifactId>commons-pool2</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>com.squareup.okhttp3</groupId>\n            <artifactId>okhttp</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>com.alibaba.fastjson2</groupId>\n            <artifactId>fastjson2</artifactId>\n            <version>2.0.29</version>\n        </dependency>\n        <dependency>\n            <groupId>com.google.guava</groupId>\n            <artifactId>guava</artifactId>\n            <version>33.3.0-jre</version>\n        </dependency>\n        <dependency>\n            <groupId>org.mapstruct</groupId>\n            <artifactId>mapstruct</artifactId>\n            <version>${org.mapstruct.version}</version>\n        </dependency>\n        <dependency>\n            <groupId>commons-chain</groupId>\n            <artifactId>commons-chain</artifactId>\n            <version>1.2</version>\n        </dependency>\n        <dependency>\n            <groupId>dev.samstevens.totp</groupId>\n            <artifactId>totp</artifactId>\n            <version>1.7.1</version>\n            <exclusions>\n                <exclusion>\n                    <groupId>com.google.zxing</groupId>\n                    <artifactId>core</artifactId>\n                </exclusion>\n                <exclusion>\n                    <groupId>com.google.zxing</groupId>\n                    <artifactId>javase</artifactId>\n                </exclusion>\n            </exclusions>\n        </dependency>\n        <dependency>\n            <groupId>org.json</groupId>\n            <artifactId>json</artifactId>\n            <version>20231013</version>\n        </dependency>\n        <dependency>\n            <groupId>org.apache.httpcomponents</groupId>\n            <artifactId>httpmime</artifactId>\n            <version>4.5.13</version>\n        </dependency>\n        <dependency>\n            <groupId>org.apache.httpcomponents.client5</groupId>\n            <artifactId>httpclient5</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.bouncycastle</groupId>\n            <artifactId>bcprov-jdk15on</artifactId>\n            <version>1.70</version>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.retry</groupId>\n            <artifactId>spring-retry</artifactId>\n        </dependency>\n\n        <!-- https://mvnrepository.com/artifact/commons-fileupload/commons-fileupload -->\n        <dependency>\n            <groupId>commons-fileupload</groupId>\n            <artifactId>commons-fileupload</artifactId>\n            <version>1.6.0</version>\n        </dependency>\n\n        <dependency>\n            <groupId>com.alibaba</groupId>\n            <artifactId>dns-cache-manipulator</artifactId>\n            <version>1.8.2</version>\n        </dependency>\n\n        <dependency>\n            <groupId>com.github.oshi</groupId>\n            <artifactId>oshi-core</artifactId>\n            <version>6.6.3</version>\n        </dependency>\n\n    </dependencies>\n\n    <build>\n        <plugins>\n            <plugin>\n                <groupId>org.springframework.boot</groupId>\n                <artifactId>spring-boot-maven-plugin</artifactId>\n            </plugin>\n            <plugin>\n                <groupId>org.apache.maven.plugins</groupId>\n                <artifactId>maven-compiler-plugin</artifactId>\n                <configuration>\n                    <source>21</source>\n                    <target>21</target>\n                    <encoding>UTF-8</encoding>\n                    <annotationProcessorPaths>\n                        <path>\n                            <groupId>org.mapstruct</groupId>\n                            <artifactId>mapstruct-processor</artifactId>\n                            <version>${org.mapstruct.version}</version>\n                        </path>\n                        <path>\n                            <groupId>org.projectlombok</groupId>\n                            <artifactId>lombok</artifactId>\n                            <version>1.18.32</version>\n                        </path>\n                        <path>\n                            <groupId>org.projectlombok</groupId>\n                            <artifactId>lombok-mapstruct-binding</artifactId>\n                            <version>0.2.0</version>\n                        </path>\n                    </annotationProcessorPaths>\n                </configuration>\n            </plugin>\n            <plugin>\n                <groupId>org.flywaydb</groupId>\n                <artifactId>flyway-maven-plugin</artifactId>\n            </plugin>\n        </plugins>\n    </build>\n\n</project>\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/ZfileApplication.java",
    "content": "package im.zhaojun.zfile;\n\nimport org.springframework.boot.SpringApplication;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.boot.web.servlet.ServletComponentScan;\nimport org.springframework.context.annotation.ComponentScan;\nimport org.springframework.context.annotation.EnableAspectJAutoProxy;\n\n\n/**\n * @author zhaojun\n */\n@SpringBootApplication\n@EnableAspectJAutoProxy(exposeProxy = true, proxyTargetClass = true)\n@ServletComponentScan(basePackages = {\"im.zhaojun.zfile.core.filter\", \"im.zhaojun.zfile.module.storage.filter\"})\n@ComponentScan(basePackages = \"im.zhaojun.zfile.*\")\npublic class ZfileApplication {\n\n    public static void main(String[] args) {\n        SpringApplication.run(ZfileApplication.class, args);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/annotation/ApiLimit.java",
    "content": "package im.zhaojun.zfile.core.annotation;\n\nimport java.lang.annotation.ElementType;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.RetentionPolicy;\nimport java.lang.annotation.Target;\nimport java.util.concurrent.TimeUnit;\n\n/**\n * 接口限流注解\n *\n * @author zhaojun\n */\n@Target(ElementType.METHOD)\n@Retention(RetentionPolicy.RUNTIME)\npublic @interface ApiLimit {\n\n    /**\n     * 持续时间\n     */\n    int timeout();\n\n    /**\n     * 时间单位, 默认为秒\n     */\n    TimeUnit timeUnit() default TimeUnit.SECONDS;\n\n    /**\n     * 单位时间内允许访问的最大次数\n     */\n    long maxCount();\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/annotation/DemoDisable.java",
    "content": "package im.zhaojun.zfile.core.annotation;\n\nimport java.lang.annotation.ElementType;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.RetentionPolicy;\nimport java.lang.annotation.Target;\n\n/**\n * 演示系统禁用功能注解\n *\n * @author zhaojun\n */\n@Target(ElementType.METHOD)\n@Retention(RetentionPolicy.RUNTIME)\npublic @interface DemoDisable {\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/aspect/ApiLimitAspect.java",
    "content": "package im.zhaojun.zfile.core.aspect;\n\nimport cn.hutool.cache.CacheUtil;\nimport cn.hutool.cache.impl.TimedCache;\nimport cn.hutool.extra.servlet.JakartaServletUtil;\nimport im.zhaojun.zfile.core.annotation.ApiLimit;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.util.RequestHolder;\nimport org.aspectj.lang.JoinPoint;\nimport org.aspectj.lang.annotation.Aspect;\nimport org.aspectj.lang.annotation.Before;\nimport org.aspectj.lang.reflect.MethodSignature;\nimport org.springframework.stereotype.Component;\n\nimport java.lang.reflect.Method;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicLong;\n\n/**\n * 接口限流切面, 通过注解 {@link ApiLimit} 进行限流.\n *\n * @author zhaojun\n */\n@Aspect\n@Component\npublic class ApiLimitAspect {\n\n    private final TimedCache<String, AtomicLong> apiLimitTimedCache = CacheUtil.newTimedCache(1000);\n\n    public static final String API_LIMIT_KEY_PREFIX = \"api_limit_\";\n\n    /**\n     * 在标记了 {@link ApiLimit} 注解的方法执行前进行限流校验.\n     *\n     * @param joinPoint 切点\n     */\n    @Before(\"@annotation(apiLimit)\")\n    public void before(JoinPoint joinPoint, ApiLimit apiLimit) {\n        // 获取当前请求的方法上的注解中设置的值\n        MethodSignature signature = (MethodSignature) joinPoint.getSignature();\n        // 反射获取当前被调用的方法\n        Method method = signature.getMethod();\n        int timeout = apiLimit.timeout();\n        TimeUnit timeUnit = apiLimit.timeUnit();\n        long millis = timeUnit.toMillis(timeout);\n        long maxCount = apiLimit.maxCount();\n\n        // 获取请求相关信息\n        String ip = JakartaServletUtil.getClientIP(RequestHolder.getRequest());\n\n        // 限制访问次数\n        String key = API_LIMIT_KEY_PREFIX.concat(ip).concat(method.getName());\n        AtomicLong atomicLong = apiLimitTimedCache.get(key, false);\n        if (atomicLong == null) {\n            apiLimitTimedCache.put(key, new AtomicLong(1), millis);\n        } else {\n            if (atomicLong.incrementAndGet() > maxCount) {\n                throw new BizException(ErrorCode.BIZ_ACCESS_TOO_FREQUENT);\n            }\n        }\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/aspect/CommonResultControllerAdvice.java",
    "content": "package im.zhaojun.zfile.core.aspect;\n\nimport im.zhaojun.zfile.core.constant.MdcConstant;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport org.slf4j.MDC;\nimport org.springframework.core.MethodParameter;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.converter.HttpMessageConverter;\nimport org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter;\nimport org.springframework.http.converter.json.MappingJacksonValue;\nimport org.springframework.http.server.ServerHttpRequest;\nimport org.springframework.http.server.ServerHttpResponse;\nimport org.springframework.lang.NonNull;\nimport org.springframework.lang.Nullable;\nimport org.springframework.web.bind.annotation.ControllerAdvice;\nimport org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;\n\n/**\n * Controller 切面, 用于处理返回值统一封装.\n *\n * @author zhaojun\n */\n@ControllerAdvice\npublic class CommonResultControllerAdvice implements ResponseBodyAdvice<Object> {\n\n\t@Override\n\tpublic boolean supports(MethodParameter returnType,\n\t\t\t\t\t\t\t@NonNull Class<? extends HttpMessageConverter<?>> converterType) {\n\t\treturn AbstractJackson2HttpMessageConverter.class.isAssignableFrom(converterType);\n\t}\n\n\t@Override\n\t@NonNull\n\tpublic final Object beforeBodyWrite(@Nullable Object body,\n\t\t\t\t\t\t\t\t\t\t@NonNull MethodParameter returnType,\n\t\t\t\t\t\t\t\t\t\t@NonNull MediaType contentType,\n\t\t\t\t\t\t\t\t\t\t@NonNull Class<? extends HttpMessageConverter<?>> converterType,\n\t\t\t\t\t\t\t\t\t\t@NonNull ServerHttpRequest request,\n\t\t\t\t\t\t\t\t\t\t@NonNull ServerHttpResponse response) {\n\t\tMappingJacksonValue container = getOrCreateContainer(body);\n\t\t// The contain body will never be null\n\t\tbeforeBodyWriteInternal(container, contentType, returnType, request, response);\n\t\treturn container;\n\t}\n\n\t/**\n\t * Wrap the body in a {@link MappingJacksonValue} value container (for providing\n\t * additional serialization instructions) or simply cast it if already wrapped.\n\t */\n\tprivate MappingJacksonValue getOrCreateContainer(Object body) {\n\t\treturn body instanceof MappingJacksonValue ? (MappingJacksonValue) body :\n\t\t\t\tnew MappingJacksonValue(body);\n\t}\n\n\tprivate void beforeBodyWriteInternal(MappingJacksonValue bodyContainer,\n\t\t\t\t\t\t\t\t\t\t MediaType contentType,\n\t\t\t\t\t\t\t\t\t\t MethodParameter returnType,\n\t\t\t\t\t\t\t\t\t\t ServerHttpRequest request,\n\t\t\t\t\t\t\t\t\t\t ServerHttpResponse response) {\n\t\t// Get return body\n\t\tObject returnBody = bodyContainer.getValue();\n\n\t\tif (returnBody instanceof AjaxJson<?> baseResponse) {\n            // 将 MDC 中的 TraceId 设置到返回值中\n\t\t\tbaseResponse.setTraceId(MDC.get(MdcConstant.TRACE_ID));\n\t\t}\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/aspect/DemoDisableAspect.java",
    "content": "package im.zhaojun.zfile.core.aspect;\n\nimport im.zhaojun.zfile.core.annotation.DemoDisable;\nimport im.zhaojun.zfile.core.config.ZFileProperties;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport jakarta.annotation.Resource;\nimport org.aspectj.lang.JoinPoint;\nimport org.aspectj.lang.annotation.Aspect;\nimport org.aspectj.lang.annotation.Before;\nimport org.aspectj.lang.annotation.Pointcut;\nimport org.springframework.stereotype.Component;\n\n/**\n * 通过注解 {@link DemoDisable} 限制演示系统不可操作的功能.\n *\n * @author zhaojun\n */\n@Aspect\n@Component\npublic class DemoDisableAspect {\n\n    @Resource\n    private ZFileProperties zFileProperties;\n\n    /**\n     * 定义一个切点（通过注解）\n     */\n    @Pointcut(\"@annotation(im.zhaojun.zfile.core.annotation.DemoDisable)\")\n    public void demoDisable() {\n    }\n\n    /**\n     * 在标记了 {@link DemoDisable} 注解的方法执行前进行限流校验.\n     *\n     * @param joinPoint 切点\n     */\n    @Before(\"demoDisable()\")\n    public void before(JoinPoint joinPoint) {\n        if (zFileProperties.isDemoSite()) {\n            throw new BizException(ErrorCode.DEMO_SITE_DISABLE_OPERATOR);\n        }\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/cache/ZFileCacheManager.java",
    "content": "package im.zhaojun.zfile.core.cache;\n\nimport im.zhaojun.zfile.module.storage.model.entity.StorageSource;\nimport org.springframework.stereotype.Component;\n\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.function.Function;\n\n/**\n * ZFile 业务缓存，针对无法使用 jsr-107 的缓存进行处理的业务逻辑。\n */\n@Component\npublic class ZFileCacheManager {\n\n    /**\n     * 用户可用的存储源列表缓存\n     */\n    private static final Map<Integer, List<StorageSource>> userEnableStorageSourceCache = new ConcurrentHashMap<>();\n\n    /**\n     * 根据用户 ID 获取可用的存储源列表，若缓存中不存在，则通过 mappingFunction 获取并返回。\n     *\n     * @param   userId\n     *          用户 ID\n     *\n     * @param   mappingFunction\n     *          当缓存中不存在时，用于获取存储源列表的函数。\n     *\n     * @return  存储源列表函数\n     */\n    public List<StorageSource> findAllEnableOrderByOrderNum(Integer userId, Function<Integer, List<StorageSource>> mappingFunction) {\n        return userEnableStorageSourceCache.computeIfAbsent(userId, mappingFunction);\n    }\n\n    /**\n     * 清空所有用户的存储源缓存。\n     */\n    public void clearUserEnableStorageSourceCache() {\n        userEnableStorageSourceCache.clear();\n    }\n\n    /**\n     * 清除指定用户的存储源缓存。\n     *\n     * @param   userId\n     *          用户 ID\n     */\n    public void clearUserEnableStorageSourceCache(Integer userId) {\n        userEnableStorageSourceCache.remove(userId);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/config/ZFileProperties.java",
    "content": "package im.zhaojun.zfile.core.config;\n\nimport lombok.Data;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\nimport org.springframework.boot.context.properties.EnableConfigurationProperties;\nimport org.springframework.stereotype.Component;\n\n/**\n * ZFile 配置类，将配置文件中的 zfile 配置项映射到该类中.\n *\n * @author zhaojun\n */\n@Data\n@EnableConfigurationProperties\n@Component\n@ConfigurationProperties(prefix = \"zfile\")\npublic class ZFileProperties {\n\n\tprivate boolean debug;\n\n\tprivate String version;\n\n\tprivate boolean isDemoSite;\n\n\tprivate OAuth2Properties onedrive = new OAuth2Properties();\n\tprivate OAuth2Properties onedriveChina = new OAuth2Properties();\n\tprivate OAuth2Properties gd = new OAuth2Properties();\n\tprivate Open115Properties open115 = new Open115Properties();\n\n\t@Data\n\tpublic static class OAuth2Properties {\n\t\tprivate String clientId;\n\t\tprivate String clientSecret;\n\t\tprivate String redirectUri;\n\t\tprivate String scope;\n\t}\n\n\t@Data\n\tpublic static class Open115Properties {\n\t\tprivate String appId;\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/config/datasource/DataSourceBeanPostProcessor.java",
    "content": "package im.zhaojun.zfile.core.config.datasource;\n\n\nimport cn.hutool.core.io.FileUtil;\nimport cn.hutool.extra.spring.SpringUtil;\nimport com.zaxxer.hikari.HikariDataSource;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.BeansException;\nimport org.springframework.beans.factory.config.BeanPostProcessor;\nimport org.springframework.boot.autoconfigure.flyway.FlywayProperties;\nimport org.springframework.core.PriorityOrdered;\nimport org.springframework.stereotype.Component;\n\nimport java.io.File;\nimport java.util.List;\n\n/**\n * 在 Spring 容器初始化时, 对数据源进行处理.\n * <br/>\n * 1. 针对 DataSource 进行处理，仅针对 sqlite：\n * <ul>\n *     <li>提前创建 sqlite 数据文件所在目录.</li>\n *     <li>检测到版本更新时(pom.xml -> project.version)自动备份原数据库.</li>\n * </ul>\n * <br/>\n * 2. 针对 Flyway 进行处理，根据数据库类型, 配置不同的 Flyway Migration Location：\n * <ul>\n *     <li>SQLite 数据库使用 migration-sqlite 目录.</li>\n *     <li>MySQL 数据库使用 migration-mysql 目录.</li>\n * </ul>\n *\n * @author zhaojun\n */\n@Slf4j\n@Component\npublic class DataSourceBeanPostProcessor implements BeanPostProcessor, PriorityOrdered {\n\n    public static final String ZFILE_VERSION_PROPERTIES = \"zfile.db.version\";\n\n    public static final String DRIVE_CLASS_NAME_PROPERTIES = \"spring.datasource.driver-class-name\";\n\n    public static final String DATA_SOURCE_BEAN_NAME = \"dataSource\";\n\n    public static final String SQLITE_DRIVE_CLASS_NAME = \"org.sqlite.JDBC\";\n\n    public static final String MYSQL_DRIVE_CLASS_NAME = \"com.mysql.cj.jdbc.Driver\";\n\n    @Override\n    public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {\n        // 如果更改了数据源类型这里要修改\n        if (bean instanceof HikariDataSource dataSource && DATA_SOURCE_BEAN_NAME.equals(beanName)) {\n            processSqliteDataSource(dataSource);\n        } else if (bean instanceof FlywayProperties flywayProperties) {\n            processFlywayLocations(flywayProperties);\n        }\n        return bean;\n    }\n\n    /**\n     * 如果是 sqlite 数据库, 提前创建数据库文件所在目录. <br/>\n     *\n     * 如果检测到版本更新, 自动备份原数据库文件.\n     *\n     * @param   dataSource\n     *          数据源\n     */\n    private void processSqliteDataSource(HikariDataSource dataSource) {\n        String driverClassName = dataSource.getDriverClassName();\n        String jdbcUrl = dataSource.getJdbcUrl();\n        if (StringUtils.equals(driverClassName, SQLITE_DRIVE_CLASS_NAME)) {\n            String path = jdbcUrl.replace(\"jdbc:sqlite:\", \"\");\n            String folderPath = FileUtil.getAbsolutePath(new File(path).getParentFile());\n            log.info(\"SQLite 数据库文件所在目录: [{}]\", folderPath);\n            File file = new File(folderPath);\n            if (!file.exists()) {\n                log.info(\"检测到 SQLite 数据库文件所在目录不存在, 已自动创建.\");\n                if (!file.mkdirs()) {\n                    log.error(\"SQLite 数据库文件创建失败.\");\n                }\n            } else {\n                log.info(\"检测到 SQLite 数据库文件所在目录已存在, 无需自动创建.\");\n\n                // 更新版本时, 先自动备份数据库文件\n                String version = SpringUtil.getProperty(ZFILE_VERSION_PROPERTIES);\n                if (StringUtils.isNotEmpty(version)) {\n                    String backupPath = folderPath + \"/zfile-update-\" + version + \"-backup.db\";\n                    if (!FileUtil.exist(path)) {\n                        log.error(\"检测到 SQLite 数据库文件不存在, 一般为初始化状态，无需备份.\");\n                        return;\n                    }\n                    if (FileUtil.exist(backupPath)) {\n                        log.info(\"检测到 SQLite 数据库备份文件 [{}] 已存在, 无需再次备份.\", backupPath);\n                    } else {\n                        FileUtil.copy(path, backupPath, false);\n                        log.info(\"自动备份 SQLite 数据库文件到: [{}]\", backupPath);\n                    }\n                }\n            }\n        }\n    }\n\n    /**\n     * 根据使用的不同数据库, 配置使用不同的 migration location\n     *\n     * @param   flywayProperties\n     *          flyway 配置项\n     */\n    private void processFlywayLocations(FlywayProperties flywayProperties) {\n        String driveClassName = SpringUtil.getProperty(DRIVE_CLASS_NAME_PROPERTIES);\n        if (SQLITE_DRIVE_CLASS_NAME.equals(driveClassName)) {\n            flywayProperties.setLocations(List.of(\"classpath:db/migration-sqlite\"));\n        } else if (MYSQL_DRIVE_CLASS_NAME.equals(driveClassName)) {\n            flywayProperties.setLocations(List.of(\"classpath:db/migration-mysql\"));\n        }\n    }\n\n    @Override\n    public int getOrder() {\n        return Integer.MIN_VALUE;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/config/docs/Knife4jConfiguration.java",
    "content": "package im.zhaojun.zfile.core.config.docs;\n\nimport io.swagger.v3.oas.models.OpenAPI;\nimport io.swagger.v3.oas.models.info.Contact;\nimport io.swagger.v3.oas.models.info.Info;\nimport io.swagger.v3.oas.models.info.License;\nimport io.swagger.v3.oas.models.media.StringSchema;\nimport io.swagger.v3.oas.models.parameters.HeaderParameter;\nimport org.springdoc.core.customizers.OperationCustomizer;\nimport org.springdoc.core.models.GroupedOpenApi;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\n\n/**\n * Knife4j 参数配置，区分前台功能和管理员功能，并为管理员接口增加统一 token header 配置.\n *\n * @author zhaojun\n */\n@Configuration\npublic class Knife4jConfiguration {\n\n    @Bean\n    public GroupedOpenApi groupedOpenApi() {\n        String groupName = \"前台功能\";\n        return GroupedOpenApi.builder()\n                .group(groupName)\n                .packagesToScan(\"im.zhaojun.zfile.module\")\n                .pathsToExclude(\"/admin/**\")\n                .build();\n    }\n\n    @Bean\n    public GroupedOpenApi groupedOpenApi2() {\n        String groupName = \"管理员功能\";\n        return GroupedOpenApi.builder()\n                .group(groupName)\n                .packagesToScan(\"im.zhaojun.zfile.module\")\n                .pathsToMatch(\"/admin/**\")\n                .addOperationCustomizer(globalOperationCustomizer())\n                .build();\n    }\n\n    public OperationCustomizer globalOperationCustomizer() {\n        return (operation, handlerMethod) -> {\n            operation.addParametersItem(new HeaderParameter()\n                    .name(\"zfile-token\")\n                    .description(\"token\")\n                    .required(true)\n                    .schema(new StringSchema()));\n            return operation;\n        };\n    }\n\n    @Bean\n    public OpenAPI customOpenAPI() {\n        Contact contact = new Contact();\n        contact.setName(\"zhaojun\");\n        contact.setUrl(\"https://zfile.vip\");\n        contact.setEmail(\"873019219@qq.com\");\n\n        return new OpenAPI()\n                .info(new Info()\n                        .title(\"ZFILE 文档\")\n                        .description(\"# 这是 ZFILE Restful 接口文档展示页面\")\n                        .termsOfService(\"https://www.zfile.vip\")\n                        .contact(contact)\n                        .version(\"1.0\")\n                        .license(new License()\n                                .name(\"Apache 2.0\")\n                                .url(\"http://doc.xiaominfo.com\")));\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/config/jackson/JSONStringDeserializer.java",
    "content": "package im.zhaojun.zfile.core.config.jackson;\n\nimport com.fasterxml.jackson.core.JsonParser;\nimport com.fasterxml.jackson.databind.DeserializationContext;\nimport com.fasterxml.jackson.databind.JsonDeserializer;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\n\nimport java.io.IOException;\n\n/**\n * JSON String 反序列化器, 用于将 JSON 字符串反序列化为 JSON 对象.\n *\n * @author zhaojun\n */\npublic class JSONStringDeserializer extends JsonDeserializer<String> {\n\n    @Override\n    public String deserialize(JsonParser p, DeserializationContext context) throws IOException {\n        JsonNode node = p.getCodec().readTree(p);\n        ObjectMapper mapper = new ObjectMapper();\n        return mapper.writeValueAsString(node);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/config/jackson/JSONStringSerializer.java",
    "content": "package im.zhaojun.zfile.core.config.jackson;\n\nimport com.fasterxml.jackson.core.JsonGenerator;\nimport com.fasterxml.jackson.databind.JsonSerializer;\nimport com.fasterxml.jackson.databind.SerializerProvider;\n\nimport java.io.IOException;\n\n\n/**\n * JSON String 序列化器, 用于将 JSON 字符串序列化为 JSON 对象.\n *\n * @author zhaojun\n */\npublic class JSONStringSerializer extends JsonSerializer<String> {\n\n    @Override\n    public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException {\n        gen.writeRawValue(value);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/config/mybatis/CollectionIntegerTypeHandler.java",
    "content": "package im.zhaojun.zfile.core.config.mybatis;\n\nimport java.util.Set;\n\npublic class CollectionIntegerTypeHandler extends CollectionTypeHandler<Set<Integer>> {\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/config/mybatis/CollectionStrTypeHandler.java",
    "content": "package im.zhaojun.zfile.core.config.mybatis;\n\nimport java.util.Set;\n\npublic class CollectionStrTypeHandler extends CollectionTypeHandler<Set<String>> {\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/config/mybatis/CollectionTypeHandler.java",
    "content": "package im.zhaojun.zfile.core.config.mybatis;\n\nimport cn.hutool.core.convert.Convert;\nimport cn.hutool.core.util.NumberUtil;\nimport cn.hutool.core.util.StrUtil;\nimport org.apache.ibatis.type.BaseTypeHandler;\nimport org.apache.ibatis.type.JdbcType;\nimport org.apache.ibatis.type.MappedJdbcTypes;\nimport org.apache.ibatis.type.MappedTypes;\nimport org.springframework.core.ResolvableType;\n\nimport java.lang.reflect.ParameterizedType;\nimport java.lang.reflect.Type;\nimport java.sql.CallableStatement;\nimport java.sql.PreparedStatement;\nimport java.sql.ResultSet;\nimport java.sql.SQLException;\nimport java.util.*;\n\n/**\n * 自定义 Set 类型处理器, 用于处理数据库 VARCHAR 类型字段和 Java Set 类型属性之间的转换.\n * 支持字符串格式为: \"[a, b, c]\".\n *\n * @author zhaojun\n */\n@MappedJdbcTypes(JdbcType.VARCHAR)\npublic abstract class CollectionTypeHandler<T> extends BaseTypeHandler<Object> {\n\n    @Override\n    public void setNonNullParameter(PreparedStatement ps, int i, Object parameter, JdbcType jdbcType)\n            throws SQLException {\n        if (parameter instanceof Collection collection) {\n            StringJoiner joiner = new StringJoiner(\",\");\n            for (Object o : collection) {\n                joiner.add(Convert.toStr(o));\n            }\n            ps.setString(i, joiner.toString());\n        } else {\n            ps.setString(i, Convert.toStr(parameter));\n        }\n    }\n\n    @Override\n    public Object getNullableResult(ResultSet rs, String columnName)\n            throws SQLException {\n        String str = rs.getString(columnName);\n        return convertToEntityAttribute(str);\n    }\n\n    @Override\n    public Object getNullableResult(ResultSet rs, int columnIndex)\n            throws SQLException {\n        String str = rs.getString(columnIndex);\n        return convertToEntityAttribute(str);\n    }\n\n    @Override\n    public Object getNullableResult(CallableStatement cs, int columnIndex)\n            throws SQLException {\n        String str = cs.getString(columnIndex);\n        return convertToEntityAttribute(str);\n    }\n\n    private Class<?> collectionClazz;\n\n    private Type innerType;\n\n    /**\n     * 构造方法\n     */\n    public CollectionTypeHandler() {\n        ResolvableType resolvableType = ResolvableType.forClass(getClass());\n        Type type = resolvableType.as(CollectionTypeHandler.class).getGeneric().getType();\n\n        if (type instanceof ParameterizedType parameterizedType) {\n            collectionClazz = (Class<?>) parameterizedType.getRawType();\n            // 获取实际类型参数（泛型参数，例如 List<String> 中的 String）\n            Type[] actualTypeArguments = parameterizedType.getActualTypeArguments();\n\n            // 使用这些信息做进一步操作\n            for (Type actualTypeArgument : actualTypeArguments) {\n                innerType = actualTypeArgument;\n                break;\n            }\n        }\n    }\n\n    private Object convertToEntityAttribute(String dbData) {\n        if (StrUtil.isEmpty(dbData)) {\n            if (List.class.isAssignableFrom(collectionClazz)) {\n                return Collections.emptyList();\n            } else if (Set.class.isAssignableFrom(collectionClazz)) {\n                return Collections.emptySet();\n            } else {\n                return null;\n            }\n        }\n\n        Collection collection;\n\n        if (List.class.isAssignableFrom(collectionClazz)) {\n            collection = new ArrayList<>();\n        } else if (Set.class.isAssignableFrom(collectionClazz)) {\n            collection = new HashSet<>();\n        } else {\n            return null;\n        }\n\n        String[] split = dbData.split(\",\");\n        for (String s : split) {\n            if (NumberUtil.isNumber(s)) {\n                collection.add(Convert.convert(Integer.class, s));\n            } else {\n                collection.add(s);\n            }\n        }\n\n        return collection;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/config/mybatis/MyBatisPlusConfig.java",
    "content": "package im.zhaojun.zfile.core.config.mybatis;\n\nimport com.baomidou.mybatisplus.annotation.DbType;\nimport com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;\nimport com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\n\n\nimport javax.sql.DataSource;\nimport java.sql.SQLException;\n\n/**\n * mybatis-plus 配置类\n *\n * @author zhaojun\n */\n@Configuration\npublic class MyBatisPlusConfig {\n\n    /**\n     * mybatis plus 分页插件配置\n     */\n    @Bean\n    public MybatisPlusInterceptor mybatisPlusInterceptor(DataSource dataSource) throws SQLException {\n        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();\n        String databaseProductName = dataSource.getConnection().getMetaData().getDatabaseProductName();\n        DbType dbType = DbType.getDbType(databaseProductName);\n        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(dbType));\n        return interceptor;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/config/mybatis/MyDatabaseIdProvider.java",
    "content": "package im.zhaojun.zfile.core.config.mybatis;\n\nimport org.apache.ibatis.mapping.DatabaseIdProvider;\nimport org.springframework.stereotype.Component;\n\nimport javax.sql.DataSource;\nimport java.sql.Connection;\nimport java.sql.SQLException;\n\n/**\n * MyBatis 数据库 ID Provider, 用于判断当前数据库类型来执行不同的 SQL 语句. <br>\n * 可在 xml 中使用 <code>&lt;if test=\"_databaseId = 'mysql'\"&gt; </code> 来判断数据库类型. <br>\n * 也可以在外层使用，如 <code>&lt;delete id=\"xxx\" databaseId=\"sqlite\"&gt;</code> 来判断数据库类型.\n *\n * @author zhaojun\n */\n@Component\npublic class MyDatabaseIdProvider implements DatabaseIdProvider {\n\n    private static final String DATABASE_MYSQL = \"MySQL\";\n    private static final String DATABASE_SQLITE = \"SQLite\";\n\n    @Override\n    public String getDatabaseId(DataSource dataSource) throws SQLException {\n        Connection conn = dataSource.getConnection();\n        String dbName = conn.getMetaData().getDatabaseProductName();\n        String dbAlias = \"\";\n        switch (dbName) {\n            case DATABASE_MYSQL:\n                dbAlias = \"mysql\";\n                break;\n            case DATABASE_SQLITE:\n                dbAlias = \"sqlite\";\n                break;\n            default:\n                break;\n        }\n        return dbAlias;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/config/mybatis/MyMetaObjectHandler.java",
    "content": "package im.zhaojun.zfile.core.config.mybatis;\n\nimport com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;\nimport lombok.extern.slf4j.Slf4j;\nimport org.apache.ibatis.reflection.MetaObject;\nimport org.springframework.stereotype.Component;\n\nimport java.util.Date;\n\n/**\n * MyBatis Plus 自动填充配置类\n * 用于自动填充 createTime 和 updateTime 字段\n *\n * @author zhaojun\n */\n@Slf4j\n@Component\npublic class MyMetaObjectHandler implements MetaObjectHandler {\n\n    @Override\n    public void insertFill(MetaObject metaObject) {\n        this.strictInsertFill(metaObject, \"createTime\", Date.class, new Date());\n    }\n\n    @Override\n    public void updateFill(MetaObject metaObject) {\n        this.strictUpdateFill(metaObject, \"updateTime\", Date.class, new Date());\n    }\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/config/security/SaSessionForJacksonCustomized.java",
    "content": "package im.zhaojun.zfile.core.config.security;\n\nimport cn.dev33.satoken.session.SaSession;\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\n\n/**\n * Jackson 定制版 SaSession，忽略 timeout 等属性的序列化\n *\n * @author click33\n * @since 1.34.0\n */\n@JsonIgnoreProperties({\"timeout\"})\npublic class SaSessionForJacksonCustomized extends SaSession {\n\n    /**\n     *\n     */\n    private static final long serialVersionUID = -7600983549653130681L;\n\n    public SaSessionForJacksonCustomized() {\n        super();\n    }\n\n    /**\n     * 构建一个Session对象\n     * @param id Session的id\n     */\n    public SaSessionForJacksonCustomized(String id) {\n        super(id);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/config/security/SaTokenConfigure.java",
    "content": "package im.zhaojun.zfile.core.config.security;\n\nimport cn.dev33.satoken.interceptor.SaInterceptor;\nimport cn.dev33.satoken.router.SaRouter;\nimport cn.dev33.satoken.stp.StpUtil;\nimport cn.dev33.satoken.strategy.SaStrategy;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.web.servlet.config.annotation.InterceptorRegistry;\nimport org.springframework.web.servlet.config.annotation.WebMvcConfigurer;\n\n/**\n * SaToken 权限配置, 配置管理员才能访问管理员功能.\n *\n * @author zhaojun\n */\n@Configuration\npublic class SaTokenConfigure implements WebMvcConfigurer {\n\n    /**\n     * 注册权限校验拦截器, 拦截所有 /admin/** 请求，但不包含 /admin 因为这个是登录页面.\n     *\n     * @param   registry\n     *          拦截器注册器\n     */\n    @Override\n    public void addInterceptors(InterceptorRegistry registry) {\n        registry.addInterceptor(new SaInterceptor(handle -> {\n            SaRouter.match(\"/admin/**\", () -> {\n                StpUtil.checkLogin();\n                StpUtil.checkRole(\"admin\");\n            });\n        })).addPathPatterns(\"/**\").excludePathPatterns(\"/admin\");\n\n        // 不再依赖 SaToken 的默认路径检查功能\n        SaStrategy.instance.checkRequestPath = (path, extArg1, extArg2) -> {};\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/config/security/SaTokenDaoRedisJackson.java",
    "content": "//\n// Source code recreated from a .class file by IntelliJ IDEA\n// (powered by FernFlower decompiler)\n//\n\npackage im.zhaojun.zfile.core.config.security;\n\nimport cn.dev33.satoken.dao.SaTokenDao;\nimport cn.dev33.satoken.strategy.SaStrategy;\nimport cn.dev33.satoken.util.SaFoxUtil;\nimport com.fasterxml.jackson.databind.DeserializationFeature;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;\nimport com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;\nimport com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;\nimport com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;\nimport com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;\nimport com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;\nimport com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;\nimport org.springframework.data.redis.connection.RedisConnectionFactory;\nimport org.springframework.data.redis.core.RedisTemplate;\nimport org.springframework.data.redis.core.StringRedisTemplate;\nimport org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;\nimport org.springframework.data.redis.serializer.StringRedisSerializer;\nimport org.springframework.stereotype.Component;\n\nimport java.lang.reflect.Field;\nimport java.time.LocalDate;\nimport java.time.LocalDateTime;\nimport java.time.LocalTime;\nimport java.time.format.DateTimeFormatter;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Set;\nimport java.util.concurrent.TimeUnit;\n\n/**\n * Sa-Token 持久层实现 [ Redis存储、Jackson序列化 ]\n *\n * @author click33\n * @since 1.34.0\n */\n@Component\n@ConditionalOnProperty(name = \"spring.data.redis.host\")\npublic class SaTokenDaoRedisJackson implements SaTokenDao {\n\n    public static final String DATE_TIME_PATTERN = \"yyyy-MM-dd HH:mm:ss\";\n    public static final String DATE_PATTERN = \"yyyy-MM-dd\";\n    public static final String TIME_PATTERN = \"HH:mm:ss\";\n    public static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern(DATE_TIME_PATTERN);\n    public static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern(DATE_PATTERN);\n    public static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern(TIME_PATTERN);\n\n    /**\n     * ObjectMapper 对象 (以 public 作用域暴露出此对象，方便开发者二次更改配置)\n     *\n     * <p> 例如：\n     * \t<pre>\n     *      SaTokenDaoRedisJackson redisJackson = (SaTokenDaoRedisJackson) SaManager.getSaTokenDao();\n     *      redisJackson.objectMapper.xxx = xxx;\n     * \t</pre>\n     * </p>\n     */\n    public ObjectMapper objectMapper;\n\n    /**\n     * String 读写专用\n     */\n    public StringRedisTemplate stringRedisTemplate;\n\n    /**\n     * Object 读写专用\n     */\n    public RedisTemplate<String, Object> objectRedisTemplate;\n\n    /**\n     * 标记：是否已初始化成功\n     */\n    public boolean isInit;\n\n    @Autowired\n    public void init(RedisConnectionFactory connectionFactory) {\n        // 如果已经初始化成功了，就立刻退出，不重复初始化\n        if(this.isInit) {\n            return;\n        }\n\n        // 指定相应的序列化方案\n        StringRedisSerializer keySerializer = new StringRedisSerializer();\n        GenericJackson2JsonRedisSerializer valueSerializer = new GenericJackson2JsonRedisSerializer();\n\n        // 通过反射获取Mapper对象, 增加一些配置, 增强兼容性\n        try {\n            Field field = GenericJackson2JsonRedisSerializer.class.getDeclaredField(\"mapper\");\n            field.setAccessible(true);\n            this.objectMapper = (ObjectMapper) field.get(valueSerializer);\n\n            // 配置[忽略未知字段]\n            this.objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);\n\n            // 配置[时间类型转换]\n            JavaTimeModule timeModule = new JavaTimeModule();\n\n            // LocalDateTime序列化与反序列化\n            timeModule.addSerializer(new LocalDateTimeSerializer(DATE_TIME_FORMATTER));\n            timeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DATE_TIME_FORMATTER));\n\n            // LocalDate序列化与反序列化\n            timeModule.addSerializer(new LocalDateSerializer(DATE_FORMATTER));\n            timeModule.addDeserializer(LocalDate.class, new LocalDateDeserializer(DATE_FORMATTER));\n\n            // LocalTime序列化与反序列化\n            timeModule.addSerializer(new LocalTimeSerializer(TIME_FORMATTER));\n            timeModule.addDeserializer(LocalTime.class, new LocalTimeDeserializer(TIME_FORMATTER));\n\n            this.objectMapper.registerModule(timeModule);\n\n            // 重写 SaSession 生成策略\n            SaStrategy.instance.createSession = (sessionId) -> new SaSessionForJacksonCustomized(sessionId);\n        } catch (Exception e) {\n            System.err.println(e.getMessage());\n        }\n        // 构建StringRedisTemplate\n        StringRedisTemplate stringTemplate = new StringRedisTemplate();\n        stringTemplate.setConnectionFactory(connectionFactory);\n        stringTemplate.afterPropertiesSet();\n\n        // 构建RedisTemplate\n        RedisTemplate<String, Object> template = new RedisTemplate<>();\n        template.setConnectionFactory(connectionFactory);\n        template.setKeySerializer(keySerializer);\n        template.setHashKeySerializer(keySerializer);\n        template.setValueSerializer(valueSerializer);\n        template.setHashValueSerializer(valueSerializer);\n        template.afterPropertiesSet();\n\n        // 开始初始化相关组件\n        this.stringRedisTemplate = stringTemplate;\n        this.objectRedisTemplate = template;\n\n        // 打上标记，表示已经初始化成功，后续无需再重新初始化\n        this.isInit = true;\n    }\n\n\n    /**\n     * 获取Value，如无返空\n     */\n    @Override\n    public String get(String key) {\n        return stringRedisTemplate.opsForValue().get(key);\n    }\n\n    /**\n     * 写入Value，并设定存活时间 (单位: 秒)\n     */\n    @Override\n    public void set(String key, String value, long timeout) {\n        if(timeout == 0 || timeout <= SaTokenDao.NOT_VALUE_EXPIRE)  {\n            return;\n        }\n        // 判断是否为永不过期\n        if(timeout == SaTokenDao.NEVER_EXPIRE) {\n            stringRedisTemplate.opsForValue().set(key, value);\n        } else {\n            stringRedisTemplate.opsForValue().set(key, value, timeout, TimeUnit.SECONDS);\n        }\n    }\n\n    /**\n     * 修修改指定key-value键值对 (过期时间不变)\n     */\n    @Override\n    public void update(String key, String value) {\n        long expire = getTimeout(key);\n        // -2 = 无此键\n        if(expire == SaTokenDao.NOT_VALUE_EXPIRE) {\n            return;\n        }\n        this.set(key, value, expire);\n    }\n\n    /**\n     * 删除Value\n     */\n    @Override\n    public void delete(String key) {\n        stringRedisTemplate.delete(key);\n    }\n\n    /**\n     * 获取Value的剩余存活时间 (单位: 秒)\n     */\n    @Override\n    public long getTimeout(String key) {\n        return stringRedisTemplate.getExpire(key);\n    }\n\n    /**\n     * 修改Value的剩余存活时间 (单位: 秒)\n     */\n    @Override\n    public void updateTimeout(String key, long timeout) {\n        // 判断是否想要设置为永久\n        if(timeout == SaTokenDao.NEVER_EXPIRE) {\n            long expire = getTimeout(key);\n            if(expire == SaTokenDao.NEVER_EXPIRE) {\n                // 如果其已经被设置为永久，则不作任何处理\n            } else {\n                // 如果尚未被设置为永久，那么再次set一次\n                this.set(key, this.get(key), timeout);\n            }\n            return;\n        }\n        stringRedisTemplate.expire(key, timeout, TimeUnit.SECONDS);\n    }\n\n\n\n    /**\n     * 获取Object，如无返空\n     */\n    @Override\n    public Object getObject(String key) {\n        return objectRedisTemplate.opsForValue().get(key);\n    }\n\n    /**\n     * 写入Object，并设定存活时间 (单位: 秒)\n     */\n    @Override\n    public void setObject(String key, Object object, long timeout) {\n        if(timeout == 0 || timeout <= SaTokenDao.NOT_VALUE_EXPIRE)  {\n            return;\n        }\n        // 判断是否为永不过期\n        if(timeout == SaTokenDao.NEVER_EXPIRE) {\n            objectRedisTemplate.opsForValue().set(key, object);\n        } else {\n            objectRedisTemplate.opsForValue().set(key, object, timeout, TimeUnit.SECONDS);\n        }\n    }\n\n    /**\n     * 更新Object (过期时间不变)\n     */\n    @Override\n    public void updateObject(String key, Object object) {\n        long expire = getObjectTimeout(key);\n        // -2 = 无此键\n        if(expire == SaTokenDao.NOT_VALUE_EXPIRE) {\n            return;\n        }\n        this.setObject(key, object, expire);\n    }\n\n    /**\n     * 删除Object\n     */\n    @Override\n    public void deleteObject(String key) {\n        objectRedisTemplate.delete(key);\n    }\n\n    /**\n     * 获取Object的剩余存活时间 (单位: 秒)\n     */\n    @Override\n    public long getObjectTimeout(String key) {\n        return objectRedisTemplate.getExpire(key);\n    }\n\n    /**\n     * 修改Object的剩余存活时间 (单位: 秒)\n     */\n    @Override\n    public void updateObjectTimeout(String key, long timeout) {\n        // 判断是否想要设置为永久\n        if(timeout == SaTokenDao.NEVER_EXPIRE) {\n            long expire = getObjectTimeout(key);\n            if(expire == SaTokenDao.NEVER_EXPIRE) {\n                // 如果其已经被设置为永久，则不作任何处理\n            } else {\n                // 如果尚未被设置为永久，那么再次set一次\n                this.setObject(key, this.getObject(key), timeout);\n            }\n            return;\n        }\n        objectRedisTemplate.expire(key, timeout, TimeUnit.SECONDS);\n    }\n\n\n    /**\n     * 搜索数据\n     */\n    @Override\n    public List<String> searchData(String prefix, String keyword, int start, int size, boolean sortType) {\n        Set<String> keys = stringRedisTemplate.keys(prefix + \"*\" + keyword + \"*\");\n        List<String> list = new ArrayList<>(keys);\n        return SaFoxUtil.searchList(list, start, size, sortType);\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/config/security/StpInterfaceImpl.java",
    "content": "package im.zhaojun.zfile.core.config.security;\n\nimport cn.dev33.satoken.stp.StpInterface;\nimport cn.hutool.core.convert.Convert;\nimport im.zhaojun.zfile.module.user.service.UserService;\nimport jakarta.annotation.Resource;\nimport org.springframework.stereotype.Component;\n\nimport java.util.Collections;\nimport java.util.List;\n\n/**\n * 自定义权限加载接口实现类\n *\n * @author zhaojun\n */\n@Component\npublic class StpInterfaceImpl implements StpInterface {\n\n    private static final List<String> ADMIN_ROLE_LIST = Collections.singletonList(\"admin\");\n\n    public static final List<String> EMPTY_ROLE_LIST = Collections.emptyList();\n\n    @Resource\n    private UserService userService;\n\n    /**\n     * 返回一个账号所拥有的权限码集合，这里没用到这个功能，所以返回空集合\n     */\n    @Override\n    public List<String> getPermissionList(Object loginId, String loginType) {\n        return Collections.emptyList();\n    }\n\n    /**\n     * 返回一个账号所拥有的角色标识集合 (权限与角色可分开校验)\n     */\n    @Override\n    public List<String> getRoleList(Object loginId, String loginType) {\n        boolean isAdmin = userService.isAdmin(Convert.toInt(loginId));\n        return isAdmin ? ADMIN_ROLE_LIST : EMPTY_ROLE_LIST;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/config/spring/JacksonEnumDeserializer.java",
    "content": "package im.zhaojun.zfile.core.config.spring;\n\nimport com.fasterxml.jackson.core.JsonParser;\nimport com.fasterxml.jackson.databind.BeanProperty;\nimport com.fasterxml.jackson.databind.DeserializationContext;\nimport com.fasterxml.jackson.databind.JsonDeserializer;\nimport com.fasterxml.jackson.databind.deser.ContextualDeserializer;\nimport lombok.Setter;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.boot.jackson.JsonComponent;\n\nimport java.io.IOException;\nimport java.lang.reflect.InvocationTargetException;\nimport java.lang.reflect.Method;\nimport java.util.Objects;\n\n/**\n * Jackson 枚举反序列化器, 用于将接收请求中的参数(一般为字符串)转换为枚举对象.\n *\n * @author zhaojun\n */\n@Setter\n@Slf4j\n@JsonComponent\npublic class JacksonEnumDeserializer extends JsonDeserializer<Enum<?>> implements ContextualDeserializer {\n\n\tprivate Class<?> clazz;\n\n    /**\n     * 反序列化操作\n     *\n     * @param   jsonParser\n     *          json 解析器\n     *\n     * @param   ctx\n     *          反序列化上下文\n     *\n     * @return  反序列化后的枚举值\n     * @throws  IOException  反序列化异常\n\t */\n\t@Override\n\tpublic Enum<?> deserialize(JsonParser jsonParser, DeserializationContext ctx) throws IOException {\n\t\tClass<?> enumType = clazz;\n\t\tif (Objects.isNull(enumType) || !enumType.isEnum()) {\n\t\t\treturn null;\n\t\t}\n\t\tString text = jsonParser.getText();\n\t\tMethod method = StringToEnumConverterFactory.getMethod(clazz);\n\t\tEnum<?>[] enumConstants = (Enum<?>[]) enumType.getEnumConstants();\n\n\t\t// 将值与枚举对象对应并缓存\n\t\tfor (Enum<?> e : enumConstants) {\n\t\t\ttry {\n\t\t\t\tif (Objects.equals(method.invoke(e).toString(), text)) {\n\t\t\t\t\treturn e;\n\t\t\t\t}\n\t\t\t} catch (IllegalAccessException | InvocationTargetException ex) {\n\t\t\t\tlog.error(\"获取枚举值错误!!! \", ex);\n\t\t\t}\n\t\t}\n\t\treturn null;\n\t}\n\n\n\t/**\n\t * 为不同的枚举获取合适的解析器\n\t *\n\t * @param   ctx\n     *          反序列化上下文\n     *\n\t * @param   property\n     *          property\n\t */\n\t@Override\n\tpublic JsonDeserializer<Enum<?>> createContextual(DeserializationContext ctx, BeanProperty property) {\n\t\tClass<?> rawCls = ctx.getContextualType().getRawClass();\n\t\tJacksonEnumDeserializer converter = new JacksonEnumDeserializer();\n\t\tconverter.setClazz(rawCls);\n\t\treturn converter;\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/config/spring/SpringCacheConfig.java",
    "content": "package im.zhaojun.zfile.core.config.spring;\n\nimport im.zhaojun.zfile.core.config.security.SaTokenDaoRedisJackson;\nimport org.apache.commons.lang3.BooleanUtils;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;\nimport org.springframework.cache.CacheManager;\nimport org.springframework.cache.annotation.EnableCaching;\nimport org.springframework.cache.concurrent.ConcurrentMapCacheManager;\nimport org.springframework.cache.support.NoOpCacheManager;\nimport org.springframework.cache.transaction.TransactionAwareCacheManagerProxy;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\n\n/**\n * Spring Cache 相关配置\n *\n * @author zhaojun\n */\n@Configuration\n@EnableCaching\npublic class SpringCacheConfig {\n\n\t@Value(\"${zfile.dbCache.enable:true}\")\n\tprivate Boolean dbCacheEnable;\n\n\t/**\n\t * 使用 TransactionAwareCacheManagerProxy 装饰 ConcurrentMapCacheManager，使其支持事务 （将 put、evict、clear 操作延迟到事务成功提交再执行.）\n\t */\n\t@Bean\n\t@ConditionalOnMissingBean(SaTokenDaoRedisJackson.class)\n\tpublic CacheManager cacheManager() {\n\t\treturn BooleanUtils.isNotTrue(dbCacheEnable) ? new NoOpCacheManager() : new TransactionAwareCacheManagerProxy(new ConcurrentMapCacheManager());\n\t}\n\t\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/config/spring/StringToEnumConverterFactory.java",
    "content": "package im.zhaojun.zfile.core.config.spring;\n\nimport com.baomidou.mybatisplus.annotation.EnumValue;\nimport com.baomidou.mybatisplus.annotation.IEnum;\nimport com.baomidou.mybatisplus.core.toolkit.StringUtils;\nimport im.zhaojun.zfile.core.exception.core.SystemException;\nimport jakarta.validation.constraints.NotNull;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.core.convert.converter.Converter;\nimport org.springframework.core.convert.converter.ConverterFactory;\n\nimport java.lang.reflect.Field;\nimport java.lang.reflect.InvocationTargetException;\nimport java.lang.reflect.Method;\nimport java.util.Arrays;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.concurrent.ConcurrentHashMap;\n\n/**\n * String 转枚举通用转换器工厂\n *\n * @author zhaojun\n */\n@Slf4j\npublic class StringToEnumConverterFactory implements ConverterFactory<String, Enum<?>> {\n\n    /**\n     * 存储枚举类型的缓存\n     */\n    private static final Map<Class<?>, Converter<String, ? extends Enum<?>>> CONVERTER_MAP = new ConcurrentHashMap<>();\n\n    /**\n     * 枚举类的获取枚举值方法缓存\n     */\n    private static final Map<Class<?>, Method> TABLE_METHOD_OF_ENUM_TYPES = new ConcurrentHashMap<>();\n\n    @Override\n    @SuppressWarnings(\"unchecked cast\")\n    public <T extends Enum<?>> Converter<String, T> getConverter(Class<T> targetType) {\n        // 缓存转换器\n        Converter<String, T> converter = (Converter<String, T>) CONVERTER_MAP.get(targetType);\n        if (converter == null) {\n            converter = new StringToEnumConverter<>(targetType);\n            CONVERTER_MAP.put(targetType, converter);\n        }\n        return converter;\n    }\n\n    static class StringToEnumConverter<T extends Enum<?>> implements Converter<String, T> {\n\n        private final Map<String, T> enumMap = new ConcurrentHashMap<>();\n\n        StringToEnumConverter(Class<T> enumType) {\n            Method method = getMethod(enumType);\n            T[] enums = enumType.getEnumConstants();\n\n            // 将值与枚举对象对应并缓存\n            for (T e : enums) {\n                try {\n                    enumMap.put(method.invoke(e).toString(), e);\n                } catch (IllegalAccessException | InvocationTargetException ex) {\n                    log.error(\"获取枚举值错误!!! \", ex);\n                }\n            }\n        }\n\n\n        @Override\n        public T convert(@NotNull String source) {\n            // 获取\n            T t = enumMap.get(source);\n            if (t == null) {\n                throw new SystemException(\"该字符串找不到对应的枚举对象 字符串:\" + source);\n            }\n            return t;\n        }\n    }\n\n\n    public static <T> Method getMethod(Class<T> enumType) {\n        Method method;\n        // 找到取值的方法\n        if (IEnum.class.isAssignableFrom(enumType)) {\n            try {\n                method = enumType.getMethod(\"getValue\");\n            } catch (NoSuchMethodException e) {\n                throw new SystemException(String.format(\"类:%s 找不到 getValue方法\",\n                        enumType.getName()));\n            }\n        } else {\n            method = TABLE_METHOD_OF_ENUM_TYPES.computeIfAbsent(enumType, k -> {\n                Field field =\n                        dealEnumType(enumType).orElseThrow(() -> new IllegalArgumentException(String.format(\n                                \"类:%s 找不到 EnumValue注解\", enumType.getName())));\n\n                Class<?> fieldType = field.getType();\n                String fieldName = field.getName();\n                String methodName =  StringUtils.concatCapitalize(boolean.class.equals(fieldType) ? \"is\" : \"get\", fieldName);\n                try {\n                    return enumType.getDeclaredMethod(methodName);\n                } catch (NoSuchMethodException e) {\n                    e.printStackTrace();\n                }\n                return null;\n            });\n        }\n        return method;\n    }\n\n\n    private static Optional<Field> dealEnumType(Class<?> clazz) {\n        return clazz.isEnum() ?\n                Arrays.stream(clazz.getDeclaredFields()).filter(field -> field.isAnnotationPresent(EnumValue.class)).findFirst() : Optional.empty();\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/config/spring/WebMvcConfig.java",
    "content": "package im.zhaojun.zfile.core.config.spring;\n\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;\nimport org.springframework.boot.web.servlet.server.ServletWebServerFactory;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.format.FormatterRegistry;\nimport org.springframework.web.servlet.config.annotation.WebMvcConfigurer;\n\n/**\n * ZFile Web 相关配置.\n *\n * @author zhaojun\n */\n@Configuration\npublic class WebMvcConfig implements WebMvcConfigurer {\n\n    /**\n     * 支持 url 中传入 <>[\\]^`{|} 这些特殊字符.\n     */\n    @Bean\n    public ServletWebServerFactory webServerFactory() {\n        TomcatServletWebServerFactory webServerFactory = new TomcatServletWebServerFactory();\n\n        // 添加对 URL 中特殊符号的支持.\n        webServerFactory.addConnectorCustomizers(connector -> {\n            connector.setProperty(\"relaxedPathChars\", \"<>[\\\\]^`{|}%[]\");\n            connector.setProperty(\"relaxedQueryChars\", \"<>[\\\\]^`{|}%[]\");\n        });\n        return webServerFactory;\n    }\n\n    /**\n     * 添加自定义枚举格式化器.\n     * @see StorageTypeEnum\n     */\n    @Override\n    public void addFormatters(FormatterRegistry registry) {\n        registry.addConverterFactory(new StringToEnumConverterFactory());\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/config/totp/TotpAutoConfiguration.java",
    "content": "package im.zhaojun.zfile.core.config.totp;\n\nimport dev.samstevens.totp.TotpInfo;\nimport dev.samstevens.totp.code.*;\nimport dev.samstevens.totp.qr.QrDataFactory;\nimport dev.samstevens.totp.secret.DefaultSecretGenerator;\nimport dev.samstevens.totp.secret.SecretGenerator;\nimport dev.samstevens.totp.time.SystemTimeProvider;\nimport dev.samstevens.totp.time.TimeProvider;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.autoconfigure.condition.ConditionalOnClass;\nimport org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;\nimport org.springframework.boot.context.properties.EnableConfigurationProperties;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\n\n@Configuration\n@ConditionalOnClass({TotpInfo.class})\n@EnableConfigurationProperties({TotpProperties.class})\npublic class TotpAutoConfiguration {\n\n    private final TotpProperties props;\n\n    @Autowired\n    public TotpAutoConfiguration(TotpProperties props) {\n        this.props = props;\n    }\n\n    @Bean\n    @ConditionalOnMissingBean\n    public SecretGenerator secretGenerator() {\n        int length = this.props.getSecret().getLength();\n        return new DefaultSecretGenerator(length);\n    }\n\n    @Bean\n    @ConditionalOnMissingBean\n    public TimeProvider timeProvider() {\n        return new SystemTimeProvider();\n    }\n\n    @Bean\n    @ConditionalOnMissingBean\n    public HashingAlgorithm hashingAlgorithm() {\n        return HashingAlgorithm.SHA1;\n    }\n\n    @Bean\n    @ConditionalOnMissingBean\n    public QrDataFactory qrDataFactory(HashingAlgorithm hashingAlgorithm) {\n        return new QrDataFactory(hashingAlgorithm, this.getCodeLength(), this.getTimePeriod());\n    }\n\n    @Bean\n    @ConditionalOnMissingBean\n    public CodeGenerator codeGenerator(HashingAlgorithm algorithm) {\n        return new DefaultCodeGenerator(algorithm, this.getCodeLength());\n    }\n\n    @Bean\n    @ConditionalOnMissingBean\n    public CodeVerifier codeVerifier(CodeGenerator codeGenerator, TimeProvider timeProvider) {\n        DefaultCodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider);\n        verifier.setTimePeriod(this.getTimePeriod());\n        verifier.setAllowedTimePeriodDiscrepancy(this.props.getTime().getDiscrepancy());\n        return verifier;\n    }\n\n    private int getCodeLength() {\n        return this.props.getCode().getLength();\n    }\n\n    private int getTimePeriod() {\n        return this.props.getTime().getPeriod();\n    }\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/config/totp/TotpProperties.java",
    "content": "package im.zhaojun.zfile.core.config.totp;\n\nimport org.springframework.boot.context.properties.ConfigurationProperties;\n\n@ConfigurationProperties(\n    prefix = \"totp\"\n)\npublic class TotpProperties {\n    private static final int DEFAULT_SECRET_LENGTH = 32;\n    private static final int DEFAULT_CODE_LENGTH = 6;\n    private static final int DEFAULT_TIME_PERIOD = 30;\n    private static final int DEFAULT_TIME_DISCREPANCY = 1;\n    private final Secret secret = new Secret();\n    private final Code code = new Code();\n    private final Time time = new Time();\n\n    public TotpProperties() {\n    }\n\n    public Secret getSecret() {\n        return this.secret;\n    }\n\n    public Code getCode() {\n        return this.code;\n    }\n\n    public Time getTime() {\n        return this.time;\n    }\n\n    public static class Time {\n        private int period = 30;\n        private int discrepancy = 1;\n\n        public Time() {\n        }\n\n        public int getPeriod() {\n            return this.period;\n        }\n\n        public void setPeriod(int period) {\n            this.period = period;\n        }\n\n        public int getDiscrepancy() {\n            return this.discrepancy;\n        }\n\n        public void setDiscrepancy(int discrepancy) {\n            this.discrepancy = discrepancy;\n        }\n    }\n\n    public static class Code {\n        private int length = 6;\n\n        public Code() {\n        }\n\n        public int getLength() {\n            return this.length;\n        }\n\n        public void setLength(int length) {\n            this.length = length;\n        }\n    }\n\n    public static class Secret {\n        private int length = 32;\n\n        public Secret() {\n        }\n\n        public int getLength() {\n            return this.length;\n        }\n\n        public void setLength(int length) {\n            this.length = length;\n        }\n    }\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/constant/MdcConstant.java",
    "content": "package im.zhaojun.zfile.core.constant;\n\n/**\n * Slf4j mdc 常量\n *\n * @author zhaojun\n */\npublic class MdcConstant {\n\t\n\tpublic static final String TRACE_ID = \"traceId\";\n\t\n\tpublic static final String IP = \"ip\";\n\t\n\tpublic static final String USER = \"user\";\n\t\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/constant/RuleTypeConstant.java",
    "content": "package im.zhaojun.zfile.core.constant;\n\n/**\n * 规则表达式类型常量\n *\n * @author zhaojun\n */\npublic class RuleTypeConstant {\n\n    public static final String IP = \"ip\";\n\n    public static final String REGEX = \"regex\";\n\n    public static final String ANT_PATH = \"antPath\";\n\n    public static final String SPRING_SIMPLE = \"springSimple\";\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/constant/ZFileConstant.java",
    "content": "package im.zhaojun.zfile.core.constant;\n\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.context.annotation.Configuration;\n\n/**\n * ZFile 常量\n *\n * @author zhaojun\n */\n@Configuration\npublic class ZFileConstant {\n\n    /**\n     * 最大支持文本文件大小为 ? KB 的文件内容.\n     */\n    public static Long TEXT_MAX_FILE_SIZE_KB = 100L;\n\n    @Autowired(required = false)\n    public void setTextMaxFileSizeMb(@Value(\"${zfile.preview.text.maxFileSizeKb}\") Long maxFileSizeKb) {\n        ZFileConstant.TEXT_MAX_FILE_SIZE_KB = maxFileSizeKb;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/constant/ZFileHttpHeaderConstant.java",
    "content": "package im.zhaojun.zfile.core.constant;\n\n/**\n * ZFile 自定义 HTTP 请求头常量\n *\n * @author zhaojun\n */\npublic class ZFileHttpHeaderConstant {\n\n    public static final String ZFILE_TOKEN = \"Zfile-Token\";\n\n    public static final String AXIOS_REQUEST = \"Axios-Request\";\n\n    public static final String AXIOS_FROM = \"Axios-From\";\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/controller/FrontIndexController.java",
    "content": "package im.zhaojun.zfile.core.controller;\n\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;\nimport im.zhaojun.zfile.module.config.service.SystemConfigService;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.boot.autoconfigure.web.WebProperties;\nimport org.springframework.core.io.FileSystemResourceLoader;\nimport org.springframework.core.io.ResourceLoader;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.stereotype.Controller;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.ResponseBody;\n\nimport java.nio.charset.StandardCharsets;\n\n/**\n * 处理前端首页 Controller\n *\n * @author zhaojun\n */\n@Slf4j\n@Controller\npublic class FrontIndexController {\n\n\t@Resource\n\tprivate SystemConfigService systemConfigService;\n\n\t@Resource\n\tprivate WebProperties webProperties;\n\n\t/**\n\t * 所有未找到的页面都跳转到首页, 用户解决 vue history 直接访问 404 的问题\n\t * 同时, 读取 index.html 文件, 修改 title 和 favicon 后返回.\n\t *\n\t * @return  转发到 /index.html\n\t */\n\t@RequestMapping(value = { \"/\"})\n\t@ResponseBody\n\tpublic ResponseEntity<String> redirect() {\n\t\t// 读取 resources/static/index.html 文件修改 title 和 favicon 后返回\n\t\tResourceLoader resourceLoader = new FileSystemResourceLoader();\n\t\tString[] staticLocations = webProperties.getResources().getStaticLocations();\n\n\t\t// 如果 staticLocations 里没有包含 file:static/, 则手动添加\n\t\tboolean fileStaticExist = false;\n\t\tfor (String staticLocation : staticLocations) {\n\t\t\tif (staticLocation.startsWith(\"file:\")) {\n\t\t\t\tfileStaticExist = true;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\tif (!fileStaticExist) {\n\t\t\tstaticLocations = org.apache.commons.lang3.ArrayUtils.add(staticLocations, \"file:static/\");\n\t\t}\n\n\t\tfor (String staticLocation : staticLocations) {\n\t\t\torg.springframework.core.io.Resource resource = resourceLoader.getResource(staticLocation + \"/index.html\");\n\t\t\tboolean exists = resource.exists();\n\t\t\tif (exists) {\n\t\t\t\tString content;\n\t\t\t\ttry {\n\t\t\t\t\tcontent = resource.getContentAsString(StandardCharsets.UTF_8);\n\t\t\t\t\tif (log.isTraceEnabled()) {\n\t\t\t\t\t\tlog.trace(\"读取 index.html 文件成功, 文件路径: {}\", staticLocation);\n\t\t\t\t\t}\n\t\t\t\t} catch (Exception e) {\n\t\t\t\t\tlog.error(\"{} 资源存在但读取 index.html 文件失败.\", staticLocation);\n\t\t\t\t\treturn ResponseEntity.status(500).body(\"static index.html read error\");\n\t\t\t\t}\n\n\t\t\t\tSystemConfigDTO systemConfig = systemConfigService.getSystemConfig();\n\n\t\t\t\t// 替换为系统设置中的站点名称\n\t\t\t\tString siteName = systemConfig.getSiteName();\n\t\t\t\tif (StringUtils.isNotBlank(siteName)) {\n\t\t\t\t\tcontent = content.replace(\"<title>ZFile</title>\", \"<title>\" + siteName + \"</title>\");\n\t\t\t\t}\n\n\t\t\t\t// 替换为系统设置中的 favicon 地址\n\t\t\t\tString faviconUrl = systemConfig.getFaviconUrl();\n\t\t\t\tif (StringUtils.isNotBlank(faviconUrl)) {\n\t\t\t\t\tcontent = content.replace(\"/favicon.svg\", faviconUrl);\n\t\t\t\t}\n\n\t\t\t\t// 添加缓存控制头\n\t\t\t\treturn ResponseEntity.ok()\n\t\t\t\t\t\t.header(\"Cache-Control\", \"max-age=600, must-revalidate, proxy-revalidate\")\t\t\t\t\t\t.header(\"Pragma\", \"no-cache\")\n\t\t\t\t\t\t.body(content);\n\t\t\t}\n\t\t}\n\n\t\treturn ResponseEntity.status(404).body(\"static index.html not found\");\n\t}\n\n\t@RequestMapping(value = { \"/guest\"})\n\t@ResponseBody\n\tpublic String guest() {\n\t\tSystemConfigDTO systemConfig = systemConfigService.getSystemConfig();\n\t\treturn systemConfig.getGuestIndexHtml();\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/controller/LogController.java",
    "content": "package im.zhaojun.zfile.core.controller;\n\nimport cn.hutool.core.date.DateUtil;\nimport cn.hutool.core.util.ZipUtil;\nimport com.github.xiaoymin.knife4j.annotations.ApiSort;\nimport im.zhaojun.zfile.core.annotation.DemoDisable;\nimport im.zhaojun.zfile.core.util.FileResponseUtil;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.Resource;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport java.io.File;\nimport java.util.Date;\n\n/**\n * 获取系统日志接口\n *\n * @author zhaojun\n */\n@Tag(name = \"日志\")\n@ApiSort(8)\n@Slf4j\n@RestController\n@RequestMapping(\"/admin\")\npublic class LogController {\n\n    @Value(\"${zfile.log.path}\")\n    private String zfileLogPath;\n\n    @GetMapping(\"/log/download\")\n    @Operation(summary = \"下载系统日志\")\n    @DemoDisable\n    public ResponseEntity<Resource> downloadLog() {\n        if (log.isDebugEnabled()) {\n            log.debug(\"下载诊断日志\");\n        }\n\n        File fileZip = ZipUtil.zip(zfileLogPath);\n        String currentDate = DateUtil.format(new Date(), \"yyyy-MM-dd HH:mm:ss\");\n        return FileResponseUtil.exportSingleThread(fileZip, \"ZFile 诊断日志 - \" + currentDate + \".zip\");\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/exception/ErrorCode.java",
    "content": "package im.zhaojun.zfile.core.exception;\n\nimport lombok.Getter;\n\n/**\n * 异常信息枚举类\n *\n * @author zhaojun\n */\n@Getter\npublic enum ErrorCode {\n\n    /**\n     * 系统异常\n     */\n    SYSTEM_ERROR(\"50000\", \"系统异常\"),\n    INVALID_STORAGE_SOURCE(\"50001\", \"无效或初始化失败的存储源\"),\n    DEMO_SITE_DISABLE_OPERATOR(\"50002\", \"演示站点不允许此操作\"),\n\n    /**\n     * 业务异常 4xxxx.\n     * 第二位为 0 时，是系统初始化相关错误\n     * 第二位为 1 时，是前台(文件管理)错误\n     * 第二位为 2 时，是登录错误\n     * 第二位为 3 时，是管理员端错误\n     */\n    BIZ_ERROR(\"40000\", \"操作失败\"),\n    BIZ_NOT_FOUND(\"40400\", \"NOT FOUND\"),\n\n    // 第二位为 0 时，是系统初始化相关错误\n    BIZ_SYSTEM_ALREADY_INIT(\"40001\", \"系统已初始化，请勿重复初始化\"),\n    BIZ_SYSTEM_INIT_ERROR(\"40002\", \"系统初始化错误\"),\n\n    // 第二位为 1 时，是前台(文件管理)错误\n    BIZ_BAD_REQUEST(\"41000\", \"请求参数异常\"),\n    BIZ_UNSUPPORTED_PROXY_DOWNLOAD(\"41001\", \"该存储源不支持代理下载\"),\n    BIZ_INVALID_SIGNATURE(\"41002\", \"签名无效或下载地址已过期\"),\n    BIZ_PREVIEW_FILE_SIZE_EXCEED(\"41003\", \"预览文本文件大小超出系统限制\"),\n    BIZ_FILE_NOT_EXIST(\"41004\", \"文件不存在\"),\n    BIZ_ACCESS_TOO_FREQUENT(\"41005\", \"请求太频繁了，请稍后再试\"),\n    BIZ_UPLOAD_FILE_NOT_EMPTY(\"41006\", \"上传文件不能为空\"),\n    BIZ_UPLOAD_FILE_ERROR(\"41010\", \"上传文件失败\"),\n    BIZ_UPLOAD_FILE_TIMEOUT_ERROR(\"41026\", \"上传文件超时\"),\n    BIZ_EXPIRE_TIME_ILLEGAL(\"41007\", \"过期时间不合法\"),\n    BIZ_DELETE_FILE_NOT_EMPTY(\"41008\", \"非空文件夹不允许删除\"),\n    BIZ_FILE_PATH_ILLEGAL(\"41009\", \"文件名/路径存在安全隐患\"),\n    BIZ_DIRECT_LINK_NOT_ALLOWED(\"41011\", \"当前系统不允许使用直链\"),\n    BIZ_SHORT_LINK_NOT_ALLOWED(\"41012\", \"当前系统不允许使用短链\"),\n    BIZ_SHORT_LINK_EXPIRED(\"41013\", \"短链已失效\"),\n    BIZ_SHORT_LINK_NOT_FOUNT(\"41014\", \"短链不存在\"),\n    BIZ_DIRECT_LINK_EXPIRED(\"41015\", \"直链已失效\"),\n    BIZ_STORAGE_NOT_SUPPORT_OPERATION(\"41016\", \"该存储类型不支持此操作\"),\n    BIZ_STORAGE_NOT_FOUND(\"41017\", \"存储源不存在\"),\n    BIZ_STORAGE_SOURCE_ILLEGAL_OPERATION(\"41018\", \"非法或未授权的操作\"),\n    BIZ_STORAGE_SOURCE_FILE_FORBIDDEN(\"41019\", \"文件目录无访问权限\"),\n    BIZ_STORAGE_SOURCE_FOLDER_PASSWORD_REQUIRED(\"41020\", \"此文件夹需要密码\"),\n    BIZ_STORAGE_SOURCE_FOLDER_PASSWORD_ERROR(\"41021\", \"密码错误\"),\n    BIZ_INVALID_FILE_NAME(\"41022\", \"文件名不合法\"),\n    BIZ_UNSUPPORTED_OPERATION(\"41023\", \"不支持的操作\"),\n    BIZ_FTP_CLIENT_POOL_FULL(\"41024\", \"FTP 客户端连接池已满\"),\n    BIZ_SFTP_CLIENT_POOL_FULL(\"41025\", \"SFTP 客户端连接池已满\"),\n    BIZ_FOLDER_NOT_EXIST(\"41026\", \"文件夹不存在\"),\n    BIZ_UPLOAD_FILE_TYPE_NOT_ALLOWED(\"41027\", \"不允许上传的文件\"),\n    BIZ_RENAME_FILE_TYPE_NOT_ALLOWED(\"41028\", \"不允许重命名到该名称\"),\n    BIZ_UNSUPPORTED_OPERATION_TYPE(\"41029\", \"不支持的操作类型\"),\n    BIZ_CUSTOM_SHARE_LINK_KEY_FORMAT_ILLEGAL(\"41030\", \"自定义分享 key 格式不正确，只能包含字母、数字、下划线和短横线，长度为 3-8 位\"),\n    BIZ_SHARE_LINK_KEY_ALREADY_EXIST(\"41031\", \"分享 key 已存在\"),\n    BIZ_SHARE_LINK_EXPIRY_MUST_BE_FUTURE(\"41032\", \"过期时间必须是未来的时间\"),\n    BIZ_SHARE_LINK_NOT_EXIST(\"41033\", \"分享链接不存在\"),\n    BIZ_SHARE_LINK_EXPIRED(\"41034\", \"分享链接已过期\"),\n    BIZ_SHARE_PASSWORD_ERROR(\"41036\", \"分享密码错误\"),\n    BIZ_SHARE_FILE_LIST_ERROR(\"41037\", \"获取分享文件列表失败\"),\n    BIZ_SHARE_FILE_DOWNLOAD_ERROR(\"41038\", \"获取文件下载地址失败\"),\n    BIZ_SHARE_FILE_INFO_ERROR(\"41039\", \"获取文件信息失败\"),\n\n    // 第二位为 2 时，是登录错误\n    BIZ_UNAUTHORIZED(\"42000\", \"未登录或未授权\"),\n    BIZ_LOGIN_ERROR(\"42001\", \"登录失败, 账号或密码错误\"),\n    BIZ_VERIFY_CODE_ERROR(\"42002\", \"验证码错误或已失效\"),\n\n    // 第二位为 3 时，是管理员端错误\n    BIZ_ADMIN_ERROR(\"43000\", \"操作失败\"),\n    BIZ_USER_NOT_EXIST(\"43001\", \"用户不存在\"),\n    BIZ_USER_EXIST(\"43002\", \"用户已存在\"),\n    BIZ_PASSWORD_NOT_SAME(\"43003\", \"两次密码不一致\"),\n    BIZ_OLD_PASSWORD_ERROR(\"43004\", \"旧密码不匹配\"),\n    BIZ_DELETE_BUILT_IN_USER(\"43005\", \"不能删除内置用户\"),\n    BIZ_UNSUPPORTED_STORAGE_TYPE(\"43006\", \"不支持的存储类型\"),\n    BIZ_STORAGE_KEY_EXIST(\"43007\", \"存储源别名已存在\"),\n    BIZ_AUTO_GET_SHARE_POINT_SITES_ERROR(\"43008\", \"自动获取 SharePoint 网站列表失败\"),\n    BIZ_ORIGINS_NOT_EMPTY(\"43009\", \"请先在 \\\"站点设置\\\" 中配置站点域名\"),\n    BIZ_2FA_CODE_ERROR(\"43010\", \"双因素认证验证失败\"),\n    BIZ_STORAGE_INIT_ERROR(\"43011\", \"存储源初始化失败\"),\n    BIZ_RULE_EXIST(\"43012\", \"规则已存在\"),\n    BIZ_SSO_PROVIDER_EXIST(\"43013\", \"单点登录配置已存在\"),\n    BIZ_SSO_PROVIDER_DISABLED(\"43014\", \"此单点登录未启用\"),\n\n\n    /**\n     * 通用的无权限异常\n     */\n    NO_FORBIDDEN(\"30000\", \"没有权限\"),\n    NO_CUSTOM_SHARE_LINK_KEY_PERMISSION(\"30001\", \"没有自定义分享链接 key 的权限\"),\n\n\n\n    /**\n     * 授权校验异常\n     */\n    PRO_AUTH_CODE_EMPTY(\"20000\", \"请先去后台 \\\"基本设置\\\" 填写 \\\"授权码\\\"\"),\n    PRO_CHECK_REFERER_EMPTY(\"20001\", \"Referer 无效，请检查服务端设置，20001\"), // Referer 无效，请检查服务端设置\n    PRO_CHECK_TIME_NO_SYNC(\"20002\", \"授权校验失败, 服务器时间异常，20002\"), // 授权校验失败, 服务器时间异常.\n    PRO_AUTH_CODE_INVALID_ERROR(\"20003\", \"授权码无效, 请检查后台 \\\"站点设置\\\" 中的 \\\"授权码\\\" 20003\"),\n    PRO_CHECK_UNKNOWN_ERROR(\"20004\", \"授权验证异常，未知异常，20098\"),\n    PRO_MSG_ERROR(\"20005\", null);\n\n    private String code;\n\n    private String message;\n\n    ErrorCode(String code, String message) {\n        this.code = code;\n        this.message = message;\n    }\n\n    /**\n     * 设置错误码\n     *\n     * @param code 错误码\n     * @return 返回当前枚举\n     */\n    public ErrorCode setCode(String code) {\n        this.code = code;\n        return this;\n    }\n\n    /**\n     * 设置错误信息\n     *\n     * @param message 错误信息\n     * @return 返回当前枚举\n     */\n    public ErrorCode setMessage(String message) {\n        this.message = message;\n        return this;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/exception/GlobalExceptionHandler.java",
    "content": "package im.zhaojun.zfile.core.exception;\n\nimport cn.dev33.satoken.exception.NotLoginException;\nimport cn.dev33.satoken.exception.NotRoleException;\nimport im.zhaojun.zfile.core.controller.FrontIndexController;\nimport im.zhaojun.zfile.core.exception.biz.*;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.exception.core.ErrorPageBizException;\nimport im.zhaojun.zfile.core.exception.core.SystemException;\nimport im.zhaojun.zfile.core.exception.status.*;\nimport im.zhaojun.zfile.core.exception.system.UploadFileFailSystemException;\nimport im.zhaojun.zfile.core.exception.system.ZFileAuthorizationSystemException;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.core.util.RequestHolder;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.config.service.SystemConfigService;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.apache.catalina.connector.ClientAbortException;\nimport org.springframework.core.annotation.Order;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.validation.BindException;\nimport org.springframework.validation.BindingResult;\nimport org.springframework.validation.FieldError;\nimport org.springframework.web.bind.MethodArgumentNotValidException;\nimport org.springframework.web.bind.annotation.ControllerAdvice;\nimport org.springframework.web.bind.annotation.ExceptionHandler;\nimport org.springframework.web.bind.annotation.ResponseBody;\nimport org.springframework.web.bind.annotation.ResponseStatus;\nimport org.springframework.web.servlet.resource.NoResourceFoundException;\nimport org.sqlite.SQLiteException;\n\nimport java.io.FileNotFoundException;\nimport java.io.IOException;\nimport java.util.HashMap;\nimport java.util.Map;\nimport java.util.Optional;\n\n/**\n * 全局异常处理\n *\n * @author zhaojun\n */\n@ControllerAdvice\n@Slf4j\n@Order(1)\npublic class GlobalExceptionHandler {\n\n    private static final ThreadLocal<String> exceptionMessage = new ThreadLocal<>();\n\n    @Resource\n    private SystemConfigService systemConfigService;\n\n    @Resource\n    private FrontIndexController frontIndexController;\n\n\n    private static final int MAX_FIND_CAUSE_EXCEPTION_DEPTH = 10;\n\n    // ---------------------- status exception start ----------------------\n\n    @ExceptionHandler(value = UnauthorizedAccessException.class)\n    @ResponseBody\n    @ResponseStatus(HttpStatus.UNAUTHORIZED)\n    public AjaxJson<?> unauthorizedAccessException() {\n        if (RequestHolder.isAxiosRequest()) {\n            return AjaxJson.getUnauthorizedResult();\n        }\n        try {\n            String unauthorizedUrl = systemConfigService.getUnauthorizedUrl();\n            RequestHolder.getResponse().sendRedirect(unauthorizedUrl);\n        } catch (IOException ex) {\n            return AjaxJson.getUnauthorizedResult();\n        }\n\n        return null;\n    }\n\n    @ExceptionHandler(value = {\n            NotRoleException.class\n    })\n    @ResponseBody\n    @ResponseStatus(HttpStatus.FORBIDDEN)\n    public AjaxJson<?> forbiddenAccessException() {\n        if (RequestHolder.isAxiosRequest()) {\n            return AjaxJson.getForbiddenResult();\n        }\n        try {\n            String forbiddenUrl = systemConfigService.getForbiddenUrl();\n            RequestHolder.getResponse().sendRedirect(forbiddenUrl);\n        } catch (IOException ex) {\n            return AjaxJson.getForbiddenResult();\n        }\n\n        return null;\n    }\n\n    @ExceptionHandler(value = ForbiddenAccessException.class)\n    @ResponseBody\n    @ResponseStatus(HttpStatus.FORBIDDEN)\n    public AjaxJson<?> forbiddenAccessException(ForbiddenAccessException e) {\n        if (RequestHolder.isAxiosRequest()) {\n            return AjaxJson.getError(e.getCode(), e.getMessage());\n        }\n        try {\n            String forbiddenUrl = systemConfigService.getForbiddenUrl(e.getCode(), e.getMessage());\n            RequestHolder.getResponse().sendRedirect(forbiddenUrl);\n        } catch (IOException ex) {\n            return AjaxJson.getError(e.getCode(), e.getMessage());\n        }\n\n        return null;\n    }\n\n    @ExceptionHandler(value = NotFoundAccessException.class)\n    @ResponseBody\n    @ResponseStatus(HttpStatus.NOT_FOUND)\n    public AjaxJson<?> notFoundAccessException(NotFoundAccessException e) {\n        if (RequestHolder.isAxiosRequest()) {\n            return AjaxJson.getError(e.getCode(), e.getMessage());\n        }\n        try {\n            String notFoundUrl = systemConfigService.getNotFoundUrl(e.getCode(), e.getMessage());\n            RequestHolder.getResponse().sendRedirect(notFoundUrl);\n        } catch (IOException ex) {\n            return AjaxJson.getError(e.getCode(), e.getMessage());\n        }\n\n        return null;\n    }\n\n\n    /**\n     * 所有未找到的页面都跳转到首页, 用户解决 vue history 直接访问 404 的问题\n     * 同时, 读取 index.html 文件, 修改 title 和 favicon 后返回.\n     *\n     * @return  转发到 /index.html\n     */\n    @ExceptionHandler(value = NoResourceFoundException.class)\n    @ResponseBody\n    public String notFoundAccessException() {\n        return frontIndexController.redirect().getBody();\n    }\n\n    @ExceptionHandler(value = MethodNotAllowedAccessException.class)\n    @ResponseBody\n    @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)\n    public AjaxJson<String> methodNotAllowedAccessException(MethodNotAllowedAccessException e) {\n        return new AjaxJson<>(e.getCode(), e.getMessage());\n    }\n\n    @ExceptionHandler(value = BadRequestAccessException.class)\n    @ResponseBody\n    @ResponseStatus(HttpStatus.BAD_REQUEST)\n    public AjaxJson<String> badRequestAccessException(BadRequestAccessException e) {\n        return new AjaxJson<>(e.getCode(), e.getMessage());\n    }\n\n    // ---------------------- status exception end ----------------------\n\n\n\n\n    // ---------------------- biz exception start ----------------------\n\n    @ExceptionHandler(value = APIHttpRequestBizException.class)\n    @ResponseBody\n    @ResponseStatus\n    public AjaxJson<String> apiHttpRequestBizException(APIHttpRequestBizException e) {\n        log.warn(\"请求第三方 API 异常, 请求地址: {}, 响应码: {}, 响应体: {}\", e.getUrl(), e.getResponseCode(), e.getResponseBody());\n        return new AjaxJson<>(e.getCode(), e.getMessage());\n    }\n\n    @ExceptionHandler(value = FilePathSecurityBizException.class)\n    @ResponseBody\n    @ResponseStatus(HttpStatus.BAD_REQUEST)\n    public AjaxJson<String> filePathSecurityBizException(FilePathSecurityBizException e) {\n        log.warn(\"获取文件路径存在安全风险, 文件路径: {}\", e.getPath());\n        return new AjaxJson<>(e.getCode(), e.getMessage());\n    }\n\n    @ExceptionHandler(value = GetPreviewTextContentBizException.class)\n    @ResponseBody\n    @ResponseStatus\n    public AjaxJson<String> getPreviewTextContentBizException(GetPreviewTextContentBizException e) {\n        log.warn(\"获取预览文件内容失败, 文件 url: {}\", e.getUrl(), e);\n        return new AjaxJson<>(e.getCode(), \"预览文件内容失败, 请联系管理员.\");\n    }\n\n    @ExceptionHandler(value = InitializeStorageSourceBizException.class)\n    @ResponseBody\n    @ResponseStatus\n    public AjaxJson<String> initializeStorageSourceBizException(InitializeStorageSourceBizException e) {\n        log.error(\"存储源初始化失败, 存储源 ID: {}.\", e.getStorageId(), e);\n        return new AjaxJson<>(e.getCode(), \"存储源初始化失败：\" + e.getMessage());\n    }\n\n    @ExceptionHandler(value = StorageSourceFileForbiddenAccessBizException.class)\n    @ResponseBody\n    @ResponseStatus\n    public AjaxJson<String> storageSourceFileForbiddenAccessBizException(StorageSourceFileForbiddenAccessBizException e) {\n        log.warn(\"尝试访问不被授权的文件/目录, 存储源 ID: {}: 目录: {}\", e.getStorageId(), e.getPath());\n        return new AjaxJson<>(e.getCode(), e.getMessage());\n    }\n\n    @ExceptionHandler(value = StorageSourceIllegalOperationBizException.class)\n    @ResponseBody\n    @ResponseStatus\n    public AjaxJson<String> storageSourceIllegalOperationBizException(StorageSourceIllegalOperationBizException e) {\n        log.warn(\"存储源非法或未授权的操作, 存储源 ID: {}, 操作类型: {}\", e.getStorageId(), e.getAction());\n        return new AjaxJson<>(e.getCode(), e.getMessage());\n    }\n\n    @ExceptionHandler(value = CorsBizException.class)\n    @ResponseBody\n    @ResponseStatus\n    public AjaxJson<String> corsBizException(CorsBizException e) {\n        log.warn(\"跨域异常:\", e);\n        return new AjaxJson<>(e.getCode(), e.getMessage());\n    }\n\n    @ExceptionHandler(value = ErrorPageBizException.class)\n    @ResponseBody\n    @ResponseStatus\n    public AjaxJson<?> errorPageBizException(ErrorPageBizException e) {\n        if (RequestHolder.isAxiosRequest()) {\n            return AjaxJson.getError(e.getCode(), e.getMessage());\n        }\n        try {\n            String errorPageUrl = systemConfigService.getErrorPageUrl(e.getCode(), e.getMessage());\n            RequestHolder.getResponse().sendRedirect(errorPageUrl);\n        } catch (IOException ex) {\n            return AjaxJson.getError(e.getCode(), e.getMessage());\n        }\n\n        return null;\n    }\n\n\n    @ExceptionHandler(value = BizException.class)\n    @ResponseBody\n    @ResponseStatus\n    public AjaxJson<String> bizException(BizException e) {\n        return new AjaxJson<>(e.getCode(), e.getMessage());\n    }\n\n    // ---------------------- biz exception end ----------------------\n\n\n    // ---------------------- system exception end ----------------------\n\n    @ExceptionHandler(value = UploadFileFailSystemException.class)\n    @ResponseBody\n    @ResponseStatus\n    public AjaxJson<String> uploadFileFailSystemException(UploadFileFailSystemException e) {\n        log.warn(\"上传文件失败, 存储类型: {}, 上传路径: {}, 输入流可用字节数: {}, 响应码: {}, 响应体: {}\",\n                e.getStorageTypeEnum(), e.getUploadPath(), e.getInputStreamAvailable(), e.getResponseCode(), e.getResponseBody());\n        return new AjaxJson<>(e.getCode(), e.getMessage());\n    }\n\n    @ExceptionHandler(value = ZFileAuthorizationSystemException.class)\n    @ResponseBody\n    @ResponseStatus\n    public AjaxJson<?> zfileAuthorizationSystemException(ZFileAuthorizationSystemException e) {\n        return new AjaxJson<>(e.getCode(), e.getMessage());\n    }\n\n    @ExceptionHandler(value = SystemException.class)\n    @ResponseBody\n    @ResponseStatus\n    public AjaxJson<?> systemException(SystemException e) {\n        return new AjaxJson<>(e.getCode(), e.getMessage());\n    }\n\n\n\n    // ---------------------- system exception end ----------------------\n\n\n\n    // ---------------------- common exception end ----------------------\n\n    @ExceptionHandler(value = {MethodArgumentNotValidException.class, BindException.class})\n    @ResponseBody\n    @ResponseStatus(code = HttpStatus.BAD_REQUEST)\n    public AjaxJson<Map<String, String>> handleValidException(Exception e) {\n        BindingResult bindingResult = null;\n        if (e instanceof MethodArgumentNotValidException) {\n            bindingResult = ((MethodArgumentNotValidException) e).getBindingResult();\n        } else if (e instanceof BindException) {\n            bindingResult = ((BindException) e).getBindingResult();\n        }\n        Map<String, String> errorMap = new HashMap<>(16);\n\n        Optional.ofNullable(bindingResult)\n                .map(BindingResult::getFieldErrors)\n                .ifPresent(fieldErrors -> {\n                    for (FieldError fieldError : fieldErrors) {\n                        errorMap.put(fieldError.getField(), fieldError.getDefaultMessage());\n                    }\n                });\n        return new AjaxJson<>(ErrorCode.BIZ_BAD_REQUEST.getCode(), ErrorCode.BIZ_BAD_REQUEST.getMessage(), errorMap);\n\n    }\n\n    @ExceptionHandler({FileNotFoundException.class})\n    @ResponseBody\n    @ResponseStatus(HttpStatus.NOT_FOUND)\n    public AjaxJson<Void> fileNotFound() {\n        return AjaxJson.getError(\"文件不存在\");\n    }\n\n\n    /**\n     * 登录异常拦截器\n     */\n    @ExceptionHandler(NotLoginException.class)\n    @ResponseStatus(HttpStatus.UNAUTHORIZED)\n    @ResponseBody\n    public AjaxJson<?> handlerNotLoginException(NotLoginException e) {\n        if (RequestHolder.isAxiosRequest()) {\n            return AjaxJson.getUnauthorizedResult();\n        }\n        try {\n            String domain = systemConfigService.getRealFrontDomain();\n            if (StringUtils.isBlank(domain)) {\n                domain = \"\";\n            }\n            String loginUrl = StringUtils.concat(domain, \"/login\");\n            RequestHolder.getResponse().sendRedirect(loginUrl);\n        } catch (IOException ex) {\n            return AjaxJson.getUnauthorizedResult();\n        }\n\n        return null;\n    }\n\n    @ExceptionHandler\n    @ResponseBody\n    @ResponseStatus\n    public AjaxJson<?> extraExceptionHandler(Exception e) {\n        ExceptionType exceptionType = getExceptionType(e);\n        if (exceptionType == ExceptionType.IGNORE_PRINT_STACK_TRACE_EXCEPTION) {\n            log.warn(e.getMessage());\n        } else if (exceptionType == ExceptionType.OTHER) {\n            log.error(e.getMessage(), e);\n        } else if (exceptionType == ExceptionType.SPECIFY_MESSAGE_EXCEPTION) {\n            if (exceptionMessage.get() != null) {\n                String message = exceptionMessage.get();\n                log.error(\"发生异常: {}\", message,e );\n                exceptionMessage.remove();\n                return AjaxJson.getError(message);\n            }\n        } else if (exceptionType == ExceptionType.IGNORE_EXCEPTION) {\n            // 忽略异常\n            return null;\n        }\n\n        if (e.getClass() == Exception.class) {\n            return AjaxJson.getError(\"系统异常, 请联系管理员\");\n        } else {\n            return AjaxJson.getError(e.getMessage());\n        }\n    }\n\n\n    private static ExceptionType getExceptionType(Exception e) {\n        int findCauseCount = 0;\n        do {\n            if (e instanceof BizException) {\n                return ExceptionType.IGNORE_PRINT_STACK_TRACE_EXCEPTION;\n            } else if (e instanceof ClientAbortException) {\n                return ExceptionType.IGNORE_EXCEPTION;\n            } else if (e instanceof SQLiteException && e.getMessage().contains(\"database is locked\")) {\n                exceptionMessage.set(\"数据库繁忙，请稍后再试\");\n                return ExceptionType.SPECIFY_MESSAGE_EXCEPTION;\n            }\n            e = (Exception) e.getCause();\n            findCauseCount++;\n        } while (e != null && findCauseCount < MAX_FIND_CAUSE_EXCEPTION_DEPTH);\n\n        return ExceptionType.OTHER;\n    }\n\n    enum ExceptionType {\n        /**\n         * 忽略打印异常信息和堆栈信息\n         */\n        IGNORE_EXCEPTION,\n\n        /**\n         * 仅打印异常信息, 不打印堆栈信息\n         */\n        IGNORE_PRINT_STACK_TRACE_EXCEPTION,\n\n        /**\n         * 不打印堆栈信息，但指定异常信息\n         */\n        SPECIFY_MESSAGE_EXCEPTION,\n\n        /**\n         * 其他异常, 打印异常信息和堆栈信息\n         */\n        OTHER;\n    }\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/exception/biz/APIHttpRequestBizException.java",
    "content": "package im.zhaojun.zfile.core.exception.biz;\n\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.exception.GlobalExceptionHandler;\nimport lombok.Getter;\n\n/**\n * 请求第三方 API 时如果返回非 2xx 状态码, 则抛出此异常. 需记录请求地址, 响应状态码, 响应内容.\n * <p/>\n * 需要全局异常处理器捕获此异常, 并记录日志. {@link GlobalExceptionHandler#apiHttpRequestBizException(APIHttpRequestBizException)}\n *\n * @author zhaojun\n */\n@Getter\npublic class APIHttpRequestBizException extends BizException {\n\n    private final String url;\n\n    private final int responseCode;\n\n    private final String responseBody;\n\n    public APIHttpRequestBizException(ErrorCode errorCode, String url, int responseCode, String responseBody) {\n        super(errorCode);\n        this.url = url;\n        this.responseCode = responseCode;\n        this.responseBody = responseBody;\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/exception/biz/CorsBizException.java",
    "content": "package im.zhaojun.zfile.core.exception.biz;\n\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport lombok.Getter;\n\n/**\n * @author zhaojun\n */\n@Getter\npublic class CorsBizException extends BizException {\n\n    public CorsBizException(String message, Throwable cause) {\n        super(message, cause);\n    }\n\n    @Override\n    public boolean printExceptionStackTrace() {\n        return true;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/exception/biz/FilePathSecurityBizException.java",
    "content": "package im.zhaojun.zfile.core.exception.biz;\n\n\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.exception.GlobalExceptionHandler;\nimport lombok.Getter;\n\n/**\n * 文件路径安全异常, 表示文件路径不合法，如包含了 \"./\" 或 \"../\" 等字符来尝试访问非法目录.\n * <p/>\n * 需要全局异常处理器捕获此异常, 并记录日志. {@link GlobalExceptionHandler#filePathSecurityBizException(FilePathSecurityBizException)}\n *\n * @author zhaojun\n */\n@Getter\npublic class FilePathSecurityBizException extends BizException {\n\n    private final String path;\n\n    public FilePathSecurityBizException(String path) {\n        super(ErrorCode.BIZ_FILE_PATH_ILLEGAL);\n        this.path = path;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/exception/biz/GetPreviewTextContentBizException.java",
    "content": "package im.zhaojun.zfile.core.exception.biz;\n\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.exception.GlobalExceptionHandler;\nimport lombok.Getter;\n\n/**\n * 获取预览文件内容异常, 可能是目标连接无法访问/文件不存在等原因.\n * <p/>\n * 需要全局异常处理器捕获此异常, 并记录日志. {@link GlobalExceptionHandler#getPreviewTextContentBizException(GetPreviewTextContentBizException)}\n *\n * @author zhaojun\n */\n@Getter\npublic class GetPreviewTextContentBizException extends BizException {\n\n    /**\n     * 获取预览文件的 URL\n     */\n    private final String url;\n\n    public GetPreviewTextContentBizException(String url, Throwable cause) {\n        super(cause);\n        this.url = url;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/exception/biz/InitializeStorageSourceBizException.java",
    "content": "package im.zhaojun.zfile.core.exception.biz;\n\nimport im.zhaojun.zfile.core.exception.GlobalExceptionHandler;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport lombok.Getter;\n\n/**\n * 初始化存储源时失败产生的异常\n * <p/>\n * 需要全局异常处理器捕获此异常, 并记录日志. {@link GlobalExceptionHandler#initializeStorageSourceBizException(InitializeStorageSourceBizException)}\n *\n * @author zhaojun\n */\n@Getter\npublic class InitializeStorageSourceBizException extends BizException {\n\n    private final Integer storageId;\n\n    public InitializeStorageSourceBizException(String message, Integer storageId) {\n        super(message);\n        this.storageId = storageId;\n    }\n\n    public InitializeStorageSourceBizException(String code, String message, Integer storageId, Throwable cause) {\n        super(code, message, cause);\n        this.storageId = storageId;\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/exception/biz/InvalidStorageSourceBizException.java",
    "content": "package im.zhaojun.zfile.core.exception.biz;\n\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport lombok.Getter;\n\n/**\n * 不存在或初始化失败的存储源异常。\n *\n * @author zhaojun\n */\n@Getter\npublic class InvalidStorageSourceBizException extends BizException {\n\n\tprivate final Integer storageId;\n\n\tprivate final String storageKey;\n\n\tpublic InvalidStorageSourceBizException(String storageKey) {\n\t\tsuper(ErrorCode.INVALID_STORAGE_SOURCE);\n\t\tthis.storageKey = storageKey;\n\t\tthis.storageId = null;\n\t}\n\n\tpublic InvalidStorageSourceBizException(Integer storageId) {\n\t\tsuper(ErrorCode.INVALID_STORAGE_SOURCE);\n\t\tthis.storageId = storageId;\n\t\tthis.storageKey = null;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/exception/biz/StorageSourceFileForbiddenAccessBizException.java",
    "content": "package im.zhaojun.zfile.core.exception.biz;\n\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.exception.GlobalExceptionHandler;\nimport lombok.Getter;\n\n/**\n * 访问了禁止访问的存储源文件/目录时抛出此异常.\n * <p/>\n * 需要全局异常处理器捕获此异常, 并记录日志. {@link GlobalExceptionHandler#storageSourceFileForbiddenAccessBizException(StorageSourceFileForbiddenAccessBizException)}\n *\n * @author zhaojun\n */\n@Getter\npublic class StorageSourceFileForbiddenAccessBizException extends BizException {\n\n    private final Integer storageId;\n\n    private final String path;\n\n    public StorageSourceFileForbiddenAccessBizException(Integer storageId, String path) {\n        super(ErrorCode.BIZ_STORAGE_SOURCE_FILE_FORBIDDEN);\n        this.storageId = storageId;\n        this.path = path;\n    }\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/exception/biz/StorageSourceIllegalOperationBizException.java",
    "content": "package im.zhaojun.zfile.core.exception.biz;\n\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.exception.GlobalExceptionHandler;\nimport im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;\nimport lombok.Getter;\n\n/**\n * 对存储源进行非法(未授权)的操作产生的异常\n * <p/>\n * 需要全局异常处理器捕获此异常, 并记录日志. {@link GlobalExceptionHandler#storageSourceIllegalOperationBizException(StorageSourceIllegalOperationBizException)}\n *\n * @author zhaojun\n */\n@Getter\npublic class StorageSourceIllegalOperationBizException extends BizException {\n\n    private final Integer storageId;\n\n    private final FileOperatorTypeEnum action;\n\n    public StorageSourceIllegalOperationBizException(Integer storageId, FileOperatorTypeEnum action) {\n        super(ErrorCode.BIZ_STORAGE_SOURCE_ILLEGAL_OPERATION);\n        this.storageId = storageId;\n        this.action = action;\n    }\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/exception/core/BizException.java",
    "content": "package im.zhaojun.zfile.core.exception.core;\n\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport lombok.Getter;\n\n/**\n * 业务异常，该类异常用户可自行处理，无需记录日志，属于正常业务流程中的异常. 如: 用户名密码错误, 未登录等.\n *\n * @author zhaojun\n */\n@Getter\npublic class BizException extends RuntimeException {\n\n    private static final long serialVersionUID = 8312907182931723379L;\n\n    /**\n     * 错误码\n     */\n    private String code;\n\n    /**\n     * 是否打印堆栈信息，业务异常默认不打印堆栈信息，如果需要打印堆栈信息，可以通过子类覆盖该方法修改返回值为 true.\n     */\n    public boolean printExceptionStackTrace() {\n        return false;\n    }\n\n    /**\n     * 构造一个没有错误信息的 <code>SystemException</code>\n     */\n    public BizException() {\n        super();\n    }\n\n    /**\n     * 使用指定的 Throwable 和 Throwable.toString() 作为异常信息来构造 SystemException\n     *\n     * @param cause 错误原因， 通过 Throwable.getCause() 方法可以获取传入的 cause信息\n     */\n    public BizException(Throwable cause) {\n        super(cause);\n    }\n\n    /**\n     * 使用错误信息 message 构造 SystemException\n     *\n     * @param message 错误信息\n     */\n    public BizException(String message) {\n        super(message);\n    }\n\n    /**\n     * 使用错误码和错误信息构造 SystemException\n     *\n     * @param code    错误码\n     * @param message 错误信息\n     */\n    public BizException(String code, String message) {\n        super(message);\n        this.code = code;\n    }\n\n    /**\n     * 使用错误信息和 Throwable 构造 SystemException\n     *\n     * @param message 错误信息\n     * @param cause   错误原因\n     */\n    public BizException(String message, Throwable cause) {\n        super(message, cause);\n    }\n\n    /**\n     * @param code    错误码\n     * @param message 错误信息\n     * @param cause   错误原因\n     */\n    public BizException(String code, String message, Throwable cause) {\n        super(message, cause);\n        this.code = code;\n    }\n\n    /**\n     * @param errorCode ErrorCode\n     */\n    public BizException(ErrorCode errorCode) {\n        super(errorCode.getMessage());\n        this.code = errorCode.getCode();\n    }\n\n    /**\n     * @param errorCode ErrorCode\n     * @param cause     错误原因\n     */\n    public BizException(ErrorCode errorCode, Throwable cause) {\n        super(errorCode.getMessage(), cause);\n        this.code = errorCode.getCode();\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/exception/core/ErrorPageBizException.java",
    "content": "package im.zhaojun.zfile.core.exception.core;\n\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport lombok.Getter;\n\n/**\n * 业务异常，该类异常用户可自行处理，无需记录日志，属于正常业务流程中的异常. 如: 用户名密码错误, 未登录等.<br>\n * 使用该类的异常，当该异常被抛出时，会跳转到 500 错误页面(错误码和错误消息可被 {@link #code} 和 {@link #getMessage()} 覆盖)，而不是返回 JSON 数据.<br>\n * 一般使用该异常得请求不会是 AJAX 请求，而是直接在浏览器中访问的页面请求.\n *\n * @author zhaojun\n */\n@Getter\npublic class ErrorPageBizException extends RuntimeException {\n\n    private static final long serialVersionUID = 8312907182931723379L;\n\n    /**\n     * 错误码\n     */\n    private String code;\n\n    /**\n     * 是否打印堆栈信息，业务异常默认不打印堆栈信息，如果需要打印堆栈信息，可以通过子类覆盖该方法修改返回值为 true.\n     */\n    public boolean printExceptionStackTrace() {\n        return false;\n    }\n\n    /**\n     * 构造一个没有错误信息的 <code>SystemException</code>\n     */\n    public ErrorPageBizException() {\n        super();\n    }\n\n    /**\n     * 使用指定的 Throwable 和 Throwable.toString() 作为异常信息来构造 SystemException\n     *\n     * @param cause 错误原因， 通过 Throwable.getCause() 方法可以获取传入的 cause信息\n     */\n    public ErrorPageBizException(Throwable cause) {\n        super(cause);\n    }\n\n    /**\n     * 使用错误信息 message 构造 SystemException\n     *\n     * @param message 错误信息\n     */\n    public ErrorPageBizException(String message) {\n        super(message);\n    }\n\n    /**\n     * 使用错误码和错误信息构造 SystemException\n     *\n     * @param code    错误码\n     * @param message 错误信息\n     */\n    public ErrorPageBizException(String code, String message) {\n        super(message);\n        this.code = code;\n    }\n\n    /**\n     * 使用错误信息和 Throwable 构造 SystemException\n     *\n     * @param message 错误信息\n     * @param cause   错误原因\n     */\n    public ErrorPageBizException(String message, Throwable cause) {\n        super(message, cause);\n    }\n\n    /**\n     * @param code    错误码\n     * @param message 错误信息\n     * @param cause   错误原因\n     */\n    public ErrorPageBizException(String code, String message, Throwable cause) {\n        super(message, cause);\n        this.code = code;\n    }\n\n    /**\n     * @param errorCode ErrorCode\n     */\n    public ErrorPageBizException(ErrorCode errorCode) {\n        super(errorCode.getMessage());\n        this.code = errorCode.getCode();\n    }\n\n    /**\n     * @param errorCode ErrorCode\n     * @param cause     错误原因\n     */\n    public ErrorPageBizException(ErrorCode errorCode, Throwable cause) {\n        super(errorCode.getMessage(), cause);\n        this.code = errorCode.getCode();\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/exception/core/SystemException.java",
    "content": "package im.zhaojun.zfile.core.exception.core;\n\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport lombok.Getter;\n\n/**\n * 系统异常, 该类异常用户无法处理，需要记录日志, 属于系统异常. 如: 网络异常, 服务器异常等.\n *\n * @author zhaojun\n */\n@Getter\npublic class SystemException extends RuntimeException {\n\n    private static final long serialVersionUID = 8312907182931723379L;\n\n    /**\n     * 错误码\n     */\n    private String code;\n\n    /**\n     * 构造一个没有错误信息的 <code>SystemException</code>\n     */\n    public SystemException() {\n        super();\n    }\n\n    /**\n     * 使用指定的 Throwable 和 Throwable.toString() 作为异常信息来构造 SystemException\n     *\n     * @param cause 错误原因， 通过 Throwable.getCause() 方法可以获取传入的 cause信息\n     */\n    public SystemException(Throwable cause) {\n        super(cause);\n    }\n\n    /**\n     * 使用错误信息 message 构造 SystemException\n     *\n     * @param message 错误信息\n     */\n    public SystemException(String message) {\n        super(message);\n    }\n\n    /**\n     * 使用错误码和错误信息构造 SystemException\n     *\n     * @param code    错误码\n     * @param message 错误信息\n     */\n    public SystemException(String code, String message) {\n        super(message);\n        this.code = code;\n    }\n\n    /**\n     * 使用错误信息和 Throwable 构造 SystemException\n     *\n     * @param message 错误信息\n     * @param cause   错误原因\n     */\n    public SystemException(String message, Throwable cause) {\n        super(message, cause);\n    }\n\n    /**\n     * @param code    错误码\n     * @param message 错误信息\n     * @param cause   错误原因\n     */\n    public SystemException(String code, String message, Throwable cause) {\n        super(message, cause);\n        this.code = code;\n    }\n\n    /**\n     * @param errorCode ErrorCode\n     */\n    public SystemException(ErrorCode errorCode) {\n        super(errorCode.getMessage());\n        this.code = errorCode.getCode();\n    }\n\n    /**\n     * @param errorCode ErrorCode\n     * @param cause     错误原因\n     */\n    public SystemException(ErrorCode errorCode, Throwable cause) {\n        super(errorCode.getMessage(), cause);\n        this.code = errorCode.getCode();\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/exception/status/BadRequestAccessException.java",
    "content": "package im.zhaojun.zfile.core.exception.status;\n\nimport im.zhaojun.zfile.core.exception.GlobalExceptionHandler;\nimport im.zhaojun.zfile.core.exception.core.BizException;\n\n/**\n * 错误请求异常, 表示请求参数有误或者服务器无法理解, 一般返回 400 状态码\n * <p/>\n * 需要全局异常处理器捕获此异常, 并记录日志. {@link GlobalExceptionHandler#badRequestAccessException(BadRequestAccessException)}\n *\n * @author zhaojun\n */\npublic class BadRequestAccessException extends BizException {\n\n    public BadRequestAccessException(String message) {\n        super(message);\n    }\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/exception/status/ForbiddenAccessException.java",
    "content": "package im.zhaojun.zfile.core.exception.status;\n\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\n\n/**\n * 禁止访问异常, 表示用户没有权限访问该资源, 一般返回 403 状态码. (已经有身份，如果没有身份，应该是 UnauthorizedAccessException)\n * <p/>\n * 需要全局异常处理器捕获此异常, 并记录日志. {@link im.zhaojun.zfile.core.exception.GlobalExceptionHandler#forbiddenAccessException}\n *\n * @author zhaojun\n */\npublic class ForbiddenAccessException extends BizException {\n\n    public ForbiddenAccessException(ErrorCode errorCode) {\n        super(errorCode);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/exception/status/MethodNotAllowedAccessException.java",
    "content": "package im.zhaojun.zfile.core.exception.status;\n\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.GlobalExceptionHandler;\nimport im.zhaojun.zfile.core.exception.core.BizException;\n\n/**\n * 错误请求异常, 表示请求方法有误或者服务器无法理解, 一般返回 405 状态码\n * <p/>\n * 需要全局异常处理器捕获此异常, 并记录日志. {@link GlobalExceptionHandler#methodNotAllowedAccessException(MethodNotAllowedAccessException)}\n *\n * @author zhaojun\n */\npublic class MethodNotAllowedAccessException extends BizException {\n\n    public MethodNotAllowedAccessException(ErrorCode errorCode) {\n        super(errorCode);\n    }\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/exception/status/NotFoundAccessException.java",
    "content": "package im.zhaojun.zfile.core.exception.status;\n\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\n\n/**\n * 访问内容不存在异常, 表示用户请求的资源不存在时抛出, 一般返回 404 状态码.\n * <p/>\n * 需要全局异常处理器捕获此异常, 并记录日志. {@link im.zhaojun.zfile.core.exception.GlobalExceptionHandler#notFoundAccessException}\n *\n * @author zhaojun\n */\npublic class NotFoundAccessException extends BizException {\n\n    public NotFoundAccessException(ErrorCode errorCode) {\n        super(errorCode);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/exception/status/UnauthorizedAccessException.java",
    "content": "package im.zhaojun.zfile.core.exception.status;\n\nimport im.zhaojun.zfile.core.exception.core.BizException;\n\n/**\n * 禁止访问异常, 表示用户未进行身份认证, 一般返回 401 状态码.\n * <p/>\n * 需要全局异常处理器捕获此异常, 并记录日志. {@link im.zhaojun.zfile.core.exception.GlobalExceptionHandler#unauthorizedAccessException}\n *\n * @author zhaojun\n */\npublic class UnauthorizedAccessException extends BizException {\n\n    public UnauthorizedAccessException(String message) {\n        super(message);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/exception/system/UploadFileFailSystemException.java",
    "content": "package im.zhaojun.zfile.core.exception.system;\n\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.GlobalExceptionHandler;\nimport im.zhaojun.zfile.core.exception.core.SystemException;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport lombok.Getter;\n\n\n/**\n * 上传文件失败系统异常, 该异常用户无法处理，需要记录日志, 属于系统异常. 如: 网络异常, 目标存储源异常等\n * <p/>\n * 需要全局异常处理器捕获此异常, 并记录日志. {@link GlobalExceptionHandler#uploadFileFailSystemException(UploadFileFailSystemException)}\n *\n * @author zhaojun\n */\n@Getter\npublic class UploadFileFailSystemException extends SystemException {\n\n    private final StorageTypeEnum storageTypeEnum;\n\n    private final String uploadPath;\n\n    private final Long inputStreamAvailable;\n\n    private final int responseCode;\n\n    private final String responseBody;\n\n    public UploadFileFailSystemException(StorageTypeEnum storageTypeEnum, String uploadPath, Long inputStreamAvailable, int responseCode, String responseBody) {\n        this(storageTypeEnum, uploadPath, inputStreamAvailable, responseCode, responseBody, null);\n    }\n\n    public UploadFileFailSystemException(StorageTypeEnum storageTypeEnum, String uploadPath, Long inputStreamAvailable, int responseCode, String responseBody, Throwable cause) {\n        super(ErrorCode.BIZ_UPLOAD_FILE_ERROR, cause);\n        this.storageTypeEnum = storageTypeEnum;\n        this.uploadPath = uploadPath;\n        this.inputStreamAvailable = inputStreamAvailable;\n        this.responseCode = responseCode;\n        this.responseBody = responseBody;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/exception/system/ZFileAuthorizationSystemException.java",
    "content": "package im.zhaojun.zfile.core.exception.system;\n\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.GlobalExceptionHandler;\nimport im.zhaojun.zfile.core.exception.core.SystemException;\n\n/**\n * ZFile 授权异常\n * <p/>\n * 需要全局异常处理器捕获此异常, 并记录日志. {@link GlobalExceptionHandler#zfileAuthorizationSystemException(ZFileAuthorizationSystemException)}\n *\n * @author zhaojun\n */\npublic class ZFileAuthorizationSystemException extends SystemException {\n\n    public ZFileAuthorizationSystemException(String code, String message) {\n        super(code, message);\n    }\n\n    public ZFileAuthorizationSystemException(ErrorCode errorCode) {\n        super(errorCode);\n    }\n\n    public ZFileAuthorizationSystemException(ErrorCode errorCode, Throwable cause) {\n        super(errorCode, cause);\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/filter/CorsFilter.java",
    "content": "package im.zhaojun.zfile.core.filter;\n\nimport cn.hutool.core.util.ObjectUtil;\nimport im.zhaojun.zfile.core.constant.ZFileHttpHeaderConstant;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport jakarta.servlet.*;\nimport jakarta.servlet.annotation.WebFilter;\nimport jakarta.servlet.http.HttpServletRequest;\nimport jakarta.servlet.http.HttpServletResponse;\nimport org.springframework.core.annotation.Order;\nimport org.springframework.http.HttpHeaders;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.cors.CorsUtils;\n\nimport java.io.IOException;\nimport java.util.Arrays;\nimport java.util.List;\n\n/**\n * 开启跨域支持. 一般用于开发环境, 或前后端分离部署时开启.\n *\n * @author zhaojun\n */\n@WebFilter(urlPatterns = \"/*\")\n@Order(Integer.MIN_VALUE)\n@Component\npublic class CorsFilter implements Filter {\n\n    @Override\n    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {\n        HttpServletRequest httpServletRequest = (HttpServletRequest) request;\n        HttpServletResponse httpServletResponse = (HttpServletResponse) response;\n\n        if (httpServletRequest.getRequestURI().equals(\"/favicon.ico\")) {\n            return;\n        }\n    \n        String header = httpServletRequest.getHeader(HttpHeaders.ORIGIN);\n\n        List<String> allowHeaders = Arrays.asList(\"Origin\", \"X-Requested-With\", \"Content-Type\", \"Accept\", ZFileHttpHeaderConstant.ZFILE_TOKEN, ZFileHttpHeaderConstant.AXIOS_REQUEST, ZFileHttpHeaderConstant.AXIOS_FROM);\n        httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, ObjectUtil.defaultIfNull(header, \"*\"));\n        httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, StringUtils.join(\",\", allowHeaders));\n        httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, \"GET, POST, PUT, DELETE, OPTIONS\");\n        httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, \"false\");\n        httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_MAX_AGE, \"600\");\n\n        if (!CorsUtils.isPreFlightRequest(httpServletRequest)) {\n            chain.doFilter(httpServletRequest, httpServletResponse);\n        }\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/filter/MDCFilter.java",
    "content": "package im.zhaojun.zfile.core.filter;\n\nimport cn.hutool.core.util.IdUtil;\nimport cn.hutool.extra.servlet.JakartaServletUtil;\nimport im.zhaojun.zfile.core.constant.MdcConstant;\nimport im.zhaojun.zfile.core.util.ZFileAuthUtil;\nimport jakarta.servlet.*;\nimport jakarta.servlet.annotation.WebFilter;\nimport jakarta.servlet.http.HttpServletRequest;\nimport jakarta.servlet.http.HttpServletResponse;\nimport org.slf4j.MDC;\n\nimport java.io.IOException;\n\n/**\n * MDC 过滤器, 用于写入 TraceId, 请求 IP, 用户名等信息到日志中.\n *\n * @author zhaojun\n */\n@WebFilter(urlPatterns = \"/*\")\npublic class MDCFilter implements Filter {\n\t\n\t@Override\n\tpublic void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {\n\t\tHttpServletRequest httpServletRequest = (HttpServletRequest) request;\n\t\tHttpServletResponse httpServletResponse = (HttpServletResponse) response;\n\t\t\n\t\tMDC.put(MdcConstant.TRACE_ID, IdUtil.fastUUID());\n\t\tMDC.put(MdcConstant.IP, JakartaServletUtil.getClientIP(httpServletRequest));\n\t\tMDC.put(MdcConstant.USER, ZFileAuthUtil.getCurrentUserId().toString());\n\t\t\n\t\ttry {\n\t\t\tfilterChain.doFilter(httpServletRequest, httpServletResponse);\n\t\t} finally {\n\t\t\tMDC.clear();\n\t\t}\n\t}\n\t\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/filter/SecurityFilter.java",
    "content": "package im.zhaojun.zfile.core.filter;\n\nimport cn.hutool.extra.servlet.JakartaServletUtil;\nimport cn.hutool.extra.spring.SpringUtil;\nimport im.zhaojun.zfile.core.constant.RuleTypeConstant;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.core.util.matcher.IRuleMatcher;\nimport im.zhaojun.zfile.core.util.matcher.RuleMatcherFactory;\nimport im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;\nimport im.zhaojun.zfile.module.config.service.SystemConfigService;\nimport jakarta.servlet.*;\nimport jakarta.servlet.annotation.WebFilter;\nimport jakarta.servlet.http.HttpServletRequest;\nimport jakarta.servlet.http.HttpServletResponse;\nimport org.springframework.http.HttpHeaders;\nimport org.springframework.http.HttpStatus;\n\nimport java.io.IOException;\nimport java.util.List;\n\n/**\n * 检测访问的 IP 和 UA 是否符合系统安全设置中的规则\n *\n * @author zhaojun\n */\n@WebFilter(urlPatterns = \"/*\")\npublic class SecurityFilter implements Filter {\n\n    private static volatile SystemConfigService systemConfigService;\n\n    @Override\n    public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {\n        HttpServletRequest httpServletRequest = (HttpServletRequest) request;\n        HttpServletResponse httpServletResponse = (HttpServletResponse) response;\n\n        // 双重检测锁, 防止多次初始化\n        if (systemConfigService == null) {\n            synchronized (this) {\n                if (systemConfigService == null) {\n                    systemConfigService = SpringUtil.getBean(SystemConfigService.class);\n                }\n            }\n        }\n\n        SystemConfigDTO systemConfig = systemConfigService.getSystemConfig();\n        String accessIpBlocklist = systemConfig.getAccessIpBlocklist();\n        String accessUaBlocklist = systemConfig.getAccessUaBlocklist();\n\n        // 判断当前访问 IP 是否在黑名单中\n        String currentAccessIp = JakartaServletUtil.getClientIP(httpServletRequest);\n        if (StringUtils.isNotBlank(accessIpBlocklist) && checkIsDisableIP(accessIpBlocklist, currentAccessIp)) {\n            httpServletResponse.setStatus(HttpStatus.FORBIDDEN.value());\n            httpServletResponse.getWriter().write(\"disable access.[\" + currentAccessIp + \"]\");\n            return;\n        }\n\n        // 判断当前访问 User-Agent 是否在黑名单中\n        String userAgent = httpServletRequest.getHeader(HttpHeaders.USER_AGENT);\n        if (StringUtils.isNotBlank(accessUaBlocklist) && checkIsDisableUA(accessUaBlocklist, userAgent)) {\n            httpServletResponse.setStatus(HttpStatus.FORBIDDEN.value());\n            httpServletResponse.getWriter().write(\"disable access.[\" + userAgent + \"]\");\n            return;\n        }\n\n        filterChain.doFilter(httpServletRequest, httpServletResponse);\n    }\n\n    private boolean checkIsDisableIP(String accessIpBlocklist, String currentAccessIp) {\n        IRuleMatcher ruleMatcher = RuleMatcherFactory.getRuleMatcher(RuleTypeConstant.IP);\n        List<String> ruleList = StringUtils.split(accessIpBlocklist, StringUtils.LF);\n        return ruleMatcher.matchAny(ruleList, currentAccessIp);\n    }\n\n    private boolean checkIsDisableUA(String accessUaBlocklist, String currentAccessUA) {\n        IRuleMatcher ruleMatcher = RuleMatcherFactory.getRuleMatcher(RuleTypeConstant.SPRING_SIMPLE);\n        List<String> ruleList = StringUtils.split(accessUaBlocklist, StringUtils.LF);\n        return ruleMatcher.matchAny(ruleList, currentAccessUA);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/io/EnsureContentLengthInputStreamResource.java",
    "content": "/*\n * Copyright 2002-2018 the original author or authors.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage im.zhaojun.zfile.core.io;\n\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.core.io.InputStreamResource;\n\nimport java.io.InputStream;\n\n/**\n * \n * 自定义 EnsureContentLengthInputStreamResource 可以保证必须实现 InputStream 的 contentLength 方法返回实际的长度.\n * 此类相较于 {@link org.springframework.core.io.InputStreamResource} 仅实现了 contentLength 方法.\n * <br><br>\n * {@link org.springframework.core.io.Resource} implementation for a given {@link InputStream}.\n * <p>Should only be used if no other specific {@code Resource} implementation\n * is applicable. In particular, prefer {@link ByteArrayResource} or any of the\n * file-based {@code Resource} implementations where possible.\n *\n * <p>In contrast to other {@code Resource} implementations, this is a descriptor\n * for an <i>already opened</i> resource - therefore returning {@code true} from\n * {@link #isOpen()}. Do not use an {@code InputStreamResource} if you need to\n * keep the resource descriptor somewhere, or if you need to read from a stream\n * multiple times.\n *\n * @author Juergen Hoeller\n * @author Sam Brannen\n * @since 28.12.2003\n * @see ByteArrayResource\n * @see org.springframework.core.io.ClassPathResource\n * @see org.springframework.core.io.FileSystemResource\n * @see org.springframework.core.io.UrlResource\n */\npublic class EnsureContentLengthInputStreamResource extends InputStreamResource {\n\n\tprivate final long contentLength;\n\n\t/**\n\t * Create a new InputStreamResource.\n\t * @param inputStream the InputStream to use\n\t */\n\tpublic EnsureContentLengthInputStreamResource(InputStream inputStream, long contentLength) {\n        super(inputStream);\n        this.contentLength = contentLength;\n\t}\n\n\t@Override\n\tpublic long contentLength() {\n\t\treturn contentLength;\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/model/request/PageQueryRequest.java",
    "content": "package im.zhaojun.zfile.core.model.request;\n\nimport com.baomidou.mybatisplus.core.metadata.OrderItem;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport java.util.Objects;\n\n/**\n * 通用分页请求对象，可继承该类增加业务字段.\n *\n * @author zhaojun\n */\n@Data\npublic class PageQueryRequest {\n\t\n\t@Schema(title=\"分页页数\")\n\tprivate Integer page = 1;\n\t\n\t@Schema(title=\"每页条数\")\n\tprivate Integer limit = 10;\n\t\n\t@Schema(title=\"排序字段\")\n\tprivate String orderBy = \"create_date\";\n\t\n\t@Schema(title=\"排序顺序\")\n\tprivate String orderDirection = \"desc\";\n\n    public OrderItem getOrderItem() {\n        boolean asc = Objects.equals(orderDirection, \"asc\");\n        return asc ? OrderItem.asc(orderBy) : OrderItem.desc(orderBy);\n    }\n\t\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/AjaxJson.java",
    "content": "package im.zhaojun.zfile.core.util;\n\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\nimport lombok.ToString;\n\nimport java.io.Serializable;\n\n/**\n * ajax 请求返回 JSON 格式数据的封装\n *\n * @author zhaojun\n */\n@Data\n@ToString\npublic class AjaxJson<T> implements Serializable {\n\n\tprivate static final long serialVersionUID = 1L;    // 序列化版本号\n\n\tpublic static final String CODE_SUCCESS = \"0\";            // 成功状态码\n\n\t@Schema(title = \"业务状态码，0 为正常，其他值均为异常，异常情况下见响应消息\", example = \"0\")\n\tprivate final String code;\n\n\t@Schema(title = \"响应消息\", example = \"ok\")\n\tprivate String msg;\n\n\t@Schema(title = \"响应数据\")\n\tprivate T data;\n\n\t@Schema(title = \"数据总条数，分页情况有效\")\n\tprivate final Long dataCount;\n\t\n\t@Schema(title = \"跟踪 ID\")\n\tprivate String traceId;\n\n\tpublic AjaxJson(String code, String msg) {\n\t\tif (code == null) {\n\t\t\tcode = ErrorCode.SYSTEM_ERROR.getCode();\n\t\t}\n\t\tthis.code = code;\n\t\tthis.msg = msg;\n\t\tthis.dataCount = null;\n\t}\n\n\tpublic AjaxJson(String code, String msg, T data) {\n\t\tthis.code = code;\n\t\tthis.msg = msg;\n\t\tthis.data = data;\n\t\tthis.dataCount = null;\n\t}\n\n\tpublic AjaxJson(String code, String msg, T data, Long dataCount) {\n\t\tthis.code = code;\n\t\tthis.msg = msg;\n\t\tthis.data = data;\n\t\tthis.dataCount = dataCount;\n\t}\n\n\t// 返回成功\n\tpublic static AjaxJson<Void> getSuccess() {\n\t\treturn new AjaxJson<>(CODE_SUCCESS, \"ok\");\n\t}\n\n\tpublic static AjaxJson<Void> getSuccess(String msg) {\n\t\treturn new AjaxJson<>(CODE_SUCCESS, msg);\n\t}\n\n\tpublic static <T> AjaxJson<T> getSuccess(String msg, T data) {\n\t\treturn new AjaxJson<>(CODE_SUCCESS, msg, data);\n\t}\n\n\tpublic static <T> AjaxJson<T> getSuccessData(T data) {\n\t\treturn new AjaxJson<>(CODE_SUCCESS, \"ok\", data);\n\t}\n\n\t// 返回分页和数据的\n\tpublic static <T> AjaxJson<T> getPageData(Long dataCount, T data) {\n\t\treturn new AjaxJson<>(CODE_SUCCESS, \"ok\", data, dataCount);\n\t}\n\n\t// 返回错误\n\tpublic static AjaxJson<Void> getError(String msg) {\n\t\treturn new AjaxJson<>(ErrorCode.SYSTEM_ERROR.getCode(), msg);\n\t}\n\n\t// 返回未登录\n\tpublic static AjaxJson<?> getUnauthorizedResult() {\n\t\treturn new AjaxJson<>(ErrorCode.BIZ_UNAUTHORIZED.getCode(), \"未登录，请登录后再次访问\");\n\t}\n\n\t// 返回没权限的\n\tpublic static AjaxJson<?> getForbiddenResult() {\n\t\treturn new AjaxJson<>(ErrorCode.NO_FORBIDDEN.getCode(), \"未授权，请登录正确权限账号再试\");\n\t}\n\n\t// 返回未找到的\n\tpublic static AjaxJson<?> getNotFoundResult() {\n\t\treturn new AjaxJson<>(ErrorCode.BIZ_NOT_FOUND.getCode(), ErrorCode.BIZ_NOT_FOUND.getMessage());\n\t}\n\n\tpublic static AjaxJson<?> getError(String code, String msg) {\n\t\treturn new AjaxJson<>(code, msg);\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/ArrayUtils.java",
    "content": "package im.zhaojun.zfile.core.util;\n\n/**\n * 数组工具类\n *\n * @author zhaojun\n */\npublic class ArrayUtils {\n\n    /**\n     * 数组是否为空\n     *\n     * @param   <T>\n     *          数组元素类型\n     *\n     * @param   array\n     *          数组\n     *\n     * @return  是否为空\n     */\n    public static <T> boolean isEmpty(T[] array) {\n        return array == null || array.length == 0;\n    }\n\n    /**\n     * 数组是否不为空\n     *\n     * @param   <T>\n     *          数组元素类型\n     *\n     * @param   array\n     *          数组\n     *\n     * @return  是否不为空\n     */\n    public static <T> boolean isNotEmpty(T[] array) {\n        return !isEmpty(array);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/CharPool.java",
    "content": "package im.zhaojun.zfile.core.util;\n\npublic interface CharPool {\n\n    /**\n     * CHAR 常量：斜杠 {@code '/'} ASCII 47\n     */\n    char SLASH_CHAR = '/';\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/CharSequenceUtil.java",
    "content": "package im.zhaojun.zfile.core.util;\n\nimport cn.hutool.core.text.StrSplitter;\nimport jakarta.annotation.Nullable;\nimport org.apache.commons.lang3.StringUtils;\n\nimport java.util.Collection;\nimport java.util.List;\n\n/**\n * 字符串工具类\n *\n * @author zhaojun\n */\npublic class CharSequenceUtil implements CharPool {\n\n    /**\n     * 找不到索引时的返回值\n     */\n    public static final int INDEX_NOT_FOUND = -1;\n\n    /**\n     * 字符串常量：{@code \"null\"} <br>\n     * 注意：{@code \"null\" != null}\n     */\n    public static final String NULL = \"null\";\n\n    /**\n     * 字符串常量：空字符串 {@code \"\"}\n     */\n    public static final String EMPTY = \"\";\n\n    /**\n     * 字符串常量：空格符 {@code \" \"}\n     */\n    public static final String SPACE = \" \";\n\n\n    /**\n     * 获取 CharSequence 的长度, 如果为 null, 返回 0\n     *\n     * @param   ch\n     *          要获取长度的 CharSequence, 可能为 null\n     *\n     * @return  CharSequence 的长度\n     */\n    public static int length(final @Nullable CharSequence ch) {\n        return ch == null ? 0 : ch.length();\n    }\n\n\n    /**\n     * {@link CharSequence} 转为字符串\n     *\n     * @param   cs\n     *          {@link CharSequence}\n     *\n     * @return  字符串\n     */\n    public static String str(final @Nullable CharSequence cs) {\n        return null == cs ? null : cs.toString();\n    }\n\n\n    /**\n     * 判断 CharSequence 是否为空\n     *\n     * @param   cs\n     *          {@link CharSequence}\n     *\n     * @return  是否为空\n     */\n    public static boolean isEmpty(final @Nullable CharSequence cs) {\n        return cs == null || cs.isEmpty();\n    }\n\n\n    /**\n     * CharSequence 是否不为空\n     *\n     * @param   cs\n     *          {@link CharSequence}\n     *\n     * @return  是否不为空\n     */\n    public static boolean isNotEmpty(final @Nullable CharSequence cs) {\n        return !isEmpty(cs);\n    }\n\n\n    /**\n     * <p>指定字符串数组中的元素，是否全部为空字符串。</p>\n     * <p>如果指定的字符串数组的长度为 0，或者所有元素都是空字符串，则返回 true。</p>\n     * <br>\n     *\n     * <p>例：</p>\n     * <ul>\n     *     <li>{@code CharSequenceUtil.isAllEmpty()                  // true}</li>\n     *     <li>{@code CharSequenceUtil.isAllEmpty(\"\", null)          // true}</li>\n     *     <li>{@code CharSequenceUtil.isAllEmpty(\"123\", \"\")         // false}</li>\n     *     <li>{@code CharSequenceUtil.isAllEmpty(\"123\", \"abc\")      // false}</li>\n     *     <li>{@code CharSequenceUtil.isAllEmpty(\" \", \"\\t\", \"\\n\")   // false}</li>\n     * </ul>\n     *\n     * @param   strs\n     *          字符串列表\n     *\n     * @return  所有字符串是否都为空\n     */\n    public static boolean isAllEmpty(final @Nullable CharSequence... strs) {\n        if (strs == null) {\n            return true;\n        }\n\n        for (CharSequence str : strs) {\n            if (isNotEmpty(str)) {\n                return false;\n            }\n        }\n        return true;\n    }\n\n    /**\n     * <p>是否包含空字符串。</p>\n     * <p>如果指定的字符串数组的长度为 0，或者其中的任意一个元素是空字符串，则返回 true。</p>\n     * <br>\n     *\n     * <p>例：</p>\n     * <ul>\n     *     <li>{@code CharSequenceUtil.hasEmpty()                  // true}</li>\n     *     <li>{@code CharSequenceUtil.hasEmpty(\"\", null)          // true}</li>\n     *     <li>{@code CharSequenceUtil.hasEmpty(\"123\", \"\")         // true}</li>\n     *     <li>{@code CharSequenceUtil.hasEmpty(\"123\", \"abc\")      // false}</li>\n     *     <li>{@code CharSequenceUtil.hasEmpty(\" \", \"\\t\", \"\\n\")   // false}</li>\n     * </ul>\n     *\n     * @param   strs\n     *          字符串列表\n     *\n     * @return  是否包含空字符串\n     */\n    public static boolean hasEmpty(final @Nullable CharSequence... strs) {\n        if (ArrayUtils.isEmpty(strs)) {\n            return true;\n        }\n\n        for (CharSequence str : strs) {\n            if (isEmpty(str)) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n\n    /**\n     * <p>指定字符串数组中的元素，是否都不为空字符串。</p>\n     * <p>如果指定的字符串数组的长度不为 0，或者所有元素都不是空字符串，则返回 true。</p>\n     * <br>\n     *\n     * <p>例：</p>\n     * <ul>\n     *     <li>{@code CharSequenceUtil.isAllNotEmpty()                  // false}</li>\n     *     <li>{@code CharSequenceUtil.isAllNotEmpty(\"\", null)          // false}</li>\n     *     <li>{@code CharSequenceUtil.isAllNotEmpty(\"123\", \"\")         // false}</li>\n     *     <li>{@code CharSequenceUtil.isAllNotEmpty(\"123\", \"abc\")      // true}</li>\n     *     <li>{@code CharSequenceUtil.isAllNotEmpty(\" \", \"\\t\", \"\\n\")   // true}</li>\n     * </ul>\n     *\n     * @param   args\n     *          字符串数组\n     *\n     * @return  所有字符串是否都不为为空白\n     */\n    public static boolean isAllNotEmpty(final @Nullable CharSequence... args) {\n        return !hasEmpty(args);\n    }\n\n\n    /**\n     * 字符串是否为空白\n     *\n     * @param   ch\n     *          要判断的字符串, 可能为 null\n     *\n     * @return  是否为空白\n     */\n    public static boolean isBlank(final @Nullable CharSequence ch) {\n        final int strLen = ch == null ? 0 : ch.length();\n        if (strLen == 0) {\n            return true;\n        }\n        for (int i = 0; i < strLen; i++) {\n            if (!Character.isWhitespace(ch.charAt(i))) {\n                return false;\n            }\n        }\n        return true;\n    }\n\n\n    /**\n     * 字符串是否不为空白\n     *\n     * @param   cs\n     *          字符串\n     *\n     * @return  是否不为空白\n     */\n    public static boolean isNotBlank(final @Nullable CharSequence cs) {\n        return !isBlank(cs);\n    }\n\n\n    /**\n     * 比较两个 CharSequence 是否相等, 区分大小写, 如果两个都为 null, 返回 true\n     *\n     * @param   cs1\n     *          CharSequence 1, 可能为 null\n     *\n     * @param   cs2\n     *          CharSequence 2, 可能为 null\n     *\n     * @return  是否相等\n     */\n    public static boolean equals(final @Nullable CharSequence cs1, final @Nullable CharSequence cs2) {\n        if (cs1 == cs2) {\n            return true;\n        }\n        if (cs1 == null || cs2 == null) {\n            return false;\n        }\n        if (cs1.length() != cs2.length()) {\n            return false;\n        }\n        if (cs1 instanceof String && cs2 instanceof String) {\n            return cs1.equals(cs2);\n        }\n        // 逐个比较\n        final int length = cs1.length();\n        for (int i = 0; i < length; i++) {\n            if (cs1.charAt(i) != cs2.charAt(i)) {\n                return false;\n            }\n        }\n        return true;\n    }\n\n\n    /**\n     * 比较两个 CharSequence 是否相等, 可以选择是否忽略大小写, 如果两个都为 null, 返回 true\n     *\n     * @param   cs1\n     *          字符串 1\n     *\n     * @param   cs2\n     *          字符串 2\n     *\n     * @param   ignoreCase\n     *          是否忽略大小写\n     *\n     * @return  是否相等\n     */\n    public static boolean equals(final @Nullable CharSequence cs1,final @Nullable CharSequence cs2, boolean ignoreCase) {\n        return ignoreCase ? equalsIgnoreCase(cs1, cs2) : equals(cs1, cs2);\n    }\n\n\n    /**\n     * 字符串是否相等, 忽略大小写\n     *\n     * @param   cs1\n     *          字符串 1\n     *\n     * @param   cs2\n     *          字符串 2\n     *\n     * @return  忽略大小写后是否相等\n     */\n    public static boolean equalsIgnoreCase(final @Nullable CharSequence cs1, final @Nullable CharSequence cs2) {\n        if (cs1 == cs2) {\n            return true;\n        }\n        if (cs1 == null || cs2 == null) {\n            return false;\n        }\n        if (cs1.length() != cs2.length()) {\n            return false;\n        }\n\n        return cs1.toString().equalsIgnoreCase(cs2.toString());\n    }\n\n\n    /**\n     * 切分字符串，如果分隔符不存在则返回原字符串\n     *\n     * @param   str\n     *          被切分的字符串\n     *\n     * @param   separator\n     *          分隔符\n     *\n     * @return  字符串\n     */\n    public static List<String> split(final CharSequence str, final CharSequence separator) {\n        return split(str, separator, false, false);\n    }\n\n\n    /**\n     * 切分字符串\n     *\n     * @param   str\n     *          被切分的字符串\n     *\n     * @param   separator\n     *          分隔符字符\n     *\n     * @param   isTrim\n     *          是否去除切分字符串后每个元素两边的空格\n     *\n     * @param   ignoreEmpty\n     *          是否忽略空串\n     *\n     * @return  切分后的集合\n     */\n    public static List<String> split(CharSequence str, CharSequence separator, boolean isTrim, boolean ignoreEmpty) {\n        return split(str, separator, 0, isTrim, ignoreEmpty);\n    }\n\n\n    /**\n     * 切分字符串\n     *\n     * @param   str\n     *          被切分的字符串\n     *\n     * @param   separator\n     *          分隔符字符\n     *\n     * @param   limit\n     *          限制分片数，-1 不限制\n     *\n     * @param   isTrim\n     *          是否去除切分字符串后每个元素两边的空格\n     *\n     * @param   ignoreEmpty\n     *          是否忽略空串\n     *\n     * @return  切分后的集合\n     */\n    public static List<String> split(CharSequence str, CharSequence separator, int limit, boolean isTrim, boolean ignoreEmpty) {\n        final String separatorStr = (null == separator) ? null : separator.toString();\n        return StrSplitter.split(str, separatorStr, limit, isTrim, ignoreEmpty);\n    }\n\n\n    /**\n     * 指定字符串是否在字符串中出现过\n     *\n     * @param   str\n     *          字符串\n     *\n     * @param   searchStr\n     *          被查找的字符串\n     *\n     * @return  是否包含\n     */\n    public static boolean contains(final @Nullable CharSequence str, final @Nullable CharSequence searchStr) {\n        if (null == str || null == searchStr) {\n            return false;\n        }\n        return str.toString().contains(searchStr);\n    }\n\n\n    /**\n     * 查找指定字符串是否包含指定字符串列表中的任意一个字符串\n     *\n     * @param   str\n     *          指定字符串\n     *\n     * @param   testStrs\n     *          需要检查的字符串数组\n     *\n     * @return  是否包含任意一个字符串\n     */\n    public static boolean containsAny(final @Nullable CharSequence str, final @Nullable  CharSequence... testStrs) {\n        if (isEmpty(str) || ArrayUtils.isEmpty(testStrs)) {\n            return false;\n        }\n        for (CharSequence checkStr : testStrs) {\n            if (null != checkStr && str.toString().contains(checkStr)) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n\n    /**\n     * 查找指定字符串是否包含指定字符串列表中的任意一个字符串<br>\n     * 忽略大小写\n     *\n     * @param   str\n     *          指定字符串\n     *\n     * @param   testStrs\n     *          需要检查的字符串数组\n     *\n     * @return  是否包含任意一个字符串\n     */\n    public static boolean containsAnyIgnoreCase(final @Nullable CharSequence str, final @Nullable CharSequence... testStrs) {\n        return StringUtils.containsAnyIgnoreCase(str, testStrs);\n    }\n\n\n    /**\n     * 以 conjunction 为分隔符将多个对象转换为字符串\n     *\n     * @param   conjunction\n     *          分隔符\n     *\n     * @param   objs\n     *          数组\n     *\n     * @return  连接后的字符串\n     */\n    public static String join(CharSequence conjunction, Object... objs) {\n        StringBuilder sb = new StringBuilder();\n        for (int i = 0; i < objs.length; i++) {\n            Object item = objs[i];\n            sb.append(item);\n            if (i < objs.length - 1) {\n                sb.append(conjunction);\n            }\n        }\n        return sb.toString();\n    }\n\n\n    /**\n     * 以 conjunction 为分隔符将 Collection 对象转换为字符串\n     *\n     * @param   conjunction\n     *          分隔符\n     *\n     * @param   collection\n     *          集合\n     *\n     * @return  连接后的字符串\n     */\n    public static String join(CharSequence conjunction, Collection<?> collection) {\n        StringBuilder sb = new StringBuilder();\n        for (Object item : collection) {\n            sb.append(item).append(conjunction);\n        }\n        if (!sb.isEmpty()) {\n            sb.delete(sb.length() - conjunction.length(), sb.length());\n        }\n        return sb.toString();\n    }\n\n\n    /**\n     * 是否以指定字符串开头\n     *\n     * @param   str\n     *          被监测字符串\n     *\n     * @param   prefix\n     *          开头字符串\n     *\n     * @return  是否以指定字符串开头\n     */\n    public static boolean startWith(CharSequence str, CharSequence prefix) {\n        return startWith(str, prefix, false);\n    }\n\n\n    /**\n     * 是否以指定字符串开头，忽略大小写\n     *\n     * @param   str\n     *          被监测字符串\n     *\n     * @param   prefix\n     *          开头字符串\n     *\n     * @return  是否以指定字符串开头\n     */\n    public static boolean startWithIgnoreCase(CharSequence str, CharSequence prefix) {\n        return startWith(str, prefix, true);\n    }\n\n\n    /**\n     * 是否以指定字符串开头<br>\n     * 如果给定的字符串和开头字符串都为null则返回true，否则任意一个值为null返回false\n     *\n     * @param   str\n     *          被监测字符串\n     *\n     * @param   prefix\n     *          开头字符串\n     *\n     * @param   ignoreCase\n     *          是否忽略大小写\n     *\n     * @return  是否以指定字符串开头\n     */\n    public static boolean startWith(CharSequence str, CharSequence prefix, boolean ignoreCase) {\n        return startWith(str, prefix, ignoreCase, false);\n    }\n\n\n    /**\n     * 是否以指定字符串开头<br>\n     * 如果给定的字符串和开头字符串都为 null 则返回 true，否则任意一个值为 null 返回 false<br>\n     * <pre>\n     *     CharSequenceUtil.startWith(\"123\", \"123\", false, true);   -- false\n     *     CharSequenceUtil.startWith(\"ABCDEF\", \"abc\", true, true); -- true\n     *     CharSequenceUtil.startWith(\"abc\", \"abc\", true, true);    -- false\n     * </pre>\n     *\n     * @param   str\n     *          被监测字符串\n     *\n     * @param   prefix\n     *          开头字符串\n     *\n     * @param   ignoreCase\n     *          是否忽略大小写\n     *\n     * @param   ignoreEquals\n     *          是否忽略字符串相等的情况\n     *\n     * @return  是否以指定字符串开头\n     */\n    public static boolean startWith(final @Nullable CharSequence str, final @Nullable CharSequence prefix, boolean ignoreCase, boolean ignoreEquals) {\n        if (null == str || null == prefix) {\n            if (ignoreEquals) {\n                return false;\n            }\n            return null == str && null == prefix;\n        }\n\n        boolean isStartWith = str.toString()\n                .regionMatches(ignoreCase, 0, prefix.toString(), 0, prefix.length());\n\n        if (isStartWith) {\n            return (!ignoreEquals) || (!equals(str, prefix, ignoreCase));\n        }\n        return false;\n    }\n\n\n    /**\n     * 是否以指定字符串结尾\n     *\n     * @param   str\n     *          被监测字符串\n     *\n     * @param   suffix\n     *          结尾字符串\n     *\n     * @return  是否以指定字符串结尾\n     */\n    public static boolean endWith(final @Nullable CharSequence str, final @Nullable CharSequence suffix) {\n        return endWith(str, suffix, false);\n    }\n\n\n    /**\n     * 是否以指定字符串结尾<br>\n     * 如果给定的字符串和开头字符串都为null则返回true，否则任意一个值为null返回false\n     *\n     * @param   str\n     *          被监测字符串\n     *\n     * @param   suffix\n     *          结尾字符串\n     *\n     * @param   ignoreCase\n     *          是否忽略大小写\n     *\n     * @return  是否以指定字符串结尾\n     */\n    public static boolean endWith(final @Nullable CharSequence str, final @Nullable CharSequence suffix, boolean ignoreCase) {\n        return endWith(str, suffix, ignoreCase, false);\n    }\n\n\n    /**\n     * 是否以指定字符串结尾<br>\n     * 如果给定的字符串和开头字符串都为null则返回true，否则任意一个值为null返回false\n     *\n     * @param   str\n     *          被监测字符串\n     *\n     * @param   suffix\n     *          结尾字符串\n     *\n     * @param   ignoreCase\n     *          是否忽略大小写\n     *\n     * @param   ignoreEquals\n     *          是否忽略字符串相等的情况\n     *\n     * @return  是否以指定字符串结尾\n     */\n    public static boolean endWith(final @Nullable CharSequence str, final @Nullable CharSequence suffix, boolean ignoreCase, boolean ignoreEquals) {\n        if (null == str || null == suffix) {\n            if (ignoreEquals) {\n                return false;\n            }\n            return null == str && null == suffix;\n        }\n\n        final int strOffset = str.length() - suffix.length();\n        boolean isEndWith = str.toString()\n                .regionMatches(ignoreCase, strOffset, suffix.toString(), 0, suffix.length());\n\n        if (isEndWith) {\n            return (!ignoreEquals) || (!equals(str, suffix, ignoreCase));\n        }\n        return false;\n    }\n\n\n    /**\n     * 去掉指定前缀\n     *\n     * @param   str\n     *          字符串\n     *\n     * @param   prefix\n     *          前缀\n     *\n     * @return  切掉后的字符串，若前缀不是 preffix， 返回原字符串\n     */\n    public static String removePrefix(final @Nullable CharSequence str, final @Nullable CharSequence prefix) {\n        if (isEmpty(str) || isEmpty(prefix)) {\n            return str(str);\n        }\n        String str2 = str.toString();\n        String prefix2 = prefix.toString();\n        if (str2.startsWith(prefix2)) {\n            return str.subSequence(prefix.length(), str.length()).toString();\n        }\n        return str2; // 若前缀不是 prefix，返回原字符串\n    }\n\n\n    /**\n     * 返回第一个非 {@code null} 元素\n     *\n     * @param   strs\n     *          多个元素\n     *\n     * @param   <T>\n     *          元素类型\n     *\n     * @return  第一个非空元素，如果给定的数组为空或者都为空，返回{@code null}\n     */\n    @SuppressWarnings(\"unchecked\")\n    public static <T extends CharSequence> T firstNonNull(T... strs) {\n        if (ArrayUtils.isNotEmpty(strs)) {\n            for (T str : strs) {\n                if (isNotEmpty(str)) {\n                    return str;\n                }\n            }\n        }\n        return null;\n    }\n\n\n    /**\n     * 截取分隔字符串之前的字符串，不包括分隔字符串<br>\n     * 如果给定的字符串为空串（null或\"\"）或者分隔字符串为null，返回原字符串<br>\n     * 如果分隔字符串为空串\"\"，则返回空串，如果分隔字符串未找到，返回原字符串，举例如下：\n     *\n     * <pre>\n     * CharSequenceUtil.subBefore(null, *, false)      = null\n     * CharSequenceUtil.subBefore(\"\", *, false)        = \"\"\n     * CharSequenceUtil.subBefore(\"abc\", \"a\", false)   = \"\"\n     * CharSequenceUtil.subBefore(\"abcba\", \"b\", false) = \"a\"\n     * CharSequenceUtil.subBefore(\"abc\", \"c\", false)   = \"ab\"\n     * CharSequenceUtil.subBefore(\"abc\", \"d\", false)   = \"abc\"\n     * CharSequenceUtil.subBefore(\"abc\", \"\", false)    = \"\"\n     * CharSequenceUtil.subBefore(\"abc\", null, false)  = \"abc\"\n     * </pre>\n     *\n     * @param   string\n     *          被查找的字符串\n     *\n     * @param   separator\n     *          分隔字符串（不包括）\n     *\n     * @param   isLastSeparator\n     *          是否查找最后一个分隔字符串（多次出现分隔字符串时选取最后一个），true为选取最后一个\n     *\n     * @return  切割后的字符串\n     */\n    public static String subBefore(final @Nullable CharSequence string, final @Nullable CharSequence separator, boolean isLastSeparator) {\n        if (isEmpty(string) || separator == null) {\n            return null == string ? null : string.toString();\n        }\n\n        final String str = string.toString();\n        final String sep = separator.toString();\n        if (sep.isEmpty()) {\n            return EMPTY;\n        }\n        final int pos = isLastSeparator ? str.lastIndexOf(sep) : str.indexOf(sep);\n        if (INDEX_NOT_FOUND == pos) {\n            return str;\n        }\n        if (0 == pos) {\n            return EMPTY;\n        }\n        return str.substring(0, pos);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/ClassUtils.java",
    "content": "package im.zhaojun.zfile.core.util;\n\nimport java.lang.reflect.Field;\nimport java.lang.reflect.ParameterizedType;\nimport java.lang.reflect.Type;\n\n/**\n * Class & 反射相关工具类\n *\n * @author zhaojun\n */\npublic class ClassUtils {\n\n\tpublic static Class<?> forName(String className) {\n        try {\n            return Class.forName(className);\n        } catch (ClassNotFoundException e) {\n            throw new RuntimeException(e);\n        }\n    }\n\n\t/**\n\t * 获取指定类的泛型类型, 只获取第一个泛型类型\n\t *\n\t * @param   clazz\n\t *          泛型类\n\t *\n\t * @return  泛型类型\n\t */\n\tpublic static Class<?> getClassFirstGenericsParam(Class<?> clazz) {\n\t\tType genericSuperclass = clazz.getGenericSuperclass();\n\t\tType actualTypeArgument = ((ParameterizedType) genericSuperclass).getActualTypeArguments()[0];\n\t\treturn (Class<?>) actualTypeArgument;\n\t}\n\n\tpublic static Class<?> getGenericType(Field field) {\n\t\tParameterizedType listType = (ParameterizedType) field.getGenericType();\n\t\treturn (Class<?>) listType.getActualTypeArguments()[0];\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/CollectionUtils.java",
    "content": "package im.zhaojun.zfile.core.util;\n\nimport cn.hutool.core.lang.func.Func1;\n\nimport javax.annotation.Nullable;\nimport java.util.*;\n\npublic class CollectionUtils {\n\n\n    /**\n     * 判断集合是否为空\n     *\n     * @param   collection\n     *          集合\n     *\n     * @return  是否为空\n     */\n    public static boolean isEmpty(@Nullable Collection<?> collection) {\n        return (collection == null || collection.isEmpty());\n    }\n\n\n    /**\n     * 判断集合是否不为空\n     *\n     * @param   collection\n     *          集合\n     *\n     * @return  是否不为空\n     */\n    public static boolean isNotEmpty(@Nullable Collection<?> collection) {\n        return !isEmpty(collection);\n    }\n\n\n    /**\n     * 从集合中获取第一个元素, 如果集合为空则返回 {@code null}\n     *\n     * @param   list\n     *          集合，可能为 {@code null}\n     *\n     * @return  第一个元素，如果集合为空则返回 {@code null}\n     */\n    @Nullable\n    public static <T> T getFirst(@Nullable List<T> list) {\n        if (isEmpty(list)) {\n            return null;\n        }\n        return list.get(0);\n    }\n\n\n    /**\n     * 从集合中获取最后一个元素, 如果集合为空则返回 {@code null}\n     *\n     * @param   list\n     *          集合，可能为 {@code null}\n     *\n     * @return  最后一个元素，如果集合为空则返回 {@code null}\n     */\n    @Nullable\n    public static <T> T getLast(@Nullable List<T> list) {\n        if (isEmpty(list)) {\n            return null;\n        }\n        return list.get(list.size() - 1);\n    }\n\n\n    /**\n     * 加入全部\n     *\n     * @param   <T>\n     *          集合元素类型\n     *\n     * @param   collection\n     *          被加入的集合 {@link Collection}\n     *\n     * @param   values\n     *          要加入的内容数组\n     *\n     * @return  原集合\n     */\n    public static <T> Collection<T> addAll(Collection<T> collection, T[] values) {\n        if (null != collection && null != values) {\n            Collections.addAll(collection, values);\n        }\n        return collection;\n    }\n\n\n    /**\n     * Iterable 转换为 Map, 根据指定的 keyFunc 函数生成 Key. Value 为 Iterable 中的元素.<br>\n     * 可以指定将结果放入的 Map, 如不指定则会新建一个 HashMap 返回.\n     *\n     * @param   <K>\n     *          Map Key 类型\n     *\n     * @param   <V>\n     *          Map Value 类型\n     *\n     * @param   values\n     *          被转换的 Iterable\n     *\n     * @param   map\n     *          转换后的 Value 存放的 Map, 如果为 {@code null} 则新建一个 HashMap\n     *\n     * @param   keyFunc\n     *          生成 Map 的 Key 的函数\n     *\n     * @return  转换后的 Map\n     */\n    public static <K, V> Map<K, V> toMap(final @Nullable Iterable<V> values, final @Nullable Map<K, V> map, final @Nullable Func1<V, K> keyFunc) {\n        if (values == null || keyFunc == null) {\n            return Collections.emptyMap();\n        }\n\n        final Map<K, V> result = map == null ? new HashMap<>() : map;\n\n        for (V value : values) {\n            try {\n                result.put(keyFunc.call(value), value);\n            } catch (Exception e) {\n                throw new RuntimeException(e);\n            }\n        }\n        return result;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/DnsUtil.java",
    "content": "package im.zhaojun.zfile.core.util;\n\nimport com.alibaba.dcm.DnsCacheManipulator;\nimport com.alibaba.fastjson2.JSONArray;\nimport org.springframework.lang.Nullable;\n\npublic class DnsUtil {\n\n    /**\n     * 通过 HTTP DNS 获取域名对应的 IP 地址\n     *\n     * @param   domain\n     *          域名\n     *\n     * @return  IP 地址数组\n     */\n    public static @Nullable String[] getDomainIpByHttpDns(String domain) {\n        String jsonArrayStr = cn.hutool.http.HttpUtil.get(\"http://223.5.5.5/resolve?name=\" + domain + \"&short=1\", 3000);\n        JSONArray jsonArray = JSONArray.parseArray(jsonArrayStr);\n        if (!jsonArray.isEmpty()) {\n            String[] result = new String[jsonArray.size()];\n            for (int i = 0; i < jsonArray.size(); i++) {\n                result[i] = jsonArray.getString(i);\n            }\n            return result;\n        } else {\n            return null;\n        }\n    }\n\n\n    /**\n     * 通过 HTTP DNS 获取域名对应的 IP 地址, 并设置 DNS 缓存.\n     *\n     * @param   domain\n     *          域名\n     *\n     * @param   cacheTime\n     *          缓存时间, 单位: 毫秒\n     *\n     * @return  IP 地址数组\n     */\n    public static String[] getDomainIpByHttpDnsAndCache(String domain, int cacheTime) {\n        String[] domainIpByHttpDns = getDomainIpByHttpDns(domain);\n        if (domainIpByHttpDns != null) {\n            // 设置 DNS 缓存\n            DnsCacheManipulator.setDnsCache(cacheTime, domain, domainIpByHttpDns);\n        }\n        return domainIpByHttpDns;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/EnumConvertUtils.java",
    "content": "package im.zhaojun.zfile.core.util;\n\nimport cn.hutool.core.convert.Convert;\nimport cn.hutool.core.util.ClassUtil;\nimport cn.hutool.core.util.ObjectUtil;\nimport cn.hutool.core.util.ReflectUtil;\nimport com.baomidou.mybatisplus.annotation.EnumValue;\nimport com.fasterxml.jackson.annotation.JsonValue;\n\nimport java.lang.reflect.Field;\n\n/**\n * 枚举转换工具类\n *\n * @author zhaojun\n */\npublic class EnumConvertUtils {\n\n\n\t/**\n\t * 根据枚举 class 和值获取对应的枚举对象\n\t *\n\t * @param   clazz\n\t *          枚举类 Class\n\t *\n\t * @param   value\n\t *          枚举值\n\t *\n\t * @return  枚举对象\n\t */\n\tpublic static Enum<?> convertStrToEnum(Class<?> clazz, Object value) {\n\t\tif (!ClassUtil.isEnum(clazz)) {\n\t\t\treturn null;\n\t\t}\n\n\t\tField[] fields = ReflectUtil.getFields(clazz);\n\t\tfor (Field field : fields) {\n\t\t\tboolean jsonValuePresent = field.isAnnotationPresent(JsonValue.class);\n\t\t\tboolean enumValuePresent = field.isAnnotationPresent(EnumValue.class);\n\n\t\t\tif (jsonValuePresent || enumValuePresent) {\n\t\t\t\tObject[] enumConstants = clazz.getEnumConstants();\n\n\t\t\t\tfor (Object enumObj : enumConstants) {\n\t\t\t\t\tif (ObjectUtil.equal(value, ReflectUtil.getFieldValue(enumObj, field))) {\n\t\t\t\t\t\treturn (Enum<?>) enumObj;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn null;\n\t}\n\n\n\t/**\n\t * 转换枚举对象为字符串, 如果枚举对象没有定义 JsonValue 注解, 则使用 EnumValue 注解的值\n\t *\n\t * @param   enumObj\n\t *          枚举对象\n\t *\n\t * @return  字符串\n\t */\n\tpublic static String convertEnumToStr(Object enumObj) {\n\t\tClass<?> clazz = enumObj.getClass();\n\t\tif (!ClassUtil.isEnum(clazz)) {\n\t\t\treturn null;\n\t\t}\n\n\t\tField[] fields = ReflectUtil.getFields(clazz);\n\t\tfor (Field field : fields) {\n\t\t\tboolean jsonValuePresent = field.isAnnotationPresent(JsonValue.class);\n\t\t\tboolean enumValuePresent = field.isAnnotationPresent(EnumValue.class);\n\n\t\t\tif (jsonValuePresent || enumValuePresent) {\n\t\t\t\treturn Convert.toStr(ReflectUtil.getFieldValue(enumObj, field));\n\t\t\t}\n\t\t}\n\n\t\treturn null;\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/FileComparator.java",
    "content": "package im.zhaojun.zfile.core.util;\n\nimport cn.hutool.core.comparator.CompareUtil;\nimport im.zhaojun.zfile.module.storage.model.result.FileItemResult;\nimport im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum;\n\nimport java.util.Comparator;\n\n/**\n * 文件比较器\n * <ul>\n *     <li>文件夹始终比文件排序高</li>\n *     <li>默认按照名称排序</li>\n *     <li>默认排序为升序</li>\n *     <li>按名称排序不区分大小写</li>\n * </ul>\n * @author zhaojun\n */\npublic class FileComparator implements Comparator<FileItemResult> {\n\n    private String sortBy;\n\n    private String order;\n\n    public FileComparator(String sortBy, String order) {\n        this.sortBy = sortBy;\n        this.order = order;\n    }\n\n\n    /**\n     * 比较两个文件的大小\n     *\n     * @param   o1\n     *          第一个文件\n     *\n     * @param   o2\n     *          第二个文件\n     *\n     * @return  比较结果\n     */\n    @Override\n    public int compare(FileItemResult o1, FileItemResult o2) {\n        if (sortBy == null) {\n            sortBy = \"name\";\n        }\n\n        if (order == null) {\n            order = \"asc\";\n        }\n        FileTypeEnum o1Type = o1.getType();\n        FileTypeEnum o2Type = o2.getType();\n        NaturalOrderComparator naturalOrderComparator = new NaturalOrderComparator();\n        if (o1Type.equals(o2Type)) {\n            int result = switch (sortBy) {\n                case \"time\" -> CompareUtil.compare(o1.getTime(), o2.getTime());\n                case \"size\" -> CompareUtil.compare(o1.getSize(), o2.getSize());\n                default -> naturalOrderComparator.compare(o1.getName(), o2.getName());\n            };\n            return \"asc\".equals(order) ? result : -result;\n        }\n\n        if (o1Type.equals(FileTypeEnum.FOLDER)) {\n            return -1;\n        } else {\n            return 1;\n        }\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/FileResponseUtil.java",
    "content": "package im.zhaojun.zfile.core.util;\n\nimport cn.hutool.core.io.FileUtil;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.status.NotFoundAccessException;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.core.io.InputStreamResource;\nimport org.springframework.core.io.Resource;\nimport org.springframework.http.ContentDisposition;\nimport org.springframework.http.HttpHeaders;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.ResponseEntity;\n\nimport java.io.File;\nimport java.nio.charset.StandardCharsets;\n\n/**\n * 将文件输出对象\n *\n * @author zhaojun\n */\n@Slf4j\npublic class FileResponseUtil {\n\n\n    /**\n     * 文件下载，单线程，不支持断点续传\n     *\n     * @param   file\n     *          文件对象\n     *\n     * @param   fileName\n     *          要保存为的文件名\n     *\n     * @return  文件下载对象\n     */\n    public static ResponseEntity<Resource> exportSingleThread(File file, String fileName) {\n        if (!file.exists()) {\n            throw new NotFoundAccessException(ErrorCode.BIZ_FILE_NOT_EXIST);\n        }\n\n        MediaType mediaType = MediaType.APPLICATION_OCTET_STREAM;\n\n        HttpHeaders headers = new HttpHeaders();\n\n        if (StringUtils.isEmpty(fileName)) {\n            fileName = file.getName();\n        }\n\n        ContentDisposition contentDisposition = ContentDisposition\n                .builder(\"inline\")\n                .filename(fileName, StandardCharsets.UTF_8)\n                .build();\n        headers.setContentDisposition(contentDisposition);\n\n        return ResponseEntity\n                .ok()\n                .headers(headers)\n                .contentLength(file.length())\n                .contentType(mediaType)\n                .body(new InputStreamResource(FileUtil.getInputStream(file)));\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/FileSizeConverter.java",
    "content": "package im.zhaojun.zfile.core.util;\n\nimport java.util.regex.Matcher;\nimport java.util.regex.Pattern;\n\npublic class FileSizeConverter {\n\n    private static final long KB_FACTOR = 1024L;\n    private static final long MB_FACTOR = 1024L * KB_FACTOR;\n    private static final long GB_FACTOR = 1024L * MB_FACTOR;\n    private static final long TB_FACTOR = 1024L * GB_FACTOR;\n    private static final long PB_FACTOR = 1024L * TB_FACTOR;\n\n    private static final Pattern FILE_SIZE_PATTERN = Pattern.compile(\"([\\\\d.]+)\\\\s*([a-zA-Z]+)\");\n\n    public static long convertFileSizeToBytes(String sizeStr) {\n        if (sizeStr == null || sizeStr.trim().isEmpty()) {\n            throw new IllegalArgumentException(\"输入字符串不能为空\");\n        }\n\n        Matcher matcher = FILE_SIZE_PATTERN.matcher(sizeStr.trim());\n\n        if (!matcher.matches()) {\n            throw new IllegalArgumentException(\"无效的文件大小格式: \" + sizeStr);\n        }\n\n        String valueStr = matcher.group(1);\n        String unitStr = matcher.group(2).toUpperCase();\n\n        double value;\n        try {\n            value = Double.parseDouble(valueStr);\n        } catch (NumberFormatException e) {\n            throw new IllegalArgumentException(\"无效的数字格式: \" + valueStr, e);\n        }\n\n        if (value < 0) {\n            throw new IllegalArgumentException(\"文件大小不能为负数: \" + valueStr);\n        }\n\n        long multiplier = switch (unitStr) {\n            case \"B\" ->\n                    1L;\n            case \"KB\", \"KIB\" ->\n                    KB_FACTOR;\n            case \"MB\", \"MIB\" ->\n                    MB_FACTOR;\n            case \"GB\", \"GIB\" ->\n                    GB_FACTOR;\n            case \"TB\", \"TIB\" ->\n                    TB_FACTOR;\n            case \"PB\", \"PIB\" ->\n                    PB_FACTOR;\n            default -> throw new IllegalArgumentException(\"不支持的单位: \" + unitStr + \" (支持 B, KB, MB, GB, TB, PB)\");\n        };\n\n        double bytesDouble = value * multiplier;\n        if (bytesDouble > Long.MAX_VALUE) {\n            throw new ArithmeticException(\"转换后的字节数超过了 Long 类型的最大值: \" + bytesDouble);\n        }\n\n        return Math.round(bytesDouble);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/FileUtils.java",
    "content": "package im.zhaojun.zfile.core.util;\n\nimport org.apache.commons.io.FilenameUtils;\n\n/**\n * 文件相关工具类\n *\n * @author zhaojun\n */\npublic class FileUtils {\n\n    public static String getName(final String fileName) {\n        if (fileName == null) {\n            return null;\n        }\n\n        int i = fileName.lastIndexOf(CharSequenceUtil.SLASH_CHAR);\n        if (i >= 0 && i <= fileName.length() - 1) {\n            return fileName.substring(i + 1);\n        }\n\n        return fileName;\n    }\n\n    public static String getParentPath(final String fileName) {\n        String fullPathNoEndSeparator = FilenameUtils.getFullPathNoEndSeparator(StringUtils.trimEndSlashes(fileName));\n        if (fullPathNoEndSeparator == null || fullPathNoEndSeparator.isEmpty()) {\n            return StringUtils.SLASH;\n        }\n        return fullPathNoEndSeparator;\n    }\n\n    public static String getExtension(final String fileName) throws IllegalArgumentException {\n        if (fileName == null) {\n            return null;\n        }\n\n        int i = fileName.lastIndexOf('.');\n        if (i > 0 && i < fileName.length() - 1) {\n            return fileName.substring(i + 1);\n        }\n\n        return \"\";\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/HttpUtil.java",
    "content": "package im.zhaojun.zfile.core.util;\n\nimport im.zhaojun.zfile.core.constant.ZFileConstant;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.biz.GetPreviewTextContentBizException;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport lombok.extern.slf4j.Slf4j;\n\nimport java.io.IOException;\nimport java.net.URL;\nimport java.net.URLConnection;\n\n/**\n * 网络相关工具\n *\n * @author zhaojun\n */\n@Slf4j\npublic class HttpUtil {\n\n    /**\n     * 获取 URL 对应的文件内容\n     *\n     * @param   url\n     *          文件 URL\n     *\n     * @return  文件内容\n     */\n    public static String getTextContent(String url) {\n        long maxFileSize = 1024 * ZFileConstant.TEXT_MAX_FILE_SIZE_KB;\n\n        if (getRemoteFileSize(url) > maxFileSize) {\n            throw new BizException(ErrorCode.BIZ_PREVIEW_FILE_SIZE_EXCEED);\n        }\n\n        String result;\n        try {\n            result = cn.hutool.http.HttpUtil.get(url);\n        } catch (Exception e) {\n            throw new GetPreviewTextContentBizException(url, e);\n        }\n\n        return result == null ? \"\" : result;\n    }\n\n\n    /**\n     * 获取远程文件大小\n     *\n     * @param   url\n     *          文件 URL\n     *\n     * @return  文件大小\n     */\n    public static Long getRemoteFileSize(String url) {\n        long size = 0;\n        URL urlObject;\n        try {\n            urlObject = new URL(url);\n            URLConnection conn = urlObject.openConnection();\n            size = conn.getContentLength();\n        } catch (IOException e) {\n            e.printStackTrace();\n        }\n\n        return size;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/NaturalOrderComparator.java",
    "content": "package im.zhaojun.zfile.core.util;\n/*\n NaturalOrderComparator.java -- Perform 'natural order' comparisons of strings in Java.\n Copyright (C) 2003 by Pierre-Luc Paour <natorder@paour.com>\n\n Based on the C version by Martin Pool, of which this is more or less a straight conversion.\n Copyright (C) 2000 by Martin Pool <mbp@humbug.org.au>\n\n This software is provided 'as-is', without any express or implied\n warranty.  In no event will the authors be held liable for any damages\n arising from the use of this software.\n\n Permission is granted to anyone to use this software for any purpose,\n including commercial applications, and to alter it and redistribute it\n freely, subject to the following restrictions:\n\n 1. The origin of this software must not be misrepresented; you must not\n claim that you wrote the original software. If you use this software\n in a product, an acknowledgment in the product documentation would be\n appreciated but is not required.\n 2. Altered source versions must be plainly marked as such, and must not be\n misrepresented as being the original software.\n 3. This notice may not be removed or altered from any source distribution.\n */\n\nimport java.util.Comparator;\n\n/**\n * 类 windows 文件排序算法\n *\n * @author zhaojun\n */\npublic class NaturalOrderComparator implements Comparator<String> {\n\n    private static final char ZERO_CHAR = '0';\n\n    private int compareRight(String a, String b) {\n        int bias = 0, ia = 0, ib = 0;\n\n        // The longest run of digits wins. That aside, the greatest\n        // value wins, but we can't know that it will until we've scanned\n        // both numbers to know that they have the same magnitude, so we\n        // remember it in BIAS.\n        for (; ; ia++, ib++) {\n            char ca = charAt(a, ia);\n            char cb = charAt(b, ib);\n\n            if (!isDigit(ca) && !isDigit(cb)) {\n                return bias;\n            }\n            if (!isDigit(ca)) {\n                return -1;\n            }\n            if (!isDigit(cb)) {\n                return +1;\n            }\n            if (ca == 0 && cb == 0) {\n                return bias;\n            }\n\n            if (bias == 0) {\n                if (ca < cb) {\n                    bias = -1;\n                } else if (ca > cb) {\n                    bias = +1;\n                }\n            }\n        }\n    }\n\n    @Override\n    public int compare(String a, String b) {\n        int ia = 0, ib = 0;\n        int nza, nzb;\n        char ca, cb;\n\n        while (true) {\n            // Only count the number of zeroes leading the last number compared\n            nza = nzb = 0;\n\n            ca = charAt(a, ia);\n            cb = charAt(b, ib);\n\n            // skip over leading spaces or zeros\n            while (Character.isSpaceChar(ca) || ca == ZERO_CHAR) {\n                if (ca == ZERO_CHAR) {\n                    nza++;\n                } else {\n                    // Only count consecutive zeroes\n                    nza = 0;\n                }\n\n                ca = charAt(a, ++ia);\n            }\n\n            while (Character.isSpaceChar(cb) || cb == '0') {\n                if (cb == '0') {\n                    nzb++;\n                } else {\n                    // Only count consecutive zeroes\n                    nzb = 0;\n                }\n\n                cb = charAt(b, ++ib);\n            }\n\n            // Process run of digits\n            if (Character.isDigit(ca) && Character.isDigit(cb)) {\n                int bias = compareRight(a.substring(ia), b.substring(ib));\n                if (bias != 0) {\n                    return bias;\n                }\n            }\n\n            if (ca == 0 && cb == 0) {\n                // The strings compare the same. Perhaps the caller\n                // will want to call strcmp to break the tie.\n                return compareEqual(a, b, nza, nzb);\n            }\n            if (ca < cb) {\n                return -1;\n            }\n            if (ca > cb) {\n                return +1;\n            }\n\n            ++ia;\n            ++ib;\n        }\n    }\n\n    private static boolean isDigit(char c) {\n        return Character.isDigit(c) || c == '.' || c == ',';\n    }\n\n    private static char charAt(String s, int i) {\n        return i >= s.length() ? 0 : s.charAt(i);\n    }\n\n    private static int compareEqual(String a, String b, int nza, int nzb) {\n        if (nza - nzb != 0) {\n            return nza - nzb;\n        }\n\n        if (a.length() == b.length()) {\n            return a.compareTo(b);\n        }\n\n        return a.length() - b.length();\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/NumberUtils.java",
    "content": "package im.zhaojun.zfile.core.util;\n\n/**\n * 数字工具类\n *\n * @author zhaojun\n */\npublic class NumberUtils {\n\n    public static boolean isNullOrZero(Integer number) {\n        return number == null || number == 0;\n    }\n\n    public static boolean isNotNullOrZero(Integer number) {\n        return number != null && number != 0;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/OnlyOfficeKeyCacheUtils.java",
    "content": "package im.zhaojun.zfile.core.util;\n\nimport cn.hutool.cache.Cache;\nimport cn.hutool.cache.CacheUtil;\nimport cn.hutool.cache.impl.CacheObj;\nimport im.zhaojun.zfile.module.onlyoffice.model.OnlyOfficeFile;\nimport lombok.extern.slf4j.Slf4j;\nimport org.apache.commons.lang3.BooleanUtils;\nimport org.apache.commons.lang3.RandomStringUtils;\n\nimport java.util.ArrayList;\nimport java.util.Iterator;\nimport java.util.List;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.locks.ReentrantLock;\n\n/**\n * OnlyOffice 文件信息与 Key 缓存工具类\n *\n * @author zhaojun\n */\n@Slf4j\npublic class OnlyOfficeKeyCacheUtils {\n\n    /**\n     * 存储 OnlyOffice 文件信息与 Key 的映射关系. 最多存储 10000 个 Key, 防止内存溢出.\n     */\n    private static final Cache<OnlyOfficeFile, String> ONLY_OFFICE_FILE_KEY_MAP = CacheUtil.newLRUCache(10000);\n\n    /**\n     * 存储 OnlyOffice Key 与文件信息的映射关系. 最多存储 10000 个 Key, 防止内存溢出.\n     */\n    private static final Cache<String, OnlyOfficeFile> ONLY_OFFICE_KEY_FILE_MAP = CacheUtil.newLRUCache(10000);\n\n    /**\n     * 存储文件锁, 防止并发操作文件缓存时出现问题.\n     */\n    private static final Cache<OnlyOfficeFile, ReentrantLock> locks = CacheUtil.newLRUCache(300);\n\n    /**\n     * 获取该文件缓存的 key, 如果不存在则生成一个新的 key 并缓存.\n     *\n     * @param   onlyOfficeFile\n     *          OnlyOffice 文件信息\n     *\n     * @return  该文件唯一标识\n     */\n    public static String getKeyOrPutNew(OnlyOfficeFile onlyOfficeFile, long timeout) {\n        ReentrantLock lock = getLock(onlyOfficeFile);\n        try {\n            boolean getLock = lock.tryLock(timeout, TimeUnit.MILLISECONDS);\n            if (BooleanUtils.isFalse(getLock)) {\n                log.warn(\"{} 尝试获取锁超时, 强制忽略锁直接操作文件.\", onlyOfficeFile);\n            }\n            try {\n                if (ONLY_OFFICE_FILE_KEY_MAP.containsKey(onlyOfficeFile)) {\n                    return ONLY_OFFICE_FILE_KEY_MAP.get(onlyOfficeFile);\n                } else {\n                    String key = RandomStringUtils.randomAlphabetic(10);\n                    ONLY_OFFICE_FILE_KEY_MAP.put(onlyOfficeFile, key);\n                    ONLY_OFFICE_KEY_FILE_MAP.put(key, onlyOfficeFile);\n                    return key;\n                }\n            } finally {\n                lock.unlock();\n            }\n        } catch (InterruptedException e) {\n            Thread.currentThread().interrupt();\n            throw new IllegalStateException(\"Thread was interrupted\", e);\n        }\n\n    }\n\n    /**\n     * 清理缓存中的 Key 与文件信息的映射关系.(文件发生了变化, 需要重新生成 OnlyOffice 预览链接时调用)\n     *\n     * @param   key\n     *          文件唯一标识\n     */\n    public static OnlyOfficeFile removeByKey(String key) {\n        OnlyOfficeFile onlyOfficeFile = ONLY_OFFICE_KEY_FILE_MAP.get(key);\n        if (onlyOfficeFile == null) {\n            return null;\n        }\n        ONLY_OFFICE_FILE_KEY_MAP.remove(onlyOfficeFile);\n        ONLY_OFFICE_KEY_FILE_MAP.remove(key);\n        return onlyOfficeFile;\n    }\n\n    /**\n     * 清理缓存中的文件信息与 Key 的映射关系.(文件发生了变化, 需要重新生成 OnlyOffice 预览链接时调用)\n     *\n     * @param   onlyOfficeFile\n     *          OnlyOffice 文件信息\n     */\n    public static OnlyOfficeFile removeByFile(OnlyOfficeFile onlyOfficeFile) {\n        String key = ONLY_OFFICE_FILE_KEY_MAP.get(onlyOfficeFile);\n        if (key == null) {\n            return null;\n        }\n        ONLY_OFFICE_FILE_KEY_MAP.remove(onlyOfficeFile);\n        ONLY_OFFICE_KEY_FILE_MAP.remove(key);\n        return onlyOfficeFile;\n    }\n\n\n    /**\n     * 清理缓存中的某个文件夹下所有文件信息与 Key 的映射关系.(文件发生了变化, 需要重新生成 OnlyOffice 预览链接时调用)\n     *\n     * @param   onlyOfficeFile\n     *          OnlyOffice 文件信息\n     */\n    public static List<OnlyOfficeFile> removeByFolder(OnlyOfficeFile onlyOfficeFile) {\n        List<OnlyOfficeFile> caches = new ArrayList<>();\n        Iterator<CacheObj<OnlyOfficeFile, String>> cacheObjIterator = ONLY_OFFICE_FILE_KEY_MAP.cacheObjIterator();\n        while (cacheObjIterator.hasNext()) {\n            CacheObj<OnlyOfficeFile, String> cacheObj = cacheObjIterator.next();\n            OnlyOfficeFile cacheOnlyOfficeFile = cacheObj.getKey();\n            if (cacheOnlyOfficeFile.getStorageKey().equals(onlyOfficeFile.getStorageKey())\n                    && StringUtils.startWith(cacheOnlyOfficeFile.getPathAndName(), onlyOfficeFile.getPathAndName())) {\n                ONLY_OFFICE_FILE_KEY_MAP.remove(cacheObj.getKey());\n                ONLY_OFFICE_KEY_FILE_MAP.remove(cacheObj.getValue());\n                caches.add(cacheOnlyOfficeFile);\n            }\n        }\n        return caches;\n    }\n\n    /**\n     * 获取文件锁, 防止并发操作文件缓存时出现问题.\n     *\n     * @param   key\n     *          文件唯一标识\n     *\n     * @return  锁对象\n     */\n    public static ReentrantLock getLock(OnlyOfficeFile key) {\n        return locks.get(key, true, ReentrantLock::new);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/PatternMatcherUtils.java",
    "content": "package im.zhaojun.zfile.core.util;\n\nimport java.nio.file.FileSystems;\nimport java.nio.file.PathMatcher;\nimport java.nio.file.Paths;\nimport java.util.HashMap;\nimport java.util.Map;\n\n/**\n * 规则表达式工具类\n *\n * @author zhaojun\n */\npublic class PatternMatcherUtils {\n\t\n\tprivate static final Map<String, PathMatcher> PATH_MATCHER_MAP = new HashMap<>();\n\t\n\t/**\n\t * 兼容模式的 glob 表达式匹配.\n\t * 默认的 glob 表达式是不支持以下情况的:<br>\n\t * <ul>\n\t * <li>pattern: /a/**</li>\n\t * <li>test1: /a</li>\n\t * <li>test2: /a/</li>\n\t * <ul>\n\t * <p>test1 和 test 2 均无法匹配这种情况, 此方法兼容了这种情况, 即对 test 内容后拼接 \"/xx\"(其实任意字符都可以), 使其可以匹配上 pattern.\n\t * <p><strong>注意：</strong>但此方法对包含文件名的情况无效, 仅支持 test 为 路径的情况.\n\t *\n\t * @param \tpattern\n\t *\t\t\tglob 规则表达式\n\t *\n\t * @param \ttest\n\t *\t\t\t匹配内容\n\t *\n\t * @return \t是否匹配.\n\t */\n\tpublic static boolean testCompatibilityGlobPattern(String pattern, String test) {\n\t\t// 如果规则表达式最开始没有 /, 则兼容在最前方加上 /.\n\t\tif (!StringUtils.startWith(pattern, StringUtils.SLASH)) {\n\t\t\tpattern = StringUtils.SLASH + pattern;\n\t\t}\n\t\t\n\t\t// 兼容性处理.\n\t\ttest = StringUtils.concat(test, StringUtils.SLASH);\n\t\tif (StringUtils.endWith(pattern, \"/**\") || StringUtils.endWith(pattern, \"/*\")) {\n\t\t\ttest += \"xxx\";\n\t\t}\n\t\treturn testGlobPattern(pattern, test);\n\t}\n\t\n\t\n\t/**\n\t * 测试密码规则表达式和文件路径是否匹配\n\t *\n\t * @param \tpattern\n\t *\t\t\tglob 规则表达式\n\t *\n\t * @param \ttest\n\t *\t\t\t测试字符串\n\t */\n\tprivate static boolean testGlobPattern(String pattern, String test) {\n\t\t// 从缓存取出 PathMatcher, 防止重复初始化\n\t\tPathMatcher pathMatcher = PATH_MATCHER_MAP.getOrDefault(pattern, FileSystems.getDefault().getPathMatcher(\"glob:\" + pattern));\n\t\tPATH_MATCHER_MAP.put(pattern, pathMatcher);\n\t\t\n\t\treturn pathMatcher.matches(Paths.get(test)) || StringUtils.equals(pattern, test);\n\t}\n\t\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/PlaceholderUtils.java",
    "content": "package im.zhaojun.zfile.core.util;\n\nimport cn.hutool.extra.spring.SpringUtil;\nimport lombok.extern.slf4j.Slf4j;\n\nimport java.util.*;\n \n/**\n * 配置文件或模板中的占位符替换工具类\n *\n * @author zhaojun\n */\n@Slf4j\npublic class PlaceholderUtils {\n\n    /**\n     * Prefix for system property placeholders: \"${\"\n     */\n    public static final String PLACEHOLDER_PREFIX = \"${\";\n\n    /**\n     * Suffix for system property placeholders: \"}\"\n     */\n    public static final String PLACEHOLDER_SUFFIX = \"}\";\n\n\n    /**\n     * 解析占位符, 将指定的占位符替换为指定的值. 变量值从 Spring 环境中获取, 如没取到, 则默认为空.\n     * <br>\n     * 必须在 Spring 环境下使用, 否则会抛出异常.\n     *\n     *\n     * @param   formatStr\n     *          模板字符串\n     *\n     * @return  替换后的字符串\n     */\n    public static String resolvePlaceholdersBySpringProperties(String formatStr) {\n        String placeholderName = getFirstPlaceholderName(formatStr);\n        if (StringUtils.isEmpty(placeholderName)) {\n            return formatStr;\n        }\n\n        String propertyValue = SpringUtil.getProperty(placeholderName);\n        Map<String, String> map = new HashMap<>();\n        map.put(placeholderName, propertyValue);\n        return resolvePlaceholders(formatStr, map);\n    }\n\n\n    /**\n     * 解析占位符, 将指定的占位符替换为指定的值.\n     *\n     * @param   formatStr\n     *          模板字符串\n     *\n     * @param   parameter\n     *          参数列表\n     *\n     * @return  替换后的字符串\n     */\n    public static String resolvePlaceholders(String formatStr, Map<String, String> parameter) {\n        if (parameter == null || parameter.isEmpty()) {\n            return formatStr;\n        }\n        StringBuilder sb = new StringBuilder(formatStr);\n        int startIndex = sb.indexOf(PLACEHOLDER_PREFIX);\n        while (startIndex != -1) {\n            int endIndex = sb.indexOf(PLACEHOLDER_SUFFIX, startIndex + PLACEHOLDER_PREFIX.length());\n            if (endIndex != -1) {\n                String placeholder = sb.substring(startIndex + PLACEHOLDER_PREFIX.length(), endIndex);\n                int nextIndex = endIndex + PLACEHOLDER_SUFFIX.length();\n                try {\n                    String propVal = parameter.get(placeholder);\n                    if (propVal != null) {\n                        sb.replace(startIndex, endIndex + PLACEHOLDER_SUFFIX.length(), propVal);\n                        nextIndex = startIndex + propVal.length();\n                    } else {\n                        log.warn(\"Could not resolve placeholder '{}' in [{}] \", placeholder, formatStr);\n                    }\n                } catch (Exception ex) {\n                    log.error(\"Could not resolve placeholder '{}' in [{}]: \", placeholder, formatStr, ex);\n                }\n                startIndex = sb.indexOf(PLACEHOLDER_PREFIX, nextIndex);\n            } else {\n                startIndex = -1;\n            }\n        }\n        return sb.toString();\n    }\n\n\n    /**\n     * 获取模板字符串第一个占位符的名称, 如 \"我的名字是: ${name}, 我的年龄是: ${age}\", 返回 \"name\".\n     *\n     * @param   formatStr\n     *          模板字符串\n     *\n     * @return  占位符名称\n     */\n    public static String getFirstPlaceholderName(String formatStr) {\n        List<String> list = getPlaceholderNames(formatStr);\n        if (CollectionUtils.isNotEmpty(list)) {\n            return list.getFirst();\n        }\n        return null;\n    }\n\n\n    /**\n     * 获取模板字符串第一个占位符的名称, 如 \"我的名字是: ${name}, 我的年龄是: ${age}\", 返回 [\"name\", \"age].\n     *\n     * @param   formatStr\n     *          模板字符串\n     *\n     * @return  占位符名称\n     */\n    public static List<String> getPlaceholderNames(String formatStr) {\n        if (StringUtils.isEmpty(formatStr)) {\n            return Collections.emptyList();\n        }\n\n        List<String> placeholderNameList = new ArrayList<>();\n\n        StringBuilder sb = new StringBuilder(formatStr);\n        int startIndex = sb.indexOf(PLACEHOLDER_PREFIX);\n        while (startIndex != -1) {\n            int endIndex = sb.indexOf(PLACEHOLDER_SUFFIX, startIndex + PLACEHOLDER_PREFIX.length());\n            if (endIndex != -1) {\n                String placeholder = sb.substring(startIndex + PLACEHOLDER_PREFIX.length(), endIndex);\n                int nextIndex = endIndex + PLACEHOLDER_SUFFIX.length();\n                startIndex = sb.indexOf(PLACEHOLDER_PREFIX, nextIndex);\n                placeholderNameList.add(placeholder);\n            } else {\n                startIndex = -1;\n            }\n        }\n        return placeholderNameList;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/ProxyDownloadUrlUtils.java",
    "content": "package im.zhaojun.zfile.core.util;\n\nimport cn.hutool.core.convert.Convert;\nimport cn.hutool.core.date.DateUtil;\nimport cn.hutool.core.util.HexUtil;\nimport cn.hutool.crypto.symmetric.SymmetricAlgorithm;\nimport cn.hutool.crypto.symmetric.SymmetricCrypto;\nimport cn.hutool.extra.spring.SpringUtil;\nimport im.zhaojun.zfile.module.config.service.SystemConfigService;\nimport lombok.extern.slf4j.Slf4j;\n\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\n\n/**\n * 代理下载链接工具类\n *\n * @author zhaojun\n */\n@Slf4j\npublic class ProxyDownloadUrlUtils {\n\n\tprivate static SystemConfigService systemConfigService;\n\n\tprivate static final String PROXY_DOWNLOAD_LINK_DELIMITER = \":\";\n\n\tprivate static final Map<String, SymmetricCrypto> AES_CACHE = new HashMap<>();\n\n\t/**\n\t * 服务器代理下载 URL 有效期 (秒).\n\t */\n\tpublic static final Integer PROXY_DOWNLOAD_LINK_EFFECTIVE_SECOND = 1800;\n\n\t/**\n\t * 生成签名：根据系统设置中 AES 密钥对生成签名.\n\t *\n\t * @param \tstorageId\n\t * \t\t\t存储源 ID\n\t *\n\t * @param \tpathAndName\n\t * \t\t\t文件路径及文件名称\n\t *\n\t * @param \teffectiveSecond\n\t * \t\t\t有效时间, 单位: 秒\n\t *\n\t * @return\t签名\n\t */\n\tpublic static String generatorSignature(Integer storageId, String pathAndName, Integer effectiveSecond) {\n\t\tif (systemConfigService == null) {\n\t\t\tsystemConfigService = SpringUtil.getBean(SystemConfigService.class);\n\t\t}\n\n\t\t// 如果有效时间为空, 则设置 30 分钟过期\n\t\tif (effectiveSecond == null || effectiveSecond < 1) {\n\t\t\teffectiveSecond = PROXY_DOWNLOAD_LINK_EFFECTIVE_SECOND;\n\t\t}\n\n\t\t// 过期时间的秒数\n\t\tlong second = DateUtil.offsetSecond(DateUtil.date(), effectiveSecond).getTime();\n\t\tString content = storageId + PROXY_DOWNLOAD_LINK_DELIMITER + pathAndName + PROXY_DOWNLOAD_LINK_DELIMITER + second;\n\n\t\tString aesHexKey = systemConfigService.getAesHexKeyOrGenerate();\n\t\tSymmetricCrypto aes = AES_CACHE.computeIfAbsent(aesHexKey, k -> new SymmetricCrypto(SymmetricAlgorithm.AES, HexUtil.decodeHex(k)));\n\n\t\t//加密\n\t\treturn aes.encryptHex(content);\n\t}\n\n\n\tpublic static boolean validSignatureExpired(Integer expectedStorageId, String expectedPathAndName, String signature) {\n\t\tif (systemConfigService == null) {\n\t\t\tsystemConfigService = SpringUtil.getBean(SystemConfigService.class);\n\t\t}\n\n\t\tString aesHexKey = systemConfigService.getAesHexKeyOrGenerate();\n\t\tSymmetricCrypto aes = AES_CACHE.computeIfAbsent(aesHexKey, k -> new SymmetricCrypto(SymmetricAlgorithm.AES, HexUtil.decodeHex(k)));\n\n\t\tlong currentTimeMillis = System.currentTimeMillis();\n\t\t\n\t\tString storageId = null;\n\t\tString pathAndName = null;\n\t\tString expiredSecond = null;\n\t\t\n\t\ttry {\n\t\t\t//解密\n\t\t\tString decryptStr = aes.decryptStr(signature);\n\t\t\tList<String> split = StringUtils.split(decryptStr, PROXY_DOWNLOAD_LINK_DELIMITER);\n\t\t\tstorageId = split.get(0);\n\t\t\tpathAndName = split.get(1);\n\t\t\texpiredSecond = split.get(2);\n\t\t\t\n\t\t\t// 校验存储源 ID 和文件路径及是否过期.\n\t\t\tif (StringUtils.equals(storageId, Convert.toStr(expectedStorageId))\n\t\t\t\t&& StringUtils.equals(StringUtils.concat(pathAndName), StringUtils.concat(expectedPathAndName))\n\t\t\t\t&& currentTimeMillis < Convert.toLong(expiredSecond)) {\n\t\t\t\treturn true;\n\t\t\t}\n\t\t\t\n\t\t\tlog.warn(\"校验链接已过期或不匹配, signature: {}, storageId={}, pathAndName={}, expiredSecond={}, now:={}\", signature, storageId, pathAndName, expiredSecond, currentTimeMillis);\n\t\t} catch (Exception e) {\n\t\t\tlog.error(\"校验签名链接异常, signature: {}, storageId={}, pathAndName={}, expiredSecond={}, now:={}\", signature, storageId, pathAndName, expiredSecond, currentTimeMillis);\n\t\t\treturn false;\n\t\t}\n\n\t\treturn false;\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/RequestHolder.java",
    "content": "package im.zhaojun.zfile.core.util;\n\nimport cn.hutool.extra.servlet.JakartaServletUtil;\nimport im.zhaojun.zfile.core.constant.ZFileHttpHeaderConstant;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.exception.core.SystemException;\nimport jakarta.servlet.http.HttpServletRequest;\nimport jakarta.servlet.http.HttpServletResponse;\nimport lombok.extern.slf4j.Slf4j;\nimport org.apache.commons.io.IOUtils;\nimport org.springframework.http.ContentDisposition;\nimport org.springframework.http.HttpHeaders;\nimport org.springframework.http.HttpRange;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.MediaTypeFactory;\nimport org.springframework.util.StreamUtils;\nimport org.springframework.web.context.request.RequestContextHolder;\nimport org.springframework.web.context.request.ServletRequestAttributes;\n\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.io.OutputStream;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Objects;\n\n/**\n * 获取 Request 工具类\n *\n * @author zhaojun\n */\n@Slf4j\npublic class RequestHolder {\n\n    /**\n     * 获取 HttpServletRequest\n     *\n     * @return HttpServletRequest\n     */\n    public static HttpServletRequest getRequest() {\n        return ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();\n    }\n\n\n    /**\n     * 获取 HttpServletResponse\n     *\n     * @return HttpServletResponse\n     */\n    public static HttpServletResponse getResponse() {\n        return ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getResponse();\n    }\n\n\n    /**\n     * 向 response 写入文件流.\n     *\n     * @param   inputStream\n     *          文件输入流\n     *\n     * @param   fileName\n     *          文件名称\n     *\n     * @param   fileSize\n     *          文件大小\n     *\n     * @param   isPartialContentFromInputStream\n     *          表示输入流是否为部分内容。\n     *          当该变量为 true 时，表示输入流已经根据 range 规则从存储源获取部分内容。\n     *          在这种情况下，不需要跳过 range start 部分，可以直接从输入流的全部内容复制到输出流。\n     *\n     * @param   forceDownload\n     *          是否强制下载\n     */\n    public static void writeFile(InputStream inputStream, String fileName, Long fileSize, boolean isPartialContentFromInputStream, boolean forceDownload) {\n        if (inputStream == null) {\n            throw new BizException(ErrorCode.BIZ_FILE_NOT_EXIST);\n        }\n        OutputStream outputStream = null;\n        try (InputStream innerInputStream = inputStream) {\n            HttpServletResponse response = RequestHolder.getResponse();\n\n            ContentDisposition contentDisposition = ContentDisposition\n                    .builder(forceDownload ? \"attachment\" : \"inline\")\n                    .filename(fileName, StandardCharsets.UTF_8)\n                    .build();\n            response.setHeader(HttpHeaders.CONTENT_DISPOSITION, contentDisposition.toString());\n            if (forceDownload) {\n                response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);\n            } else {\n                response.setContentType(MediaTypeFactory.getMediaType(fileName).orElse(MediaType.APPLICATION_OCTET_STREAM).toString());\n            }\n\n            outputStream = response.getOutputStream();\n\n            if (fileSize != null && fileSize > 0) {\n                String range = RequestHolder.getRequest().getHeader(HttpHeaders.RANGE);\n                List<HttpRange> httpRanges = HttpRange.parseRanges(range);\n                if (httpRanges.isEmpty()) {\n                    httpRanges = Collections.singletonList(HttpRange.createByteRange(0, fileSize - 1));\n                } else {\n                    response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);\n                }\n                HttpRange httpRange = CollectionUtils.getFirst(httpRanges);\n                long startPos = httpRange.getRangeStart(fileSize);\n                long endPos = httpRange.getRangeEnd(fileSize);\n                if (response.getStatus() == HttpServletResponse.SC_PARTIAL_CONTENT) {\n                    response.setHeader(HttpHeaders.CONTENT_RANGE, \"bytes \" + startPos + \"-\" + endPos + StringUtils.SLASH + fileSize);\n                }\n\n                response.setHeader(HttpHeaders.ACCEPT_RANGES, \"bytes\");\n                response.setContentLengthLong(endPos - startPos + 1);\n                if (isPartialContentFromInputStream) {\n                    StreamUtils.copy(innerInputStream, outputStream);\n                } else {\n                    StreamUtils.copyRange(innerInputStream, outputStream, startPos, endPos);\n                }\n                return;\n            }\n\n            StreamUtils.copy(innerInputStream, outputStream);\n        } catch (IOException e) {\n            boolean isBrokenPipe = e.getMessage().contains(\"Broken pipe\");\n            boolean isConnectionResetByPeer = e.getMessage().contains(\"Connection reset by peer\");\n            if (isBrokenPipe || isConnectionResetByPeer) {\n                if (log.isDebugEnabled()) {\n                    log.debug(\"skip IOException: {}\", e.getMessage());\n                }\n            } else {\n                throw new SystemException(e);\n            }\n        } finally {\n            IOUtils.closeQuietly(inputStream);\n            IOUtils.closeQuietly(outputStream);\n        }\n    }\n\n    public static boolean isAxiosRequest() {\n        HttpServletRequest request = RequestHolder.getRequest();\n        String axiosRequest = JakartaServletUtil.getHeaderIgnoreCase(request, ZFileHttpHeaderConstant.AXIOS_REQUEST);\n        return StringUtils.isNotEmpty(axiosRequest);\n    }\n\n\n    /**\n     * 获取请求头中的 Axios-From 字段\n     *\n     * @return Axios-From 字段\n     */\n    public static String getAxiosFrom() {\n        if (RequestContextHolder.getRequestAttributes() == null) {\n            return null;\n        }\n        HttpServletRequest request = RequestHolder.getRequest();\n        return JakartaServletUtil.getHeaderIgnoreCase(request, ZFileHttpHeaderConstant.AXIOS_FROM);\n    }\n\n    /**\n     * 获取后端服务地址，如果经过了反向代理，需反向代理正确配置\n     */\n    public static String getRequestServerAddress() {\n        if (RequestContextHolder.getRequestAttributes() == null) {\n            return null;\n        }\n        HttpServletRequest request = RequestHolder.getRequest();\n\n        String forwardedHost = JakartaServletUtil.getHeaderIgnoreCase(request, \"X-Forwarded-Host\");\n        String forwardedPort = JakartaServletUtil.getHeaderIgnoreCase(request, \"X-Forwarded-Port\");\n        String forwardedProto = JakartaServletUtil.getHeaderIgnoreCase(request, \"X-Forwarded-Proto\");\n\n        String scheme = StringUtils.isBlank(forwardedProto) ? request.getScheme() : forwardedProto;\n        \n        // 优先使用 X-Forwarded-Host，其次使用 Host 头，最后使用 request.getServerName()\n        String serverName;\n        String hostHeader = StringUtils.isNotBlank(forwardedHost) ? forwardedHost : request.getHeader(\"Host\");\n        if (StringUtils.isNotBlank(hostHeader)) {\n            // Host 头可能包含端口信息，如 \"example.com:8080\"\n            String[] hostParts = hostHeader.split(\":\");\n            serverName = hostParts[0];\n            // 如果 Host 头包含端口且没有显式设置 X-Forwarded-Port，则使用 Host 头中的端口\n            if (hostParts.length > 1 && StringUtils.isBlank(forwardedPort)) {\n                forwardedPort = hostParts[1];\n            }\n        } else {\n            serverName = request.getServerName();\n        }\n        \n        // 端口处理逻辑\n        String port;\n        if (StringUtils.isNotBlank(forwardedPort)) {\n            port = forwardedPort;\n        } else if (StringUtils.isNotBlank(forwardedProto)) {\n            // 如果设置了转发协议但没有设置端口，使用协议默认端口\n            port = \"https\".equalsIgnoreCase(forwardedProto) ? \"443\" : \"80\";\n        } else {\n            port = String.valueOf(request.getServerPort());\n        }\n\n        // 移除默认端口\n        if (\"443\".equals(port) && \"https\".equalsIgnoreCase(scheme)) {\n            port = \"\";\n        }\n        if (\"80\".equals(port) && \"http\".equalsIgnoreCase(scheme)) {\n            port = \"\";\n        }\n\n        if (StringUtils.isBlank(port)) {\n            return scheme + \"://\" + serverName;\n        } else {\n            return scheme + \"://\" + serverName + \":\" + port;\n        }\n    }\n\n\n    /**\n     * 获取当前请求的 Origin 请求头\n     *\n     * @return  Origin 请求头值\n     */\n    public static String getOriginAddress() {\n        if (RequestContextHolder.getRequestAttributes() == null) {\n            return null;\n        }\n        HttpServletRequest request = RequestHolder.getRequest();\n        return JakartaServletUtil.getHeaderIgnoreCase(request, HttpHeaders.ORIGIN);\n    }\n\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/RequestUtils.java",
    "content": "package im.zhaojun.zfile.core.util;\n\nimport jakarta.servlet.http.HttpServletRequest;\nimport org.springframework.http.HttpHeaders;\nimport org.springframework.http.HttpRange;\nimport org.springframework.util.CollectionUtils;\n\npublic class RequestUtils {\n\n    public static HttpRange getRequestRange(HttpServletRequest request) {\n        String rangeHeader = request.getHeader(HttpHeaders.RANGE);\n        if (rangeHeader == null) {\n            return null;\n        }\n        return CollectionUtils.firstElement(HttpRange.parseRanges(rangeHeader));\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/SizeToStrUtils.java",
    "content": "package im.zhaojun.zfile.core.util;\n\nimport cn.hutool.core.util.NumberUtil;\n\n/**\n * 文件大小或带宽大小转可读单位\n *\n * @author zhaojun\n */\npublic class SizeToStrUtils {\n\n    /**\n     * 将文件大小转换为可读单位\n     *\n     * @param   bytes\n     *          字节数\n     *\n     * @return  文件大小可读单位\n     */\n    public static String bytesToSize(long bytes) {\n        if (bytes == 0) {\n            return \"0\";\n        }\n\n        double k = 1024;\n        String[] sizes = new String[]{\"B\", \"KB\", \"MB\", \"GB\", \"TB\", \"PB\", \"EB\", \"ZB\", \"YB\"};\n        double i = Math.floor(Math.log(bytes) / Math.log(k));\n        return NumberUtil.round(bytes / Math.pow(k, i), 3) + \" \" + sizes[(int) i];\n    }\n\n\n    /**\n     * 将带宽大小转换为可读单位\n     *\n     * @param   bps\n     *          字节数\n     *\n     * @return  带宽大小可读单位\n     */\n    public static String bpsToSize(long bps) {\n        if (bps == 0) {\n            return \"0\";\n        }\n\n        double k = 1000;\n        String[] sizes = new String[]{\"B\", \"KB\", \"MB\", \"GB\", \"TB\", \"PB\", \"EB\", \"ZB\", \"YB\"};\n        double i = Math.floor(Math.log(bps) / Math.log(k));\n        return NumberUtil.round(bps / Math.pow(k, i), 3) + \" \" + sizes[(int) i];\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/SpringMvcUtils.java",
    "content": "package im.zhaojun.zfile.core.util;\n\nimport org.springframework.util.AntPathMatcher;\nimport org.springframework.web.servlet.HandlerMapping;\n\nimport jakarta.servlet.http.HttpServletRequest;\n\n/**\n * @author zhaojun\n */\npublic class SpringMvcUtils {\n\n    public static String getExtractPathWithinPattern() {\n        HttpServletRequest httpServletRequest = RequestHolder.getRequest();\n        String path = (String) httpServletRequest.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE);\n        String bestMatchPattern = (String) httpServletRequest.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);\n        AntPathMatcher apm = new AntPathMatcher();\n        return apm.extractPathWithinPattern(bestMatchPattern, path);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/StrPool.java",
    "content": "package im.zhaojun.zfile.core.util;\n\npublic interface StrPool {\n\n    /**\n     * 字符串常量：制表符 {@code \"\\t\"}\n     */\n    String TAB = \"\t\";\n\n    /**\n     * 字符串常量：点 {@code \".\"}\n     */\n    String DOT = \".\";\n\n    /**\n     * 字符串常量：双点 {@code \"..\"} <br>\n     * 用途：作为指向上级文件夹的路径，如：{@code \"../path\"}\n     */\n    String DOUBLE_DOT = \"..\";\n\n    /**\n     * 字符串常量：斜杠 {@code \"/\"}\n     */\n    String SLASH = \"/\";\n\n    /**\n     * 字符串常量：反斜杠 {@code \"\\\\\"}\n     */\n    String BACKSLASH = \"\\\\\";\n\n    /**\n     * 字符串常量：回车符 {@code \"\\r\"} <br>\n     * 解释：该字符常用于表示 Linux 系统和 MacOS 系统下的文本换行\n     */\n    String CR = \"\\r\";\n\n    /**\n     * 字符串常量：换行符 {@code \"\\n\"}\n     */\n    String LF = \"\\n\";\n\n    /**\n     * 字符串常量：Windows 换行 {@code \"\\r\\n\"} <br>\n     * 解释：该字符串常用于表示 Windows 系统下的文本换行\n     */\n    String CRLF = \"\\r\\n\";\n\n    /**\n     * 字符串常量：下划线 {@code \"_\"}\n     */\n    String UNDERLINE = \"_\";\n\n    /**\n     * 字符串常量：减号（连接符） {@code \"-\"}\n     */\n    String DASHED = \"-\";\n\n    /**\n     * 字符串常量：逗号 {@code \",\"}\n     */\n    String COMMA = \",\";\n\n    /**\n     * 字符串常量：花括号（左） <code>\"{\"</code>\n     */\n    String DELIM_START = \"{\";\n\n    /**\n     * 字符串常量：花括号（右） <code>\"}\"</code>\n     */\n    String DELIM_END = \"}\";\n\n    /**\n     * 字符串常量：中括号（左） {@code \"[\"}\n     */\n    String BRACKET_START = \"[\";\n\n    /**\n     * 字符串常量：中括号（右） {@code \"]\"}\n     */\n    String BRACKET_END = \"]\";\n\n    /**\n     * 字符串常量：冒号 {@code \":\"}\n     */\n    String COLON = \":\";\n\n    /**\n     * 字符串常量：艾特 {@code \"@\"}\n     */\n    String AT = \"@\";\n\n    /**\n     * 字符串常量：空 JSON {@code \"{}\"}\n     */\n    String EMPTY_JSON = \"{}\";\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/StringUtils.java",
    "content": "package im.zhaojun.zfile.core.util;\n\nimport cn.hutool.cache.CacheUtil;\nimport cn.hutool.cache.impl.LRUCache;\nimport cn.hutool.core.net.URLEncodeUtil;\nimport cn.hutool.core.util.StrUtil;\nimport cn.hutool.core.util.URLUtil;\n\nimport java.net.MalformedURLException;\nimport java.net.URL;\nimport java.nio.charset.StandardCharsets;\n\n/**\n * 字符串相关工具类\n *\n * @author zhaojun\n */\npublic class StringUtils extends CharSequenceUtil implements StrPool {\n\n    public static final String HTTP = \"http\";\n\n    public static final String PROTOCOL_MARKER = \"://\";\n\n    private static final LRUCache<String, String> CACHE = CacheUtil.newLRUCache(1000);\n\n    /**\n     * 移除 URL 中的前后的所有 '/'\n     *\n     * @param   path\n     *          路径\n     *\n     * @return  如 path = '/folder1/file1/', 返回 'folder1/file1'\n     *          如 path = '///folder1/file1//', 返回 'folder1/file1'\n     */\n    public static String trimSlashes(String path) {\n        path = trimStartSlashes(path);\n        path = trimEndSlashes(path);\n        return path;\n    }\n\n\n    /**\n     * 移除 URL 中的前面的所有 '/'\n     *\n     * @param   path\n     *          路径\n     *\n     * @return  如 path = '/folder1/file1', 返回 'folder1/file1'\n     *          如 path = '//folder1/file1', 返回 'folder1/file1'\n     *\n     */\n    public static String trimStartSlashes(String path) {\n        if (isEmpty(path)) {\n            return path;\n        }\n\n        while (path.startsWith(SLASH)) {\n            path = path.substring(1);\n        }\n\n        return path;\n    }\n\n\n    /**\n     * 移除 URL 中结尾的所有 '/'\n     *\n     * @param   path\n     *          路径\n     *\n     * @return  如 path = '/folder1/file1/', 返回 '/folder1/file1'\n     *          如 path = '/folder1/file1///', 返回 '/folder1/file1'\n     */\n    public static String trimEndSlashes(String path) {\n        if (isEmpty(path)) {\n            return path;\n        }\n\n        while (path.endsWith(SLASH)) {\n            path = path.substring(0, path.length() - 1);\n        }\n\n        return path;\n    }\n\n\n    /**\n     * 去除路径中所有重复的 '/'，如果最开始的协议头前有 / 也一并去除。\n     *\n     * @param   path\n     *          路径\n     *\n     * @return  如 path = '/folder1//file1/', 返回 '/folder1/file1/'\n     *          如 path = '/folder1////file1///', 返回 '/folder1/file1/'\n     */\n    public static String removeDuplicateSlashes(String path) {\n        if (isEmpty(path)) {\n            return path;\n        }\n\n        return CACHE.get(path, false, () -> {\n            StringBuilder sb = new StringBuilder(path.length());\n            int protocolIndex = path.indexOf(PROTOCOL_MARKER);\n\n            int pathStartIndex = 0;\n\n            // 1. 处理协议部分\n            if (protocolIndex > -1) {\n                // 找到协议名称的实际开始位置\n                int schemeStartIndex = 0;\n                while (schemeStartIndex < protocolIndex && path.charAt(schemeStartIndex) == '/') {\n                    schemeStartIndex++;\n                }\n\n                sb.append(path, schemeStartIndex, protocolIndex);\n                sb.append(PROTOCOL_MARKER);\n\n                pathStartIndex = protocolIndex + PROTOCOL_MARKER.length();\n            }\n\n            if (pathStartIndex < path.length()) {\n                char lastChar;\n                char firstPathChar = path.charAt(pathStartIndex);\n                sb.append(firstPathChar);\n                lastChar = firstPathChar;\n\n                for (int i = pathStartIndex + 1; i < path.length(); i++) {\n                    char current = path.charAt(i);\n                    if (current != SLASH_CHAR || lastChar != SLASH_CHAR) {\n                        sb.append(current);\n                        lastChar = current;\n                    }\n                }\n            }\n\n            return sb.toString();\n        });\n    }\n\n\n    /**\n     * 去除路径中所有重复的 '/', 并且去除开头的 '/'\n     *\n     * @param   path\n     *          路径\n     *\n     * @return  如 path = '/folder1//file1/', 返回 'folder1/file1/'\n     *          如 path = '///folder1////file1///', 返回 'folder1/file1/'\n     */\n    public static String removeDuplicateSlashesAndTrimStart(String path) {\n        path = removeDuplicateSlashes(path);\n        path = trimStartSlashes(path);\n        return path;\n    }\n\n\n    /**\n     * 去除路径中所有重复的 '/', 并且去除结尾的 '/'\n     *\n     * @param   path\n     *          路径\n     *\n     * @return  如 path = '/folder1//file1/', 返回 '/folder1/file1'\n     *          如 path = '///folder1////file1///', 返回 '/folder1/file1'\n     */\n    public static String removeDuplicateSlashesAndTrimEnd(String path) {\n        path = removeDuplicateSlashes(path);\n        path = trimEndSlashes(path);\n        return path;\n    }\n\n\n    /**\n     * 拼接 URL，并去除重复的分隔符 '/'，并去除开头的 '/', 但不会影响 http:// 和 https:// 这种头部.\n     *\n     * @param   strs\n     *          拼接的字符数组\n     *\n     * @return  拼接结果\n     */\n    public static String concatTrimStartSlashes(String... strs) {\n        return trimStartSlashes(concat(strs));\n    }\n\n\n    /**\n     * 拼接 URL，并去除重复的分隔符 '/'，并去除结尾的 '/', 但不会影响 http:// 和 https:// 这种头部.\n     *\n     * @param   strs\n     *          拼接的字符数组\n     *\n     * @return  拼接结果\n     */\n    public static String concatTrimEndSlashes(String... strs) {\n        return trimEndSlashes(concat(strs));\n    }\n\n\n    /**\n     * 拼接 URL，并去除重复的分隔符 '/'，并去除开头和结尾的 '/', 但不会影响 http:// 和 https:// 这种头部.\n     *\n     * @param   strs\n     *          拼接的字符数组\n     *\n     * @return  拼接结果\n     */\n    public static String concatTrimSlashes(String... strs) {\n        return trimSlashes(concat(strs));\n    }\n\n\n    /**\n     * 拼接 URL，并去除重复的分隔符 '/'，但不会影响 http:// 和 https:// 这种头部.\n     *\n     * @param   strs\n     *          拼接的字符数组\n     *\n     * @return  拼接结果\n     */\n    public static String concat(String... strs) {\n        StringBuilder sb = new StringBuilder(SLASH);\n        for (int i = 0; i < strs.length; i++) {\n            String str = strs[i];\n            if (isEmpty(str)) {\n                continue;\n            }\n            sb.append(str);\n            if (i != strs.length - 1) {\n                sb.append(SLASH_CHAR);\n            }\n        }\n        return removeDuplicateSlashes(sb.toString());\n    }\n\n\n    /**\n     * 拼接 URL，并去除重复的分隔符 '/'，但不会影响 http:// 和 https:// 这种头部.\n     *\n     * @param   encodeAllIgnoreSlashes\n     *          是否 encode 编码 (忽略 /)\n     *\n     * @param   strs\n     *          拼接的字符数组\n     *\n     * @return  拼接结果\n     */\n    public static String concat(boolean encodeAllIgnoreSlashes, String... strs) {\n        String res = concat(strs);\n        if (encodeAllIgnoreSlashes) {\n            return encodeAllIgnoreSlashes(res);\n        } else {\n            return res;\n        }\n    }\n\n\n    /**\n     * 替换 URL 中的 Host 部分，如替换 http://a.com/1.txt 为 https://abc.com/1.txt\n     *\n     * @param   originUrl\n     *          原 URL\n     *\n     * @param   replaceHost\n     *          替换的 HOST\n     *\n     * @return  替换后的 URL\n     */\n    public static String replaceHost(String originUrl, String replaceHost) {\n        try {\n            String path = new URL(originUrl).getFile();\n            return concat(replaceHost, path);\n        } catch (MalformedURLException e) {\n            e.printStackTrace();\n        }\n        return null;\n    }\n\n\n    /**\n     * 编码 URL，默认使用 UTF-8 编码\n     * URL 的 Fragment URLEncoder\n     * 默认的编码器针对Fragment，定义如下：\n     *\n     * <pre>\n     * fragment    = *( pchar / \"/\" / \"?\" )\n     * pchar       = unreserved / pct-encoded / sub-delims / \":\" / \"@\"\n     * unreserved  = ALPHA / DIGIT / \"-\" / \".\" / \"_\" / \"~\"\n     * sub-delims  = \"!\" / \"$\" / \"&amp;\" / \"'\" / \"(\" / \")\" / \"*\" / \"+\" / \",\" / \";\" / \"=\"\n     * </pre>\n     *\n     * 具体见：https://datatracker.ietf.org/doc/html/rfc3986#section-3.5\n     *\n     * @param   url\n     *          被编码内容\n     *\n     * @return  编码后的字符\n     */\n    public static String encode(String url) {\n        return URLEncodeUtil.encodeFragment(url);\n    }\n\n\n    /**\n     * 编码全部字符\n     *\n     * @param   str\n     *          被编码内容\n     *\n     * @return  编码后的字符\n     */\n    public static String encodeAllIgnoreSlashes(String str) {\n        if (isEmpty(str)) {\n            return str;\n        }\n\n        StringBuilder sb = new StringBuilder();\n\n        int prevIndex = -1;\n        for (int i = 0; i < str.length(); i++) {\n            char c = str.charAt(i);\n            if (c == StringUtils.SLASH_CHAR) {\n                if (prevIndex < i) {\n                    String substring = str.substring(prevIndex + 1, i);\n                    sb.append(URLEncodeUtil.encodeAll(substring));\n                    prevIndex = i;\n                }\n                sb.append(c);\n            }\n\n            if (i == str.length() - 1 && prevIndex < i) {\n                String substring = str.substring(prevIndex + 1, i + 1);\n                sb.append(URLEncodeUtil.encodeAll(substring));\n            }\n        }\n\n        return sb.toString();\n    }\n\n\n    /**\n     * 解码 URL, 默认使用 UTF8 编码. 不会将 + 转为空格.\n     *\n     * @param   url\n     *          被解码内容\n     *\n     * @return  解码后的内容\n     */\n    public static String decode(String url) {\n        return URLUtil.decode(url, StandardCharsets.UTF_8, false);\n    }\n\n\n    /**\n     * 移除字符串中所有换行符并去除前后空格\n     *\n     * @param   str\n     *          URL\n     *\n     * @return  移除协议后的 URL\n     */\n    public static String removeAllLineBreaksAndTrim(String str) {\n        String removeResult = StrUtil.removeAllLineBreaks(str);\n        return trim(removeResult);\n    }\n\n\n    /**\n     * 移除字符串前后空格\n     *\n     * @param   str\n     *          字符串\n     *\n     * @return  移除前后空格后的字符串\n     */\n    public static String trim(final String str) {\n        return str == null ? null : str.trim();\n    }\n\n\n    /**\n     * 如果给定字符串不是以suffix结尾的，在尾部补充 suffix\n     *\n     * @param str    字符串\n     * @param suffix 后缀\n     * @return 补充后的字符串\n     */\n    public static String addSuffixIfNot(CharSequence str, CharSequence suffix) {\n        if (isEmpty(str) || isEmpty(suffix)) {\n            return str.toString();\n        }\n\n        if (str.toString().endsWith(suffix.toString())) {\n            return str.toString();\n        }\n\n        return str.toString() + suffix;\n    }\n\n    /**\n     * 是否包含特定字符，忽略大小写，如果给定两个参数都为{@code null}，返回true\n     *\n     * @param str     被检测字符串\n     * @param testStr 被测试是否包含的字符串\n     * @return 是否包含\n     */\n    public static boolean containsIgnoreCase(CharSequence str, CharSequence testStr) {\n        if (null == str) {\n            // 如果被监测字符串和\n            return null == testStr;\n        }\n        return StrUtil.indexOfIgnoreCase(str, testStr) > -1;\n    }\n\n\n    /**\n     * 指定范围内查找指定字符\n     *\n     * @param   str\n     *          字符串\n     *\n     * @param   searchChar\n     *          被查找的字符\n     *\n     * @return  位置\n     */\n    public static int indexOf(String str, char searchChar) {\n        if (isEmpty(str)) {\n            return INDEX_NOT_FOUND;\n        }\n        return str.indexOf(searchChar);\n    }\n\n\n    /**\n     * 字符串驼峰转下划线格式\n     *\n     * @param   param\n     *          驼峰格式字符串\n     *\n     * @return  下划线格式字符串\n     */\n    public static String camelToUnderline(String param) {\n        if (isEmpty(param)) {\n            return EMPTY;\n        }\n\n        int len = param.length();\n        StringBuilder sb = new StringBuilder(len);\n        for (int i = 0; i < len; i++) {\n            char c = param.charAt(i);\n            if (Character.isUpperCase(c) && i > 0) {\n                sb.append(UNDERLINE);\n            }\n            sb.append(Character.toLowerCase(c));\n        }\n        return sb.toString();\n    }\n\n\n    /**\n     * 强制给 URL 设置协议\n     *\n     * @param   url\n     *          URL 地址，可以是带协议的，也可以是不带协议的，写会忽略大小写\n     *\n     * @param   schema\n     *          协议，如 http, https, http://, https://\n     *\n     * @return  设置协议后的 URL\n     */\n    public static String setSchema(String url, String schema) {\n        if (StringUtils.isEmpty(url) || StringUtils.isEmpty(schema)) {\n            return url;\n        }\n\n        if (!schema.endsWith(\"://\")) {\n            schema += \"://\";\n        }\n\n        String lowerUrl = url.toLowerCase();\n        if (lowerUrl.startsWith(\"http://\")) {\n            url = url.substring(7);\n        } else if (lowerUrl.startsWith(\"https://\")) {\n            url = url.substring(8);\n        }\n\n        return schema + url;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/UrlUtils.java",
    "content": "package im.zhaojun.zfile.core.util;\n\nimport cn.hutool.core.util.StrUtil;\n\n/**\n * url 相关工具类\n *\n * @author zhaojun\n */\npublic class UrlUtils {\n\n\t/**\n\t * 判断 URL 是否包含协议部分\n\t *\n\t * @param \turl\n\t * \t\t\tURL 地址\n\t *\n\t * @return\t是否包含协议部分\n\t */\n\tpublic static boolean hasScheme(String url) {\n\t\treturn url.startsWith(\"http://\") || url.startsWith(\"https://\");\n\t}\n\n\t/**\n\t * 为 URL 拼接参数\n\t *\n\t * @param \turl\n\t * \t\t\t原始 URL\n\t *\n\t * @param \tname\n\t * \t\t\t参数名称\n\t *\n\t * @param \tvalue\n\t * \t\t\t参数值\n\t *\n\t * @return\t拼接后的 URL\n\t */\n\tpublic static String concatQueryParam(String url, String name, String value) {\n\t\tif (StringUtils.contains(url, \"?\")) {\n\t\t\treturn url + \"&\" + name + \"=\" + value;\n\t\t} else {\n\t\t\treturn url + \"?\" + name + \"=\" + value;\n\t\t}\n\t}\n\n\t/**\n\t * 获取 URL 中的协议部分\n\t *\n\t * @param \turl\n\t * \t\t\tURL 地址\n\t *\n\t * @return\t协议部分\n\t */\n\tpublic static String getSchema(String url) {\n\t\tif (StringUtils.startWithIgnoreCase(url, \"http://\")) {\n\t\t\treturn \"http\";\n\t\t} else if (StringUtils.startWithIgnoreCase(url, \"https://\")) {\n\t\t\treturn \"https\";\n\t\t} else {\n\t\t\treturn \"http\";\n\t\t}\n\t}\n\n\t/**\n\t * 移除 URL 中的协议部分\n\t *\n\t * @param \turl\n\t * \t\t\tURL 地址\n\t *\n\t * @return\t移除协议部分后的 URL\n\t */\n\tpublic static String removeScheme(String url) {\n\t\tif (StringUtils.startWithIgnoreCase(url, \"http://\")) {\n\t\t\turl = url.substring(7);\n\t\t} else if (StringUtils.startWithIgnoreCase(url, \"https://\")) {\n\t\t\turl = url.substring(8);\n\t\t}\n\n\t\treturn url;\n\t}\n\n\t/**\n\t * 获取 URL 中的域名部分\n\t *\n\t * @param \turl\n\t * \t\t\tURL 地址\n\t *\n\t * @return\t域名部分\n\t */\n\tpublic static String getDomain(String url) {\n\t\tif (!StringUtils.isEmpty(url)) {\n\t\t\t//替换指定前缀\n\t\t\tString newStr = url.replace(\"http://\", \"\");\n\t\t\tnewStr = newStr.replace(\"https://\", \"\");\n\n\t\t\tint index = StrUtil.indexOf(newStr, '/');\n\t\t\tif (index > 0) {\n\t\t\t\tnewStr = newStr.substring(0, index);\n\t\t\t}\n\n\t\t\tString[] split = newStr.split(\":\");\n\t\t\tif (split.length > 1) {\n\t\t\t\treturn split[0];\n\t\t\t} else {\n\t\t\t\treturn newStr;\n\t\t\t}\n\t\t} else {\n\t\t\treturn null;\n\t\t}\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/ZFileAuthUtil.java",
    "content": "package im.zhaojun.zfile.core.util;\n\nimport cn.dev33.satoken.stp.StpUtil;\nimport cn.hutool.extra.spring.SpringUtil;\nimport im.zhaojun.zfile.module.share.context.ShareAccessContext;\nimport im.zhaojun.zfile.module.user.model.constant.UserConstant;\nimport im.zhaojun.zfile.module.user.model.entity.User;\nimport im.zhaojun.zfile.module.user.service.UserService;\n\n/**\n * 登录认证工具类\n *\n * @author zhaojun\n */\npublic class ZFileAuthUtil {\n\t\n\tprivate static UserService userService;\n\n\tpublic static User getCurrentUser() {\n\t\tif (userService == null) {\n\t\t\tuserService = SpringUtil.getBean(UserService.class);\n\t\t}\n\n        // 检查是否为分享访问，如果是则返回分享者用户 ID\n        if (ShareAccessContext.isShareAccess()) {\n            Integer shareUserId = ShareAccessContext.getShareUserId();\n            return userService.getById(shareUserId);\n        }\n\n        return userService.getById(StpUtil.getLoginId(UserConstant.ANONYMOUS_ID));\n\t}\n\n\tpublic static Integer getCurrentUserId() {\n\t\tif (userService == null) {\n\t\t\tuserService = SpringUtil.getBean(UserService.class);\n\t\t}\n\n        // 检查是否为分享访问，如果是则返回分享者用户 ID\n        if (ShareAccessContext.isShareAccess()) {\n            return ShareAccessContext.getShareUserId();\n        }\n\n        try {\n\t\t\treturn StpUtil.getLoginId(UserConstant.ANONYMOUS_ID);\n\t\t} catch (Exception e) {\n\t\t\treturn UserConstant.ANONYMOUS_ID;\n\t\t}\n\t}\n\t\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/matcher/AbstractRuleMatcher.java",
    "content": "package im.zhaojun.zfile.core.util.matcher;\n\nimport java.util.Collection;\n\n/**\n * 抽象规则匹配器, 实现了部分方法, 用于简化规则匹配器的实现.\n *\n * @author zhaojun\n */\npublic abstract class AbstractRuleMatcher implements IRuleMatcher {\n\n    @Override\n    public boolean contains(String ruleExpression, String testStr) {\n        return match(ruleExpression, testStr);\n    }\n\n    @Override\n    public boolean matchAny(Collection<String> ruleExpressionList, String testStr) {\n        if (ruleExpressionList == null || ruleExpressionList.isEmpty()) {\n            return false;\n        }\n        for (String ruleExpression : ruleExpressionList) {\n            if (match(ruleExpression, testStr)) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    @Override\n    public String matchAnyReturnFirst(Collection<String> ruleExpressionList, String testStr) {\n        if (ruleExpressionList == null || ruleExpressionList.isEmpty()) {\n            return null;\n        }\n        for (String ruleExpression : ruleExpressionList) {\n            if (match(ruleExpression, testStr)) {\n                return ruleExpression;\n            }\n        }\n        return null;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/matcher/IRuleMatcher.java",
    "content": "package im.zhaojun.zfile.core.util.matcher;\n\nimport java.util.Collection;\n\n/**\n * 规则匹配器接口\n *\n * @author zhaojun\n */\npublic interface IRuleMatcher {\n\n    /**\n     * 匹配规则\n     *\n     * @param   ruleExpression\n     *          规则表达式\n     *\n     * @param   testStr\n     *          测试字符串\n     *\n     * @return  是否匹配\n     */\n    boolean match(String ruleExpression, String testStr);\n\n    /**\n     * 部分匹配规则\n     *\n     * @param   ruleExpression\n     *          规则表达式\n     *\n     * @param   testStr\n     *          测试字符串\n     *\n     * @return  是否部分匹配\n     */\n    boolean contains(String ruleExpression, String testStr);\n\n\n    /**\n     * 匹配规则, 可以匹配多个规则表达式, 只要有一个匹配成功, 则返回 true\n     *\n     * @param   ruleExpressionList\n     *          规则表达式列表\n     *\n     * @param   testStr\n     *          测试字符串\n     *\n     * @return  是否匹配\n     */\n    boolean matchAny(Collection<String> ruleExpressionList, String testStr);\n\n\n    /**\n     * 匹配规则, 可以匹配多个规则表达式, 只要有一个匹配成功, 则返回第一个匹配成功的表达式。\n     *\n     * @param   ruleExpressionList\n     *          规则表达式列表\n     *\n     * @param   testStr\n     *          测试字符串\n     *\n     * @return  匹配成功的第一个表达式\n     */\n    String matchAnyReturnFirst(Collection<String> ruleExpressionList, String testStr);\n\n\n    /**\n     * 获取规则类型\n     *\n     * @return  规则类型\n     */\n    String getRuleType();\n\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/matcher/RuleMatcherFactory.java",
    "content": "package im.zhaojun.zfile.core.util.matcher;\n\nimport im.zhaojun.zfile.core.util.matcher.impl.AntPathRuleMatcher;\nimport im.zhaojun.zfile.core.util.matcher.impl.IpRuleMatcher;\nimport im.zhaojun.zfile.core.util.matcher.impl.RegexRuleMatcher;\nimport im.zhaojun.zfile.core.util.matcher.impl.SpringSimpleRuleMatcher;\n\nimport java.util.HashMap;\nimport java.util.Map;\n\n/**\n * 规则匹配器工厂, 用于获取规则匹配器实例.\n *\n * @author zhaojun\n */\npublic class RuleMatcherFactory {\n\n    private static final Map<String, IRuleMatcher> RULE_MATCHER_MAP = new HashMap<>();\n\n    static {\n        IpRuleMatcher ipRuleMatcher = new IpRuleMatcher();\n        RULE_MATCHER_MAP.put(ipRuleMatcher.getRuleType(), ipRuleMatcher);\n\n        RegexRuleMatcher regexRuleMatcher = new RegexRuleMatcher();\n        RULE_MATCHER_MAP.put(regexRuleMatcher.getRuleType(), regexRuleMatcher);\n\n        AntPathRuleMatcher antPathRuleMatcher = new AntPathRuleMatcher();\n        RULE_MATCHER_MAP.put(antPathRuleMatcher.getRuleType(), antPathRuleMatcher);\n\n        SpringSimpleRuleMatcher springSimpleRuleMatcher = new SpringSimpleRuleMatcher();\n        RULE_MATCHER_MAP.put(springSimpleRuleMatcher.getRuleType(), springSimpleRuleMatcher);\n    }\n\n    public static IRuleMatcher getRuleMatcher(String ruleType) {\n        if (ruleType == null || ruleType.isEmpty()) {\n            return null;\n        }\n\n        return RULE_MATCHER_MAP.get(ruleType);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/matcher/impl/AntPathRuleMatcher.java",
    "content": "package im.zhaojun.zfile.core.util.matcher.impl;\n\nimport im.zhaojun.zfile.core.constant.RuleTypeConstant;\nimport im.zhaojun.zfile.core.util.matcher.AbstractRuleMatcher;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.util.AntPathMatcher;\n\n/**\n * Ant 路径匹配器, 用于匹配路径规则.\n *\n * @author zhaojun\n */\n@Slf4j\npublic class AntPathRuleMatcher extends AbstractRuleMatcher {\n\n    private final AntPathMatcher pathMatcher = new AntPathMatcher();\n\n    @Override\n    public boolean match(String ruleExpression, String testStr) {\n        boolean match = pathMatcher.match(ruleExpression, testStr);\n        if (log.isDebugEnabled()) {\n            log.debug(\"Ant 表达式匹配结果: {}, 规则表达式: {}, 测试值: {}\", match, ruleExpression, testStr);\n        }\n        return match;\n    }\n\n    @Override\n    public String getRuleType() {\n        return RuleTypeConstant.ANT_PATH;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/matcher/impl/IpRuleMatcher.java",
    "content": "package im.zhaojun.zfile.core.util.matcher.impl;\n\nimport im.zhaojun.zfile.core.constant.RuleTypeConstant;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.core.util.matcher.AbstractRuleMatcher;\nimport lombok.extern.slf4j.Slf4j;\n\nimport java.net.Inet6Address;\nimport java.net.InetAddress;\nimport java.net.UnknownHostException;\nimport java.util.regex.Pattern;\n\n/**\n * <p>IP 匹配器, 用于 IP 规则，支持匹配完整 IP 或 IP 段，同时支持 IPV4 和 IPV6.</p>\n *\n *\n * <ul>\n *     <li>IPV4 示例：</li>\n *     <ul>\n *          <li>127.0.0.1</li>\n *          <li>192.168.0.0/24</li>\n *     <ul/>\n * </ul>\n *\n * <p>\n *\n * <ul>\n *     <li>IPV6 示例：</li>\n *     <ul>\n *          <li>0:0:0:0:0:0:0:1</li>\n *          <li>0:0:0:0:0:0:0:0/64</li>\n *     <ul/>\n * </ul>\n * <p>\n *\n * @author zhaojun\n */\n@Slf4j\npublic class IpRuleMatcher extends AbstractRuleMatcher {\n\n    @Override\n    public boolean match(String ruleExpression, String testStr) {\n        try {\n            InetAddress inetAddress = InetAddress.getByName(testStr);\n            IpRule rule = createRule(ruleExpression);\n            boolean match = rule != null && rule.matches(inetAddress);\n            if (log.isDebugEnabled()) {\n                log.debug(\"IP 匹配结果: {}, 规则表达式: {}, 测试值: {}, 校验规则: {}\", match, ruleExpression, testStr, rule);\n            }\n            return match;\n        } catch (UnknownHostException e) {\n            log.error(\"IP 地址解析失败, ruleExpression: {}, testStr: {}\", ruleExpression, testStr);\n        }\n        return false;\n    }\n\n    @Override\n    public String getRuleType() {\n        return RuleTypeConstant.IP;\n    }\n\n    private IpRule createRule(String ruleExpression) {\n        if (isValidIpv4(ruleExpression)) {\n            return new Ipv4Rule(ruleExpression);\n        } else if (isValidIpv6(ruleExpression)) {\n            return new Ipv6Rule(ruleExpression);\n        } else if (isValidIpv4Range(ruleExpression)) {\n            return new Ipv4RangeRule(ruleExpression);\n        } else if (isValidIpv6Range(ruleExpression)) {\n            return new Ipv6RangeRule(ruleExpression);\n        } else {\n            return null;\n        }\n    }\n\n    private boolean isValidIpv4(String ipAddress) {\n        String ipv4Pattern = \"^([01]?\\\\d\\\\d?|2[0-4]\\\\d|25[0-5])\\\\.\"\n                + \"([01]?\\\\d\\\\d?|2[0-4]\\\\d|25[0-5])\\\\.\"\n                + \"([01]?\\\\d\\\\d?|2[0-4]\\\\d|25[0-5])\\\\.\"\n                + \"([01]?\\\\d\\\\d?|2[0-4]\\\\d|25[0-5])$\";\n        return Pattern.matches(ipv4Pattern, ipAddress);\n    }\n\n    private boolean isValidIpv6(String ipAddress) {\n        try {\n            InetAddress.getByName(ipAddress);\n            return ipAddress.contains(\":\");\n        } catch (UnknownHostException e) {\n            return false;\n        }\n    }\n\n    private boolean isValidIpv4Range(String ipRange) {\n        String ipv4RangePattern = \"^\\\\d{1,3}\\\\.\\\\d{1,3}\\\\.\\\\d{1,3}\\\\.\\\\d{1,3}/\\\\d{1,2}$\";\n        return Pattern.matches(ipv4RangePattern, ipRange);\n    }\n\n    private boolean isValidIpv6Range(String ipRange) {\n        String ipv6RangePattern = \"^([0-9A-Fa-f]{0,4}:){1,7}([0-9A-Fa-f]{0,4})?/[0-9]{1,3}$\";\n        return Pattern.matches(ipv6RangePattern, ipRange);\n    }\n    \n    private interface IpRule {\n\n        boolean matches(InetAddress ipAddress);\n\n        String getExpression();\n\n    }\n\n    private static class Ipv4Rule implements IpRule {\n        \n        private final String expression;\n\n        Ipv4Rule(String expression) {\n            this.expression = expression;\n        }\n\n        @Override\n        public boolean matches(InetAddress ipAddress) {\n            if (ipAddress instanceof Inet6Address) {\n                return false;\n            }\n            return ipAddress.getHostAddress().equals(expression);\n        }\n\n        @Override\n        public String getExpression() {\n            return expression;\n        }\n    }\n\n    private static class Ipv6Rule implements IpRule {\n        \n        private final String expression;\n\n        Ipv6Rule(String expression) {\n            this.expression = expression;\n        }\n\n        @Override\n        public boolean matches(InetAddress ipAddress) {\n            if (ipAddress instanceof Inet6Address) {\n                return ipAddress.getHostAddress().equals(expression);\n            }\n            return false;\n        }\n\n        @Override\n        public String getExpression() {\n            return expression;\n        }\n    }\n\n    private static class Ipv4RangeRule implements IpRule {\n        \n        private final String expression;\n        private final int prefixLength;\n\n        Ipv4RangeRule(String expression) {\n            this.expression = expression.substring(0, expression.indexOf('/'));\n            this.prefixLength = Integer.parseInt(expression.substring(expression.indexOf('/') + 1));\n        }\n\n        @Override\n        public boolean matches(InetAddress ipAddress) {\n            if (ipAddress instanceof Inet6Address) {\n                return false;\n            }\n            String[] rangeParts = expression.split(\"\\\\.\");\n            byte[] rangeAddrBytes = new byte[4];\n            for (int i = 0; i < rangeParts.length; i++) {\n                rangeAddrBytes[i] = (byte) Integer.parseInt(rangeParts[i]);\n            }\n\n            byte[] ipValue = ipAddress.getAddress();\n            if (ipValue.length != 4) {\n                return false;\n            }\n\n            for (int i = 0; i < prefixLength / 8; i++) {\n                if (rangeAddrBytes[i] != ipValue[i]) {\n                    return false;\n                }\n            }\n\n            int remainingBits = prefixLength % 8;\n            if (remainingBits != 0) {\n                int rangeByte = rangeAddrBytes[prefixLength / 8];\n                int ipByte = ipValue[prefixLength / 8];\n\n                int shift = 8 - remainingBits;\n                int mask = 0xFF >> shift;\n\n                return (rangeByte >> shift & mask) == (ipByte >> shift & mask);\n            }\n\n            return true;\n        }\n\n        @Override\n        public String getExpression() {\n            return expression + StringUtils.SLASH + prefixLength;\n        }\n    }\n\n    private static class Ipv6RangeRule implements IpRule {\n        \n        private final String expression;\n        private final int prefixLength;\n\n        Ipv6RangeRule(String expression) {\n            this.expression = expression.substring(0, expression.indexOf('/'));\n            this.prefixLength = Integer.parseInt(expression.substring(expression.indexOf('/') + 1));\n        }\n\n        @Override\n        public boolean matches(InetAddress ipAddress) {\n            if (ipAddress instanceof Inet6Address) {\n                byte[] ipValue = ipAddress.getAddress();\n                byte[] rangeValue = Inet6AddressConverter.convert(expression);\n\n                if (ipValue.length != 16) {\n                    return false;\n                }\n\n                for (int i = 0; i < prefixLength / 8; i++) {\n                    if (rangeValue[i] != ipValue[i]) {\n                        return false;\n                    }\n                }\n\n                int remainingBits = prefixLength % 8;\n                if (remainingBits != 0) {\n                    int rangeByte = rangeValue[prefixLength / 8];\n                    int ipByte = ipValue[prefixLength / 8];\n\n                    int shift = 8 - remainingBits;\n                    int mask = 0xFF >> shift;\n\n                    return (rangeByte >> shift & mask) == (ipByte >> shift & mask);\n                }\n\n                return true;\n            }\n            return false;\n        }\n\n        @Override\n        public String getExpression() {\n            return expression + StringUtils.SLASH + prefixLength;\n        }\n    }\n\n    // Utility class to convert IPv6 address to byte array\n    private static class Inet6AddressConverter {\n        public static byte[] convert(String ipv6Address) {\n            byte[] ipAddress = new byte[16];\n            String[] blocks = ipv6Address.split(\":\");\n\n            for (int i = 0; i < blocks.length; i++) {\n                String block = blocks[i];\n                if (!block.isEmpty()) {\n                    ipAddress[i * 2] = (byte) Integer.parseInt(block.substring(0, 2), 16);\n                    ipAddress[i * 2 + 1] = (byte) Integer.parseInt(block.substring(2, 4), 16);\n                }\n            }\n\n            return ipAddress;\n        }\n    }\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/matcher/impl/RegexRuleMatcher.java",
    "content": "package im.zhaojun.zfile.core.util.matcher.impl;\n\nimport cn.hutool.core.util.ReUtil;\nimport im.zhaojun.zfile.core.constant.RuleTypeConstant;\nimport im.zhaojun.zfile.core.util.matcher.AbstractRuleMatcher;\nimport lombok.extern.slf4j.Slf4j;\n\n/**\n * 正则匹配器\n *\n * @author zhaojun\n */\n@Slf4j\npublic class RegexRuleMatcher extends AbstractRuleMatcher {\n\n    @Override\n    public boolean match(String ruleExpression, String testStr) {\n        boolean match = ReUtil.isMatch(ruleExpression, testStr);\n        if (log.isDebugEnabled()) {\n            log.debug(\"正则匹配结果: {}, 规则表达式: {}, 测试值: {}\", match, ruleExpression, testStr);\n        }\n        return match;\n    }\n\n    @Override\n    public boolean contains(String ruleExpression, String testStr) {\n        boolean match = ReUtil.contains(ruleExpression, testStr);\n        if (log.isDebugEnabled()) {\n            log.debug(\"正则部分匹配结果: {}, 规则表达式: {}, 测试值: {}\", match, ruleExpression, testStr);\n        }\n        return match;\n    }\n\n    @Override\n    public String getRuleType() {\n        return RuleTypeConstant.REGEX;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/util/matcher/impl/SpringSimpleRuleMatcher.java",
    "content": "package im.zhaojun.zfile.core.util.matcher.impl;\n\nimport im.zhaojun.zfile.core.constant.RuleTypeConstant;\nimport im.zhaojun.zfile.core.util.matcher.AbstractRuleMatcher;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.util.PatternMatchUtils;\n\n/**\n * 使用 {@link org.springframework.util.PatternMatchUtils} 来匹配规则\n *\n * @author zhaojun\n */\n@Slf4j\npublic class SpringSimpleRuleMatcher extends AbstractRuleMatcher {\n\n    @Override\n    public boolean match(String ruleExpression, String testStr) {\n        boolean match = PatternMatchUtils.simpleMatch(ruleExpression, testStr);\n        if (log.isDebugEnabled()) {\n            log.debug(\"Spring Simple 规则匹配结果: {}, 规则表达式: {}, 测试值: {}\", match, ruleExpression, testStr);\n        }\n        return match;\n    }\n\n    @Override\n    public String getRuleType() {\n        return RuleTypeConstant.SPRING_SIMPLE;\n    }\n\n    @Override\n    public boolean contains(String ruleExpression, String testStr) {\n        return match(\"*\" + ruleExpression + \"*\", testStr);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/validation/StringListValue.java",
    "content": "package im.zhaojun.zfile.core.validation;\n\nimport jakarta.validation.Constraint;\nimport jakarta.validation.Payload;\nimport java.lang.annotation.Documented;\nimport java.lang.annotation.ElementType;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.Target;\n\nimport static java.lang.annotation.RetentionPolicy.RUNTIME;\n\n/**\n * 字符串列表值校验注解\n *\n * @author zhaojun\n */\n@Documented\n@Constraint(validatedBy = { StringListValueConstraintValidator.class })\n@Target({ ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE })\n@Retention(RUNTIME)\npublic @interface StringListValue {\n\n    String message() default \"\";\n\n    Class<?>[] groups() default { };\n\n    Class<? extends Payload>[] payload() default { };\n\n    String[] vals() default { };\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/core/validation/StringListValueConstraintValidator.java",
    "content": "package im.zhaojun.zfile.core.validation;\n\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport jakarta.validation.ConstraintValidator;\nimport jakarta.validation.ConstraintValidatorContext;\n\nimport java.util.Arrays;\nimport java.util.HashSet;\nimport java.util.Set;\n\n/**\n * 字符串列表值校验器\n *\n * @author zhaojun\n */\npublic class StringListValueConstraintValidator implements ConstraintValidator<StringListValue, String> {\n\n\tprivate final Set<String> set = new HashSet<>();\n\n\t/**\n\t * 初始化方法\n\t *\n\t * @param   constraintAnnotation\n\t *          校验注解对象\n\t */\n\t@Override\n\tpublic void initialize(StringListValue constraintAnnotation) {\n\t\tString[] vals = constraintAnnotation.vals();\n\t\tset.addAll(Arrays.asList(vals));\n\n\t}\n\n\n\t/**\n\t * 判断是否校验成功\n\t *\n\t * @param   value\n\t *          需要校验的值\n\t *\n\t * @param   context\n\t *          校验上下文\n\t *\n\t * @return  是否校验成功\n\t */\n\t@Override\n\tpublic boolean isValid(String value, ConstraintValidatorContext context) {\n\t\tif (StringUtils.isEmpty(value)) {\n\t\t\treturn true;\n\t\t}\n\t\treturn set.contains(value);\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/admin/controller/IpHelperController.java",
    "content": "package im.zhaojun.zfile.module.admin.controller;\n\nimport cn.hutool.extra.servlet.JakartaServletUtil;\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONWriter;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.core.util.RequestHolder;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.annotation.Resource;\nimport jakarta.servlet.http.HttpServletRequest;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport java.util.List;\nimport java.util.Map;\n\n/**\n * @author zhaojun\n */\n@Tag(name = \"IP 地址辅助 Controller\")\n@Slf4j\n@RequestMapping(\"/admin\")\n@RestController\npublic class IpHelperController {\n\n    @Resource\n    private HttpServletRequest httpServletRequest;\n\n    @GetMapping(\"clientIp\")\n    @Operation(summary = \"获取客户端IP\", description =\"获取当前请求的客户端IP地址\")\n    public AjaxJson<String> clientIp() {\n        String clientIp = JakartaServletUtil.getClientIP(httpServletRequest);\n        return AjaxJson.getSuccessData(clientIp);\n    }\n\n    @GetMapping(\"serverAddress\")\n    @Operation(summary = \"获取服务器地址\", description = \"获取当前请求的服务器地址(如果是反向代理过，可能获取到的是反向代理服务器的地址)\")\n    public AjaxJson<String> serverAddress() {\n        return AjaxJson.getSuccessData(RequestHolder.getRequestServerAddress());\n    }\n\n    @GetMapping(\"headers\")\n    @Operation(summary = \"获取 Headers\", description = \"获取服务器接收到的请求头信息，可用于排查反向代理配置问题\")\n    public AjaxJson<String> headers() {\n        Map<String, List<String>> headersMap = JakartaServletUtil.getHeadersMap(httpServletRequest);\n        Map<String, String> singleValueHeaderMap = headersMap.entrySet().stream()\n                .collect(java.util.stream.Collectors.toMap(\n                        Map.Entry::getKey,\n                        entry -> String.join(\",\", entry.getValue())\n                ));\n        return AjaxJson.getSuccessData(JSON.toJSONString(singleValueHeaderMap, JSONWriter.Feature.PrettyFormat));\n    }\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/admin/controller/RuleMatcherTestController.java",
    "content": "package im.zhaojun.zfile.module.admin.controller;\n\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.core.util.matcher.IRuleMatcher;\nimport im.zhaojun.zfile.core.util.matcher.RuleMatcherFactory;\nimport im.zhaojun.zfile.module.admin.model.request.TestRuleMatcherRequest;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.validation.Valid;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport java.util.List;\n\n/**\n * @author zhaojun\n */\n@Tag(name = \"规则匹配辅助 Controller\")\n@Slf4j\n@RequestMapping(\"/admin\")\n@RestController\npublic class RuleMatcherTestController {\n\n    /**\n     * 根据传入的规则和测试值, 测试规则是否匹配, 规则支持多个, 用换行符分割. 如果匹配, 则返回匹配的规则表达式行.\n     * @param   testRuleMatcherRequest\n     *          测试规则匹配请求\n     *\n     * @return  匹配成功的第一个表达式\n     */\n    @PostMapping(\"/rule-test\")\n    public AjaxJson<String> testRule(@RequestBody @Valid TestRuleMatcherRequest testRuleMatcherRequest) {\n        if (testRuleMatcherRequest == null) {\n            return AjaxJson.getSuccessData(null);\n        }\n        String rules = testRuleMatcherRequest.getRules();\n        String testValue = testRuleMatcherRequest.getTestValue();\n        if (StringUtils.isBlank(testValue) || StringUtils.isBlank(rules)) {\n            return AjaxJson.getSuccessData(null);\n        }\n\n        List<String> ruleList = StringUtils.split(rules, StringUtils.LF);\n        IRuleMatcher ipRuleMatcher = RuleMatcherFactory.getRuleMatcher(testRuleMatcherRequest.getRuleType());\n        return AjaxJson.getSuccessData(ipRuleMatcher.matchAnyReturnFirst(ruleList, testValue));\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/admin/model/request/TestRuleMatcherRequest.java",
    "content": "package im.zhaojun.zfile.module.admin.model.request;\n\nimport lombok.Data;\n\nimport jakarta.validation.constraints.NotBlank;\n\n@Data\npublic class TestRuleMatcherRequest {\n\n    @NotBlank(message = \"规则类型不能为空\")\n    private String ruleType;\n\n    @NotBlank(message = \"规则不能为空\")\n    private String rules;\n\n    @NotBlank(message = \"测试值不能为空\")\n    private String testValue;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/config/annotation/JSONStringParse.java",
    "content": "package im.zhaojun.zfile.module.config.annotation;\n\nimport java.lang.annotation.ElementType;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.RetentionPolicy;\nimport java.lang.annotation.Target;\n\n/**\n * 标注是否按 JSON 字符串解析\n *\n * @author zhaojun\n */\n@Target(ElementType.FIELD)\n@Retention(RetentionPolicy.RUNTIME)\npublic @interface JSONStringParse {\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/config/constant/SystemConfigConstant.java",
    "content": "package im.zhaojun.zfile.module.config.constant;\n\n/**\n * 系统设置字段常量.\n *\n * @author zhaojun\n */\npublic class SystemConfigConstant {\n\n    public static final String USERNAME = \"username\";\n\n    public static final String PASSWORD = \"password\";\n\n    public static final String LOGIN_VERIFY_MODE = \"loginVerifyMode\";\n\n    // 这里名称和值不一样是历史遗留问题，最开始设计时弄混了名称，实际使用的是 aes\n    public static final String AES_HEX_KEY = \"rsaHexKey\";\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/config/controller/SettingController.java",
    "content": "package im.zhaojun.zfile.module.config.controller;\n\nimport cn.hutool.core.util.StrUtil;\nimport com.alibaba.fastjson2.JSON;\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport com.github.xiaoymin.knife4j.annotations.ApiSort;\nimport im.zhaojun.zfile.core.annotation.DemoDisable;\nimport im.zhaojun.zfile.core.config.ZFileProperties;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;\nimport im.zhaojun.zfile.module.config.model.request.*;\nimport im.zhaojun.zfile.module.config.service.SystemConfigService;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.annotation.Resource;\nimport jakarta.validation.Valid;\nimport org.apache.commons.lang3.BooleanUtils;\nimport org.springframework.beans.BeanUtils;\nimport org.springframework.web.bind.annotation.*;\n\n/**\n * 站点设定值接口\n *\n * @author zhaojun\n */\n@Tag(name = \"站点设置模块\")\n@ApiSort(2)\n@RestController\n@RequestMapping(\"/admin\")\npublic class SettingController {\n\n    @Resource\n    private SystemConfigService systemConfigService;\n\n    @Resource\n    private ZFileProperties zFileProperties;\n\n    @ApiOperationSupport(order = 1)\n    @Operation(summary = \"获取站点信息\", description = \"获取站点相关信息，如站点名称，风格样式，是否显示公告，是否显示文档区，自定义 CSS，JS 等参数\")\n    @GetMapping(\"/config\")\n    public AjaxJson<SystemConfigDTO> getConfig() {\n        SystemConfigDTO systemConfigDTO = systemConfigService.getSystemConfig();\n        if (zFileProperties != null && zFileProperties.isDemoSite()) {\n            SystemConfigDTO copy = JSON.parseObject(JSON.toJSONString(systemConfigDTO), SystemConfigDTO.class);\n            copy.setAuthCode(null);\n            copy.setRsaHexKey(null);\n            return AjaxJson.getSuccessData(copy);\n        }\n        return AjaxJson.getSuccessData(systemConfigDTO);\n    }\n\n    @ApiOperationSupport(order = 3)\n    @Operation(summary = \"修改站点设置\")\n    @PutMapping(\"/config/site\")\n    @DemoDisable\n    public AjaxJson<Void> updateSiteSetting(@Valid @RequestBody UpdateSiteSettingRequest settingRequest) {\n        if (StrUtil.length(settingRequest.getAuthCode()) > 36 && StrUtil.length(settingRequest.getAuthCode()) < 100) {\n            throw new BizException(\"授权码长度异常，请检查是否额外复制了空格或特殊字符！\");\n        }\n        SystemConfigDTO systemConfigDTO = new SystemConfigDTO();\n        BeanUtils.copyProperties(settingRequest, systemConfigDTO);\n        systemConfigService.updateSystemConfig(systemConfigDTO);\n        return AjaxJson.getSuccess();\n    }\n\n\n    @ApiOperationSupport(order = 4)\n    @Operation(summary = \"修改显示设置\")\n    @PutMapping(\"/config/view\")\n    @DemoDisable\n    public AjaxJson<Void> updateViewSetting(@Valid @RequestBody UpdateViewSettingRequest settingRequest) {\n        SystemConfigDTO systemConfigDTO = new SystemConfigDTO();\n        BeanUtils.copyProperties(settingRequest, systemConfigDTO);\n        systemConfigService.updateSystemConfig(systemConfigDTO);\n        return AjaxJson.getSuccess();\n    }\n\n\n    @ApiOperationSupport(order = 5)\n    @Operation(summary = \"修改登陆安全设置\")\n    @PutMapping(\"/config/security\")\n    @DemoDisable\n    public AjaxJson<Void> updateSecuritySetting(@Valid @RequestBody UpdateSecuritySettingRequest settingRequest) {\n        SystemConfigDTO systemConfigDTO = new SystemConfigDTO();\n        BeanUtils.copyProperties(settingRequest, systemConfigDTO);\n        if (BooleanUtils.isNotTrue(settingRequest.getAdminTwoFactorVerify())) {\n            systemConfigDTO.setLoginVerifySecret(\"\");\n        }\n        systemConfigService.updateSystemConfig(systemConfigDTO);\n        return AjaxJson.getSuccess();\n    }\n\n\n    @ApiOperationSupport(order = 6)\n    @Operation(summary = \"修改直链设置\")\n    @PutMapping(\"/config/link\")\n    @DemoDisable\n    public AjaxJson<Void> updateLinkSetting(@Valid @RequestBody UpdateLinkSettingRequest settingRequest) {\n        SystemConfigDTO systemConfigDTO = new SystemConfigDTO();\n        BeanUtils.copyProperties(settingRequest, systemConfigDTO);\n        systemConfigService.updateSystemConfig(systemConfigDTO);\n        return AjaxJson.getSuccess();\n    }\n\n    @ApiOperationSupport(order = 7)\n    @Operation(summary = \"修改访问控制设置\")\n    @PutMapping(\"/config/access\")\n    @DemoDisable\n    public AjaxJson<Void> updateSecuritySetting(@Valid @RequestBody UpdateAccessSettingRequest updateAccessSettingRequest) {\n        SystemConfigDTO systemConfigDTO = new SystemConfigDTO();\n        BeanUtils.copyProperties(updateAccessSettingRequest, systemConfigDTO);\n        systemConfigService.updateSystemConfig(systemConfigDTO);\n        return AjaxJson.getSuccess();\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/config/controller/SiteController.java",
    "content": "package im.zhaojun.zfile.module.config.controller;\n\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport com.github.xiaoymin.knife4j.annotations.ApiSort;\nimport im.zhaojun.zfile.core.config.ZFileProperties;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.core.util.ZFileAuthUtil;\nimport im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;\nimport im.zhaojun.zfile.module.config.model.result.FrontSiteConfigResult;\nimport im.zhaojun.zfile.module.config.service.SystemConfigService;\nimport im.zhaojun.zfile.module.storage.annotation.ProCheck;\nimport im.zhaojun.zfile.module.storage.context.StorageSourceContext;\nimport im.zhaojun.zfile.module.storage.model.request.base.FileListConfigRequest;\nimport im.zhaojun.zfile.module.storage.model.result.StorageSourceConfigResult;\nimport im.zhaojun.zfile.module.storage.service.StorageSourceService;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;\nimport im.zhaojun.zfile.module.user.model.constant.UserConstant;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.annotation.Resource;\nimport jakarta.validation.Valid;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.BeanUtils;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.Objects;\n\n/**\n * 面向前台的站点基础模块接口\n *\n * @author zhaojun\n */\n@Tag(name = \"站点基础模块\")\n@ApiSort(1)\n@Slf4j\n@RequestMapping(\"/api/site\")\n@RestController\npublic class SiteController {\n\n\t@Resource\n\tprivate ZFileProperties zFileProperties;\n\n\t@Resource\n\tprivate StorageSourceService storageSourceService;\n\n\t@Resource\n\tprivate SystemConfigService systemConfigService;\n\n\t@ApiOperationSupport(order = 1)\n\t@Operation(summary = \"获取站点全局设置\", description = \"获取站点全局设置, 包括是否页面布局、列表尺寸、公告、配置信息\")\n\t@GetMapping(\"/config/global\")\n\t@ProCheck\n\tpublic AjaxJson<FrontSiteConfigResult> globalConfig() {\n\t\tSystemConfigDTO systemConfig = systemConfigService.getSystemConfig();\n\n\t\tFrontSiteConfigResult frontSiteConfigResult = new FrontSiteConfigResult();\n\t\tBeanUtils.copyProperties(systemConfig, frontSiteConfigResult);\n\n\t\tfrontSiteConfigResult.setDebugMode(zFileProperties.isDebug());\n\t\tboolean guestUser = Objects.equals(ZFileAuthUtil.getCurrentUserId(), UserConstant.ANONYMOUS_ID);\n\t\tboolean guestIndexNotBlank = StringUtils.isNotBlank(systemConfig.getGuestIndexHtml());\n\t\tfrontSiteConfigResult.setGuest(guestUser && guestIndexNotBlank);\n\t\treturn AjaxJson.getSuccessData(frontSiteConfigResult);\n\t}\n\n\n\t@ApiOperationSupport(order = 2)\n\t@Operation(summary = \"获取存储源设置\", description = \"获取某个存储源的设置信息, 包括是否启用, 名称, 存储源类型, 存储源配置信息\")\n\t@PostMapping(\"/config/storage\")\n\tpublic AjaxJson<StorageSourceConfigResult> storageList(@Valid @RequestBody FileListConfigRequest fileListConfigRequest) {\n\t\tStorageSourceConfigResult storageSourceConfigResult = storageSourceService.getStorageConfigSource(fileListConfigRequest);\n\t\treturn AjaxJson.getSuccessData(storageSourceConfigResult);\n\t}\n\n\n\t@ApiOperationSupport(order = 3)\n\t@Operation(summary = \"获取用户存储源路径\", description = \"获取用户存储源路径\")\n\t@GetMapping(\"/config/userRootPath/{storageKey}\")\n\tpublic AjaxJson<String> getUserRootPath(@PathVariable(\"storageKey\") String storageKey) {\n\t\tAbstractBaseFileService<?> baseFileService = StorageSourceContext.getByStorageKey(storageKey);\n\t\tif (baseFileService == null || baseFileService.getCurrentUserBasePath() == null) {\n\t\t\treturn AjaxJson.getSuccessData(\"\");\n\t\t}\n\t\treturn AjaxJson.getSuccessData(baseFileService.getCurrentUserBasePath());\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/config/event/DirectLinkPrefixModifyHandler.java",
    "content": "package im.zhaojun.zfile.module.config.event;\n\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.config.model.entity.SystemConfig;\nimport im.zhaojun.zfile.module.link.controller.DirectLinkController;\nimport im.zhaojun.zfile.module.link.service.DynamicDirectLinkPrefixService;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.servlet.mvc.method.RequestMappingInfo;\n\n/**\n * 接收系统设置修改事件, 修改直链前缀时, 动态更新直链前缀.\n *\n * @author zhaojun\n */\n@Slf4j\n@Component\npublic class DirectLinkPrefixModifyHandler implements ISystemConfigModifyHandler {\n\n    @Resource\n    private DynamicDirectLinkPrefixService dynamicDirectLinkPrefixService;\n\n    @Override\n    public void modify(SystemConfig originalSystemConfig, SystemConfig newSystemConfig) {\n        String oldValue = originalSystemConfig.getValue();\n        String newValue = newSystemConfig.getValue();\n        if (StringUtils.equals(oldValue, newValue)) {\n            log.info(\"检测到修改了直链前缀, 但是新值和旧值相同, 不做处理.\");\n            return;\n        }\n\n        RequestMappingInfo requestMappingInfo = RequestMappingInfo.paths(newValue + DirectLinkController.DIRECT_LINK_SUFFIX_PATH).build();\n        dynamicDirectLinkPrefixService.updateRegisterMappingHandler(SystemConfig.DIRECT_LINK_PREFIX_NAME, requestMappingInfo);\n        log.info(\"检测到修改了直链前缀, [{}] -> [{}], 已自动更新直链前缀.\", oldValue, newValue);\n    }\n\n    @Override\n    public boolean matches(String name) {\n        return SystemConfig.DIRECT_LINK_PREFIX_NAME.equals(name);\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/config/event/ISystemConfigModifyHandler.java",
    "content": "package im.zhaojun.zfile.module.config.event;\n\nimport im.zhaojun.zfile.module.config.model.entity.SystemConfig;\n\n/**\n * 系统设置修改事件\n *\n * @author zhaojun\n */\npublic interface ISystemConfigModifyHandler {\n\n    /**\n     * 修改系统设置时会触发此事件\n     *\n     */\n    void modify(SystemConfig originalSystemConfig, SystemConfig newSystemConfig);\n\n    /**\n     * 判断是否匹配当前处理器\n     *\n     * @param   name\n     *          配置项名称\n     *\n     * @return  是否匹配\n     */\n    boolean matches(String name);\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/config/event/SecureLoginEntryModifyHandler.java",
    "content": "package im.zhaojun.zfile.module.config.event;\n\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.config.model.entity.SystemConfig;\nimport im.zhaojun.zfile.module.user.service.DynamicLoginEntryService;\nimport im.zhaojun.zfile.module.user.util.LoginEntryPathUtils;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.servlet.mvc.method.RequestMappingInfo;\n\n/**\n * 监听安全登录入口配置变更，动态更新登录接口映射。\n *\n * @author zhaojun\n */\n@Slf4j\n@Component\npublic class SecureLoginEntryModifyHandler implements ISystemConfigModifyHandler {\n\n    @Resource\n    private DynamicLoginEntryService dynamicLoginEntryService;\n\n    @Override\n    public void modify(SystemConfig originalSystemConfig, SystemConfig newSystemConfig) {\n        String oldPath = LoginEntryPathUtils.resolveLoginPath(originalSystemConfig.getValue());\n        String newPath = LoginEntryPathUtils.resolveLoginPath(newSystemConfig.getValue());\n\n        if (StringUtils.equals(oldPath, newPath)) {\n            log.info(\"检测到修改安全登录入口，但实际登录路径未变化，跳过处理。\");\n            return;\n        }\n\n        RequestMappingInfo requestMappingInfo = dynamicLoginEntryService.buildLoginRequestMappingInfo(newSystemConfig.getValue());\n        dynamicLoginEntryService.updateRegisterMappingHandler(SystemConfig.SECURE_LOGIN_ENTRY_NAME, requestMappingInfo);\n        log.info(\"安全登录入口已更新，{} -> {}\", oldPath, newPath);\n    }\n\n    @Override\n    public boolean matches(String name) {\n        return SystemConfig.SECURE_LOGIN_ENTRY_NAME.equals(name);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/config/event/SystemConfigModifyHandlerChain.java",
    "content": "package im.zhaojun.zfile.module.config.event;\n\nimport im.zhaojun.zfile.module.config.model.entity.SystemConfig;\nimport org.springframework.stereotype.Component;\n\nimport jakarta.annotation.Resource;\nimport java.util.List;\n\n/**\n * @author zhaojun\n */\n@Component\npublic class SystemConfigModifyHandlerChain {\n\n    @Resource\n    private List<ISystemConfigModifyHandler> handlers;\n\n    public void execute(SystemConfig originalSystemConfig, SystemConfig newSystemConfig) {\n        handlers.stream()\n                .filter(handler -> handler.matches(originalSystemConfig.getName()))\n                .forEach(handler -> handler.modify(originalSystemConfig, newSystemConfig));\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/config/mapper/SystemConfigMapper.java",
    "content": "package im.zhaojun.zfile.module.config.mapper;\n\nimport com.baomidou.mybatisplus.core.mapper.BaseMapper;\nimport im.zhaojun.zfile.module.config.model.entity.SystemConfig;\nimport org.apache.ibatis.annotations.Mapper;\nimport org.apache.ibatis.annotations.Param;\n\nimport java.util.List;\n\n\n/**\n * 系统配置 Mapper 接口\n *\n * @author zhaojun\n */\n@Mapper\npublic interface SystemConfigMapper extends BaseMapper<SystemConfig> {\n\n    /**\n     * 获取所有系统设置\n     *\n     * @return  系统设置列表\n     */\n    List<SystemConfig> findAll();\n\n\n    /**\n     * 根据系统设置名称获取设置信息\n     *\n     * @param   name\n     *          系统设置名称\n     *\n     * @return  系统设置信息\n     */\n    SystemConfig findByName(@Param(\"name\")String name);\n\n\n    /**\n     * 批量保存系统设置\n     *\n     * @param   list\n     *          系统设置列表\n     *\n     * @return  保存记录数\n     */\n    int saveAll(@Param(\"list\")List<SystemConfig> list);\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/config/model/dto/LinkExpireDTO.java",
    "content": "package im.zhaojun.zfile.module.config.model.dto;\n\nimport lombok.Data;\n\nimport java.io.Serializable;\n\n@Data\npublic class LinkExpireDTO implements Serializable {\n\n    private static final long serialVersionUID = 1L;\n\n    private Integer value;\n\n    private String unit;\n\n    private Long seconds;\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/config/model/dto/SystemConfigDTO.java",
    "content": "package im.zhaojun.zfile.module.config.model.dto;\n\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport im.zhaojun.zfile.module.config.annotation.JSONStringParse;\nimport im.zhaojun.zfile.module.config.model.enums.FileClickModeEnum;\nimport im.zhaojun.zfile.module.link.model.enums.RefererTypeEnum;\nimport im.zhaojun.zfile.module.user.model.enums.LoginLogModeEnum;\nimport im.zhaojun.zfile.module.user.model.enums.LoginVerifyModeEnum;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport java.io.Serializable;\nimport java.util.ArrayList;\nimport java.util.List;\n\n/**\n * 系统设置传输类\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"系统设置类\")\npublic class SystemConfigDTO implements Serializable {\n\n    private static final long serialVersionUID = 1L;\n\n    @Schema(title = \"站点名称\", example = \"ZFile Site Name\")\n    private String siteName;\n\n    @Schema(title = \"用户名\", example = \"admin\")\n    @Deprecated\n    private String username;\n\n    @Schema(title = \"头像地址\", example = \"https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png\")\n    private String avatar;\n\n    @Schema(title = \"备案号\", example = \"冀ICP备12345678号-1\")\n    private String icp;\n\n    @JsonIgnore\n    @Deprecated\n    private String password;\n\n    @Schema(title = \"自定义 JS\")\n    private String customJs;\n\n    @Schema(title = \"自定义 CSS\")\n    private String customCss;\n\n    @Schema(title = \"列表尺寸\", description =\"large:大,default:中,small:小\", example = \"default\")\n    private String tableSize;\n\n    @Schema(title = \"是否显示文档区\", example = \"true\")\n    private Boolean showDocument;\n\n    @Schema(title = \"网站公告\", example = \"ZFile 网站公告\")\n    private String announcement;\n\n    @Schema(title = \"是否显示网站公告\", example = \"true\")\n    private Boolean showAnnouncement;\n\n    @Schema(title = \"页面布局\", description =\"full:全屏,center:居中\", example = \"full\")\n    private String layout;\n\n    @Schema(title = \"移动端页面布局\", description =\"full:全屏,center:居中\", example = \"full\")\n    private String mobileLayout;\n\n    @Schema(title = \"移动端显示文件大小\", description = \"仅适用列表视图\", example = \"true\")\n    private Boolean mobileShowSize;\n\n    @Schema(title = \"是否显示生成直链功能（含直链和路径短链）\", example = \"true\")\n    private Boolean showLinkBtn;\n\n    @Schema(title = \"是否显示生成短链功能\", example = \"true\")\n    private Boolean showShortLink;\n\n    @Schema(title = \"是否显示生成路径链接功能\", example = \"true\")\n    private Boolean showPathLink;\n\n    @Schema(title = \"是否已初始化\", example = \"true\")\n    private Boolean installed;\n\n    @Schema(title = \"自定义视频文件后缀格式\")\n    private String customVideoSuffix;\n\n    @Schema(title = \"自定义图像文件后缀格式\")\n    private String customImageSuffix;\n\n    @Schema(title = \"自定义音频文件后缀格式\")\n    private String customAudioSuffix;\n\n    @Schema(title = \"自定义文本文件后缀格式\")\n    private String customTextSuffix;\n\n    @Schema(title = \"自定义Office后缀格式\")\n    private String customOfficeSuffix;\n\n    @Schema(title = \"自定义kkFileView后缀格式\")\n    private String customKkFileViewSuffix;\n\n    @Schema(title = \"直链地址前缀\")\n    private String directLinkPrefix;\n\n    @Schema(title = \"直链 Referer 防盗链类型\")\n    private RefererTypeEnum refererType;\n\n    @Schema(title = \"是否记录下载日志\", example = \"true\")\n    private Boolean recordDownloadLog;\n\n    @Schema(title = \"直链 Referer 是否允许为空\")\n    private Boolean refererAllowEmpty;\n\n    @Schema(title = \"直链 Referer 值\")\n    private String refererValue;\n\n    /**\n     * 废弃的字段，改为使用 {@link #adminTwoFactorVerify} 和 {@link #loginVerifySecret} 代替\n     */\n    @Schema(title = \"管理员登陆验证方式，目前仅支持 2FA 认证或关闭\")\n    @Deprecated\n    private LoginVerifyModeEnum loginVerifyMode;\n\n    @Schema(title = \"登陆验证 Secret\")\n    private String loginVerifySecret;\n\n    @Schema(title = \"是否启用登陆验证码\", example = \"true\")\n    private Boolean loginImgVerify;\n\n    @Schema(title = \"是否为管理员启用双因素认证\", example = \"true\")\n    private Boolean adminTwoFactorVerify;\n\n    @Schema(title = \"根目录是否显示所有存储源\", description =\"勾选则根目录显示所有存储源列表, 反之会自动显示第一个存储源的内容.\", example = \"true\", requiredMode = Schema.RequiredMode.REQUIRED)\n    private Boolean rootShowStorage;\n\n    @Schema(title = \"强制后端地址\", description =\"强制指定生成直链，短链，获取回调地址时的地址。\", example = \"http://xxx.example.com\")\n    private String forceBackendAddress;\n\n    @Schema(title = \"前端域名\", description =\"前端域名，前后端分离情况下需要配置.\", example = \"http://xxx.example.com\")\n    private String frontDomain;\n\n    @Schema(title = \"是否在前台显示登陆按钮\", example = \"true\")\n    private Boolean showLogin;\n\n    @Schema(title = \"安全登录入口\", description = \"用于隐藏默认登录地址的安全入口，不包含 '/'\", example = \"admin\")\n    private String secureLoginEntry;\n\n    @Schema(title = \"登录日志模式\", example = \"all\")\n    private LoginLogModeEnum loginLogMode;\n\n    @Schema(title = \"RAS Hex Key\", example = \"r2HKbzc1DfvOs5uHhLn7pA==\")\n    private String rsaHexKey;\n\n    @Schema(title = \"默认文件点击习惯\", example = \"click\")\n    private FileClickModeEnum fileClickMode;\n\n    @Schema(title = \"移动端默认文件点击习惯\", example = \"click\")\n    private FileClickModeEnum mobileFileClickMode;\n\n    @Schema(title = \"授权码\", example = \"e619510f-cdcd-f657-6c5e-2d12e9a28ae5\")\n    private String authCode;\n\n    @Schema(title = \"最大同时上传文件数\", example = \"5\")\n    private Integer maxFileUploads;\n\n    @Schema(title = \"onlyOffice 在线预览地址\", example = \"http://office.zfile.vip\")\n    private String onlyOfficeUrl;\n\n    @Schema(title = \"onlyOffice Secret\", example = \"X9rBGypwWE86Lca8e4Mo55iHFoiyh9ed\")\n    private String onlyOfficeSecret;\n\n    @Schema(title = \"kkFileView 在线预览地址\", example = \"http://kkfile.zfile.vip\")\n    private String kkFileViewUrl;\n\n    @Schema(title = \"kkFileView 预览方式\", example = \"iframe/newTab\")\n    private String kkFileViewOpenMode;\n\n    @Schema(title = \"启用 WebDAV\", example = \"true\")\n    private Boolean webdavEnable;\n\n    @Schema(title = \"WebDAV 服务器中转下载\", example = \"true\")\n    private Boolean webdavProxy;\n\n    @Schema(title = \"WebDAV 匿名用户访问\", example = \"true\")\n    private Boolean webdavAllowAnonymous;\n\n    @Schema(title = \"WebDAV 账号\", example = \"admin\")\n    private String webdavUsername;\n\n    @Schema(title = \"WebDAV 密码\", example = \"123456\")\n    private String webdavPassword;\n\n    @Schema(title = \"是否允许路径直链可直接访问\", example = \"true\", requiredMode = Schema.RequiredMode.REQUIRED)\n    private Boolean allowPathLinkAnonAccess;\n\n    @Schema(title = \"默认最大显示文件数\", example = \"1000\")\n    private Integer maxShowSize;\n\n    @Schema(title = \"每次加载更多文件数\", example = \"50\")\n    private Integer loadMoreSize;\n\n    @Schema(title = \"默认排序字段\", example = \"name\")\n    private String defaultSortField;\n\n    @Schema(title = \"默认排序方向\", example = \"asc\")\n    private String defaultSortOrder;\n\n    @Schema(title = \"站点 Home 名称\", example = \"xxx 的小站\")\n    private String siteHomeName;\n\n    @Schema(title = \"站点 Home Logo\", example = \"true\")\n    private String siteHomeLogo;\n\n    @Schema(title = \"站点 Logo 点击后链接\", example = \"https://www.zfile.vip\")\n    private String siteHomeLogoLink;\n\n    @Schema(title = \"站点 Logo 链接打开方式\", example = \"_blank\")\n    private String siteHomeLogoTargetMode;\n\n    @Schema(title = \"管理员页面点击 Logo 回到首页打开方式\", example = \"_blank\")\n    private String siteAdminLogoTargetMode;\n\n    @Schema(title = \"管理员页面点击版本号打开更新日志\", example = \"true\")\n    private Boolean siteAdminVersionOpenChangeLog;\n\n    @Schema(title = \"限制直链下载秒数\", example = \"_blank\")\n    private Integer linkLimitSecond;\n\n    @Schema(title = \"限制直链下载次数\", example = \"_blank\")\n    private Integer linkDownloadLimit;\n\n    @Schema(title = \"网站 favicon 图标地址\", example = \"https://www.example.com/favicon.ico\")\n    private String faviconUrl;\n\n    @Schema(title = \"短链过期时间设置\", example = \"[{value: 1, unit: \\\"day\\\"}, {value: 1, unit: \\\"week\\\"}, {value: 1, unit: \\\"month\\\"}, {value: 1, unit: \\\"year\\\"}]\")\n    @JSONStringParse\n    private List<LinkExpireDTO> linkExpireTimes;\n\n    @Schema(title = \"是否默认记住密码\", example = \"true\")\n    private Boolean defaultSavePwd;\n\n    @Schema(title = \"普通下载是否启用确认弹窗\", example = \"true\")\n    private Boolean enableNormalDownloadConfirm;\n\n    @Schema(title = \"打包下载是否启用确认弹窗\", example = \"true\")\n    private Boolean enablePackageDownloadConfirm;\n\n    @Schema(title = \"批量下载是否启用确认弹窗\", example = \"true\")\n    private Boolean enableBatchDownloadConfirm;\n\n    /**\n     * 废弃的字段，不再使用悬浮菜单\n     */\n    @Deprecated\n    @Schema(title = \"是否启用 hover 菜单\", example = \"true\")\n    private Boolean enableHoverMenu;\n\n    @Schema(title = \"访问 ip 黑名单\", example = \"162.13.1.0/24\\n192.168.1.1\")\n    private String accessIpBlocklist;\n\n    @Schema(title = \"访问 ua 黑名单\", example = \"Mozilla/5.0 (Linux; Android) AppleWebKit/537.36*\")\n    private String accessUaBlocklist;\n\n    @Schema(title = \"匿名用户首页显示内容\")\n    private String guestIndexHtml;\n\n    public String getAnnouncement() {\n        return announcement == null ? \"\" : announcement;\n    }\n\n    public List<LinkExpireDTO> getLinkExpireTimes() {\n        if (linkExpireTimes == null) {\n            LinkExpireDTO linkExpireDTO = new LinkExpireDTO();\n            linkExpireDTO.setValue(1);\n            linkExpireDTO.setUnit(\"d\");\n            linkExpireDTO.setSeconds(86400L);\n            linkExpireTimes = new ArrayList<>();\n            linkExpireTimes.add(linkExpireDTO);\n        }\n        return linkExpireTimes;\n    }\n\n    public String getLayout() {\n        return layout == null ? \"full\" : layout;\n    }\n\n    public String getMobileLayout() {\n        return mobileLayout == null ? getLayout() : mobileLayout;\n    }\n\n    /**\n     * 获取普通下载是否启用确认弹窗配置.\n     *\n     * @return  若为空则返回 true\n     */\n    public Boolean getEnableNormalDownloadConfirm() {\n        return enableNormalDownloadConfirm == null ? Boolean.TRUE : enableNormalDownloadConfirm;\n    }\n\n    /**\n     * 获取打包下载是否启用确认弹窗配置.\n     *\n     * @return  若为空则返回 true\n     */\n    public Boolean getEnablePackageDownloadConfirm() {\n        return enablePackageDownloadConfirm == null ? Boolean.TRUE : enablePackageDownloadConfirm;\n    }\n\n    /**\n     * 获取批量下载是否启用确认弹窗配置.\n     *\n     * @return  若为空则返回 true\n     */\n    public Boolean getEnableBatchDownloadConfirm() {\n        return enableBatchDownloadConfirm == null ? Boolean.TRUE : enableBatchDownloadConfirm;\n    }\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/config/model/entity/SystemConfig.java",
    "content": "package im.zhaojun.zfile.module.config.model.entity;\n\nimport com.baomidou.mybatisplus.annotation.IdType;\nimport com.baomidou.mybatisplus.annotation.TableField;\nimport com.baomidou.mybatisplus.annotation.TableId;\nimport com.baomidou.mybatisplus.annotation.TableName;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport java.io.Serializable;\n\n/**\n * 系统设置 entity\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"系统设置\")\n@TableName(value = \"system_config\")\npublic class SystemConfig implements Serializable {\n\n    public static final String DIRECT_LINK_PREFIX_NAME = \"directLinkPrefix\";\n\n    public static final String SECURE_LOGIN_ENTRY_NAME = \"secureLoginEntry\";\n\n    private static final long serialVersionUID = 1L;\n\n    @TableId(value = \"id\", type = IdType.AUTO)\n    @Schema(title = \"ID, 新增无需填写\", example = \"1\")\n    private Integer id;\n\n\n    @TableField(value = \"name\")\n    @Schema(title = \"系统设置名称\", example = \"siteName\")\n    private String name;\n\n\n    @TableField(value = \"`value`\")\n    @Schema(title = \"系统设置值\", example = \"ZFile 演示站\")\n    private String value;\n\n\n    @TableField(value = \"title\")\n    @Schema(title = \"系统设置描述\", example = \"站点名称\")\n    private String title;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/config/model/enums/FileClickModeEnum.java",
    "content": "package im.zhaojun.zfile.module.config.model.enums;\n\nimport com.baomidou.mybatisplus.annotation.EnumValue;\nimport com.fasterxml.jackson.annotation.JsonValue;\nimport lombok.AllArgsConstructor;\nimport lombok.Getter;\n\n/**\n * 文件点击模式枚举, 用于控制文件是单击打开还是双击打开\n *\n * @author zhaojun\n */\n@Getter\n@AllArgsConstructor\npublic enum FileClickModeEnum {\n\n\t/**\n\t * 单击打开文件/文件夹\n\t */\n\tCLICK(\"click\"),\n\n\t/**\n\t * 双击打开文件/文件夹\n\t */\n\tDBCLICK(\"dbclick\");\n\n\t@EnumValue\n\t@JsonValue\n\tprivate final String value;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateAccessSettingRequest.java",
    "content": "package im.zhaojun.zfile.module.config.model.request;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\n/**\n * 站点访问控制请求参数类\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"站点访问控制参数类\")\npublic class UpdateAccessSettingRequest {\n\n    @Schema(title = \"访问 ip 黑名单\", example = \"162.13.1.0/24\\n192.168.1.1\")\n    private String accessIpBlocklist;\n\n    @Schema(title = \"访问 ua 黑名单\", example = \"Mozilla/5.0 (Linux; Android) AppleWebKit/537.36*\")\n    private String accessUaBlocklist;\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateLinkSettingRequest.java",
    "content": "package im.zhaojun.zfile.module.config.model.request;\n\nimport im.zhaojun.zfile.module.config.model.dto.LinkExpireDTO;\nimport im.zhaojun.zfile.module.link.model.enums.RefererTypeEnum;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotBlank;\nimport lombok.Data;\n\nimport java.util.List;\n\n/**\n * 直链设置请求参数类\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"直链设置请求参数类\")\npublic class UpdateLinkSettingRequest {\n\n\t@Schema(title = \"是否记录下载日志\", example = \"true\")\n\tprivate Boolean recordDownloadLog;\n\n\t@Schema(title = \"直链 Referer 防盗链类型\")\n\tprivate RefererTypeEnum refererType;\n\n\t@Schema(title = \"直链 Referer 是否允许为空\")\n\tprivate Boolean refererAllowEmpty;\n\n\t@Schema(title = \"直链 Referer 值\")\n\tprivate String refererValue;\n\n\t@Schema(title = \"直链地址前缀\")\n\t@NotBlank(message = \"直链地址前缀不能为空\")\n\tprivate String directLinkPrefix;\n\n\t@Schema(title = \"是否显示生成直链功能（含直链和路径短链）\", example = \"true\", requiredMode = Schema.RequiredMode.REQUIRED)\n\tprivate Boolean showLinkBtn;\n\n\t@Schema(title = \"是否显示生成短链功能\", example = \"true\", requiredMode = Schema.RequiredMode.REQUIRED)\n\tprivate Boolean showShortLink;\n\n\t@Schema(title = \"是否显示生成路径链接功能\", example = \"true\", requiredMode = Schema.RequiredMode.REQUIRED)\n\tprivate Boolean showPathLink;\n\n\t@Schema(title = \"是否允许路径直链可直接访问\", example = \"true\", requiredMode = Schema.RequiredMode.REQUIRED)\n\tprivate Boolean allowPathLinkAnonAccess;\n\n\t@Schema(title = \"限制直链下载秒数\", example = \"_blank\")\n\tprivate Integer linkLimitSecond;\n\n\t@Schema(title = \"限制直链下载次数\", example = \"_blank\")\n\tprivate Integer linkDownloadLimit;\n\n\t@Schema(title = \"短链过期时间设置\", example = \"[{value: 1, unit: \\\"day\\\"}, {value: 1, unit: \\\"week\\\"}, {value: 1, unit: \\\"month\\\"}, {value: 1, unit: \\\"year\\\"}]\")\n\tprivate List<LinkExpireDTO> linkExpireTimes;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateSecuritySettingRequest.java",
    "content": "package im.zhaojun.zfile.module.config.model.request;\n\nimport im.zhaojun.zfile.module.user.model.enums.LoginLogModeEnum;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.Pattern;\nimport jakarta.validation.constraints.Size;\nimport lombok.Data;\n\n/**\n * 登陆安全设置请求参数类\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"登陆安全设置请求参数类\")\npublic class UpdateSecuritySettingRequest {\n\n\t@Schema(title = \"是否在前台显示登陆按钮\", example = \"true\")\n\tprivate Boolean showLogin;\n\n    @Schema(title = \"安全登录入口\", description = \"仅允许字母、数字、短横线和下划线，长度不超过 32\", example = \"admin\")\n    @Size(max = 32, message = \"安全登录入口长度不能超过 32 个字符\")\n    @Pattern(regexp = \"^[A-Za-z0-9_-]*$\", message = \"安全登录入口只能包含字母、数字、短横线和下划线\")\n    private String secureLoginEntry;\n\n\t@Schema(title = \"登录日志模式\", example = \"all\")\n\tprivate LoginLogModeEnum loginLogMode;\n\n\t@Schema(title = \"是否启用登陆验证码\", example = \"true\")\n\tprivate Boolean loginImgVerify;\n\n\t@Schema(title = \"是否为管理员启用双因素认证\", example = \"true\")\n\tprivate Boolean adminTwoFactorVerify;\n\n\t@Schema(title = \"2FA登陆验证 Secret\")\n\tprivate String loginVerifySecret;\n\n\t@Schema(title = \"匿名用户首页显示内容\")\n\tprivate String guestIndexHtml;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateSiteSettingRequest.java",
    "content": "package im.zhaojun.zfile.module.config.model.request;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport jakarta.validation.constraints.NotBlank;\n\n/**\n * 站点设置请求参数类\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"站点设置请求参数类\")\npublic class UpdateSiteSettingRequest {\n\n\t@Schema(title = \"站点名称\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"ZFile Site Name\")\n\t@NotBlank(message = \"站点名称不能为空\")\n\tprivate String siteName;\n\n\t@Schema(title = \"强制后端地址\", description =\"强制指定生成直链，短链，获取回调地址时的地址。\", example = \"http://xxx.example.com\")\n\tprivate String forceBackendAddress;\n\n\t@Schema(title = \"前端域名\", description =\"前端域名，前后端分离情况下需要配置.\", example = \"http://xxx.example.com\")\n\tprivate String frontDomain;\n\n\t@Schema(title = \"头像地址\", example = \"https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png\")\n\tprivate String avatar;\n\n\t@Schema(title = \"备案号\", example = \"冀ICP备12345678号-1\")\n\tprivate String icp;\n\n\t@Schema(title = \"授权码\", example = \"e619510f-cdcd-f657-6c5e-2d12e9a28ae5\")\n\tprivate String authCode;\n\n\t@Schema(title = \"最大同时上传文件数\", example = \"5\")\n\tprivate Integer maxFileUploads;\n\n\t@Schema(title = \"站点 Home 名称\", example = \"xxx 的小站\")\n\tprivate String siteHomeName;\n\n\t@Schema(title = \"站点 Home Logo\", example = \"true\")\n\tprivate String siteHomeLogo;\n\n\t@Schema(title = \"站点 Logo 点击后链接\", example = \"https://www.zfile.vip\")\n\tprivate String siteHomeLogoLink;\n\n\t@Schema(title = \"站点 Logo 链接打开方式\", example = \"_blank\")\n\tprivate String siteHomeLogoTargetMode;\n\n\t@Schema(title = \"网站 favicon 图标地址\", example = \"https://www.example.com/favicon.ico\")\n\tprivate String faviconUrl;\n\n\t@Schema(title = \"管理员页面点击 Logo 回到首页打开方式\", example = \"_blank\")\n\tprivate String siteAdminLogoTargetMode;\n\n\t@Schema(title = \"管理员页面点击版本号打开更新日志\", example = \"true\")\n\tprivate Boolean siteAdminVersionOpenChangeLog;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateUserNameAndPasswordRequest.java",
    "content": "package im.zhaojun.zfile.module.config.model.request;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport jakarta.validation.constraints.NotBlank;\n\n/**\n * 用户修改密码请求参数类\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"用户修改密码请求参数类\")\npublic class UpdateUserNameAndPasswordRequest {\n\n    @Schema(title = \"用户名\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"admin\")\n    @NotBlank(message = \"用户名不能为空\")\n    private String username;\n\n    @Schema(title = \"密码\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"123456\")\n    @NotBlank(message = \"密码不能为空\")\n    private String password;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateViewSettingRequest.java",
    "content": "package im.zhaojun.zfile.module.config.model.request;\n\nimport im.zhaojun.zfile.module.config.model.enums.FileClickModeEnum;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\n/**\n * 显示设置请求参数类\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"显示设置请求参数类\")\npublic class UpdateViewSettingRequest {\n\n\t@Schema(title = \"根目录是否显示所有存储源\", description =\"勾选则根目录显示所有存储源列表, 反之会自动显示第一个存储源的内容.\", example = \"true\", requiredMode = Schema.RequiredMode.REQUIRED)\n\tprivate Boolean rootShowStorage;\n\n\t@Schema(title = \"页面布局\", description =\"full:全屏,center:居中\", example = \"full\", requiredMode = Schema.RequiredMode.REQUIRED)\n\tprivate String layout;\n\n\t@Schema(title = \"移动端页面布局\", description =\"full:全屏,center:居中\", example = \"full\")\n\tprivate String mobileLayout;\n\n\t@Schema(title = \"移动端显示文件大小\", description = \"仅适用列表视图\", example = \"true\")\n\tprivate Boolean mobileShowSize;\n\n\t@Schema(title = \"列表尺寸\", description =\"large:大,default:中,small:小\", example = \"default\", requiredMode = Schema.RequiredMode.REQUIRED)\n\tprivate String tableSize;\n\n\t@Schema(title = \"自定义视频文件后缀格式\")\n\tprivate String customVideoSuffix;\n\n\t@Schema(title = \"自定义图像文件后缀格式\")\n\tprivate String customImageSuffix;\n\n\t@Schema(title = \"自定义音频文件后缀格式\")\n\tprivate String customAudioSuffix;\n\n\t@Schema(title = \"自定义文本文件后缀格式\")\n\tprivate String customTextSuffix;\n\n\t@Schema(title = \"自定义Office后缀格式\")\n\tprivate String customOfficeSuffix;\n\n\t@Schema(title = \"自定义kkFileView后缀格式\")\n\tprivate String customKkFileViewSuffix;\n\n\t@Schema(title = \"是否显示文档区\", example = \"true\", requiredMode = Schema.RequiredMode.REQUIRED)\n\tprivate Boolean showDocument;\n\n\t@Schema(title = \"是否显示网站公告\", example = \"true\", requiredMode = Schema.RequiredMode.REQUIRED)\n\tprivate Boolean showAnnouncement;\n\n\t@Schema(title = \"网站公告\", example = \"ZFile 网站公告\")\n\tprivate String announcement;\n\n\t@Schema(title = \"自定义 CSS\")\n\tprivate String customCss;\n\n\t@Schema(title = \"自定义 JS\")\n\tprivate String customJs;\n\n\t@Schema(title = \"默认文件点击习惯\", example = \"click\")\n\tprivate FileClickModeEnum fileClickMode;\n\n\t@Schema(title = \"移动端默认文件点击习惯\", example = \"click\")\n\tprivate FileClickModeEnum mobileFileClickMode;\n\n\t@Schema(title = \"onlyOffice 在线预览地址\", example = \"http://office.zfile.vip\")\n\tprivate String onlyOfficeUrl;\n\n\t@Schema(title = \"onlyOffice Secret\", example = \"X9rBGypwWE86Lca8e4Mo55iHFoiyh9ed\")\n\tprivate String onlyOfficeSecret;\n\n\t@Schema(title = \"kkFileView 在线预览地址\", example = \"http://kkfile.zfile.vip\")\n\tprivate String kkFileViewUrl;\n\n\t@Schema(title = \"kkFileView 预览方式\", example = \"iframe/newTab\")\n\tprivate String kkFileViewOpenMode;\n\n\t@Schema(title = \"默认最大显示文件数\", example = \"1000\")\n\tprivate Integer maxShowSize;\n\n\t@Schema(title = \"每次加载更多文件数\", example = \"50\")\n\tprivate Integer loadMoreSize;\n\n\t@Schema(title = \"默认排序字段\", example = \"name\")\n\tprivate String defaultSortField;\n\n\t@Schema(title = \"默认排序方向\", example = \"asc\")\n\tprivate String defaultSortOrder;\n\n\t@Schema(title = \"是否默认记住密码\", example = \"true\")\n\tprivate Boolean defaultSavePwd;\n\n\t@Schema(title = \"普通下载是否启用确认弹窗\", example = \"true\")\n\tprivate Boolean enableNormalDownloadConfirm;\n\n\t@Schema(title = \"打包下载是否启用确认弹窗\", example = \"true\")\n\tprivate Boolean enablePackageDownloadConfirm;\n\n\t@Schema(title = \"批量下载是否启用确认弹窗\", example = \"true\")\n\tprivate Boolean enableBatchDownloadConfirm;\n\n\t/**\n\t * 废弃的字段，不再使用悬浮菜单\n\t */\n\t@Deprecated\n\t@Schema(title = \"是否启用 hover 菜单\", example = \"true\")\n\tprivate Boolean enableHoverMenu;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/config/model/result/FrontSiteConfigResult.java",
    "content": "package im.zhaojun.zfile.module.config.model.result;\n\nimport im.zhaojun.zfile.module.config.model.dto.LinkExpireDTO;\nimport im.zhaojun.zfile.module.config.model.enums.FileClickModeEnum;\nimport im.zhaojun.zfile.module.user.model.enums.LoginLogModeEnum;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport java.util.List;\n\n/**\n * 全局站点设置响应类\n *\n * @author zhaojun\n */\n@Data\n@Schema(title=\"全局站点设置响应类\")\npublic class FrontSiteConfigResult {\n\n\t@Schema(title = \"是否已初始化\", example = \"true\")\n\tprivate Boolean installed;\n\n\t@Schema(title = \"Debug 模式\", example = \"true\", description =\"开启 debug 模式后，可重置管理员密码\")\n\tprivate Boolean debugMode;\n\n\t@Schema(title = \"直链地址前缀\", example = \"true\", description =\"直链地址前缀, 如 http(s)://ip:port/${直链前缀}/path/filename\")\n\tprivate String directLinkPrefix;\n\n\t@Schema(title = \"站点名称\", example = \"ZFile Site Name\")\n\tprivate String siteName;\n\n\t@Schema(title = \"备案号\", example = \"冀ICP备12345678号-1\")\n\tprivate String icp;\n\n\t@Schema(title = \"页面布局\", description =\"full:全屏,center:居中\", example = \"full\", requiredMode = Schema.RequiredMode.REQUIRED)\n\tprivate String layout;\n\n\t@Schema(title = \"移动端页面布局\", description =\"full:全屏,center:居中\", example = \"full\")\n\tprivate String mobileLayout;\n\n\t@Schema(title = \"移动端显示文件大小\", description = \"仅适用列表视图\", example = \"true\")\n\tprivate Boolean mobileShowSize;\n\n\t@Schema(title = \"列表尺寸\", description =\"large:大,default:中,small:小\", example = \"default\", requiredMode = Schema.RequiredMode.REQUIRED)\n\tprivate String tableSize;\n\n\t@Schema(title = \"是否显示生成直链功能（含直链和路径短链）\", example = \"true\", requiredMode = Schema.RequiredMode.REQUIRED)\n\tprivate Boolean showLinkBtn;\n\n\t@Schema(title = \"是否显示生成短链功能\", example = \"true\", requiredMode = Schema.RequiredMode.REQUIRED)\n\tprivate Boolean showShortLink;\n\n\t@Schema(title = \"是否显示生成路径链接功能\", example = \"true\", requiredMode = Schema.RequiredMode.REQUIRED)\n\tprivate Boolean showPathLink;\n\n\t@Schema(title = \"是否显示文档区\", example = \"true\", requiredMode = Schema.RequiredMode.REQUIRED)\n\tprivate Boolean showDocument;\n\n\t@Schema(title = \"是否显示网站公告\", example = \"true\", requiredMode = Schema.RequiredMode.REQUIRED)\n\tprivate Boolean showAnnouncement;\n\n\t@Schema(title = \"网站公告\", example = \"ZFile 网站公告\")\n\tprivate String announcement;\n\n\t@Schema(title = \"自定义 JS\")\n\tprivate String customJs;\n\n\t@Schema(title = \"自定义 CSS\")\n\tprivate String customCss;\n\n\t@Schema(title = \"自定义视频文件后缀格式\")\n\tprivate String customVideoSuffix;\n\n\t@Schema(title = \"自定义图像文件后缀格式\")\n\tprivate String customImageSuffix;\n\n\t@Schema(title = \"自定义音频文件后缀格式\")\n\tprivate String customAudioSuffix;\n\n\t@Schema(title = \"自定义文本文件后缀格式\")\n\tprivate String customTextSuffix;\n\n\t@Schema(title = \"自定义Office后缀格式\")\n\tprivate String customOfficeSuffix;\n\n\t@Schema(title = \"自定义kkFileView后缀格式\")\n\tprivate String customKkFileViewSuffix;\n\n\t@Schema(title = \"根目录是否显示所有存储源\", description =\"勾选则根目录显示所有存储源列表, 反之会自动显示第一个存储源的内容.\", example = \"true\", requiredMode = Schema.RequiredMode.REQUIRED)\n\tprivate Boolean rootShowStorage;\n\n\t@Schema(title = \"强制后端地址\", description =\"强制指定生成直链，短链，获取回调地址时的地址。\", example = \"http://xxx.example.com\")\n\tprivate String forceBackendAddress;\n\n\t@Schema(title = \"前端域名\", description =\"前端域名，前后端分离情况下需要配置.\", example = \"http://xxx.example.com\")\n\tprivate String frontDomain;\n\n\t@Schema(title = \"是否在前台显示登陆按钮\", example = \"true\")\n\tprivate Boolean showLogin;\n\n\t@Schema(title = \"登录日志模式\", example = \"all\")\n\tprivate LoginLogModeEnum loginLogMode;\n\n\t@Schema(title = \"默认文件点击习惯\", example = \"click\")\n\tprivate FileClickModeEnum fileClickMode;\n\n\t@Schema(title = \"移动端默认文件点击习惯\", example = \"click\")\n\tprivate FileClickModeEnum mobileFileClickMode;\n\n\t@Schema(title = \"最大同时上传文件数\", example = \"5\")\n\tprivate Integer maxFileUploads;\n\n\t@Schema(title = \"onlyOffice 在线预览地址\", example = \"http://office.zfile.vip\")\n\tprivate String onlyOfficeUrl;\n\n\t@Schema(title = \"kkFileView 在线预览地址\", example = \"http://kkfile.zfile.vip\")\n\tprivate String kkFileViewUrl;\n\n\t@Schema(title = \"kkFileView 预览方式\", example = \"iframe/newTab\")\n\tprivate String kkFileViewOpenMode;\n\n\t@Schema(title = \"默认最大显示文件数\", example = \"1000\")\n\tprivate Integer maxShowSize;\n\n\t@Schema(title = \"每次加载更多文件数\", example = \"50\")\n\tprivate Integer loadMoreSize;\n\n\t@Schema(title = \"默认排序字段\", example = \"name\")\n\tprivate String defaultSortField;\n\n\t@Schema(title = \"默认排序方向\", example = \"asc\")\n\tprivate String defaultSortOrder;\n\n\t@Schema(title = \"站点 Home 名称\", example = \"xxx 的小站\")\n\tprivate String siteHomeName;\n\n\t@Schema(title = \"站点 Home Logo\", example = \"true\")\n\tprivate String siteHomeLogo;\n\n\t@Schema(title = \"站点 Logo 点击后链接\", example = \"https://www.zfile.vip\")\n\tprivate String siteHomeLogoLink;\n\n\t@Schema(title = \"站点 Logo 链接打开方式\", example = \"_blank\")\n\tprivate String siteHomeLogoTargetMode;\n\n\t@Schema(title = \"短链过期时间设置\", example = \"[{value: 1, unit: \\\"day\\\"}, {value: 1, unit: \\\"week\\\"}, {value: 1, unit: \\\"month\\\"}, {value: 1, unit: \\\"year\\\"}]\")\n\tprivate List<LinkExpireDTO> linkExpireTimes;\n\n\t@Schema(title = \"是否默认记住密码\", example = \"true\")\n\tprivate Boolean defaultSavePwd;\n\n\t@Schema(title = \"普通下载是否启用确认弹窗\", example = \"true\")\n\tprivate Boolean enableNormalDownloadConfirm;\n\n\t@Schema(title = \"打包下载是否启用确认弹窗\", example = \"true\")\n\tprivate Boolean enablePackageDownloadConfirm;\n\n\t@Schema(title = \"批量下载是否启用确认弹窗\", example = \"true\")\n\tprivate Boolean enableBatchDownloadConfirm;\n\n\t/**\n\t * 废弃的字段，不再使用悬浮菜单\n\t */\n\t@Deprecated\n\t@Schema(title = \"是否启用 hover 菜单\", example = \"true\")\n\tprivate Boolean enableHoverMenu;\n\n\t@Schema(title = \"是否是游客\", example = \"true\")\n\tprivate boolean guest;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/config/service/SystemConfigService.java",
    "content": "package im.zhaojun.zfile.module.config.service;\n\nimport cn.hutool.core.convert.Convert;\nimport cn.hutool.core.convert.ConvertException;\nimport cn.hutool.core.net.url.UrlBuilder;\nimport cn.hutool.core.util.EnumUtil;\nimport cn.hutool.core.util.HexUtil;\nimport cn.hutool.core.util.ObjUtil;\nimport cn.hutool.crypto.SecureUtil;\nimport cn.hutool.crypto.symmetric.SymmetricAlgorithm;\nimport com.alibaba.fastjson2.JSONArray;\nimport com.alibaba.fastjson2.JSONObject;\nimport im.zhaojun.zfile.core.util.*;\nimport im.zhaojun.zfile.module.config.annotation.JSONStringParse;\nimport im.zhaojun.zfile.module.config.constant.SystemConfigConstant;\nimport im.zhaojun.zfile.module.config.event.SystemConfigModifyHandlerChain;\nimport im.zhaojun.zfile.module.config.mapper.SystemConfigMapper;\nimport im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;\nimport im.zhaojun.zfile.module.config.model.entity.SystemConfig;\nimport im.zhaojun.zfile.module.user.model.enums.LoginVerifyModeEnum;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.aop.framework.AopContext;\nimport org.springframework.cache.Cache;\nimport org.springframework.cache.CacheManager;\nimport org.springframework.cache.annotation.CacheConfig;\nimport org.springframework.cache.annotation.CacheEvict;\nimport org.springframework.cache.annotation.Cacheable;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.lang.reflect.Field;\nimport java.util.*;\n\nimport static im.zhaojun.zfile.module.config.service.SystemConfigService.CACHE_NAME;\n\n\n/**\n * 系统设置 Service\n *\n * @author zhaojun\n */\n@Slf4j\n@Service\n@CacheConfig(cacheNames = CACHE_NAME)\npublic class SystemConfigService {\n\n    public static final String CACHE_NAME = \"systemConfig\";\n\n    private static final String SERIAL_VERSION_UID_FIELD_NAME = \"serialVersionUID\";\n\n    @Resource\n    private SystemConfigMapper systemConfigMapper;\n\n    @Resource\n    private CacheManager cacheManager;\n\n    @Resource\n    private SystemConfigModifyHandlerChain systemConfigModifyHandlerChain;\n\n    private final Class<SystemConfigDTO> systemConfigClazz = SystemConfigDTO.class;\n\n    public static final List<String> ignoreFieldList = Arrays.asList(\"domain\");\n\n    /**\n     * 获取系统设置, 如果缓存中有, 则去缓存取, 没有则查询数据库并写入到缓存中.\n     *\n     * @return  系统设置\n     */\n    @Cacheable(key = \"'dto'\")\n    public SystemConfigDTO getSystemConfig() {\n        SystemConfigDTO systemConfigDTO = new SystemConfigDTO();\n        List<SystemConfig> systemConfigList = systemConfigMapper.findAll();\n\n        for (SystemConfig systemConfig : systemConfigList) {\n            String key = systemConfig.getName();\n            if (ignoreFieldList.contains(key)) {\n                if (log.isTraceEnabled()) {\n                    log.trace(\"从数据库加载字段填充到 DTO 时，忽略字段: {}\", key);\n                }\n                continue;\n            }\n            try {\n                Field field = systemConfigClazz.getDeclaredField(key);\n                field.setAccessible(true);\n                String strVal = systemConfig.getValue();\n                Class<?> fieldType = field.getType();\n\n                Object convertVal;\n                if (EnumUtil.isEnum(fieldType)) {\n                    convertVal = EnumConvertUtils.convertStrToEnum(fieldType, strVal);\n                } else if (field.isAnnotationPresent(JSONStringParse.class)) {\n                    // 如果类是 Collection 类型, 则需要将 JSON 字符串转换为 List\n                    if (Collection.class.isAssignableFrom(fieldType)) {\n                        Class<?> genericType = ClassUtils.getGenericType(field);\n                        convertVal = JSONArray.parseArray(strVal, genericType);\n                    } else {\n                        // 否则转换为普通对象\n                        convertVal = JSONObject.parseObject(strVal, fieldType);\n                    }\n                } else {\n                    convertVal = Convert.convert(fieldType, strVal);\n                }\n                field.set(systemConfigDTO, convertVal);\n            } catch (NoSuchFieldException | IllegalAccessException | ConvertException e) {\n                log.error(\"通过反射, 将字段 {} 注入 SystemConfigDTO 时出现异常:\", key, e);\n            }\n        }\n\n        return systemConfigDTO;\n    }\n\n\n    /**\n     * 更新系统设置, 并清空缓存中的内容.\n     *\n     * @param   systemConfigDTO\n     *          系统设置 dto\n     */\n    @Transactional(rollbackFor = Exception.class)\n    @CacheEvict(allEntries = true)\n    public synchronized void updateSystemConfig(SystemConfigDTO systemConfigDTO) {\n        // 获取更新前的值\n        List<SystemConfig> systemConfigListInDb = systemConfigMapper.findAll();\n        Map<String, SystemConfig> systemConfigMapInDb = CollectionUtils.toMap(systemConfigListInDb, null, SystemConfig::getName);\n\n        // 存储更新后的值\n        List<SystemConfig> updateSystemConfigList = new ArrayList<>();\n\n        Field[] fields = systemConfigClazz.getDeclaredFields();\n        for (Field field : fields) {\n            // 获取数据库中的值对象\n            String key = field.getName();\n            if (SERIAL_VERSION_UID_FIELD_NAME.equals(key)) {\n                continue;\n            }\n            SystemConfig systemConfig = systemConfigMapInDb.get(key);\n            if (systemConfig != null) {\n                field.setAccessible(true);\n                Object val = null;\n\n                try {\n                    val = field.get(systemConfigDTO);\n                } catch (IllegalAccessException e) {\n                    log.error(\"通过反射, 从 SystemConfigDTO 获取字段 {}  时出现异常:\", key, e);\n                }\n\n                if (val != null) {\n                    // 如果是枚举类型, 则取 value 值.\n                    if (EnumUtil.isEnum(val)) {\n                        val = EnumConvertUtils.convertEnumToStr(val);\n                    } else if (field.isAnnotationPresent(JSONStringParse.class)) {\n                        val = JSONObject.toJSONString(val);\n                    }\n                    // 如果和原来的值一样, 则跳过\n                    String originVal = systemConfig.getValue();\n                    if (ObjUtil.equals(originVal, val)) {\n                        continue;\n                    }\n                    // 将更新后的值存到更新列表中\n                    SystemConfig updateSystemConfig = new SystemConfig();\n                    updateSystemConfig.setId(systemConfig.getId());\n                    updateSystemConfig.setName(systemConfig.getName());\n                    updateSystemConfig.setValue(Convert.toStr(val));\n                    updateSystemConfig.setTitle(systemConfig.getTitle());\n                    updateSystemConfigList.add(updateSystemConfig);\n                }\n            } else {\n                log.warn(\"尝试保存系统配置表中不存在字段: {}\", key);\n            }\n        }\n\n        updateSystemConfigList.forEach(systemConfigInForm -> {\n            SystemConfig systemConfigInDb = systemConfigMapInDb.get(systemConfigInForm.getName());\n            systemConfigModifyHandlerChain.execute(systemConfigInDb, systemConfigInForm);\n            systemConfigMapper.updateById(systemConfigInForm);\n        });\n    }\n\n\n    /**\n     * 获取 AES Hex 格式密钥\n     *\n     * @return  AES Hex 格式密钥\n     */\n    public synchronized String getAesHexKeyOrGenerate() {\n        SystemConfigDTO systemConfigDTO = ((SystemConfigService)AopContext.currentProxy()).getSystemConfig();\n        String aesHexKey = systemConfigDTO.getRsaHexKey();\n        if (StringUtils.isEmpty(aesHexKey)) {\n            byte[] key = SecureUtil.generateKey(SymmetricAlgorithm.AES.getValue()).getEncoded();\n            aesHexKey = HexUtil.encodeHexStr(key);\n\n            SystemConfig loginVerifyModeConfig = systemConfigMapper.findByName(SystemConfigConstant.AES_HEX_KEY);\n            loginVerifyModeConfig.setValue(aesHexKey);\n            systemConfigMapper.updateById(loginVerifyModeConfig);\n            systemConfigDTO.setRsaHexKey(aesHexKey);\n\n            Cache cache = cacheManager.getCache(CACHE_NAME);\n            Optional.ofNullable(cache).ifPresent(cache1 -> cache1.put(\"dto\", systemConfigDTO));\n        }\n        return aesHexKey;\n    }\n\n\n    /**\n     * 获取前端站点域名\n     *\n     * @return  前端站点域名\n     */\n    public String getFrontDomain() {\n        SystemConfigDTO systemConfigDTO = ((SystemConfigService)AopContext.currentProxy()).getSystemConfig();\n        return systemConfigDTO.getFrontDomain();\n    }\n\n\n    /**\n     * 获取实际的前端站点域名\n     *\n     * @return  实际的前端站点域名\n     */\n    public String getRealFrontDomain() {\n        SystemConfigDTO systemConfigDTO = ((SystemConfigService)AopContext.currentProxy()).getSystemConfig();\n        return StringUtils.firstNonNull(systemConfigDTO.getFrontDomain(), getAxiosFromDomainOrSetting(), RequestHolder.getOriginAddress());\n    }\n\n\n    /**\n     * 优先级：\n     * 1. 如果设置了强制后端地址，则使用强制后端地址。\n     * 2. 如果请求中有 axios-from 参数，则使用该参数。\n     * 3. 如果没有强制后端地址和 axios-from 参数，则使用请求的服务器地址（如果经过多个代理，可能不是实际的后端地址）。\n     *\n     * @return  后端站点地址\n     */\n    public String getAxiosFromDomainOrSetting() {\n        SystemConfigDTO systemConfigDTO = ((SystemConfigService)AopContext.currentProxy()).getSystemConfig();\n        if (StringUtils.isNotBlank(systemConfigDTO.getForceBackendAddress())) {\n            return systemConfigDTO.getForceBackendAddress();\n        } else if (StringUtils.isNotEmpty(RequestHolder.getAxiosFrom())) {\n            return RequestHolder.getAxiosFrom();\n        } else {\n            return RequestHolder.getRequestServerAddress();\n        }\n    }\n\n    /**\n     * 获取前端地址下的 401 页面地址.\n     *\n     * @return 前端地址下的 401 页面地址.\n     *\n     */\n    public String getUnauthorizedUrl() {\n        return getUnauthorizedUrl(null, null);\n    }\n\n    /**\n     * 获取前端地址下的 401 页面地址. 可以指定 code 和 message.\n     *\n     * @param   code\n     *          指定错误码\n     *\n     * @param   message\n     *          指定错误信息\n     *\n     * @return 前端地址下的 401 页面地址.\n     */\n    public String getUnauthorizedUrl(String code, String message) {\n        String url = StringUtils.concat(getRealFrontDomain(), \"/401\");\n        UrlBuilder urlBuilder = UrlBuilder.of(url);\n        if (StringUtils.isNotBlank(code)) {\n            urlBuilder.addQuery(\"code\", code);\n        }\n        if (StringUtils.isNotBlank(message)) {\n            urlBuilder.addQuery(\"message\", message);\n        }\n        return urlBuilder.build();\n    }\n\n    /**\n     * 获取前端地址下的 403 页面地址.\n     *\n     * @return 前端地址下的 403 页面地址.\n     *\n     */\n    public String getForbiddenUrl() {\n        return getForbiddenUrl(null, null);\n    }\n\n    /**\n     * 获取前端地址下的 403 页面地址. 可以指定 code 和 message.\n     *\n     * @param   code\n     *          指定错误码\n     *\n     * @param   message\n     *          指定错误信息\n     *\n     * @return 前端地址下的 403 页面地址.\n     */\n    public String getForbiddenUrl(String code, String message) {\n        String url = StringUtils.concat(getRealFrontDomain(), \"/403\");\n        UrlBuilder urlBuilder = UrlBuilder.of(url);\n        if (StringUtils.isNotBlank(code)) {\n            urlBuilder.addQuery(\"code\", code);\n        }\n        if (StringUtils.isNotBlank(message)) {\n            urlBuilder.addQuery(\"message\", message);\n        }\n        return urlBuilder.build();\n    }\n\n    /**\n     * 获取前端地址下的 404 页面地址.\n     *\n     * @return 前端地址下的 404 页面地址.\n     *\n     */\n    public String getNotFoundUrl() {\n        return getNotFoundUrl(null, null);\n    }\n\n    /**\n     * 获取前端地址下的 404 页面地址. 可以指定 code 和 message.\n     *\n     * @param   code\n     *          指定错误码\n     *\n     * @param   message\n     *          指定错误信息\n     *\n     * @return 前端地址下的 404 页面地址.\n     */\n    public String getNotFoundUrl(String code, String message) {\n        String url = StringUtils.concat(getRealFrontDomain(), \"/404\");\n        UrlBuilder urlBuilder = UrlBuilder.of(url);\n        if (StringUtils.isNotBlank(code)) {\n            urlBuilder.addQuery(\"code\", code);\n        }\n        if (StringUtils.isNotBlank(message)) {\n            urlBuilder.addQuery(\"message\", message);\n        }\n        return urlBuilder.build();\n    }\n\n    /**\n     * 获取前端地址下的 500 页面地址. 可以指定 code 和 message.\n     *\n     * @param   code\n     *          指定错误码\n     *\n     * @param   message\n     *          指定错误信息\n     *\n     * @return 前端地址下的 500 页面地址.\n     */\n    public String getErrorPageUrl(String code, String message) {\n        String url = StringUtils.concat(getRealFrontDomain(), \"/500\");\n        UrlBuilder urlBuilder = UrlBuilder.of(url);\n        if (StringUtils.isNotBlank(code)) {\n            urlBuilder.addQuery(\"code\", code);\n        }\n        if (StringUtils.isNotBlank(message)) {\n            urlBuilder.addQuery(\"message\", message);\n        }\n        return urlBuilder.build();\n    }\n\n\n    /**\n     * 重置登录验证模式，去除所有登录额外验证方式.\n     */\n    public void resetLoginVerifyMode() {\n        SystemConfigDTO systemConfigDTO = ((SystemConfigService)AopContext.currentProxy()).getSystemConfig();\n        systemConfigDTO.setLoginImgVerify(false);\n        systemConfigDTO.setAdminTwoFactorVerify(false);\n        systemConfigDTO.setLoginVerifySecret(\"\");\n        systemConfigDTO.setLoginVerifyMode(LoginVerifyModeEnum.OFF_MODE);\n        ((SystemConfigService)AopContext.currentProxy()).updateSystemConfig(systemConfigDTO);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/filter/controller/StorageSourceFilterController.java",
    "content": "package im.zhaojun.zfile.module.filter.controller;\n\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport com.github.xiaoymin.knife4j.annotations.ApiSort;\nimport im.zhaojun.zfile.core.annotation.DemoDisable;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.module.filter.model.entity.FilterConfig;\nimport im.zhaojun.zfile.module.filter.service.FilterConfigService;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.Parameter;\nimport io.swagger.v3.oas.annotations.enums.ParameterIn;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.annotation.Resource;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.List;\n\n/**\n * 存储源过滤器维护接口\n *\n * @author zhaojun\n */\n@Tag(name = \"存储源模块-过滤文件\")\n@ApiSort(6)\n@RestController\n@RequestMapping(\"/admin\")\npublic class StorageSourceFilterController {\n\n    @Resource\n    private FilterConfigService filterConfigService;\n\n    @ApiOperationSupport(order = 1)\n    @Operation(summary = \"获取存储源过滤文件列表\", description =\"根据存储源 ID 获取存储源设置的过滤文件列表\")\n    @Parameter(in = ParameterIn.PATH, name = \"storageId\", description = \"存储源 id\", required = true, schema = @Schema(type = \"integer\"))\n    @GetMapping(\"/storage/{storageId}/filters\")\n    public AjaxJson<List<FilterConfig>> getFilters(@PathVariable Integer storageId) {\n        return AjaxJson.getSuccessData(filterConfigService.findByStorageId(storageId));\n    }\n\n\n    @ApiOperationSupport(order = 2)\n    @Operation(summary = \"保存存储源过滤文件列表\", description =\"保存指定存储源 ID 设置的过滤文件列表\")\n    @Parameter(in = ParameterIn.PATH, name = \"storageId\", description = \"存储源 id\", required = true, schema = @Schema(type = \"integer\"))\n    @PostMapping(\"/storage/{storageId}/filters\")\n    @DemoDisable\n    public AjaxJson<Void> saveFilters(@PathVariable Integer storageId, @RequestBody List<FilterConfig> filter) {\n        filterConfigService.batchSave(storageId, filter);\n        return AjaxJson.getSuccess();\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/filter/mapper/FilterConfigMapper.java",
    "content": "package im.zhaojun.zfile.module.filter.mapper;\n\nimport com.baomidou.mybatisplus.core.mapper.BaseMapper;\nimport im.zhaojun.zfile.module.filter.model.entity.FilterConfig;\nimport org.apache.ibatis.annotations.Mapper;\nimport org.apache.ibatis.annotations.Param;\n\nimport java.util.List;\n\n/**\n * 过滤器配置表 Mapper 接口\n *\n * @author zhaojun\n */\n@Mapper\npublic interface FilterConfigMapper extends BaseMapper<FilterConfig> {\n\n    /**\n     * 根据存储源 ID 获取存储源配置列表\n     *\n     * @param   storageId\n     *          存储源 ID\n     *\n     * @return  存储源过滤器配置列表\n     */\n    List<FilterConfig> findByStorageId(@Param(\"storageId\") Integer storageId);\n\n\n    /**\n     * 根据存储源 ID 删除过滤器配置\n     *\n     * @param   storageId\n     *          存储源 ID\n     *\n     * @return  删除条数\n     */\n    int deleteByStorageId(@Param(\"storageId\") Integer storageId);\n\n\n    /**\n     * 获取所有类型为禁止访问的过滤规则\n     *\n     * @param   storageId\n     *          存储 ID\n     *\n     * @return  禁止访问的过滤规则列表\n     */\n    List<FilterConfig> findByStorageIdAndInaccessible(@Param(\"storageId\")Integer storageId);\n\n\n    /**\n     * 获取所有类型为禁止下载的过滤规则\n     *\n     * @param   storageId\n     *          存储 ID\n     *\n     * @return  禁止下载的过滤规则列表\n     */\n    List<FilterConfig> findByStorageIdAndDisableDownload(@Param(\"storageId\")Integer storageId);\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/filter/model/entity/FilterConfig.java",
    "content": "package im.zhaojun.zfile.module.filter.model.entity;\n\nimport com.baomidou.mybatisplus.annotation.IdType;\nimport com.baomidou.mybatisplus.annotation.TableField;\nimport com.baomidou.mybatisplus.annotation.TableId;\nimport com.baomidou.mybatisplus.annotation.TableName;\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport im.zhaojun.zfile.module.filter.model.enums.FilterConfigHiddenModeEnum;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport java.io.Serializable;\n\n/**\n * 存储源过滤配置 entity\n *\n * @author zhaojun\n */\n@Data\n@Schema(title=\"存储源过滤配置\")\n@TableName(value = \"filter_config\")\npublic class FilterConfig implements Serializable {\n\n    private static final long serialVersionUID = 1L;\n\n    @TableId(value = \"id\", type = IdType.AUTO)\n    @Schema(title = \"ID, 新增无需填写\", example = \"1\")\n    private Integer id;\n\n\n    @TableField(value = \"storage_id\")\n    @Schema(title = \"存储源 ID\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"1\")\n    private Integer storageId;\n\n\n    @TableField(value = \"expression\")\n    @Schema(title = \"过滤表达式\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"/*.png\")\n    private String expression;\n\n\n    @TableField(value = \"description\")\n    @Schema(title = \"表达式描述\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"用来辅助记忆表达式\")\n    private String description;\n\n\n    @TableField(value = \"mode\")\n    @Schema(title = \"模式\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"隐藏模式，仅隐藏: hidden, 隐藏后不可访问: inaccessible, 隐藏后不可下载: disable_download\")\n    private FilterConfigHiddenModeEnum mode;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/filter/model/enums/FilterConfigHiddenModeEnum.java",
    "content": "package im.zhaojun.zfile.module.filter.model.enums;\n\nimport com.baomidou.mybatisplus.annotation.EnumValue;\nimport com.fasterxml.jackson.annotation.JsonValue;\nimport lombok.AllArgsConstructor;\nimport lombok.Getter;\n\n/**\n * 文件夹隐藏模式枚举\n *\n * @author zhaojun\n */\n@Getter\n@AllArgsConstructor\npublic enum FilterConfigHiddenModeEnum {\n\n\t/**\n\t * 仅隐藏\n\t */\n\tHIDDEN(\"hidden\"),\n\n\t/**\n\t * 隐藏并不可访问 (针对目录)\n\t */\n\tINACCESSIBLE(\"inaccessible\"),\n\n\t/**\n\t * 隐藏并不可访问 (针对文件)\n\t */\n\tDISABLE_DOWNLOAD(\"disable_download\");\n\n\t@EnumValue\n\t@JsonValue\n\tprivate final String value;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/filter/service/FilterConfigService.java",
    "content": "package im.zhaojun.zfile.module.filter.service;\n\nimport im.zhaojun.zfile.core.util.CollectionUtils;\nimport im.zhaojun.zfile.core.util.FileUtils;\nimport im.zhaojun.zfile.core.util.PatternMatcherUtils;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.filter.mapper.FilterConfigMapper;\nimport im.zhaojun.zfile.module.filter.model.entity.FilterConfig;\nimport im.zhaojun.zfile.module.storage.event.StorageSourceCopyEvent;\nimport im.zhaojun.zfile.module.storage.event.StorageSourceDeleteEvent;\nimport im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;\nimport im.zhaojun.zfile.module.user.service.UserStorageSourceService;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.aop.framework.AopContext;\nimport org.springframework.beans.BeanUtils;\nimport org.springframework.cache.annotation.CacheConfig;\nimport org.springframework.cache.annotation.CacheEvict;\nimport org.springframework.cache.annotation.Cacheable;\nimport org.springframework.cache.annotation.Caching;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.List;\n\n/**\n * 存储源过滤规则 Service\n *\n * @author zhaojun\n */\n@Slf4j\n@Service\n@CacheConfig(cacheNames = \"filterConfig\")\npublic class FilterConfigService {\n\n    @Resource\n    private FilterConfigMapper filterConfigMapper;\n\n    @Resource\n    private UserStorageSourceService userStorageSourceService;\n\n    /**\n     * 根据存储源 ID 获取存储源配置列表\n     *\n     * @param   storageId\n     *          存储源 ID\n     *\n     * @return  存储源过滤规则配置列表\n     */\n    @Cacheable(key = \"'filter-base-' + #storageId\",\n            condition = \"#storageId != null\")\n    public List<FilterConfig> findByStorageId(Integer storageId) {\n        return filterConfigMapper.findByStorageId(storageId);\n    }\n\n\n    /**\n     * 获取所有类型为禁止访问的过滤规则\n     *\n     * @param   storageId\n     *          存储 ID\n     *\n     * @return  禁止访问的过滤规则列表\n     */\n    @Cacheable(key = \"'filter-inaccessible-' + #storageId\",\n            condition = \"#storageId != null\")\n    public List<FilterConfig> findByStorageIdAndInaccessible(Integer storageId) {\n        return filterConfigMapper.findByStorageIdAndInaccessible(storageId);\n    }\n\n\n    /**\n     * 获取所有类型为禁止下载的过滤规则\n     *\n     * @param   storageId\n     *          存储 ID\n     *\n     * @return  禁止下载的过滤规则列表\n     */\n    @Cacheable(key = \"'filter-disable-download-' + #storageId\",\n            condition = \"#storageId != null\")\n    public List<FilterConfig> findByStorageIdAndDisableDownload(Integer storageId) {\n        return filterConfigMapper.findByStorageIdAndDisableDownload(storageId);\n    }\n\n\n    /**\n     * 批量保存存储源过滤规则配置, 会先删除之前的所有配置(在事务中运行)\n     *\n     * @param   storageId\n     *          存储源 ID\n     *\n     * @param   filterConfigList\n     *          存储源过滤规则配置列表\n     */\n    @Transactional(rollbackFor = Exception.class)\n    public void batchSave(Integer storageId, List<FilterConfig> filterConfigList) {\n        ((FilterConfigService) AopContext.currentProxy()).deleteByStorageId(storageId);\n        log.info(\"更新存储源 ID 为 {} 的过滤规则 {} 条\", storageId, filterConfigList.size());\n\n        for (FilterConfig filterConfig : filterConfigList) {\n            filterConfig.setId(null);\n            filterConfig.setStorageId(storageId);\n            filterConfigMapper.insert(filterConfig);\n\n            if (log.isDebugEnabled()) {\n                log.debug(\"新增过滤规则, 存储源 ID: {}, 表达式: {}, 描述: {}, 隐藏模式: {}\",\n                        filterConfig.getStorageId(), filterConfig.getExpression(),\n                        filterConfig.getDescription(), filterConfig.getMode().getValue());\n            }\n        }\n    }\n\n\n    /**\n     * 根据存储源 ID 删除所有过滤规则配置\n     *\n     * @param   storageId\n     *          存储源 ID\n     */\n    @Caching(evict = {\n            @CacheEvict(key = \"'filter-base-' + #storageId\"),\n            @CacheEvict(key = \"'filter-inaccessible-' + #storageId\"),\n            @CacheEvict(key = \"'filter-disable-download-' + #storageId\")\n    })\n    public int deleteByStorageId(Integer storageId) {\n        int deleteSize = filterConfigMapper.deleteByStorageId(storageId);\n        log.info(\"删除存储源 ID 为 {} 的过滤规则 {} 条\", storageId, deleteSize);\n        return deleteSize;\n    }\n\n    /**\n     * 监听存储源删除事件，根据存储源 id 删除相关的过滤条件设置\n     *\n     * @param   storageSourceDeleteEvent\n     *          存储源删除事件\n     */\n    @EventListener\n    public void onStorageSourceDelete(StorageSourceDeleteEvent storageSourceDeleteEvent) {\n        Integer storageId = storageSourceDeleteEvent.getId();\n        int updateRows = ((FilterConfigService) AopContext.currentProxy()).deleteByStorageId(storageId);\n        if (log.isDebugEnabled()) {\n            log.debug(\"删除存储源 [id {}, name: {}, type: {}] 时，关联删除存储源过滤条件设置 {} 条\",\n                    storageId,\n                    storageSourceDeleteEvent.getName(),\n                    storageSourceDeleteEvent.getType().getDescription(),\n                    updateRows);\n        }\n\n    }\n\n    /**\n     * 判断访问的路径是否是不允许访问的\n     *\n     * @param   storageId\n     *          存储源 ID\n     *\n     * @param   path\n     *          请求路径\n     *\n     */\n    public boolean checkFileIsInaccessible(Integer storageId, String path) {\n        List<FilterConfig> filterConfigList = ((FilterConfigService) AopContext.currentProxy()).findByStorageIdAndInaccessible(storageId);\n        return testPattern(storageId, filterConfigList, path);\n    }\n\n\n    /**\n     * 指定存储源下的文件名称, 根据过滤表达式判断是否会显示, 如果符合任意一条表达式, 表示隐藏则返回 true, 反之表示不隐藏则返回 false.\n     *\n     * @param   storageId\n     *          存储源 ID\n     *\n     * @param   fileName\n     *          文件名\n     *\n     * @return  是否是隐藏文件夹\n     */\n    public boolean checkFileIsHidden(Integer storageId, String fileName) {\n        List<FilterConfig> filterConfigList = ((FilterConfigService) AopContext.currentProxy()).findByStorageId(storageId);\n        return testPattern(storageId, filterConfigList, fileName);\n    }\n\n\n    /**\n     * 指定存储源下的文件名称, 根据过滤表达式判断文件名和所在路径是否禁止下载, 如果符合任意一条表达式, 则返回 true, 反之则返回 false.\n     *\n     * @param   storageId\n     *          存储源 ID\n     *\n     * @param   fileName\n     *          文件名\n     *\n     * @return  是否显示\n     */\n    public boolean checkFileIsDisableDownload(Integer storageId, String fileName) {\n        List<FilterConfig> filterConfigList = ((FilterConfigService) AopContext.currentProxy()).findByStorageIdAndDisableDownload(storageId);\n        String filePath = FileUtils.getParentPath(fileName);\n        if (StringUtils.isEmpty(filePath)) {\n            return testPattern(storageId, filterConfigList, fileName);\n        } else {\n            return testPattern(storageId, filterConfigList, fileName) || testPattern(storageId, filterConfigList, filePath);\n        }\n    }\n\n\n    /**\n     * 根据规则表达式和测试字符串进行匹配，如测试字符串和其中一个规则匹配上，则返回 true，反之返回 false。\n     *\n     * @param   patternList\n     *          规则列表\n     *\n     * @param   test\n     *\n     *          测试字符串\n     *\n     * @return  是否显示\n     */\n    private boolean testPattern(Integer storageId, List<FilterConfig> patternList, String test) {\n        // 如果规则列表为空, 则表示不需要过滤, 直接返回 false\n        if (CollectionUtils.isEmpty(patternList)) {\n            if (log.isDebugEnabled()) {\n                log.debug(\"过滤规则列表为空, 存储源 ID: {}, 测试字符串: {}\", storageId, test);\n            }\n            return false;\n        }\n\n        // 判断是否需要忽略文件隐藏校验\n        boolean isIgnoreHidden = userStorageSourceService.hasCurrentUserStorageOperatorPermission(storageId, FileOperatorTypeEnum.IGNORE_HIDDEN);\n        if (isIgnoreHidden) {\n            if (log.isDebugEnabled()) {\n                log.debug(\"权限配置忽略过滤规则, 存储源 ID: {}, 测试字符串: {}\", storageId, test);\n            }\n            return false;\n        }\n\n        // 校验表达式\n        for (FilterConfig filterConfig : patternList) {\n            String expression = filterConfig.getExpression();\n\n            if (StringUtils.isEmpty(expression)) {\n                if (log.isDebugEnabled()) {\n                    log.debug(\"存储源 {} 过滤文件测试表达式: {}, 测试字符串: {}, 表达式为空，跳过该规则校验\", storageId, expression, test);\n                }\n                continue;\n            }\n\n            try {\n                boolean match = PatternMatcherUtils.testCompatibilityGlobPattern(expression, test);\n\n                if (log.isDebugEnabled()) {\n                    log.debug(\"存储源 {} 过滤文件测试表达式: {}, 测试字符串: {}, 匹配结果: {}\", storageId, expression, test, match);\n                }\n\n                if (match) {\n                    return true;\n                }\n            } catch (Exception e) {\n                log.error(\"存储源 {} 过滤文件测试表达式: {}, 测试字符串: {}, 匹配异常，跳过该规则.\", storageId, expression, test, e);\n            }\n        }\n\n        return false;\n    }\n\n    /**\n     * 监听存储源复制事件, 复制存储源的过滤条件设置到新的存储源\n     *\n     * @param   storageSourceCopyEvent\n     *          存储源复制事件\n     */\n    @EventListener\n    public void onStorageSourceCopy(StorageSourceCopyEvent storageSourceCopyEvent) {\n        Integer fromId = storageSourceCopyEvent.getFromId();\n        Integer newId = storageSourceCopyEvent.getNewId();\n\n        List<FilterConfig> filterConfigList = ((FilterConfigService) AopContext.currentProxy()).findByStorageId(fromId);\n\n        filterConfigList.forEach(filterConfig -> {\n            FilterConfig newFilterConfig = new FilterConfig();\n            BeanUtils.copyProperties(filterConfig, newFilterConfig);\n            newFilterConfig.setId(null);\n            newFilterConfig.setStorageId(newId);\n            filterConfigMapper.insert(newFilterConfig);\n        });\n\n        log.info(\"复制存储源 ID 为 {} 的存储源过滤条件设置到存储源 ID 为 {} 成功, 共 {} 条\", fromId, newId, filterConfigList.size());\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/install/controller/InstallController.java",
    "content": "package im.zhaojun.zfile.module.install.controller;\n\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport im.zhaojun.zfile.core.annotation.DemoDisable;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.module.install.model.request.InstallSystemRequest;\nimport im.zhaojun.zfile.module.install.service.InstallService;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.annotation.Resource;\nimport org.springframework.web.bind.annotation.*;\n\n/**\n * 系统初始化接口\n *\n * @author zhaojun\n */\n@Tag(name = \"初始化模块\")\n@RestController\n@RequestMapping(\"/api\")\npublic class InstallController {\n\n    @Resource\n    private InstallService installService;\n\n    @GetMapping(\"/install/status\")\n    @ApiOperationSupport(order = 1)\n    @Operation(summary = \"获取系统初始化状态\", description = \"根据管理员用户名是否存在判断系统已初始化, 已初始化返回 true, 未初始化返回 false\")\n    public AjaxJson<Boolean> isInstall() {\n        return AjaxJson.getSuccessData(installService.getSystemIsInstalled());\n    }\n\n    @ApiOperationSupport(order = 2)\n    @Operation(summary = \"初始化系统\", description = \"根据管理员用户名是否存在判断系统已初始化, 已初始化返回 true, 未初始化返回 false\")\n    @PostMapping(\"/install\")\n    @DemoDisable\n    public AjaxJson<Void> install(@RequestBody InstallSystemRequest installSystemRequest) {\n        installService.install(installSystemRequest);\n        return AjaxJson.getSuccess();\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/install/model/request/InstallSystemRequest.java",
    "content": "package im.zhaojun.zfile.module.install.model.request;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\n/**\n * 系统初始化请求参数\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"系统初始化请求类\")\npublic class InstallSystemRequest {\n\n    @Schema(title = \"站点名称\", example = \"ZFile Site Name\")\n    private String siteName;\n\n    @Schema(title = \"用户名\", example = \"admin\")\n    private String username;\n\n    @Schema(title = \"密码\", example = \"123456\")\n    private String password;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/install/service/InstallService.java",
    "content": "package im.zhaojun.zfile.module.install.service;\n\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.exception.core.SystemException;\nimport im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;\nimport im.zhaojun.zfile.module.install.model.request.InstallSystemRequest;\nimport im.zhaojun.zfile.module.config.service.SystemConfigService;\nimport im.zhaojun.zfile.module.user.service.UserService;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\n@Service\n@Slf4j\npublic class InstallService {\n\n    @Resource\n    private SystemConfigService systemConfigService;\n\n    @Resource\n    private UserService userService;\n\n    @Transactional(rollbackFor = Exception.class)\n    public void install(InstallSystemRequest installSystemRequest) {\n        if (getSystemIsInstalled()) {\n            throw new BizException(ErrorCode.BIZ_SYSTEM_ALREADY_INIT);\n        }\n\n        boolean updateFlag = userService.initAdminUser(installSystemRequest.getUsername(),\n                installSystemRequest.getPassword());\n        if (!updateFlag) {\n            throw new SystemException(ErrorCode.BIZ_SYSTEM_INIT_ERROR);\n        }\n\n        SystemConfigDTO systemConfigDTO = new SystemConfigDTO();\n        systemConfigDTO.setSiteName(installSystemRequest.getSiteName());\n        systemConfigDTO.setInstalled(true);\n        systemConfigService.updateSystemConfig(systemConfigDTO);\n    }\n\n    /**\n     * 获取系统是否已初始化\n     *\n     * @return  管理员名称\n     */\n    public Boolean getSystemIsInstalled() {\n        return systemConfigService.getSystemConfig().getInstalled();\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/link/aspect/LinkRateLimiterAspect.java",
    "content": "package im.zhaojun.zfile.module.link.aspect;\n\nimport cn.hutool.extra.servlet.JakartaServletUtil;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;\nimport im.zhaojun.zfile.module.config.service.SystemConfigService;\nimport im.zhaojun.zfile.module.link.cache.LinkRateLimiterCache;\nimport im.zhaojun.zfile.module.storage.annotation.LinkRateLimiter;\nimport jakarta.annotation.Resource;\nimport jakarta.servlet.http.HttpServletRequest;\nimport lombok.extern.slf4j.Slf4j;\nimport org.aspectj.lang.ProceedingJoinPoint;\nimport org.aspectj.lang.annotation.Around;\nimport org.aspectj.lang.annotation.Aspect;\nimport org.springframework.stereotype.Component;\n\nimport java.util.concurrent.atomic.AtomicInteger;\n\n/**\n * 校验直链访问频率.\n * <p>\n * 校验所有标注了 {@link LinkRateLimiter} 的注解\n *\n * @author zhaojun\n */\n@Aspect\n@Component\n@Slf4j\npublic class LinkRateLimiterAspect {\n\n\t@Resource\n\tprivate HttpServletRequest httpServletRequest;\n\n\t@Resource\n\tprivate SystemConfigService systemConfigService;\n\n\t@Resource\n\tprivate LinkRateLimiterCache linkRateLimiterCache;\n\n\t/**\n\t * 校验直链访问频率.\n\t *\n\t * @param   point\n\t *          连接点\n\t *\n\t * @return  方法运行结果\n\t */\n\t@Around(value = \"@annotation(im.zhaojun.zfile.module.storage.annotation.LinkRateLimiter)\")\n\tpublic Object around(ProceedingJoinPoint point) throws Throwable {\n\t\tSystemConfigDTO systemConfig = systemConfigService.getSystemConfig();\n\t\tInteger linkLimitSecond = systemConfig.getLinkLimitSecond();\n\t\tInteger linkDownloadLimit = systemConfig.getLinkDownloadLimit();\n\t\t// 如果未设置直链限制, 则不进行校验\n\t\tif (linkLimitSecond == null || linkDownloadLimit == null || linkLimitSecond == 0 || linkDownloadLimit == 0) {\n\t\t\treturn point.proceed();\n\t\t}\n\n\t\tString clientIP = JakartaServletUtil.getClientIP(httpServletRequest);\n\t\tif (linkRateLimiterCache.containsKey(clientIP)) {\n\t\t\tAtomicInteger atomicInteger = linkRateLimiterCache.get(clientIP, false);\n\t\t\tif (atomicInteger.incrementAndGet() > linkDownloadLimit) {\n\t\t\t\tthrow new BizException(ErrorCode.BIZ_ACCESS_TOO_FREQUENT);\n\t\t\t}\n\t\t} else {\n\t\t\tlinkRateLimiterCache.put(clientIP, new AtomicInteger(1), linkLimitSecond * 1000);\n\t\t}\n\n\t\treturn point.proceed();\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/link/aspect/RefererCheckAspect.java",
    "content": "package im.zhaojun.zfile.module.link.aspect;\n\nimport im.zhaojun.zfile.core.util.CollectionUtils;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;\nimport im.zhaojun.zfile.module.config.service.SystemConfigService;\nimport im.zhaojun.zfile.module.link.model.enums.RefererTypeEnum;\nimport im.zhaojun.zfile.module.storage.annotation.RefererCheck;\nimport jakarta.annotation.Resource;\nimport jakarta.servlet.http.HttpServletRequest;\nimport jakarta.servlet.http.HttpServletResponse;\nimport lombok.extern.slf4j.Slf4j;\nimport org.aspectj.lang.ProceedingJoinPoint;\nimport org.aspectj.lang.annotation.Around;\nimport org.aspectj.lang.annotation.Aspect;\nimport org.springframework.http.HttpHeaders;\nimport org.springframework.stereotype.Component;\nimport org.springframework.util.AntPathMatcher;\n\nimport java.util.Collection;\nimport java.util.List;\n\n/**\n * 校验 referer 防盗链.\n * <p>\n * 校验所有标注了 {@link RefererCheck} 的注解\n *\n * @author zhaojun\n */\n@Aspect\n@Component\n@Slf4j\npublic class RefererCheckAspect {\n\n\t@Resource\n\tprivate HttpServletRequest httpServletRequest;\n\n\t@Resource\n\tprivate HttpServletResponse httpServletResponse;\n\n\t@Resource\n\tprivate SystemConfigService systemConfigService;\n\n\tprivate final AntPathMatcher pathMatcher = new AntPathMatcher();\n\n\t/**\n\t * 校验 referer 防盗链.\n\t *\n\t * @param   point\n\t *          连接点\n\t *\n\t * @return  方法运行结果\n\t */\n\t@Around(value = \"@annotation(im.zhaojun.zfile.module.storage.annotation.RefererCheck)\")\n\tpublic Object around(ProceedingJoinPoint point) throws Throwable {\n\t\t// 获取配置的 referer 类型\n\t\tSystemConfigDTO systemConfig = systemConfigService.getSystemConfig();\n\t\tRefererTypeEnum refererType = systemConfig.getRefererType();\n\n\t\t// 如果未开启 referer 防盗链则跳过.\n\t\tif (refererType == RefererTypeEnum.OFF) {\n\t\t\treturn point.proceed();\n\t\t}\n\n\t\t// 获取当前请求 referer\n\t\tString referer = httpServletRequest.getHeader(HttpHeaders.REFERER);\n\t\tString requestUrl = httpServletRequest.getRequestURI();\n\n\t\t// 获取 Forbidden 页面地址\n\t\tString forbiddenUrl = systemConfigService.getForbiddenUrl();\n\n\t\t// 如果 referer 不允许为空，且当前 referer 为空，则校验\n\t\tBoolean refererAllowEmpty = systemConfig.getRefererAllowEmpty();\n\t\tif (!refererAllowEmpty && StringUtils.isEmpty(referer)) {\n\t\t\tlog.warn(\"请求路径 {}, referer 不允许为空，当前请求 referer 为空，禁止访问.\", requestUrl);\n\t\t\thttpServletResponse.sendRedirect(forbiddenUrl);\n\t\t\treturn null;\n\t\t} else if (refererAllowEmpty && StringUtils.isEmpty(referer)) { // 如果 referer 允许为空，且当前 referer 为空，则跳过校验\n\t\t\treturn point.proceed();\n\t\t}\n\n\t\t// 获取允许的 referer 地址\n\t\tString refererValue = systemConfig.getRefererValue();\n\t\tList<String> refererValueList = StringUtils.split(refererValue, StringUtils.LF);\n\n\t\t// 如果是白名单模式，则校验当前 referer, 如果未在允许的列表中，则禁止访问.\n\t\tif (refererType == RefererTypeEnum.WHITE_LIST && containsPathMatcher(refererValueList, referer) == null) {\n\t\t\tlog.warn(\"请求路径 {}, referer 为白名单模式，当前请求 referer {} 未在白名单中，禁止访问.\", requestUrl, referer);\n\t\t\thttpServletResponse.sendRedirect(forbiddenUrl);\n\t\t\treturn null;\n\t\t}\n\n\t\t// 如果是黑名单模式，则校验当前 referer 是否在列表中，则禁止访问.\n\t\tif (refererType == RefererTypeEnum.BLACK_LIST && containsPathMatcher(refererValueList, referer) != null) {\n\t\t\tlog.warn(\"请求路径 {}, referer 为黑名单模式，当前请求 referer {} 在黑名单中，禁止访问.\", requestUrl, referer);\n\n\t\t\thttpServletResponse.sendRedirect(forbiddenUrl);\n\t\t\treturn null;\n\t\t}\n\n\t\treturn point.proceed();\n\t}\n\n\t/**\n\t * 校验 value 是否在 Ant 表达式列表中.\n\t *\n\t * @param   patternList\n\t *          Ant 表达式列表\n\t *\n\t * @param   value\n\t *          要校验的值\n\t *\n\t * @return  返回匹配到的规则项，如果没有匹配到，则返回 null.\n\t */\n\tpublic String containsPathMatcher(Collection<String> patternList, String value) {\n\t\tif (CollectionUtils.isEmpty(patternList)) {\n\t\t\treturn null;\n\t\t}\n\n\t\tfor (String pattern : patternList) {\n\t\t\tif (pathMatcher.match(pattern, value)) {\n\t\t\t\treturn pattern;\n\t\t\t}\n\t\t}\n\n\t\treturn null;\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/link/cache/LinkRateLimiterCache.java",
    "content": "package im.zhaojun.zfile.module.link.cache;\n\nimport cn.hutool.cache.impl.CacheObj;\nimport cn.hutool.cache.impl.TimedCache;\nimport im.zhaojun.zfile.module.link.model.dto.CacheInfo;\nimport org.springframework.stereotype.Service;\n\nimport java.util.ArrayList;\nimport java.util.Iterator;\nimport java.util.List;\nimport java.util.concurrent.atomic.AtomicInteger;\n\n/**\n * 直链/短链访问频率限制缓存\n */\n@Service\npublic class LinkRateLimiterCache {\n\n    /**\n     * cache 在 put 时不指定 timeout, 则使用默认的 timeout. (单位: 毫秒)\n     */\n    public static final Integer DEFAULT_TIME_OUT = 60_000;\n\n    private final TimedCache<String, AtomicInteger> timedCache = new TimedCache<>(DEFAULT_TIME_OUT);\n\n    public boolean containsKey(String key) {\n        return timedCache.containsKey(key);\n    }\n\n    public AtomicInteger get(String key, boolean isUpdateLastAccess) {\n        return timedCache.get(key, isUpdateLastAccess);\n    }\n\n    public void put(String key, AtomicInteger object, long timeout) {\n        timedCache.put(key, object, timeout);\n    }\n\n    public List<CacheInfo<String, AtomicInteger>> getCacheInfo() {\n        List<CacheInfo<String, AtomicInteger>> cacheInfoList = new ArrayList<>();\n\n        Iterator<CacheObj<String, AtomicInteger>> cacheObjIterator = timedCache.cacheObjIterator();\n        while (cacheObjIterator.hasNext()) {\n            CacheObj<String, AtomicInteger> next = cacheObjIterator.next();\n            CacheInfo<String, AtomicInteger> cacheInfo = new CacheInfo<>();\n            cacheInfo.setKey(next.getKey());\n            cacheInfo.setValue(next.getValue());\n            cacheInfo.setTtl(next.getTtl());\n            cacheInfo.setExpiredTime(next.getExpiredTime());\n            cacheInfoList.add(cacheInfo);\n        }\n\n        return cacheInfoList;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/link/controller/DirectLinkController.java",
    "content": "package im.zhaojun.zfile.module.link.controller;\n\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport com.github.xiaoymin.knife4j.annotations.ApiSort;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.core.util.SpringMvcUtils;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.config.model.entity.SystemConfig;\nimport im.zhaojun.zfile.module.config.service.SystemConfigService;\nimport im.zhaojun.zfile.module.link.model.request.BatchGenerateLinkRequest;\nimport im.zhaojun.zfile.module.link.model.result.BatchGenerateLinkResponse;\nimport im.zhaojun.zfile.module.link.service.DynamicDirectLinkPrefixService;\nimport im.zhaojun.zfile.module.link.service.LinkDownloadService;\nimport im.zhaojun.zfile.module.storage.annotation.StoragePermissionCheck;\nimport im.zhaojun.zfile.module.storage.context.StorageSourceContext;\nimport im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.annotation.Resource;\nimport jakarta.validation.Valid;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.boot.context.event.ApplicationReadyEvent;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.stereotype.Controller;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.ResponseBody;\nimport org.springframework.web.servlet.mvc.method.RequestMappingInfo;\n\nimport java.io.IOException;\nimport java.lang.reflect.Method;\nimport java.util.ArrayList;\nimport java.util.List;\n\n/**\n * 短链接口\n *\n * @author zhaojun\n */\n@Tag(name = \"短链\")\n@ApiSort(5)\n@Controller\n@Slf4j\npublic class DirectLinkController {\n\n    @Resource\n    private LinkDownloadService linkDownloadService;\n\n    @Resource\n    private SystemConfigService systemConfigService;\n\n    @Resource\n    private DynamicDirectLinkPrefixService dynamicDirectLinkPrefixService;\n\n    public static final String DIRECT_LINK_SUFFIX_PATH = \"/{storageKey}/**\";\n\n    @EventListener(ApplicationReadyEvent.class)\n    public void init() throws NoSuchMethodException {\n        String directLinkPrefix = systemConfigService.getSystemConfig().getDirectLinkPrefix();\n        Method directLinkMethod = DirectLinkController.class.getMethod(\"directLink\", String.class);\n        RequestMappingInfo requestMappingInfo = RequestMappingInfo.paths(directLinkPrefix + DIRECT_LINK_SUFFIX_PATH).build();\n        dynamicDirectLinkPrefixService.registerMappingHandlerMapping(SystemConfig.DIRECT_LINK_PREFIX_NAME, requestMappingInfo, this, directLinkMethod);\n    }\n\n    /**\n     * 路径直链处理方法，会根据 URL 中的存储源 key 和文件路径, 获取到文件，判断文件是否有短链，没有则生成，然后跳转到短链.\n     *\n     * @param   storageKey\n     *          存储源 key\n     */\n    public ResponseEntity<?> directLink(@PathVariable(\"storageKey\") String storageKey) throws IOException {\n        // 获取直链全路径\n        String filePath = SpringMvcUtils.getExtractPathWithinPattern();\n\n        // 如果路径不是以 / 开头, 则补充上\n        if (StringUtils.isNotEmpty(filePath) && filePath.charAt(0) != StringUtils.SLASH_CHAR) {\n            filePath = StringUtils.SLASH + filePath;\n        }\n\n        return linkDownloadService.handlerDirectLink(storageKey, filePath);\n    }\n\n    @PostMapping(\"/api/path-link/batch/generate\")\n    @ResponseBody\n    @ApiOperationSupport(order = 1)\n    @Operation(summary = \"生成路径直链\", description =\"对指定存储源的某文件路径生成路径直链\")\n    @StoragePermissionCheck(action = FileOperatorTypeEnum.LINK)\n    public AjaxJson<List<BatchGenerateLinkResponse>> generatorShortLink(@RequestBody @Valid BatchGenerateLinkRequest batchGenerateLinkRequest) {\n        List<BatchGenerateLinkResponse> result = new ArrayList<>();\n\n        // 获取站点域名\n        String serverAddress = systemConfigService.getAxiosFromDomainOrSetting();\n        String directLinkPrefix = systemConfigService.getSystemConfig().getDirectLinkPrefix();\n\n        String storageKey = batchGenerateLinkRequest.getStorageKey();\n\n        AbstractBaseFileService<?> baseFileService = StorageSourceContext.getByStorageKey(storageKey);\n        if (baseFileService == null) {\n            throw new BizException(ErrorCode.BIZ_STORAGE_NOT_FOUND);\n        }\n        String currentUserBasePath = baseFileService.getCurrentUserBasePath();\n\n        for (String path : batchGenerateLinkRequest.getPaths()) {\n            // 拼接全路径地址.\n            String fullPath = StringUtils.concat(serverAddress, directLinkPrefix, storageKey, currentUserBasePath, path);\n            result.add(new BatchGenerateLinkResponse(fullPath));\n        }\n        return AjaxJson.getSuccessData(result);\n    }\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/link/controller/ShortLinkController.java",
    "content": "package im.zhaojun.zfile.module.link.controller;\n\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport com.github.xiaoymin.knife4j.annotations.ApiSort;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.config.service.SystemConfigService;\nimport im.zhaojun.zfile.module.link.model.entity.ShortLink;\nimport im.zhaojun.zfile.module.link.model.request.BatchGenerateLinkRequest;\nimport im.zhaojun.zfile.module.link.model.result.BatchGenerateLinkResponse;\nimport im.zhaojun.zfile.module.link.service.LinkDownloadService;\nimport im.zhaojun.zfile.module.link.service.ShortLinkService;\nimport im.zhaojun.zfile.module.storage.annotation.StoragePermissionCheck;\nimport im.zhaojun.zfile.module.storage.context.StorageSourceContext;\nimport im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;\nimport im.zhaojun.zfile.module.storage.service.StorageSourceService;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.Parameter;\nimport io.swagger.v3.oas.annotations.enums.ParameterIn;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.annotation.Resource;\nimport jakarta.validation.Valid;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.stereotype.Controller;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.io.IOException;\nimport java.util.ArrayList;\nimport java.util.List;\n\n/**\n * 短链接口\n *\n * @author zhaojun\n */\n@Tag(name = \"直短链模块\")\n@ApiSort(5)\n@Controller\n@Slf4j\npublic class ShortLinkController {\n\n    @Resource\n    private SystemConfigService systemConfigService;\n\n    @Resource\n    private ShortLinkService shortLinkService;\n\n    @Resource\n    private StorageSourceService storageSourceService;\n\n    @Resource\n    private LinkDownloadService linkDownloadService;\n\n    @PostMapping(\"/api/short-link/batch/generate\")\n    @ResponseBody\n    @ApiOperationSupport(order = 1)\n    @Operation(summary = \"生成短链\", description =\"对指定存储源的某文件路径生成短链\")\n    @StoragePermissionCheck(action = FileOperatorTypeEnum.SHORT_LINK)\n    public AjaxJson<List<BatchGenerateLinkResponse>> generatorShortLink(@RequestBody @Valid BatchGenerateLinkRequest batchGenerateLinkRequest) {\n        List<BatchGenerateLinkResponse> result = new ArrayList<>();\n\n        // 获取站点域名\n        String domain = systemConfigService.getAxiosFromDomainOrSetting();\n        Long expireTime = batchGenerateLinkRequest.getExpireTime();\n        String storageKey = batchGenerateLinkRequest.getStorageKey();\n        Integer storageId = storageSourceService.findIdByKey(storageKey);\n\n        for (String path : batchGenerateLinkRequest.getPaths()) {\n            // 拼接全路径地址.\n            String currentUserBasePath = StorageSourceContext.getByStorageId(storageId).getCurrentUserBasePath();\n            String fullPath = StringUtils.concat(currentUserBasePath, path);\n            ShortLink shortLink = shortLinkService.generatorShortLink(storageId, fullPath, expireTime);\n            String shortUrl = StringUtils.removeDuplicateSlashes(domain + \"/s/\" + shortLink.getShortKey());\n            result.add(new BatchGenerateLinkResponse(shortUrl));\n        }\n        return AjaxJson.getSuccessData(result);\n    }\n\n\n    @GetMapping(\"/s/{key}\")\n    @ApiOperationSupport(order = 2)\n    @Operation(summary = \"跳转短链\", description =\"根据短链 key 跳转（302 重定向）到对应的直链.\")\n    @Parameter(in = ParameterIn.PATH, name = \"key\", description = \"短链 key\", required = true, schema = @Schema(type = \"string\"))\n    public ResponseEntity<?> parseShortKey(@PathVariable String key) throws IOException {\n        return linkDownloadService.handlerShortLink(key);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/link/controller/ShortLinkManagerController.java",
    "content": "package im.zhaojun.zfile.module.link.controller;\n\nimport cn.hutool.core.io.IoUtil;\nimport cn.hutool.core.util.ObjUtil;\nimport cn.hutool.poi.excel.ExcelUtil;\nimport cn.hutool.poi.excel.ExcelWriter;\nimport com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;\nimport com.baomidou.mybatisplus.core.metadata.OrderItem;\nimport com.baomidou.mybatisplus.extension.plugins.pagination.Page;\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport com.github.xiaoymin.knife4j.annotations.ApiSort;\nimport im.zhaojun.zfile.core.annotation.DemoDisable;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.config.service.SystemConfigService;\nimport im.zhaojun.zfile.module.link.cache.LinkRateLimiterCache;\nimport im.zhaojun.zfile.module.link.convert.ShortLinkConvert;\nimport im.zhaojun.zfile.module.link.model.dto.CacheInfo;\nimport im.zhaojun.zfile.module.link.model.entity.ShortLink;\nimport im.zhaojun.zfile.module.link.model.request.BatchDeleteRequest;\nimport im.zhaojun.zfile.module.link.model.request.QueryShortLinkLogRequest;\nimport im.zhaojun.zfile.module.link.model.request.ShortLinkResult;\nimport im.zhaojun.zfile.module.link.service.ShortLinkService;\nimport im.zhaojun.zfile.module.storage.model.entity.StorageSource;\nimport im.zhaojun.zfile.module.storage.service.StorageSourceService;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.Parameter;\nimport io.swagger.v3.oas.annotations.enums.ParameterIn;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.annotation.Resource;\nimport jakarta.servlet.ServletOutputStream;\nimport jakarta.servlet.http.HttpServletResponse;\nimport jakarta.validation.constraints.NotNull;\nimport org.springframework.http.MediaType;\nimport org.springframework.stereotype.Controller;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.io.IOException;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Objects;\nimport java.util.concurrent.atomic.AtomicInteger;\nimport java.util.stream.Collectors;\nimport java.util.stream.Stream;\n\n/**\n * 直链管理接口\n *\n * @author zhaojun\n */\n@Tag(name = \"直链管理\")\n@ApiSort(7)\n@Controller\n@RequestMapping(\"/admin\")\npublic class ShortLinkManagerController {\n\n    @Resource\n    private SystemConfigService systemConfigService;\n\n    @Resource\n    private ShortLinkService shortLinkService;\n\n    @Resource\n    private StorageSourceService storageSourceService;\n\n    @Resource\n    private ShortLinkConvert shortLinkConvert;\n\n    @Resource\n    private LinkRateLimiterCache linkRateLimiterCache;\n\n\n    @ApiOperationSupport(order = 1)\n    @GetMapping(\"/link/list\")\n    @Operation(summary = \"搜索短链\")\n    @ResponseBody\n    public AjaxJson<List<ShortLinkResult>> list(QueryShortLinkLogRequest queryObj) {\n        Page<ShortLinkResult> resultPage = getShortLinkResultPage(queryObj);\n\n        String serverAddress = systemConfigService.getAxiosFromDomainOrSetting();\n\n        resultPage.getRecords().forEach(shortLinkResult -> {\n            shortLinkResult.setShortLink(StringUtils.concat(serverAddress, \"s\", shortLinkResult.getShortKey()));\n        });\n\n        return AjaxJson.getPageData(resultPage.getTotal(), resultPage.getRecords());\n    }\n\n    @ApiOperationSupport(order = 2)\n    @DeleteMapping(\"/link/delete/{id}\")\n    @Operation(summary = \"删除短链\")\n    @Parameter(in = ParameterIn.PATH, name = \"id\", description = \"短链 id\", required = true, schema = @Schema(type = \"integer\"))\n    @ResponseBody\n    @DemoDisable\n    public AjaxJson<Void> deleteById(@PathVariable Integer id) {\n        shortLinkService.removeById(id);\n        return AjaxJson.getSuccess();\n    }\n\n\n    @ApiOperationSupport(order = 3)\n    @PostMapping(\"/link/delete/batch\")\n    @ResponseBody\n    @Operation(summary = \"批量删除短链\")\n    @DemoDisable\n    public AjaxJson<Void> batchDelete(@RequestBody BatchDeleteRequest batchDeleteRequest) {\n        shortLinkService.removeBatchByIds(batchDeleteRequest.getIds());\n        return AjaxJson.getSuccess();\n    }\n\n    @ApiOperationSupport(order = 4)\n    @GetMapping(\"/link/export\")\n    @ResponseBody\n    @Operation(summary = \"导出短链\")\n    public void exportExcel(QueryShortLinkLogRequest queryObj, HttpServletResponse response) throws IOException {\n        Page<ShortLinkResult> shortLinkResultPage = getShortLinkResultPage(queryObj);\n\n        ExcelWriter writer = ExcelUtil.getWriter(true);\n        writer.addHeaderAlias(\"id\", \"ID\");\n        writer.addHeaderAlias(\"storageName\", \"存储源名称\");\n        writer.addHeaderAlias(\"storageTypeStr\", \"存储源类型\");\n        writer.addHeaderAlias(\"shortKey\", \"短链 key\");\n        writer.addHeaderAlias(\"url\", \"文件路径\");\n        writer.addHeaderAlias(\"createDate\", \"创建时间\");\n        writer.addHeaderAlias(\"expireDate\", \"过期时间\");\n        writer.setOnlyAlias(true);\n\n        writer.write(shortLinkResultPage.getRecords(), true);\n        writer.setColumnWidth(0, 8);\n        writer.setColumnWidth(1, 30);\n        writer.setColumnWidth(2, 15);\n        writer.setColumnWidth(3, 15);\n        writer.setColumnWidth(4, 50);\n        writer.setColumnWidth(5, 15);\n        writer.setColumnWidth(6, 15);\n\n        response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);\n\n        ServletOutputStream out=response.getOutputStream();\n\n        writer.flush(out, true);\n        writer.close();\n        IoUtil.close(out);\n    }\n\n\n    @ApiOperationSupport(order = 5)\n    @GetMapping(\"/link/limit/info\")\n    @ResponseBody\n    @Operation(summary = \"获取直链访问限制信息\")\n    public AjaxJson<List<CacheInfo<String, AtomicInteger>>> getLinkLimitInfo() {\n        return AjaxJson.getSuccessData(linkRateLimiterCache.getCacheInfo());\n    }\n\n    @NotNull\n    private Page<ShortLinkResult> getShortLinkResultPage(QueryShortLinkLogRequest queryObj) {\n        // 分页和排序\n        boolean asc = Objects.equals(queryObj.getOrderDirection(), \"asc\");\n        OrderItem orderItem = asc ? OrderItem.asc(queryObj.getOrderBy()) : OrderItem.desc(queryObj.getOrderBy());\n        Page<ShortLink> pages = new Page<ShortLink>(queryObj.getPage(), queryObj.getLimit())\n                .addOrder(orderItem);\n\n        // 搜索条件\n        LambdaQueryWrapper<ShortLink> queryWrapper = new LambdaQueryWrapper<ShortLink>()\n                .eq(StringUtils.isNotEmpty(queryObj.getStorageId()), ShortLink::getStorageId, queryObj.getStorageId())\n                .like(StringUtils.isNotEmpty(queryObj.getKey()), ShortLink::getShortKey, queryObj.getKey())\n                .like(StringUtils.isNotEmpty(queryObj.getUrl()), ShortLink::getUrl, queryObj.getUrl())\n                .ge(ObjUtil.isNotEmpty(queryObj.getDateFrom()), ShortLink::getCreateDate, queryObj.getDateFrom())\n                .le(ObjUtil.isNotEmpty(queryObj.getDateTo()), ShortLink::getCreateDate, queryObj.getDateTo());\n\n        // 执行查询\n        Page<ShortLink> selectResult = shortLinkService.selectPage(pages, queryWrapper);\n\n        // 转换为结果集\n        Map<Integer, StorageSource> cache = new HashMap<>();\n        Stream<ShortLinkResult> shortLinkResultList = selectResult.getRecords().stream().map(shortLink -> {\n            Integer shortLinkStorageId = shortLink.getStorageId();\n\n            StorageSource storageSource = cache.getOrDefault(shortLinkStorageId, storageSourceService.findById(shortLinkStorageId));\n            cache.put(shortLinkStorageId, storageSource);\n            return shortLinkConvert.entityToResultList(shortLink, storageSource);\n        });\n\n        Page<ShortLinkResult> resultPage = new Page<>();\n        resultPage.setTotal(selectResult.getTotal());\n        resultPage.setRecords(shortLinkResultList.collect(Collectors.toList()));\n        return resultPage;\n    }\n\n    @ApiOperationSupport(order = 6)\n    @DeleteMapping(\"/link/deleteExpireLink\")\n    @Operation(summary = \"删除过期短链\")\n    @ResponseBody\n    @DemoDisable\n    public AjaxJson<Integer> deleteExpireLink() {\n        int updateRows = shortLinkService.deleteExpireLink();\n        return AjaxJson.getSuccessData(updateRows);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/link/convert/ShortLinkConvert.java",
    "content": "package im.zhaojun.zfile.module.link.convert;\n\nimport im.zhaojun.zfile.module.link.model.entity.ShortLink;\nimport im.zhaojun.zfile.module.storage.model.entity.StorageSource;\nimport im.zhaojun.zfile.module.link.model.request.ShortLinkResult;\nimport org.mapstruct.Mapper;\nimport org.mapstruct.Mapping;\nimport org.mapstruct.ReportingPolicy;\nimport org.springframework.stereotype.Component;\n\n/**\n * 直链实体类器\n *\n * @author zhaojun\n */\n@Component\n@Mapper(componentModel = \"spring\", unmappedTargetPolicy = ReportingPolicy.IGNORE)\npublic interface ShortLinkConvert {\n\n\t@Mapping(source = \"shortLink.id\", target = \"id\")\n\t@Mapping(source = \"storageSource.name\", target = \"storageName\")\n\t@Mapping(source = \"storageSource.type\", target = \"storageType\")\n\tShortLinkResult entityToResultList(ShortLink shortLink, StorageSource storageSource);\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/link/dto/DynamicRegisterMappingHandlerDTO.java",
    "content": "package im.zhaojun.zfile.module.link.dto;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport org.springframework.web.servlet.mvc.method.RequestMappingInfo;\n\nimport java.lang.reflect.Method;\n\n@Data\n@AllArgsConstructor\npublic class DynamicRegisterMappingHandlerDTO {\n\n    private RequestMappingInfo requestMappingInfo;\n\n    private Object object;\n\n    private Method method;\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/link/event/DeleteExpireLinkEvent.java",
    "content": "package im.zhaojun.zfile.module.link.event;\n\nimport lombok.Data;\n\n@Data\npublic class DeleteExpireLinkEvent {\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/link/mapper/ShortLinkMapper.java",
    "content": "package im.zhaojun.zfile.module.link.mapper;\n\nimport com.baomidou.mybatisplus.core.mapper.BaseMapper;\nimport im.zhaojun.zfile.module.link.model.entity.ShortLink;\nimport jakarta.annotation.Nullable;\nimport org.apache.ibatis.annotations.Mapper;\nimport org.apache.ibatis.annotations.Param;\n\nimport java.util.Date;\n\n/**\n * 短链接配置表 Mapper 接口\n *\n * @author zhaojun\n */\n@Mapper\npublic interface ShortLinkMapper extends BaseMapper<ShortLink> {\n\n    /**\n     * 根据短链接 key 查询短链接\n     *\n     * @param   key\n     *          短链接 key\n     *\n     * @return  短链接信息\n     */\n    ShortLink findByKey(@Param(\"key\")String key);\n\n\n\t/**\n\t * 根据存储源 ID 删除所有数据\n\t *\n\t * @param \tstorageId\n\t * \t\t\t存储源 ID\n\t */\n\tint deleteByStorageId(@Param(\"storageId\") Integer storageId);\n\n\t/**\n\t * 根据存储源 ID 和 URL 查询短链接\n\t */\n\tShortLink findByStorageIdAndUrl(@Param(\"storageId\") Integer storageId,\n\t\t\t\t\t\t\t\t\t@Param(\"url\") String url,\n\t\t\t\t\t\t\t\t\t@Nullable @Param(\"expireDate\") Date expireDate);\n\n\t/**\n\t * 删除过期的短链接\n\t *\n\t * @return 删除的行数\n\t */\n\tint deleteExpireLink();\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/link/model/dto/CacheInfo.java",
    "content": "package im.zhaojun.zfile.module.link.model.dto;\n\nimport lombok.Data;\n\nimport java.util.Date;\n\n@Data\npublic class CacheInfo<K, V> {\n\n    private K key;\n\n    private V value;\n\n    private Date expiredTime;\n\n    private Long ttl;\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/link/model/entity/ShortLink.java",
    "content": "package im.zhaojun.zfile.module.link.model.entity;\n\nimport cn.hutool.core.date.DateUtil;\nimport com.baomidou.mybatisplus.annotation.IdType;\nimport com.baomidou.mybatisplus.annotation.TableField;\nimport com.baomidou.mybatisplus.annotation.TableId;\nimport com.baomidou.mybatisplus.annotation.TableName;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport java.io.Serializable;\nimport java.util.Date;\n\n/**\n * 短链信息 entity\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"短链信息\")\n@TableName(value = \"short_link\")\npublic class ShortLink implements Serializable {\n\n    private static final long serialVersionUID = 1L;\n\n    /**\n     * 永久直链失效时间为 -1\n     */\n    public static final Long PERMANENT_EXPIRE_TIME = -1L;\n\n    /**\n     * 永久直链失效日期为 9999-12-31\n     */\n    public static final Date PERMANENT_EXPIRE_DATE = DateUtil.parseDate(\"9999-12-31\");\n\n    @TableId(value = \"id\", type = IdType.AUTO)\n    @Schema(title = \"ID, 新增无需填写\", example = \"1\")\n    private Integer id;\n\n\n    @TableField(value = \"storage_id\")\n    @Schema(title = \"存储源 ID\", example = \"1\")\n    private Integer storageId;\n\n\n    @TableField(value = \"short_key\")\n    @Schema(title = \"短链 key\", example = \"voldd3\")\n    private String shortKey;\n\n\n    @TableField(value = \"url\")\n    @Schema(title = \"短链 url\", example = \"/directlink/1/test02.png\")\n    private String url;\n\n\n    @TableField(value = \"create_date\")\n    @Schema(title = \"创建时间\", example = \"2021-11-22 10:05\")\n    private Date createDate;\n\n\n    @TableField(value = \"expire_date\")\n    @Schema(title = \"过期时间\", example = \"2021-11-23 10:05\")\n    private Date expireDate;\n\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/link/model/enums/RefererTypeEnum.java",
    "content": "package im.zhaojun.zfile.module.link.model.enums;\n\nimport com.baomidou.mybatisplus.annotation.EnumValue;\nimport com.fasterxml.jackson.annotation.JsonValue;\nimport lombok.AllArgsConstructor;\nimport lombok.Getter;\n\n/**\n * Referer 防盗链类型枚举\n *\n * @author zhaojun\n */\n@Getter\n@AllArgsConstructor\npublic enum RefererTypeEnum {\n\n\t/**\n\t * 不启用 Referer 防盗链\n\t */\n\tOFF(\"off\"),\n\n\t/**\n\t * 启用白名单模式\n\t */\n\tWHITE_LIST(\"white_list\"),\n\n\t/**\n\t * 启用黑名单模式\n\t */\n\tBLACK_LIST(\"black_list\");\n\n\t@EnumValue\n\t@JsonValue\n\tprivate final String value;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/link/model/request/BatchDeleteRequest.java",
    "content": "package im.zhaojun.zfile.module.link.model.request;\n\nimport lombok.Data;\n\nimport java.util.List;\n\n/**\n * @author zhaojun\n */\n@Data\npublic class BatchDeleteRequest {\n\t\n\tprivate List<Integer> ids;\n\t\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/link/model/request/BatchGenerateLinkRequest.java",
    "content": "package im.zhaojun.zfile.module.link.model.request;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.NotNull;\nimport java.util.List;\n\n/**\n * 批量生成直链请求类\n * @author zhaojun\n */\n@Data\n@Schema(description = \"批量生成直链请求类\")\npublic class BatchGenerateLinkRequest {\n\t\n\t@NotBlank(message = \"存储源 key 不能为空\")\n\tprivate String storageKey;\n\t\n\t@NotEmpty(message = \"生成的文件路径不能为空\")\n\tprivate List<String> paths;\n\n\t/**\n\t * 有效期, 单位: 秒\n\t */\n\t@NotNull(message = \"过期时间不能为空\")\n\tprivate Long expireTime;\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/link/model/request/QueryDownloadLogRequest.java",
    "content": "package im.zhaojun.zfile.module.link.model.request;\n\nimport cn.hutool.core.date.DateUtil;\nimport im.zhaojun.zfile.core.model.request.PageQueryRequest;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\nimport lombok.EqualsAndHashCode;\nimport org.springframework.format.annotation.DateTimeFormat;\n\nimport java.util.Date;\nimport java.util.List;\n\n/**\n * 查询下载日志请求参数\n *\n * @author zhaojun\n */\n@Data\n@EqualsAndHashCode(callSuper = true)\npublic class QueryDownloadLogRequest extends PageQueryRequest {\n\n\t@Schema(title=\"文件路径\")\n\tprivate String path;\n\n\t@Schema(title=\"存储源 key\")\n\tprivate String storageKey;\n\n\t@Schema(title=\"链接类型\")\n\tprivate String linkType;\n\n\t@Schema(title=\"短链 key\")\n\tprivate String shortKey;\n\n\t@Schema(title=\"访问时间\")\n\t@DateTimeFormat(pattern = \"yyyy-MM-dd HH:mm:ss\")\n\tprivate List<Date> searchDate;\n\n\t@Schema(title=\"访问 ip\")\n\tprivate String ip;\n\n\t@Schema(title=\"访问 user_agent\")\n\tprivate String userAgent;\n\n\t@Schema(title=\"访问 referer\")\n\tprivate String referer;\n\t\n\t@Schema(title=\"排序字段\")\n\tprivate String orderBy = \"create_time\";\n\n\tpublic Date getDateFrom() {\n\t\tif (searchDate == null || searchDate.isEmpty()) {\n\t\t\treturn null;\n\t\t}\n\t\treturn DateUtil.beginOfDay(searchDate.getFirst());\n\t}\n\n\tpublic Date getDateTo() {\n\t\tif (searchDate == null || searchDate.isEmpty()) {\n\t\t\treturn null;\n\t\t}\n\t\treturn DateUtil.endOfDay(searchDate.getLast());\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/link/model/request/QueryLoginLogRequest.java",
    "content": "package im.zhaojun.zfile.module.link.model.request;\n\nimport cn.hutool.core.date.DateUtil;\nimport im.zhaojun.zfile.core.model.request.PageQueryRequest;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\nimport lombok.EqualsAndHashCode;\nimport org.springframework.format.annotation.DateTimeFormat;\n\nimport java.util.Date;\nimport java.util.List;\n\n/**\n * 查询下载日志请求参数\n *\n * @author zhaojun\n */\n@Data\n@EqualsAndHashCode(callSuper = true)\npublic class QueryLoginLogRequest extends PageQueryRequest {\n\n\t@Schema(title=\"用户名\")\n\tprivate String username;\n\n\t@Schema(title=\"密码\")\n\tprivate String password;\n\n\t@Schema(title=\"IP\")\n\tprivate String ip;\n\n\t@Schema(title=\"User-Agent\")\n\tprivate String userAgent;\n\n\t@Schema(title=\"来源\")\n\tprivate String referer;\n\n\t@Schema(title=\"登录结果\")\n\tprivate String result;\n\n\t@Schema(title=\"访问时间\")\n\t@DateTimeFormat(pattern = \"yyyy-MM-dd HH:mm:ss\")\n\tprivate List<Date> searchDate;\n\n\t@Schema(title=\"排序字段\")\n\tprivate String orderBy = \"create_time\";\n\n\tpublic Date getDateFrom() {\n\t\tif (searchDate == null) {\n\t\t\treturn null;\n\t\t}\n\t\treturn DateUtil.beginOfDay(searchDate.getFirst());\n\t}\n\n\tpublic Date getDateTo() {\n\t\tif (searchDate == null) {\n\t\t\treturn null;\n\t\t}\n\t\treturn DateUtil.endOfDay(searchDate.getLast());\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/link/model/request/QueryShortLinkLogRequest.java",
    "content": "package im.zhaojun.zfile.module.link.model.request;\n\nimport cn.hutool.core.date.DateUtil;\nimport im.zhaojun.zfile.core.model.request.PageQueryRequest;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\nimport lombok.EqualsAndHashCode;\nimport org.springframework.format.annotation.DateTimeFormat;\n\nimport java.util.Date;\nimport java.util.List;\n\n/**\n * @author zhaojun\n */\n@Data\n@EqualsAndHashCode(callSuper = true)\npublic class QueryShortLinkLogRequest extends PageQueryRequest {\n\t\n\t@Schema(title=\"短链 key\")\n\tprivate String key;\n\t\n\t@Schema(title=\"存储源 id\")\n\tprivate String storageId;\n\t\n\t@Schema(title=\"短链文件路径\")\n\tprivate String url;\n\n\t@Schema(title=\"访问时间\")\n\t@DateTimeFormat(pattern = \"yyyy-MM-dd HH:mm:ss\")\n\tprivate List<Date> searchDate;\n\n\tpublic Date getDateFrom() {\n\t\tif (searchDate == null) {\n\t\t\treturn null;\n\t\t}\n\t\treturn DateUtil.beginOfDay(searchDate.getFirst());\n\t}\n\n\tpublic Date getDateTo() {\n\t\tif (searchDate == null) {\n\t\t\treturn null;\n\t\t}\n\t\treturn DateUtil.endOfDay(searchDate.getLast());\n\t}\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/link/model/request/ShortLinkResult.java",
    "content": "package im.zhaojun.zfile.module.link.model.request;\n\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport java.util.Date;\n\n/**\n * 短链结果类\n *\n * @author zhaojun\n */\n@Data\npublic class ShortLinkResult {\n\n\t@Schema(title = \"短链 id\", example = \"1\")\n\tprivate Integer id;\n\n\t@Schema(title = \"存储源名称\", example = \"我的本地存储\")\n\tprivate String storageName;\n\n\t@Schema(title = \"存储源类型\")\n\tprivate StorageTypeEnum storageType;\n\n\t@JsonIgnore\n\tprivate String storageTypeStr;\n\n\tpublic String getStorageTypeStr() {\n\t\treturn storageType.getDescription();\n\t}\n\n\t@Schema(title = \"短链 key\", example = \"voldd3\")\n\tprivate String shortKey;\n\n\t@Schema(title = \"文件 url\", example = \"/directlink/1/test02.png\")\n\tprivate String url;\n\n\t@Schema(title = \"创建时间\", example = \"2021-11-22 10:05\")\n\tprivate Date createDate;\n\n\t@Schema(title = \"过期时间\", example = \"2021-11-23 10:05\")\n\tprivate Date expireDate;\n\n\t@Schema(title=\"短链地址\")\n\tprivate String shortLink;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/link/model/request/ShortLinkSearchRequest.java",
    "content": "package im.zhaojun.zfile.module.link.model.request;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport jakarta.validation.constraints.NotBlank;\n\n/**\n * 短链接搜索请求参数\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"搜索存储源中文件请求类\")\npublic class ShortLinkSearchRequest {\n\n    @Schema(title = \"存储源 id\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"1\")\n    @NotBlank(message = \"存储源 id 不能为空\")\n    private Integer storageId;\n\n    @Schema(title = \"存储源 key\", example = \"local\")\n    private String key;\n\n    @Schema(title = \"文件 url/路径\", example = \"/a\")\n    private String url;\n\n    @Schema(title = \"开始时间\", example = \"2022-01-01 00:00:00\")\n    private String dateFrom;\n\n    @Schema(title = \"结束时间\", example = \"2022-12-31 23:59:59\")\n    private String dateTo;\n\n    @Schema(title = \"页码\", example = \"1\")\n    private Integer page;\n\n    @Schema(title = \"每页数量\", example = \"10\")\n    private Integer limit;\n\n    @Schema(title = \"排序字段\", example = \"id\")\n    private String orderBy;\n\n    @Schema(title = \"排序方式\", example = \"desc\")\n    private String orderDirection;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/link/model/result/BatchGenerateLinkResponse.java",
    "content": "package im.zhaojun.zfile.module.link.model.result;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\n\n/**\n * @author zhaojun\n */\n@Data\n@Schema(description = \"批量生成直链结果类\")\n@AllArgsConstructor\npublic class BatchGenerateLinkResponse {\n\t\n\tprivate String address;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/link/service/DynamicDirectLinkPrefixService.java",
    "content": "package im.zhaojun.zfile.module.link.service;\n\nimport im.zhaojun.zfile.module.link.dto.DynamicRegisterMappingHandlerDTO;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.stereotype.Service;\nimport org.springframework.web.servlet.mvc.method.RequestMappingInfo;\nimport org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;\n\nimport java.lang.reflect.Method;\nimport java.util.Map;\nimport java.util.concurrent.ConcurrentHashMap;\n\n/**\n * 动态请求映射服务，用于在线注册、修改、注销 @RequestMapping 注解的方法.\n *\n * @author zhaojun\n */\n@Slf4j\n@Service\npublic class DynamicDirectLinkPrefixService {\n\n    @Resource\n    private RequestMappingHandlerMapping requestMappingHandlerMapping;\n\n    public static final Map<String, DynamicRegisterMappingHandlerDTO> REGISTER_MAPPING = new ConcurrentHashMap<>();\n\n    public void registerMappingHandlerMapping(String key, RequestMappingInfo requestMappingInfo, Object controllerObj, Method directLinkMethod) {\n        requestMappingHandlerMapping.registerMapping(requestMappingInfo, controllerObj, directLinkMethod);\n        REGISTER_MAPPING.put(key, new DynamicRegisterMappingHandlerDTO(requestMappingInfo, controllerObj, directLinkMethod));\n\n    }\n\n    public void updateRegisterMappingHandler(String key, RequestMappingInfo requestMappingInfo) {\n        synchronized (key.intern()) {\n            DynamicRegisterMappingHandlerDTO dynamicRegisterMappingHandlerDTO = REGISTER_MAPPING.get(key);\n            if (dynamicRegisterMappingHandlerDTO != null) {\n                requestMappingHandlerMapping.unregisterMapping(dynamicRegisterMappingHandlerDTO.getRequestMappingInfo());\n                requestMappingHandlerMapping.registerMapping(requestMappingInfo, dynamicRegisterMappingHandlerDTO.getObject(), dynamicRegisterMappingHandlerDTO.getMethod());\n                REGISTER_MAPPING.put(key, new DynamicRegisterMappingHandlerDTO(requestMappingInfo, dynamicRegisterMappingHandlerDTO.getObject(), dynamicRegisterMappingHandlerDTO.getMethod()));\n            }\n        }\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/link/service/LinkDownloadService.java",
    "content": "package im.zhaojun.zfile.module.link.service;\n\nimport cn.hutool.core.date.DateTime;\nimport cn.hutool.core.date.DateUtil;\nimport cn.hutool.core.io.FileUtil;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.biz.InvalidStorageSourceBizException;\nimport im.zhaojun.zfile.core.exception.core.ErrorPageBizException;\nimport im.zhaojun.zfile.core.exception.status.ForbiddenAccessException;\nimport im.zhaojun.zfile.core.exception.status.NotFoundAccessException;\nimport im.zhaojun.zfile.core.util.FileUtils;\nimport im.zhaojun.zfile.core.util.HttpUtil;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;\nimport im.zhaojun.zfile.module.config.service.SystemConfigService;\nimport im.zhaojun.zfile.module.filter.service.FilterConfigService;\nimport im.zhaojun.zfile.module.link.model.entity.ShortLink;\nimport im.zhaojun.zfile.module.log.model.entity.DownloadLog;\nimport im.zhaojun.zfile.module.log.service.DownloadLogService;\nimport im.zhaojun.zfile.module.storage.annotation.LinkRateLimiter;\nimport im.zhaojun.zfile.module.storage.annotation.RefererCheck;\nimport im.zhaojun.zfile.module.storage.context.StorageSourceContext;\nimport im.zhaojun.zfile.module.storage.model.entity.StorageSource;\nimport im.zhaojun.zfile.module.storage.service.StorageSourceService;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.apache.commons.lang3.BooleanUtils;\nimport org.springframework.http.ContentDisposition;\nimport org.springframework.http.HttpHeaders;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.stereotype.Service;\n\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.util.HashSet;\nimport java.util.Set;\n\n/**\n * @author zhaojun\n */\n@Slf4j\n@Service\npublic class LinkDownloadService {\n\n    @Resource\n    private StorageSourceService storageSourceService;\n\n    @Resource\n    private DownloadLogService downloadLogService;\n\n    @Resource\n    private SystemConfigService systemConfigService;\n\n    @Resource\n    private ShortLinkService shortLinkService;\n\n    @Resource\n    private FilterConfigService filterConfigService;\n\n    private final Set<String> expireKeySet = new HashSet<>();\n\n    @RefererCheck\n    @LinkRateLimiter\n    public ResponseEntity<?> handlerDirectLink(String storageKey, String filePath) {\n        // 检查系统是否允许直链\n        SystemConfigDTO systemConfigDTO = systemConfigService.getSystemConfig();\n        if (BooleanUtils.isNotTrue(systemConfigDTO.getShowPathLink())) {\n            throw new ForbiddenAccessException(ErrorCode.BIZ_DIRECT_LINK_NOT_ALLOWED);\n        }\n        return handlerDownloadGetUrl(storageKey, filePath, null, DownloadLog.DOWNLOAD_TYPE_DIRECT_LINK);\n    }\n\n    @RefererCheck\n    @LinkRateLimiter\n    public ResponseEntity<?> handlerShortLink(String shortKey) throws IOException {\n        // 从缓存中判断是否短链是否过期\n        if (expireKeySet.contains(shortKey)) {\n            throw new ForbiddenAccessException(ErrorCode.BIZ_SHORT_LINK_EXPIRED);\n        }\n\n        // 判断是否允许生成短链.\n        SystemConfigDTO systemConfigDTO = systemConfigService.getSystemConfig();\n        if (BooleanUtils.isNotTrue(systemConfigDTO.getShowShortLink())) {\n            throw new ForbiddenAccessException(ErrorCode.BIZ_SHORT_LINK_NOT_ALLOWED);\n        }\n\n        // 判断短链是否存在\n        ShortLink shortLink = shortLinkService.findByKey(shortKey);\n        if (shortLink == null) {\n            throw new NotFoundAccessException(ErrorCode.BIZ_SHORT_LINK_NOT_FOUNT);\n        }\n\n        // 判断短链是否过期\n        if (shortLink.getExpireDate() != null) {\n            DateTime now = DateUtil.date();\n            boolean isExpire = now.isAfter(shortLink.getExpireDate());\n            if (isExpire) {\n                expireKeySet.add(shortKey);\n                throw new ForbiddenAccessException(ErrorCode.BIZ_SHORT_LINK_EXPIRED);\n            }\n        }\n\n        // 获取实际文件路径，下载并记录日志\n        Integer storageId = shortLink.getStorageId();\n        String storageKey = storageSourceService.findStorageKeyById(storageId);\n        String filePath = shortLink.getUrl();\n        return handlerDownloadGetUrl(storageKey, filePath, shortKey, DownloadLog.DOWNLOAD_TYPE_SHORT_LINK);\n    }\n\n    /**\n     * 处理指定存储源的下载请求\n     *\n     * @param   storageKey\n     *          存储源 key\n     *\n     * @param   filePath\n     *          文件路径\n     *\n     * @param   shortKey\n     *          短链接 key\n     *\n     * @param   downloadType\n     *          下载类型, 直链下载(directLink)或短链下载(shortLink)\n     */\n    private ResponseEntity<?> handlerDownloadGetUrl(String storageKey, String filePath, String shortKey, String downloadType) {\n        String fileAlias = StringUtils.equals(downloadType, DownloadLog.DOWNLOAD_TYPE_DIRECT_LINK) ? filePath : shortKey;\n\n        // 获取存储源 Service\n        AbstractBaseFileService<?> fileService;\n        try {\n            fileService = StorageSourceContext.getByStorageKey(storageKey);\n        } catch (InvalidStorageSourceBizException e) {\n            throw new ErrorPageBizException(\"无效的或初始化失败的存储源 [\" + storageKey + \"] 文件 [\" + fileAlias + \"] 下载链接异常, 无法下载.\", e);\n        }\n\n        if (fileService == null) {\n            throw new ErrorPageBizException(\"未找到存储源 [\" + storageKey + \"] 文件 [\" + fileAlias + \"] 下载链接异常, 无法下载.\");\n        }\n\n        StorageSource storageSource = storageSourceService.findByStorageKey(storageKey);\n        Boolean enable = storageSource.getEnable();\n        if (!enable) {\n            throw new ErrorPageBizException(\"未启用的存储源 [\" + storageKey + \"] 文件 [\" + fileAlias + \"] 下载链接异常, 无法下载.\");\n        }\n\n        // 检查是否访问了禁止下载的目录\n        if (filterConfigService.checkFileIsDisableDownload(storageSource.getId(), filePath)) {\n            // 获取 Forbidden 页面地址\n            return ResponseEntity.status(302)\n                    .header(HttpHeaders.CACHE_CONTROL, \"no-cache, no-store, must-revalidate, private\")\n                    .header(HttpHeaders.PRAGMA, \"no-cache\")\n                    .header(HttpHeaders.EXPIRES, \"0\")\n                    .header(HttpHeaders.LOCATION, systemConfigService.getForbiddenUrl())\n                    .build();\n        }\n\n        // 获取文件下载链接\n        String downloadUrl;\n        try {\n            downloadUrl = fileService.getDownloadUrl(filePath);\n        } catch (NotFoundAccessException e) {\n            throw e;\n        } catch (Exception e) {\n            throw new ErrorPageBizException(\"获取存储源 [\" + storageKey + \"] 文件 [\" + fileAlias + \"] 下载链接异常, 无法下载.\", e);\n        }\n\n        // 判断下载链接是否为空\n        if (StringUtils.isEmpty(downloadUrl)) {\n            throw new ErrorPageBizException(\"获取存储源 [\" + storageKey + \"] 文件 [\" + fileAlias + \"] 下载链接为空, 无法下载.\");\n        }\n\n        // 记录下载日志.\n        SystemConfigDTO systemConfig = systemConfigService.getSystemConfig();\n        Boolean recordDownloadLog = systemConfig.getRecordDownloadLog();\n        if (BooleanUtils.isTrue(recordDownloadLog)) {\n            DownloadLog downloadLog = new DownloadLog(downloadType, filePath, storageKey, shortKey);\n            downloadLogService.save(downloadLog);\n        }\n\n        // 判断下载链接是否为 m3u8 格式, 如果是则返回 m3u8 内容.\n        if (StringUtils.equalsIgnoreCase(FileUtil.extName(filePath), \"m3u8\")) {\n            String textContent = HttpUtil.getTextContent(downloadUrl);\n            HttpHeaders headers = new HttpHeaders();\n            headers.setContentType(MediaType.parseMediaType(\"application/vnd.apple.mpegurl;charset=utf-8\"));\n            ContentDisposition contentDisposition = ContentDisposition\n                    .builder(\"attachment\")\n                    .filename(FileUtils.getName(filePath), StandardCharsets.UTF_8)\n                    .build();\n            headers.setContentDisposition(contentDisposition);\n\n            return ResponseEntity.ok()\n                    .headers(headers)\n                    .body(textContent);\n        }\n\n        return ResponseEntity.status(302)\n                .header(HttpHeaders.CACHE_CONTROL, \"no-cache, no-store, must-revalidate, private\")\n                .header(HttpHeaders.PRAGMA, \"no-cache\")\n                .header(HttpHeaders.EXPIRES, \"0\")\n                .header(HttpHeaders.LOCATION, downloadUrl)\n                .build();\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/link/service/ShortLinkService.java",
    "content": "package im.zhaojun.zfile.module.link.service;\n\nimport cn.hutool.core.util.RandomUtil;\nimport com.baomidou.mybatisplus.core.conditions.Wrapper;\nimport com.baomidou.mybatisplus.extension.plugins.pagination.Page;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.module.config.model.dto.LinkExpireDTO;\nimport im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;\nimport im.zhaojun.zfile.module.config.service.SystemConfigService;\nimport im.zhaojun.zfile.module.link.event.DeleteExpireLinkEvent;\nimport im.zhaojun.zfile.module.link.mapper.ShortLinkMapper;\nimport im.zhaojun.zfile.module.link.model.entity.ShortLink;\nimport im.zhaojun.zfile.module.storage.event.StorageSourceDeleteEvent;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.aop.framework.AopContext;\nimport org.springframework.cache.annotation.CacheConfig;\nimport org.springframework.cache.annotation.CacheEvict;\nimport org.springframework.cache.annotation.Cacheable;\nimport org.springframework.context.ApplicationEventPublisher;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.lang.Nullable;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.Date;\nimport java.util.List;\nimport java.util.Objects;\n\n/**\n * 短链 Service\n *\n * @author zhaojun\n */\n@Service\n@Slf4j\n@CacheConfig(cacheNames = \"shortLink\")\npublic class ShortLinkService {\n\n    @Resource\n    private ShortLinkMapper shortLinkMapper;\n\n    @Resource\n    private SystemConfigService systemConfigService;\n\n    @Resource\n    private ApplicationEventPublisher applicationEventPublisher;\n\n    /**\n     * 根据短链接 key 查询短链接\n     *\n     * @param   key\n     *          短链接 key\n     *\n     * @return  短链接信息\n     */\n    @Cacheable(key = \"#key\", unless = \"#result == null\", condition = \"#key != null\")\n    public ShortLink findByKey(String key) {\n        return shortLinkMapper.findByKey(key);\n    }\n\n    /**\n     * 根据存储源 ID 和 URL 查询短链接\n     *\n     * @param   storageId\n     *          存储源 ID\n     *\n     * @param   url\n     *          文件路径\n     *\n     * @return  短链接信息\n     */\n    public @Nullable ShortLink findByStorageIdAndUrl(Integer storageId, String url, @Nullable Date expireDate) {\n        return shortLinkMapper.findByStorageIdAndUrl(storageId, url, expireDate);\n    }\n\n\n    /**\n     * 为存储源指定路径生成短链接, 保证生成的短连接 key 是不同的 (如果是永久链接, 则不会重新生成)\n     *\n     * @param   storageId\n     *          存储源 id\n     *\n     * @param   fullPath\n     *          存储源路径\n     *\n     * @return  生成后的短链接信息\n     */\n    public ShortLink generatorShortLink(Integer storageId, String fullPath, Long expireTime) {\n        boolean validate = checkExpireDateIsValidate(expireTime);\n        if (!validate) {\n            throw new BizException(ErrorCode.BIZ_EXPIRE_TIME_ILLEGAL);\n        }\n\n        // 永久链接不在重复生成\n        if (Objects.equals(expireTime, ShortLink.PERMANENT_EXPIRE_TIME)) {\n            ShortLink shortLink = findByStorageIdAndUrl(storageId, fullPath, ShortLink.PERMANENT_EXPIRE_DATE);\n            if (shortLink != null) {\n                return shortLink;\n            }\n        }\n\n        ShortLink shortLink;\n        String randomKey;\n        int generateCount = 0;\n        do {\n            // 获取短链\n            randomKey = RandomUtil.randomString(6);\n            shortLink = ((ShortLinkService) AopContext.currentProxy()).findByKey(randomKey);\n            generateCount++;\n        } while (shortLink != null);\n\n        shortLink = new ShortLink();\n        shortLink.setStorageId(storageId);\n        shortLink.setUrl(fullPath);\n        shortLink.setCreateDate(new Date());\n        shortLink.setShortKey(randomKey);\n\n        if (expireTime == -1) {\n            shortLink.setExpireDate(ShortLink.PERMANENT_EXPIRE_DATE);\n        } else {\n            shortLink.setExpireDate(new Date(System.currentTimeMillis() + expireTime * 1000L));\n        }\n\n\n        if (log.isDebugEnabled()) {\n            log.debug(\"生成直/短链: 存储源 ID: {}, 文件路径: {}, 短链 key {}, 随机生成直链冲突次数: {}\",\n                    shortLink.getStorageId(), shortLink.getUrl(), shortLink.getShortKey(), generateCount);\n        }\n\n        shortLinkMapper.insert(shortLink);\n        return shortLink;\n    }\n\n    @CacheEvict(allEntries = true)\n    public int deleteExpireLink() {\n        applicationEventPublisher.publishEvent(new DeleteExpireLinkEvent());\n        int deleteSize = shortLinkMapper.deleteExpireLink();\n        log.info(\"删除过期直/短链 {} 条\", deleteSize);\n        return deleteSize;\n    }\n\n    @CacheEvict(allEntries = true)\n    public void removeById(Integer id) {\n        log.info(\"删除 id 为 {} 的直/短链\", id);\n        shortLinkMapper.deleteById(id);\n    }\n\n    @Transactional(rollbackFor = Exception.class)\n    @CacheEvict(allEntries = true)\n    public void removeBatchByIds(List<Integer> ids) {\n        log.info(\"批量删除直/短链，id 集合为 {}\", ids);\n        shortLinkMapper.deleteBatchIds(ids);\n    }\n\n    @CacheEvict(allEntries = true)\n    public int deleteByStorageId(Integer storageId) {\n        int deleteSize = shortLinkMapper.deleteByStorageId(storageId);\n        log.info(\"删除存储源 ID 为 {} 的短链 {} 条\", storageId, deleteSize);\n        return deleteSize;\n    }\n\n    /**\n     * 监听存储源删除事件，根据存储源 id 删除相关的短链\n     *\n     * @param   storageSourceDeleteEvent\n     *          存储源删除事件\n     */\n    @EventListener\n    public void onStorageSourceDelete(StorageSourceDeleteEvent storageSourceDeleteEvent) {\n        Integer storageId = storageSourceDeleteEvent.getId();\n        int updateRows = ((ShortLinkService) AopContext.currentProxy()).deleteByStorageId(storageId);\n        if (log.isDebugEnabled()) {\n            log.debug(\"删除存储源 [id {}, name: {}, type: {}] 时，关联删除存储源短链 {} 条\",\n                    storageId,\n                    storageSourceDeleteEvent.getName(),\n                    storageSourceDeleteEvent.getType().getDescription(),\n                    updateRows);\n        }\n    }\n\n    public Page<ShortLink> selectPage(Page<ShortLink> pages, Wrapper<ShortLink> queryWrapper) {\n        return shortLinkMapper.selectPage(pages, queryWrapper);\n    }\n\n    private boolean checkExpireDateIsValidate(Long expires) {\n        SystemConfigDTO systemConfig = systemConfigService.getSystemConfig();\n\n        List<LinkExpireDTO> linkExpireTimeList = systemConfig.getLinkExpireTimes();\n\n        for (LinkExpireDTO linkExpireDTO : linkExpireTimeList) {\n            if (linkExpireDTO.getSeconds().equals(expires)) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/log/controller/DownloadLogManagerController.java",
    "content": "package im.zhaojun.zfile.module.log.controller;\n\nimport cn.hutool.core.util.ObjUtil;\nimport com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;\nimport com.baomidou.mybatisplus.core.metadata.OrderItem;\nimport com.baomidou.mybatisplus.extension.plugins.pagination.Page;\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport com.github.xiaoymin.knife4j.annotations.ApiSort;\nimport im.zhaojun.zfile.core.annotation.DemoDisable;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;\nimport im.zhaojun.zfile.module.config.service.SystemConfigService;\nimport im.zhaojun.zfile.module.link.model.request.BatchDeleteRequest;\nimport im.zhaojun.zfile.module.link.model.request.QueryDownloadLogRequest;\nimport im.zhaojun.zfile.module.log.convert.DownloadLogConvert;\nimport im.zhaojun.zfile.module.log.model.entity.DownloadLog;\nimport im.zhaojun.zfile.module.log.model.result.DownloadLogResult;\nimport im.zhaojun.zfile.module.log.service.DownloadLogService;\nimport im.zhaojun.zfile.module.storage.model.entity.StorageSource;\nimport im.zhaojun.zfile.module.storage.service.StorageSourceService;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.Parameter;\nimport io.swagger.v3.oas.annotations.enums.ParameterIn;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.annotation.Resource;\nimport org.springframework.stereotype.Controller;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Objects;\nimport java.util.stream.Stream;\n\n/**\n * 直链下载日志接口\n *\n * @author zhaojun\n */\n@Tag(name = \"直链日志管理\")\n@ApiSort(7)\n@Controller\n@RequestMapping(\"/admin/download/log\")\npublic class DownloadLogManagerController {\n\n    @Resource\n    private StorageSourceService storageSourceService;\n\n    @Resource\n    private DownloadLogConvert downloadLogConvert;\n\n    @Resource\n    private DownloadLogService downloadLogService;\n\n    @Resource\n    private SystemConfigService systemConfigService;\n\n    @ApiOperationSupport(order = 1)\n    @GetMapping(\"/list\")\n    @Operation(summary = \"直链下载日志\")\n    @ResponseBody\n    public AjaxJson<Stream<DownloadLogResult>> list(QueryDownloadLogRequest queryDownloadLogRequest) {\n        // 分页和排序\n        boolean asc = Objects.equals(queryDownloadLogRequest.getOrderDirection(), \"asc\");\n        OrderItem orderItem = asc ? OrderItem.asc(queryDownloadLogRequest.getOrderBy()) : OrderItem.desc(queryDownloadLogRequest.getOrderBy());\n        Page<DownloadLog> pages = new Page<DownloadLog>(queryDownloadLogRequest.getPage(), queryDownloadLogRequest.getLimit())\n                .addOrder(orderItem);\n\n        LambdaQueryWrapper<DownloadLog> queryWrapper = new LambdaQueryWrapper<DownloadLog>()\n                .eq(StringUtils.isNotEmpty(queryDownloadLogRequest.getStorageKey()), DownloadLog::getStorageKey, queryDownloadLogRequest.getStorageKey())\n                .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getPath()), DownloadLog::getPath, queryDownloadLogRequest.getPath())\n                .isNotNull(\"shortLink\".equals(queryDownloadLogRequest.getLinkType()), DownloadLog::getShortKey)\n                .isNull(\"directLink\".equals(queryDownloadLogRequest.getLinkType()), DownloadLog::getShortKey)\n                .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getShortKey()), DownloadLog::getShortKey, queryDownloadLogRequest.getShortKey())\n                .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getIp()), DownloadLog::getIp, queryDownloadLogRequest.getIp())\n                .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getReferer()), DownloadLog::getReferer, queryDownloadLogRequest.getReferer())\n                .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getUserAgent()), DownloadLog::getUserAgent, queryDownloadLogRequest.getUserAgent())\n                .ge(ObjUtil.isNotEmpty(queryDownloadLogRequest.getDateFrom()), DownloadLog::getCreateTime, queryDownloadLogRequest.getDateFrom())\n                .le(ObjUtil.isNotEmpty(queryDownloadLogRequest.getDateTo()), DownloadLog::getCreateTime, queryDownloadLogRequest.getDateTo());\n\n        Page<DownloadLog> selectResult = downloadLogService.selectPage(pages, queryWrapper);\n\n        Map<String, StorageSource> cache = new HashMap<>();\n\n        String serverAddress = systemConfigService.getAxiosFromDomainOrSetting();\n        SystemConfigDTO systemConfig = systemConfigService.getSystemConfig();\n        String directLinkPrefix = systemConfig.getDirectLinkPrefix();\n\n        Stream<DownloadLogResult> shortLinkResultList = selectResult.getRecords().stream().map(model -> {\n            String storageKey = model.getStorageKey();\n\n            StorageSource storageSource = cache.computeIfAbsent(storageKey, (key) -> storageSourceService.findByStorageKey(key));\n            DownloadLogResult downloadLogResult = downloadLogConvert.entityToResultList(model, storageSource);\n\n            if (StringUtils.isNotBlank(downloadLogResult.getShortKey())) {\n                downloadLogResult.setShortLink(StringUtils.concat(serverAddress, \"s\", downloadLogResult.getShortKey()));\n            } else {\n                downloadLogResult.setPathLink(StringUtils.concat(serverAddress, directLinkPrefix, downloadLogResult.getStorageKey(), downloadLogResult.getPath()));\n            }\n\n            return downloadLogResult;\n        });\n        return AjaxJson.getPageData(selectResult.getTotal(), shortLinkResultList);\n    }\n\n\n    @ApiOperationSupport(order = 2)\n    @DeleteMapping(\"/delete/{id}\")\n    @Operation(summary = \"删除直链\")\n    @Parameter(in = ParameterIn.PATH, name = \"id\", description = \"直链 id\", required = true, schema = @Schema(type = \"integer\"))\n    @ResponseBody\n    @DemoDisable\n    public AjaxJson<Void> deleteById(@PathVariable Integer id) {\n        downloadLogService.removeById(id);\n        return AjaxJson.getSuccess();\n    }\n\n\n    @ApiOperationSupport(order = 3)\n    @PostMapping(\"/delete/batch\")\n    @ResponseBody\n    @Operation(summary = \"批量删除直链\")\n    @DemoDisable\n    public AjaxJson<Void> batchDelete(@RequestBody BatchDeleteRequest batchDeleteRequest) {\n        List<Integer> ids = batchDeleteRequest.getIds();\n        downloadLogService.removeBatchByIds(ids);\n        return AjaxJson.getSuccess();\n    }\n\n    @ApiOperationSupport(order = 4)\n    @PostMapping(\"/delete/batch/query\")\n    @ResponseBody\n    @Operation(summary = \"根据查询条件批量删除直链\")\n    @DemoDisable\n    public AjaxJson<Void> batchDeleteBySearchParams(@RequestBody QueryDownloadLogRequest queryDownloadLogRequest) {\n\n        LambdaQueryWrapper<DownloadLog> queryWrapper = new LambdaQueryWrapper<DownloadLog>()\n                .eq(StringUtils.isNotEmpty(queryDownloadLogRequest.getStorageKey()), DownloadLog::getStorageKey, queryDownloadLogRequest.getStorageKey())\n                .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getPath()), DownloadLog::getPath, queryDownloadLogRequest.getPath())\n                .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getShortKey()), DownloadLog::getShortKey, queryDownloadLogRequest.getShortKey())\n                .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getIp()), DownloadLog::getIp, queryDownloadLogRequest.getIp())\n                .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getReferer()), DownloadLog::getReferer, queryDownloadLogRequest.getReferer())\n                .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getUserAgent()), DownloadLog::getUserAgent, queryDownloadLogRequest.getUserAgent())\n                .ge(ObjUtil.isNotEmpty(queryDownloadLogRequest.getDateFrom()), DownloadLog::getCreateTime, queryDownloadLogRequest.getDateFrom())\n                .le(ObjUtil.isNotEmpty(queryDownloadLogRequest.getDateTo()), DownloadLog::getCreateTime, queryDownloadLogRequest.getDateTo());\n\n        downloadLogService.deleteByQueryWrapper(queryWrapper);\n        return AjaxJson.getSuccess();\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/log/controller/LoginLogController.java",
    "content": "package im.zhaojun.zfile.module.log.controller;\n\nimport cn.hutool.core.util.ObjUtil;\nimport com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;\nimport com.baomidou.mybatisplus.core.metadata.OrderItem;\nimport com.baomidou.mybatisplus.extension.plugins.pagination.Page;\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport com.github.xiaoymin.knife4j.annotations.ApiSort;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.link.model.request.QueryLoginLogRequest;\nimport im.zhaojun.zfile.module.log.model.entity.LoginLog;\nimport im.zhaojun.zfile.module.log.service.LoginLogService;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.annotation.Resource;\nimport org.springframework.stereotype.Controller;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.ResponseBody;\n\nimport java.util.List;\nimport java.util.Objects;\n\n/**\n * 用户登录日志接口\n *\n * @author zhaojun\n */\n@Tag(name = \"登录日志管理\")\n@ApiSort(7)\n@Controller\n@RequestMapping(\"/admin/login/log\")\npublic class LoginLogController {\n    \n    @Resource\n    private LoginLogService loginLogService;\n\n    @ApiOperationSupport(order = 1)\n    @GetMapping(\"/list\")\n    @Operation(summary = \"登录日志列表\")\n    @ResponseBody\n    public AjaxJson<List<LoginLog>> list(QueryLoginLogRequest queryLoginLogRequest) {\n        // 分页和排序\n        boolean asc = Objects.equals(queryLoginLogRequest.getOrderDirection(), \"asc\");\n        OrderItem orderItem = asc ? OrderItem.asc(queryLoginLogRequest.getOrderBy()) : OrderItem.desc(queryLoginLogRequest.getOrderBy());\n        Page<LoginLog> pages = new Page<LoginLog>(queryLoginLogRequest.getPage(), queryLoginLogRequest.getLimit())\n                .addOrder(orderItem);\n\n        LambdaQueryWrapper<LoginLog> queryWrapper = new LambdaQueryWrapper<LoginLog>()\n                .like(StringUtils.isNotEmpty(queryLoginLogRequest.getUsername()), LoginLog::getUsername, queryLoginLogRequest.getUsername())\n                .like(StringUtils.isNotEmpty(queryLoginLogRequest.getPassword()), LoginLog::getPassword, queryLoginLogRequest.getPassword())\n                .like(StringUtils.isNotEmpty(queryLoginLogRequest.getIp()), LoginLog::getIp, queryLoginLogRequest.getIp())\n                .like(StringUtils.isNotEmpty(queryLoginLogRequest.getUserAgent()), LoginLog::getUserAgent, queryLoginLogRequest.getUserAgent())\n                .like(StringUtils.isNotEmpty(queryLoginLogRequest.getReferer()), LoginLog::getReferer, queryLoginLogRequest.getReferer())\n                .like(StringUtils.isNotEmpty(queryLoginLogRequest.getResult()), LoginLog::getResult, queryLoginLogRequest.getResult())\n                .ge(ObjUtil.isNotEmpty(queryLoginLogRequest.getDateFrom()), LoginLog::getCreateTime, queryLoginLogRequest.getDateFrom())\n                .le(ObjUtil.isNotEmpty(queryLoginLogRequest.getDateTo()), LoginLog::getCreateTime, queryLoginLogRequest.getDateTo());\n\n        Page<LoginLog> selectResult = loginLogService.selectPage(pages, queryWrapper);\n        return AjaxJson.getPageData(selectResult.getTotal(), selectResult.getRecords());\n    }\n    \n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/log/convert/DownloadLogConvert.java",
    "content": "package im.zhaojun.zfile.module.log.convert;\n\nimport im.zhaojun.zfile.module.log.model.entity.DownloadLog;\nimport im.zhaojun.zfile.module.storage.model.entity.StorageSource;\nimport im.zhaojun.zfile.module.log.model.result.DownloadLogResult;\nimport org.mapstruct.Mapper;\nimport org.mapstruct.Mapping;\nimport org.springframework.stereotype.Component;\n\n/**\n * 下载日志实体类转换器\n *\n * @author zhaojun\n */\n@Component\n@Mapper(componentModel = \"spring\")\npublic interface DownloadLogConvert {\n\n\t@Mapping(source = \"downloadLog.id\", target = \"id\")\n\t@Mapping(source = \"storageSource.name\", target = \"storageName\")\n\t@Mapping(source = \"storageSource.type\", target = \"storageType\")\n\tDownloadLogResult entityToResultList(DownloadLog downloadLog, StorageSource storageSource);\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/log/mapper/DownloadLogMapper.java",
    "content": "package im.zhaojun.zfile.module.log.mapper;\n\nimport com.baomidou.mybatisplus.core.mapper.BaseMapper;\nimport im.zhaojun.zfile.module.log.model.entity.DownloadLog;\nimport org.apache.ibatis.annotations.Mapper;\n\n/**\n * 下载日志 Mapper 接口\n *\n * @author zhaojun\n */\n@Mapper\npublic interface DownloadLogMapper extends BaseMapper<DownloadLog> {\n\n\t/**\n\t * 根据存储源 KEY 删除所有数据\n\t *\n\t * @param \tstorageKey\n\t * \t\t\t存储源 KEY\n\t */\n\tint deleteByStorageKey(String storageKey);\n\n\n\t/**\n\t * 删除过期的短链下载日志\n\t *\n\t * @return\t删除的行数\n\t */\n\tint deleteExpireShortLinkLog();\n\t\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/log/mapper/LoginLogMapper.java",
    "content": "package im.zhaojun.zfile.module.log.mapper;\n\nimport com.baomidou.mybatisplus.core.mapper.BaseMapper;\nimport im.zhaojun.zfile.module.log.model.entity.LoginLog;\nimport org.apache.ibatis.annotations.Mapper;\n\n@Mapper\npublic interface LoginLogMapper extends BaseMapper<LoginLog> {\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/log/model/entity/DownloadLog.java",
    "content": "package im.zhaojun.zfile.module.log.model.entity;\n\nimport cn.hutool.extra.servlet.JakartaServletUtil;\nimport com.baomidou.mybatisplus.annotation.IdType;\nimport com.baomidou.mybatisplus.annotation.TableField;\nimport com.baomidou.mybatisplus.annotation.TableId;\nimport com.baomidou.mybatisplus.annotation.TableName;\nimport im.zhaojun.zfile.core.util.RequestHolder;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.servlet.http.HttpServletRequest;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\nimport org.springframework.http.HttpHeaders;\n\nimport java.io.Serializable;\nimport java.util.Date;\n\n/**\n * 文件下载日志 entity\n *\n * @author zhaojun\n */\n@Data\n@Tag(name =\"文件下载日志\")\n@TableName(value = \"`download_log`\")\n@NoArgsConstructor\npublic class DownloadLog implements Serializable {\n\n    public static final String DOWNLOAD_TYPE_DIRECT_LINK = \"directLink\";\n\n    public static final String DOWNLOAD_TYPE_SHORT_LINK = \"shortLink\";\n\n    private static final long serialVersionUID = 1L;\n\n    @TableId(value = \"id\", type = IdType.INPUT)\n    @Schema(title = \"ID, 新增无需填写\", example = \"1\")\n    private Integer id;\n\n\n    @TableField(value = \"`download_type`\")\n    @Schema(title=\"下载类型\", example = \"directLink\", allowableValues = \"directLink, shortLink\")\n    private String downloadType;\n\n\n    @TableField(value = \"`path`\")\n    @Schema(title=\"文件路径\")\n    private String path;\n\n\n    @TableField(value = \"`storage_key`\")\n    @Schema(title=\"存储源 key\")\n    private String storageKey;\n\n\n    @TableField(value = \"`create_time`\")\n    @Schema(title=\"访问时间\")\n    private Date createTime;\n\n\n    @TableField(value = \"`ip`\")\n    @Schema(title=\"访问 ip\")\n    private String ip;\n\n\n    @TableField(value = \"short_key\")\n    @Schema(title = \"短链 key\", example = \"voldd3\")\n    private String shortKey;\n\n\n    @TableField(value = \"`user_agent`\")\n    @Schema(title=\"访问 user_agent\")\n    private String userAgent;\n\n\n    @TableField(value = \"`referer`\")\n    @Schema(title=\"访问 referer\")\n    private String referer;\n\n    public DownloadLog(String downloadType, String path, String storageKey, String shortKey) {\n        this.downloadType = downloadType;\n        this.path = path;\n        this.storageKey = storageKey;\n        this.shortKey = shortKey;\n        this.createTime = new Date();\n        HttpServletRequest request = RequestHolder.getRequest();\n        this.ip = JakartaServletUtil.getClientIP(request);\n        this.referer = request.getHeader(HttpHeaders.REFERER);\n        this.userAgent = request.getHeader(HttpHeaders.USER_AGENT);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/log/model/entity/LoginLog.java",
    "content": "package im.zhaojun.zfile.module.log.model.entity;\n\nimport com.baomidou.mybatisplus.annotation.*;\nimport lombok.Data;\n\nimport java.io.Serializable;\nimport java.util.Date;\n\n@Data\n@TableName(value = \"login_log\")\npublic class LoginLog implements Serializable {\n\n    private static final long serialVersionUID = 1L;\n\n    @TableId(value = \"id\", type = IdType.INPUT)\n    private Integer id;\n\n    @TableField(value = \"username\")\n    private String username;\n\n    @TableField(value = \"`password`\")\n    private String password;\n\n    @TableField(value = \"create_time\", fill = FieldFill.INSERT)\n    private Date createTime;\n\n    @TableField(value = \"ip\")\n    private String ip;\n\n    @TableField(value = \"user_agent\")\n    private String userAgent;\n\n    @TableField(value = \"referer\")\n    private String referer;\n\n    @TableField(value = \"`result`\")\n    private String result;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/log/model/result/DownloadLogResult.java",
    "content": "package im.zhaojun.zfile.module.log.model.result;\n\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport java.util.Date;\n\n/**\n * 下载日志结果类\n *\n * @author zhaojun\n */\n@Data\npublic class DownloadLogResult {\n\n\t@Schema(title=\"\")\n\tprivate Integer id;\n\n\t@Schema(title=\"文件路径\")\n\tprivate String path;\n\n\t@Schema(title = \"存储源类型\")\n\tprivate StorageTypeEnum storageType;\n\t\n\t@Schema(title = \"存储源名称\", example = \"我的本地存储\")\n\tprivate String storageName;\n\n\t@Schema(title = \"存储源Key\", example = \"local\")\n\tprivate String storageKey;\n\n\t@Schema(title=\"访问时间\")\n\tprivate Date createTime;\n\n\t@Schema(title=\"访问 ip\")\n\tprivate String ip;\n\t\n\t@Schema(title = \"短链 Key\")\n\tprivate String shortKey;\n\n\t@Schema(title=\"访问 user_agent\")\n\tprivate String userAgent;\n\n\t@Schema(title=\"访问 referer\")\n\tprivate String referer;\n\n\t@Schema(title=\"短链地址\")\n\tprivate String shortLink;\n\n\t@Schema(title=\"直链地址\")\n\tprivate String pathLink;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/log/service/DownloadLogService.java",
    "content": "package im.zhaojun.zfile.module.log.service;\n\nimport com.baomidou.mybatisplus.core.conditions.Wrapper;\nimport com.baomidou.mybatisplus.extension.plugins.pagination.Page;\nimport im.zhaojun.zfile.module.link.event.DeleteExpireLinkEvent;\nimport im.zhaojun.zfile.module.log.mapper.DownloadLogMapper;\nimport im.zhaojun.zfile.module.log.model.entity.DownloadLog;\nimport im.zhaojun.zfile.module.storage.event.StorageSourceDeleteEvent;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.aop.framework.AopContext;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.List;\n\n/**\n * 下载日志 Service\n *\n * @author zhaojun\n */\n@Slf4j\n@Service\npublic class DownloadLogService {\n\n\t@Resource\n\tprivate DownloadLogMapper downloadLogMapper;\n\n\tpublic void save(DownloadLog downloadLog) {\n\t\tdownloadLogMapper.insert(downloadLog);\n\t}\n\n\tpublic Page<DownloadLog> selectPage(Page<DownloadLog> pages, Wrapper<DownloadLog> queryWrapper) {\n\t\treturn downloadLogMapper.selectPage(pages, queryWrapper);\n\t}\n\n\tpublic void removeById(Integer id) {\n\t\tdownloadLogMapper.deleteById(id);\n\t}\n\n\t@Transactional(rollbackFor = Exception.class)\n\tpublic void removeBatchByIds(List<Integer> ids) {\n\t\tdownloadLogMapper.deleteBatchIds(ids);\n\t}\n\n\tpublic void deleteByQueryWrapper(Wrapper<DownloadLog> queryWrapper) {\n\t\tdownloadLogMapper.delete(queryWrapper);\n\t}\n\n\tpublic int deleteByStorageKey(String storageKey) {\n\t\tint deleteSize = downloadLogMapper.deleteByStorageKey(storageKey);\n\t\tlog.info(\"删除存储源 ID 为 {} 的直/短链下载日志 {} 条\", storageKey, deleteSize);\n\t\treturn deleteSize;\n\t}\n\n\t/**\n\t * 监听存储源删除事件，根据存储源 id 删除相关的下载日志\n\t *\n\t * @param   storageSourceDeleteEvent\n\t *          存储源删除事件\n\t */\n\t@EventListener\n\tpublic void onStorageSourceDelete(StorageSourceDeleteEvent storageSourceDeleteEvent) {\n\t\tString storageKey = storageSourceDeleteEvent.getKey();\n\t\tint updateRows = ((DownloadLogService) AopContext.currentProxy()).deleteByStorageKey(storageKey);\n\t\tif (log.isDebugEnabled()) {\n\t\t\tlog.debug(\"删除存储源 [id {}, key: {}, name: {}, type: {}] 时，关联删除存储源直/短链下载日志 {} 条\",\n\t\t\t\t\tstorageSourceDeleteEvent.getId(),\n\t\t\t\t\tstorageKey,\n\t\t\t\t\tstorageSourceDeleteEvent.getName(),\n\t\t\t\t\tstorageSourceDeleteEvent.getType().getDescription(),\n\t\t\t\t\tupdateRows);\n\t\t}\n\t}\n\n\t/**\n\t * 删除过期下载日志\n\t *\n\t * @return  删除的条数\n\t */\n\tpublic int deleteExpireShortLinkLog() {\n\t\treturn downloadLogMapper.deleteExpireShortLinkLog();\n\t}\n\n\t@EventListener(classes = DeleteExpireLinkEvent.class)\n\tpublic void deleteExpireShortLinkLog(DeleteExpireLinkEvent event) {\n\t\tint updateRows = deleteExpireShortLinkLog();\n\t\tlog.info(\"删除过期短链关联删除日志 {} 条\", updateRows);\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/log/service/LoginLogService.java",
    "content": "package im.zhaojun.zfile.module.log.service;\n\nimport com.baomidou.mybatisplus.core.conditions.Wrapper;\nimport com.baomidou.mybatisplus.extension.plugins.pagination.Page;\nimport im.zhaojun.zfile.module.log.mapper.LoginLogMapper;\nimport im.zhaojun.zfile.module.log.model.entity.LoginLog;\nimport org.springframework.stereotype.Service;\n\nimport jakarta.annotation.Resource;\n\n@Service\npublic class LoginLogService {\n\n    @Resource\n    private LoginLogMapper loginLogMapper;\n\n    public void save(LoginLog loginLog) {\n        loginLogMapper.insert(loginLog);\n    }\n\n    public Page<LoginLog> selectPage(Page<LoginLog> pages, Wrapper<LoginLog> queryWrapper) {\n        return loginLogMapper.selectPage(pages, queryWrapper);\n    }\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/onlyoffice/controller/OnlyOfficeController.java",
    "content": "package im.zhaojun.zfile.module.onlyoffice.controller;\n\nimport cn.dev33.satoken.stp.StpUtil;\nimport cn.hutool.core.collection.CollUtil;\nimport cn.hutool.core.util.StrUtil;\nimport cn.hutool.jwt.JWTUtil;\nimport com.alibaba.fastjson2.JSONObject;\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport im.zhaojun.zfile.module.onlyoffice.model.OnlyOfficeCallback;\nimport im.zhaojun.zfile.core.exception.biz.InvalidStorageSourceBizException;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.util.*;\nimport im.zhaojun.zfile.module.onlyoffice.model.OnlyOfficeFile;\nimport im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;\nimport im.zhaojun.zfile.module.config.service.SystemConfigService;\nimport im.zhaojun.zfile.module.storage.annotation.CheckPassword;\nimport im.zhaojun.zfile.module.storage.context.StorageSourceContext;\nimport im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.request.base.FileItemRequest;\nimport im.zhaojun.zfile.module.storage.model.result.FileItemResult;\nimport im.zhaojun.zfile.module.storage.service.StorageSourceService;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractProxyTransferService;\nimport im.zhaojun.zfile.module.user.model.entity.User;\nimport im.zhaojun.zfile.module.user.service.UserStorageSourceService;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.annotation.Resource;\nimport jakarta.validation.Valid;\nimport lombok.extern.slf4j.Slf4j;\nimport org.apache.commons.lang3.tuple.Pair;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport java.beans.Beans;\nimport java.io.InputStream;\nimport java.net.URI;\nimport java.net.URL;\nimport java.net.URLConnection;\nimport java.nio.charset.StandardCharsets;\nimport java.util.List;\nimport java.util.concurrent.locks.ReentrantLock;\n\n@Slf4j\n@Tag(name = \"OnlyOffice 相关接口\")\n@RestController\n@RequestMapping(\"/onlyOffice\")\npublic class OnlyOfficeController {\n\n    @Resource\n    private SystemConfigService systemConfigService;\n\n    @Resource\n    private StorageSourceService storageSourceService;\n\n    @Resource\n    private UserStorageSourceService userStorageSourceService;\n\n    private static final String CALLBACK_ERROR_MSG = \"{\\\"error\\\":1}\";\n\n    private static final String CALLBACK_SUCCESS_MSG = \"{\\\"error\\\":0}\";\n\n    public static final List<Integer> SUPPORTED_STATUS = List.of(2, 3, 6, 7);\n\n    @ApiOperationSupport(order = 3)\n    @Operation(summary = \"OnlyOffice 预览文件\", description = \"根据传入的文件信息, 生成 OnlyOffice 预览所需的 JSON 数据.\")\n    @PostMapping(\"/config/token\")\n    @CheckPassword(storageKeyFieldExpression = \"[0].storageKey\",\n            pathFieldExpression = \"[0].path\",\n            pathIsDirectory = false,\n            passwordFieldExpression = \"[0].password\")\n    public AjaxJson<JSONObject> getPreviewFileJSONInfo(@Valid @RequestBody FileItemRequest fileItemRequest) {\n        // 根据存储策略获取文件信息(下载地址), 会校验权限.\n        Pair<FileItemResult, Boolean> pair = getFileInfo(fileItemRequest);\n        FileItemResult fileInfo = pair.getKey();\n        Boolean hasUploadPermission = pair.getRight();\n\n        // 为 OnlyOffice 获取或生成文件 Key.\n        OnlyOfficeFile onlyOfficeFile = new OnlyOfficeFile(fileItemRequest.getStorageKey(), fileItemRequest.getPath());\n        String key = OnlyOfficeKeyCacheUtils.getKeyOrPutNew(onlyOfficeFile, 3000);\n\n        JSONObject onlyOfficePayload = createOnlyOfficePayload(fileInfo, key, hasUploadPermission);\n        return AjaxJson.getSuccessData(onlyOfficePayload);\n    }\n\n    private Pair<FileItemResult, Boolean> getFileInfo(FileItemRequest fileItemRequest) {\n        String storageKey = fileItemRequest.getStorageKey();\n        Integer storageId = storageSourceService.findIdByKey(storageKey);\n        if (storageId == null) {\n            throw new InvalidStorageSourceBizException(storageKey);\n        }\n\n        // 处理请求参数默认值\n        fileItemRequest.handleDefaultValue();\n\n        // 获取文件信息\n        AbstractBaseFileService<?> fileService = StorageSourceContext.getByStorageId(storageId);\n        try {\n            FileItemResult fileItem = fileService.getFileItem(fileItemRequest.getPath());\n            if (fileItem == null) {\n                throw new BizException(\"文件不存在\");\n            }\n\n            String currentUserBasePath = fileService.getCurrentUserBasePath();\n            fileItemRequest.setPath(StringUtils.concat(currentUserBasePath, fileItemRequest.getPath()));\n\n            boolean hasUploadPermission = userStorageSourceService.hasCurrentUserStorageOperatorPermission(storageId, FileOperatorTypeEnum.UPLOAD);\n            return Pair.of(fileItem, hasUploadPermission);\n        } catch (Exception e) {\n            throw new BizException(\"获取文件信息失败: \" + e.getMessage());\n        }\n    }\n\n\n    /**\n     * 生成 OnlyOffice 预览所需的 JSON 数据. 配置参考: <a href=\"https://api.onlyoffice.com/zh/editors/config/editor\" />\n     *\n     * @param   fileItemResult\n     *          文件信息\n     *\n     * @param   key\n     *          OnlyOffice JWT 密钥\n     *\n     * @param   hasUploadPermission\n     *          是否有上传(编辑)权限\n     *\n     * @return  OnlyOffice 预览所需的 JSON 数据, 包含 JWT 密钥\n     */\n    private JSONObject createOnlyOfficePayload(FileItemResult fileItemResult, String key, boolean hasUploadPermission) {\n        JSONObject jsonObject = new JSONObject();\n        jsonObject.put(\"width\", \"100%\");\n        jsonObject.put(\"height\", \"100%\");\n\n        jsonObject.put(\"document\", new JSONObject()\n                .fluentPut(\"fileType\", FileUtils.getExtension(fileItemResult.getName()))\n                .fluentPut(\"key\", key)\n                .fluentPut(\"permissions\", new JSONObject()\n                        .fluentPut(\"edit\", hasUploadPermission))\n                .fluentPut(\"title\", fileItemResult.getName())\n                .fluentPut(\"url\", fileItemResult.getUrl())\n                .fluentPut(\"lang\", \"zh-CN\"));\n\n        User currentUser = ZFileAuthUtil.getCurrentUser();\n\n        SystemConfigDTO systemConfig = systemConfigService.getSystemConfig();\n        String onlyOfficeSecret = systemConfig.getOnlyOfficeSecret();\n\n\n        jsonObject.put(\"editorConfig\", new JSONObject()\n                .fluentPut(\"callbackUrl\", StringUtils.concat(systemConfigService.getAxiosFromDomainOrSetting(), \"/onlyOffice/callback\"))\n                .fluentPut(\"lang\", \"zh-CN\")\n                .fluentPut(\"user\", new JSONObject()\n                        .fluentPut(\"id\", currentUser.getId())\n                        .fluentPut(\"name\", StringUtils.firstNonNull(currentUser.getNickname(), currentUser.getUsername()))));\n\n        if (StringUtils.isNotEmpty(onlyOfficeSecret)) {\n            String token = JWTUtil.createToken(jsonObject, onlyOfficeSecret.getBytes(StandardCharsets.UTF_8));\n            jsonObject.put(\"token\", token);\n        }\n\n        return jsonObject;\n    }\n\n    @RequestMapping(\"/callback\")\n    public String callBack(@RequestBody OnlyOfficeCallback onlyOfficeCallback) {\n        log.debug(\"OnlyOffice 回调信息: {}, {}\", onlyOfficeCallback.getStatus(), onlyOfficeCallback);\n        boolean useOnlyOfficeSecret = StrUtil.isNotBlank(systemConfigService.getSystemConfig().getOnlyOfficeSecret());\n        if (useOnlyOfficeSecret) {\n\n            if (StrUtil.isBlank(onlyOfficeCallback.getToken())) {\n                log.error(\"OnlyOffice 回调 Token 为空: {}\", onlyOfficeCallback);\n                return CALLBACK_ERROR_MSG;\n            }\n\n            if (!JWTUtil.verify(onlyOfficeCallback.getToken(), StrUtil.bytes(systemConfigService.getSystemConfig().getOnlyOfficeSecret(), StandardCharsets.UTF_8))) {\n                log.error(\"OnlyOffice 回调 Token 验证失败: {}\", onlyOfficeCallback);\n                return CALLBACK_ERROR_MSG;\n            }\n\n        }\n        // 文件发送了变化，清空缓存中该文件的 key 信息.\n        if (SUPPORTED_STATUS.contains(onlyOfficeCallback.getStatus())) {\n            String key = onlyOfficeCallback.getKey();\n            OnlyOfficeFile onlyOfficeFile = OnlyOfficeKeyCacheUtils.removeByKey(key);\n            ReentrantLock lock = OnlyOfficeKeyCacheUtils.getLock(onlyOfficeFile);\n            lock.lock();\n            log.debug(\"开始处理 OnlyOffice 文件: {}, 加锁\", key);\n            try {\n                // 文件不存在或者存储策略不存在, 直接返回错误信息.\n                if (onlyOfficeFile == null) {\n                    return CALLBACK_ERROR_MSG;\n                }\n                AbstractBaseFileService<?> storageServiceByKey = StorageSourceContext.getByStorageKey(onlyOfficeFile.getStorageKey());\n                if (storageServiceByKey == null) {\n                    return CALLBACK_ERROR_MSG;\n                }\n\n                String userId = CollUtil.getFirst(onlyOfficeCallback.getUsers());\n                if (StringUtils.isNotBlank(userId)) {\n                    StpUtil.login(userId);\n                }\n\n                log.debug(\"开始保存 OnlyOffice 文件: {}, {}\", onlyOfficeFile.getStorageKey(), onlyOfficeFile.getPathAndName());\n\n                if (Beans.isInstanceOf(storageServiceByKey, AbstractProxyTransferService.class)) {\n                    // 进行上传.\n                    AbstractProxyTransferService<?> proxyUploadService = (AbstractProxyTransferService<?>) storageServiceByKey;\n\n                    try {\n                        URL url = new URI(onlyOfficeCallback.getUrl()).toURL();\n                        URLConnection connection = url.openConnection();\n                        long contentLength = connection.getContentLengthLong();\n                        try (InputStream inputStream = connection.getInputStream()) {\n                            String pathAndName = onlyOfficeFile.getPathAndName();\n                            proxyUploadService.uploadFile(pathAndName, inputStream, contentLength);\n                        }\n                    } catch (Exception e) {\n                        log.error(\"回调保存 OnlyOffice 文件失败\", e);\n                        return CALLBACK_ERROR_MSG;\n                    }\n                }\n\n                log.debug(\"完成保存 OnlyOffice 文件: {}, {}\", onlyOfficeFile.getStorageKey(), onlyOfficeFile.getPathAndName());\n            } finally {\n                log.debug(\"完成处理 OnlyOffice 文件: {}, 解锁\", key);\n                lock.unlock();\n            }\n        }\n        return CALLBACK_SUCCESS_MSG;\n    }\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/onlyoffice/model/OnlyOfficeCallback.java",
    "content": "package im.zhaojun.zfile.module.onlyoffice.model;\n\nimport com.fasterxml.jackson.annotation.JsonFormat;\nimport lombok.Data;\n\nimport java.util.Date;\nimport java.util.List;\nimport java.util.Map;\n\n@Data\npublic class OnlyOfficeCallback {\n\n    /**\n     * 定义编辑的文档标识符。\n     */\n    private String key;\n\n    /**\n     * 定义文档的状态。 可以有以下值：\n     * 1 - 正在编辑文档，\n     * 2 - 文档已准备好保存，\n     * 3 - 发生文档保存错误，\n     * 4 - 文档已关闭，没有任何更改，\n     * 6 - 正在编辑文档，但保存了当前文档状态，\n     * 7 - 强制保存文档时发生错误。\n     */\n    private int status;\n\n    /**\n     * 定义已编辑的要由文档存储服务保存的文档的链接。 仅当 status 值等于 2, 3, 6 或 7 时，链接才存在。\n     */\n    private String url;\n\n    /**\n     * 定义有文档更改历史的对象。 仅当 status 值等于 2 或 3 时，对象才存在。\n     * 它包含对象 changes 和 serverVersion，它们必须作为对象的属性 changes 和 serverVersion 以参数形式发送给 refreshHistory 方法。\n     */\n    private Map<String, Object> history;\n\n    /**\n     * 定义打开文档进行编辑的用户的标识符列表；\n     * 当文档被更改时，用户将返回最后编辑文档的用户的标识符（对于 status 2 和 status 6 的应答）。\n     */\n    private List<String> users;\n\n    /**\n     * 定义当用户对文档执行操作时接收到的对象。type 字段值可以具有以下值：\n     * 0 - 用户断开与文档共同编辑的连接，\n     * 1 - 新用户连接到文档共同编辑，\n     * 2 - 用户单击 强制保存按钮。\n     * userid 字段值是用户标识符。\n     */\n    private List<Action> actions;\n\n    /**\n     * 定义文档的最后保存日期和时间。\n     */\n    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = \"yyyy-MM-dd'T'HH:mm:ss.SSSX\")\n    private Date lastsave;\n\n    /**\n     * 文档是否未被修改。\n     */\n    private boolean notmodified;\n\n    /**\n     * JWT 令牌，用于验证用户。\n     */\n    private String token;\n\n    /**\n     * 定义从 url 参数指定的链接下载文档的扩展名。 文件类型默认为 OOXML，但如果启用了 assemblyFormatAsOrigin 服务器设置，则文件将以原始格式保存。\n     */\n    private String filetype;\n\n    // Inner class for actions\n    @Data\n    public static class Action {\n\n        /**\n         * 定义当用户对文档执行操作时接收到的对象。type 字段值可以具有以下值：\n         * 0 - 用户断开与文档共同编辑的连接，\n         * 1 - 新用户连接到文档共同编辑，\n         * 2 - 用户单击 强制保存按钮。\n         * userid 字段值是用户标识符。\n         */\n        private int type;\n\n        private String userid;\n\n    }\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/onlyoffice/model/OnlyOfficeFile.java",
    "content": "package im.zhaojun.zfile.module.onlyoffice.model;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\n\n@Data\n@AllArgsConstructor\npublic class OnlyOfficeFile {\n\n    private String storageKey;\n\n    private String pathAndName;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/password/controller/StorageSourcePasswordController.java",
    "content": "package im.zhaojun.zfile.module.password.controller;\n\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport com.github.xiaoymin.knife4j.annotations.ApiSort;\nimport im.zhaojun.zfile.core.annotation.DemoDisable;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.module.password.model.entity.PasswordConfig;\nimport im.zhaojun.zfile.module.password.service.PasswordConfigService;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.Parameter;\nimport io.swagger.v3.oas.annotations.enums.ParameterIn;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.annotation.Resource;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.List;\n\n/**\n * 存储源密码维护接口\n *\n * @author zhaojun\n */\n@Tag(name = \"存储源模块-密码文件夹\")\n@ApiSort(6)\n@RestController\n@RequestMapping(\"/admin\")\npublic class StorageSourcePasswordController {\n\n    @Resource\n    private PasswordConfigService passwordConfigService;\n\n    @ApiOperationSupport(order = 1)\n    @Operation(summary = \"获取存储源密码文件夹列表\", description =\"根据存储源 ID 获取存储源设置的密码文件夹列表\")\n    @Parameter(in = ParameterIn.PATH, name = \"storageId\", description = \"存储源 id\", required = true, schema = @Schema(type = \"integer\"))\n    @GetMapping(\"/storage/{storageId}/password\")\n    public AjaxJson<List<PasswordConfig>> getPasswordList(@PathVariable Integer storageId) {\n        return AjaxJson.getSuccessData(passwordConfigService.findByStorageId(storageId));\n    }\n\n\n    @ApiOperationSupport(order = 2)\n    @Operation(summary = \"保存存储源密码文件夹列表\", description =\"保存指定存储源 ID 设置的密码文件夹列表\")\n    @Parameter(in = ParameterIn.PATH, name = \"storageId\", description = \"存储源 id\", required = true, schema = @Schema(type = \"integer\"))\n    @PostMapping(\"/storage/{storageId}/password\")\n    @DemoDisable\n    public AjaxJson<Void> savePasswordList(@PathVariable Integer storageId, @RequestBody List<PasswordConfig> password) {\n        passwordConfigService.batchSave(storageId, password);\n        return AjaxJson.getSuccess();\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/password/mapper/PasswordConfigMapper.java",
    "content": "package im.zhaojun.zfile.module.password.mapper;\n\nimport com.baomidou.mybatisplus.core.mapper.BaseMapper;\nimport im.zhaojun.zfile.module.password.model.entity.PasswordConfig;\nimport org.apache.ibatis.annotations.Mapper;\nimport org.apache.ibatis.annotations.Param;\n\nimport java.util.List;\n\n/**\n * 存储源密码配置表 Mapper 接口\n *\n * @author zhaojun\n */\n@Mapper\npublic interface PasswordConfigMapper extends BaseMapper<PasswordConfig> {\n\n    /**\n     * 根据存储源 ID 获取密码规则配置\n     *\n     * @param   storageId\n     *          存储源 ID\n     *\n     * @return  存储源密码规则配置列表\n     */\n    List<PasswordConfig> findByStorageId(@Param(\"storageId\") Integer storageId);\n\n\n    /**\n     * 根据存储源 ID 删除要密码规则配置\n     *\n     * @param   storageId\n     *          存储源 ID\n     *\n     * @return  删除记录数\n     */\n    int deleteByStorageId(@Param(\"storageId\") Integer storageId);\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/password/model/dto/VerifyResultDTO.java",
    "content": "package im.zhaojun.zfile.module.password.model.dto;\n\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport lombok.Data;\n\n/**\n * 用于表示校验结果的类\n *\n * @author zhaojun\n */\n@Data\npublic class VerifyResultDTO {\n\n    /**\n     * 是否成功\n     */\n    private boolean passed;\n\n    /**\n     * 表达式\n     */\n    private String pattern;\n\n    /**\n     * 错误消息\n     */\n    private ErrorCode errorCode;\n\n    public static VerifyResultDTO success() {\n        VerifyResultDTO verifyResultDTO = new VerifyResultDTO();\n        verifyResultDTO.setPassed(true);\n        return verifyResultDTO;\n    }\n\n\n    public static VerifyResultDTO success(String pattern) {\n        VerifyResultDTO verifyResultDTO = new VerifyResultDTO();\n        verifyResultDTO.setPassed(true);\n        verifyResultDTO.setPattern(pattern);\n        return verifyResultDTO;\n    }\n\n\n    public static VerifyResultDTO fail(ErrorCode errorCode) {\n        VerifyResultDTO verifyResultDTO = new VerifyResultDTO();\n        verifyResultDTO.setPassed(false);\n        verifyResultDTO.setErrorCode(errorCode);\n        return verifyResultDTO;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/password/model/entity/PasswordConfig.java",
    "content": "package im.zhaojun.zfile.module.password.model.entity;\n\nimport com.baomidou.mybatisplus.annotation.IdType;\nimport com.baomidou.mybatisplus.annotation.TableField;\nimport com.baomidou.mybatisplus.annotation.TableId;\nimport com.baomidou.mybatisplus.annotation.TableName;\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport java.io.Serializable;\n\n/**\n * 密码设置 entity\n *\n * @author zhaojun\n */\n@Data\n@Schema(title=\"密码设置\")\n@TableName(value = \"password_config\")\npublic class PasswordConfig implements Serializable {\n\n    private static final long serialVersionUID = 1L;\n\n    @TableId(value = \"id\", type = IdType.INPUT)\n    @Schema(title = \"ID, 新增无需填写\", example = \"1\")\n    private Integer id;\n\n\n    @TableField(value = \"storage_id\")\n    @Schema(title = \"存储源 ID\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"1\")\n    private Integer storageId;\n\n\n    @TableField(value = \"expression\")\n    @Schema(title = \"密码文件夹表达式\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"/*.png\")\n    private String expression;\n\n\n    @TableField(value = \"password\")\n    @Schema(title = \"密码值\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"123456\")\n    private String password;\n\n\n    @TableField(value = \"description\")\n    @Schema(title = \"表达式描述\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"用来辅助记忆表达式\")\n    private String description;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/password/service/PasswordConfigService.java",
    "content": "package im.zhaojun.zfile.module.password.service;\n\nimport cn.hutool.core.util.ObjectUtil;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.util.CollectionUtils;\nimport im.zhaojun.zfile.core.util.PatternMatcherUtils;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.password.mapper.PasswordConfigMapper;\nimport im.zhaojun.zfile.module.password.model.dto.VerifyResultDTO;\nimport im.zhaojun.zfile.module.password.model.entity.PasswordConfig;\nimport im.zhaojun.zfile.module.storage.event.StorageSourceCopyEvent;\nimport im.zhaojun.zfile.module.storage.event.StorageSourceDeleteEvent;\nimport im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;\nimport im.zhaojun.zfile.module.user.service.UserStorageSourceService;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.aop.framework.AopContext;\nimport org.springframework.beans.BeanUtils;\nimport org.springframework.cache.annotation.CacheConfig;\nimport org.springframework.cache.annotation.CacheEvict;\nimport org.springframework.cache.annotation.Cacheable;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.List;\nimport java.util.Objects;\n\n/**\n * 存储源密码配置 Service\n *\n * @author zhaojun\n */\n@Service\n@Slf4j\n@CacheConfig(cacheNames = \"passwordConfig\")\npublic class PasswordConfigService {\n\n    @Resource\n    private PasswordConfigMapper passwordConfigMapper;\n\n    @Resource\n    private UserStorageSourceService userStorageSourceService;\n\n\n    /**\n     * 根据存储源 ID 查询密码规则列表\n     *\n     * @param   storageId\n     *          存储源 ID\n     *\n     * @return  密码规则列表\n     */\n    @Cacheable(key = \"#storageId\",\n            condition = \"#storageId != null\")\n    public List<PasswordConfig> findByStorageId(Integer storageId) {\n        return passwordConfigMapper.findByStorageId(storageId);\n    }\n\n\n    /**\n     * 批量保存指定存储源 ID 的密码规则列表\n     *\n     * @param   storageId\n     *          存储源 ID\n     *\n     * @param   passwordConfigList\n     *          存储源类别\n     */\n    @Transactional(rollbackFor = Exception.class)\n    public void batchSave(Integer storageId, List<PasswordConfig> passwordConfigList) {\n        ((PasswordConfigService) AopContext.currentProxy()).deleteByStorageId(storageId);\n        log.info(\"更新存储源 ID 为 {} 的过滤规则 {} 条\", storageId, passwordConfigList.size());\n\n        passwordConfigList.forEach(passwordConfig -> {\n            passwordConfig.setId(null);\n            passwordConfig.setStorageId(storageId);\n            passwordConfigMapper.insert(passwordConfig);\n\n            if (log.isDebugEnabled()) {\n                log.debug(\"新增过滤规则, 存储源 ID: {}, 表达式: {}, 描述: {}, 密码: {}\",\n                        passwordConfig.getStorageId(), passwordConfig.getExpression(),\n                        passwordConfig.getDescription(), passwordConfig.getPassword());\n            }\n        });\n    }\n\n\n    /**\n     * 根据存储源 id 删除所有密码规则\n     *\n     * @param   storageId\n     *          存储源 ID\n     */\n    @CacheEvict(key = \"#storageId\")\n    public int deleteByStorageId(Integer storageId) {\n        int deleteSize = passwordConfigMapper.deleteByStorageId(storageId);\n        log.info(\"删除存储源 ID 为 {} 的密码规则 {} 条\", storageId, deleteSize);\n        return deleteSize;\n    }\n\n    /**\n     * 监听存储源删除事件，根据存储源 id 删除相关的密码设置\n     *\n     * @param   storageSourceDeleteEvent\n     *          存储源删除事件\n     */\n    @EventListener\n    public void onStorageSourceDelete(StorageSourceDeleteEvent storageSourceDeleteEvent) {\n        Integer storageId = storageSourceDeleteEvent.getId();\n        int updateRows = ((PasswordConfigService) AopContext.currentProxy()).deleteByStorageId(storageId);\n        if (log.isDebugEnabled()) {\n            log.debug(\"删除存储源 [id {}, name: {}, type: {}] 时，关联删除存储源密码设置 {} 条\",\n                    storageId,\n                    storageSourceDeleteEvent.getName(),\n                    storageSourceDeleteEvent.getType().getDescription(),\n                    updateRows);\n        }\n    }\n\n    /**\n     * 校验密码\n     *\n     * @param   storageId\n     *          存储源 ID\n     *\n     * @param   path\n     *          请求路径(全路径，包含用户目录)\n     *\n     * @param   inputPassword\n     *          用户输入的密码\n     *\n     * @return  是否校验通过\n     */\n    public VerifyResultDTO verifyPassword(Integer storageId, String path, String inputPassword) {\n        // 判断是否需要忽略密码校验\n        boolean isIgnorePassword = userStorageSourceService.hasCurrentUserStorageOperatorPermission(storageId, FileOperatorTypeEnum.IGNORE_PASSWORD);\n        if (isIgnorePassword) {\n            if (log.isDebugEnabled()) {\n                log.debug(\"权限配置忽略密码校验, 请求路径: {}, 存储源 ID: {}, 输入密码: {}\", path, storageId, inputPassword);\n            }\n            return VerifyResultDTO.success();\n        }\n\n        List<PasswordConfig> passwordConfigList = ((PasswordConfigService) AopContext.currentProxy()).findByStorageId(storageId);\n\n        // 如果规则列表为空, 则表示不需要过滤, 直接返回 false\n        if (CollectionUtils.isEmpty(passwordConfigList)) {\n            if (log.isDebugEnabled()) {\n                log.debug(\"密码规则列表为空, 请求路径: {}, 存储源 ID: {}, 输入密码: {}\", path, storageId, inputPassword);\n            }\n            return VerifyResultDTO.success();\n        }\n\n        // 校验密码\n        for (PasswordConfig passwordConfig : passwordConfigList) {\n            String expression = passwordConfig.getExpression();\n            String expectPassword = passwordConfig.getPassword();\n\n            // 规则为空跳过\n            if (StringUtils.isEmpty(expression)) {\n                if (log.isDebugEnabled()) {\n                    log.debug(\"密码规则测试表达式: {}, 请求路径: {}, 表达式为空，跳过该规则比对\", expression, path);\n                }\n                continue;\n            }\n\n            try {\n                // 判断当前请求路径是否和规则路径表达式匹配\n                boolean match = PatternMatcherUtils.testCompatibilityGlobPattern(expression, path);\n\n                if (log.isDebugEnabled()) {\n                    log.debug(\"密码规则测试表达式: {}, 请求路径: {}, 匹配结果: {}, 预期密码: {}, 输入密码; {}\", expression, path, match, expectPassword, inputPassword);\n                }\n\n                // 如果匹配且输入了密码则校验\n                if (match) {\n                    if (StringUtils.isEmpty(inputPassword)) {\n                        if (log.isDebugEnabled()) {\n                            log.debug(\"密码规则匹配, 但未输入密码；\" +\n                                            \"表达式: {}, 请求路径: {}, 存储源 ID: {}, 预期密码：{}, 输入密码: {}\",\n                                    expression, path, storageId, expectPassword, inputPassword);\n                        }\n                        return VerifyResultDTO.fail(ErrorCode.BIZ_STORAGE_SOURCE_FOLDER_PASSWORD_REQUIRED);\n                    }\n\n                    if (matchPassword(expectPassword, inputPassword)) {\n                        if (log.isDebugEnabled()) {\n                            log.debug(\"密码规则匹配, 密码校验通过；\" +\n                                            \"表达式: {}, 请求路径: {}, 存储源 ID: {}, 预期密码：{}, 输入密码: {}\",\n                                    expression, path, storageId, expectPassword, inputPassword);\n                        }\n                        return VerifyResultDTO.success(expression);\n                    }\n\n                    if (log.isDebugEnabled()) {\n                        log.debug(\"密码规则匹配, 但输入密码与预期密码不同；\" +\n                                        \"表达式: {}, 请求路径: {}, 存储源 ID: {}, 预期密码：{}, 输入密码: {}\",\n                                expression, path, storageId, expectPassword, inputPassword);\n                    }\n                    return VerifyResultDTO.fail(ErrorCode.BIZ_STORAGE_SOURCE_FOLDER_PASSWORD_ERROR);\n                }\n            } catch (Exception e) {\n                log.error(\"密码规则匹配出现异常，表达式: {}, 请求路径: {}, 存储源 ID: {}, 预期密码：{}, 输入密码: {}, 解析错误, 跳过此规则.\",\n                        expression, path, storageId, expectPassword, inputPassword, e);\n            }\n        }\n\n        if (log.isDebugEnabled()) {\n            log.debug(\"校验文件夹密码 path: {}, 没有匹配的表达式, 不进行密码校验.\", path);\n        }\n\n        return VerifyResultDTO.success();\n    }\n\n\n    /**\n     * 校验两个密码是否相同, 忽略空白字符\n     *\n     * @param   expectedPasswordContent\n     *          预期密码\n     *\n     * @param   password\n     *          实际输入密码\n     *\n     * @return  是否匹配\n     */\n    private boolean matchPassword(String expectedPasswordContent, String password) {\n        if (Objects.equals(expectedPasswordContent, password)) {\n            return true;\n        }\n\n        // 如果预期密码或输入密码为空, 则不匹配\n        if (ObjectUtil.hasNull(expectedPasswordContent, password)) {\n            return false;\n        }\n\n        expectedPasswordContent = StringUtils.removeAllLineBreaksAndTrim(expectedPasswordContent);\n        password = StringUtils.removeAllLineBreaksAndTrim(password);\n        return Objects.equals(expectedPasswordContent, password);\n    }\n\n\n    /**\n     * 监听存储源复制事件, 复制存储源时, 复制存储源密码设置\n     *\n     * @param   storageSourceCopyEvent\n     *          存储源复制事件\n     */\n    @EventListener\n    public void onStorageSourceCopy(StorageSourceCopyEvent storageSourceCopyEvent) {\n        Integer fromId = storageSourceCopyEvent.getFromId();\n        Integer newId = storageSourceCopyEvent.getNewId();\n\n        List<PasswordConfig> passwordConfigList = ((PasswordConfigService) AopContext.currentProxy()).findByStorageId(fromId);\n\n        passwordConfigList.forEach(passwordConfig -> {\n            PasswordConfig newPasswordConfig = new PasswordConfig();\n            BeanUtils.copyProperties(passwordConfig, newPasswordConfig);\n            newPasswordConfig.setId(null);\n            newPasswordConfig.setStorageId(newId);\n            passwordConfigMapper.insert(newPasswordConfig);\n        });\n\n        log.info(\"复制存储源 ID 为 {} 的存储源密码设置到存储源 ID 为 {} 成功, 共 {} 条\", fromId, newId, passwordConfigList.size());\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/permission/controller/PermissionController.java",
    "content": "package im.zhaojun.zfile.module.permission.controller;\n\nimport com.github.xiaoymin.knife4j.annotations.ApiSort;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.module.permission.model.result.PermissionInfoResult;\nimport im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport java.util.List;\n\n@Tag(name = \"权限模块\")\n@ApiSort(6)\n@RestController\n@RequestMapping(\"/admin/permission\")\npublic class PermissionController {\n\n    @GetMapping(\"list\")\n    public AjaxJson<List<PermissionInfoResult>> list() {\n        FileOperatorTypeEnum[] values = FileOperatorTypeEnum.values();\n        List<PermissionInfoResult> permissionInfoResults = new java.util.ArrayList<>(values.length);\n        for (FileOperatorTypeEnum value : values) {\n            if (value.isDeprecated()) {\n                continue;\n            }\n            PermissionInfoResult permissionInfoResult = new PermissionInfoResult();\n            permissionInfoResult.setName(value.getName());\n            permissionInfoResult.setValue(value.getValue());\n            permissionInfoResult.setTips(value.getTips());\n            permissionInfoResults.add(permissionInfoResult);\n        }\n        return AjaxJson.getSuccessData(permissionInfoResults);\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/permission/controller/StorageSourcePermissionController.java",
    "content": "package im.zhaojun.zfile.module.permission.controller;\n\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport com.github.xiaoymin.knife4j.annotations.ApiSort;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.module.permission.convert.PermissionConfigConvert;\nimport im.zhaojun.zfile.module.permission.model.entity.PermissionConfig;\nimport im.zhaojun.zfile.module.permission.model.result.PermissionConfigResult;\nimport im.zhaojun.zfile.module.permission.service.PermissionConfigService;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.Parameter;\nimport io.swagger.v3.oas.annotations.enums.ParameterIn;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.annotation.Resource;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport java.util.List;\n\n/**\n * 存储源权限控制 Controller\n *\n * @author zhaojun\n */\n@Tag(name = \"存储源模块-权限控制\")\n@ApiSort(6)\n@RestController\n@RequestMapping(\"/admin\")\npublic class StorageSourcePermissionController {\n\n\t@Resource\n\tprivate PermissionConfigService permissionConfigService;\n\n\t@Resource\n\tprivate PermissionConfigConvert permissionConfigConvert;\n\n\t@ApiOperationSupport(order = 1)\n\t@Operation(summary = \"获取存储源权限列表\", description =\"根据存储源 ID 获取存储源权限列表\")\n\t@Parameter(in = ParameterIn.PATH, name = \"storageId\", description = \"存储源 id\", required = true, schema = @Schema(type = \"integer\"))\n\t@GetMapping(\"/storage/{storageId}/permission\")\n\tpublic AjaxJson<List<PermissionConfigResult>> getPermissionList(@PathVariable Integer storageId) {\n\t\tList<PermissionConfig> permissionList = permissionConfigService.findByStorageId(storageId);\n\n\t\tList<PermissionConfigResult> permissionConfigResults = permissionConfigConvert.toResult(permissionList);\n\t\tpermissionConfigResults.forEach(permissionConfigResult -> {\n\t\t\tpermissionConfigResult.setOperatorName(permissionConfigResult.getOperator().getName());\n\t\t\tpermissionConfigResult.setTips(permissionConfigResult.getOperator().getTips());\n\t\t});\n\t\t\n\t\treturn AjaxJson.getSuccessData(permissionConfigResults);\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/permission/convert/PermissionConfigConvert.java",
    "content": "package im.zhaojun.zfile.module.permission.convert;\n\nimport im.zhaojun.zfile.module.permission.model.entity.PermissionConfig;\nimport im.zhaojun.zfile.module.permission.model.result.PermissionConfigResult;\nimport org.mapstruct.Mapper;\nimport org.mapstruct.ReportingPolicy;\nimport org.springframework.stereotype.Component;\n\nimport java.util.List;\n\n/**\n * 权限配置实体类转换器\n *\n * @author zhaojun\n */\n@Component\n@Mapper(componentModel = \"spring\", unmappedTargetPolicy = ReportingPolicy.IGNORE)\npublic interface PermissionConfigConvert {\n\n\tList<PermissionConfigResult> toResult(List<PermissionConfig> permissionConfig);\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/permission/mapper/PermissionConfigMapper.java",
    "content": "package im.zhaojun.zfile.module.permission.mapper;\n\nimport com.baomidou.mybatisplus.core.mapper.BaseMapper;\nimport im.zhaojun.zfile.module.permission.model.entity.PermissionConfig;\nimport org.apache.ibatis.annotations.Mapper;\nimport org.apache.ibatis.annotations.Param;\n\nimport java.util.List;\n\n/**\n  * 权限设置 Mapper\n  *\n * @author zhaojun\n */\n@Mapper\npublic interface PermissionConfigMapper extends BaseMapper<PermissionConfig> {\n\n    /**\n     * 根据存储源 ID 查询权限配置\n     *\n     * @param   storageId\n     *          存储源ID\n     *\n     * @return  存储源权限配置列表\n     */\n    List<PermissionConfig> findByStorageId(@Param(\"storageId\") Integer storageId);\n\n\n    /**\n     * 根据存储源 ID 删除权限配置\n     *\n     * @param   storageId\n     *          存储源ID\n     *\n     * @return  删除记录数\n     */\n    int deleteByStorageId(@Param(\"storageId\") Integer storageId);\n    \n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/permission/model/entity/PermissionConfig.java",
    "content": "package im.zhaojun.zfile.module.permission.model.entity;\n\nimport com.baomidou.mybatisplus.annotation.IdType;\nimport com.baomidou.mybatisplus.annotation.TableField;\nimport com.baomidou.mybatisplus.annotation.TableId;\nimport com.baomidou.mybatisplus.annotation.TableName;\nimport im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.dto.FileOperatorTypeDefaultValueDTO;\nimport lombok.Data;\n\nimport java.io.Serializable;\n\n /**\n  * 权限设置表\n * @author zhaojun\n */\n@Data\n@TableName(value = \"`permission_config`\")\npublic class PermissionConfig implements Serializable {\n    \n    @TableId(value = \"id\", type = IdType.INPUT)\n    private Integer id;\n\n    /**\n     * 操作\n     */\n    @TableField(value = \"`operator`\")\n    private FileOperatorTypeEnum operator;\n\n    /**\n     * 允许管理员操作\n     */\n    @TableField(value = \"`allow_admin`\")\n    private Boolean allowAdmin;\n\n    /**\n     * 允许匿名用户操作\n     */\n    @TableField(value = \"`allow_anonymous`\")\n    private Boolean allowAnonymous;\n\n    /**\n     * 存储源 ID\n     */\n    @TableField(value = \"`storage_id`\")\n    private Integer storageId;\n\n    private static final long serialVersionUID = 1L;\n    \n    public static PermissionConfig getDefaultInstance(Integer storageId, FileOperatorTypeEnum operator) {\n        PermissionConfig permissionConfig = new PermissionConfig();\n        permissionConfig.storageId = storageId;\n        permissionConfig.operator = operator;\n    \n        FileOperatorTypeDefaultValueDTO defaultPermissionValue = operator.getDefaultValue(storageId);\n        permissionConfig.allowAdmin = defaultPermissionValue.isAllowAdmin();\n        permissionConfig.allowAnonymous = defaultPermissionValue.isAllowAnonymous();\n        \n        return permissionConfig;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/permission/model/result/PermissionConfigResult.java",
    "content": "package im.zhaojun.zfile.module.permission.model.result;\n\nimport im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;\nimport lombok.Data;\n\n/**\n * @author zhaojun\n */\n@Data\npublic class PermissionConfigResult {\n\n\t/**\n\t * 操作\n\t */\n\tprivate FileOperatorTypeEnum operator;\n\n\t/**\n\t * 允许管理员操作\n\t */\n\tprivate Boolean allowAdmin;\n\n\t/**\n\t * 允许匿名用户操作\n\t */\n\tprivate Boolean allowAnonymous;\n\n\t/**\n\t * 存储源 ID\n\t */\n\tprivate String operatorName;\n\n\t/**\n\t * 提示信息\n\t */\n\tprivate String tips;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/permission/model/result/PermissionInfoResult.java",
    "content": "package im.zhaojun.zfile.module.permission.model.result;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\n@Data\npublic class PermissionInfoResult {\n\n    @Schema(title=\"权限名称\")\n    private String name;\n\n    @Schema(title=\"权限标识\")\n    private String value;\n\n    @Schema(title=\"权限描述\")\n    private String tips;\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/permission/service/PermissionConfigService.java",
    "content": "package im.zhaojun.zfile.module.permission.service;\n\nimport im.zhaojun.zfile.module.permission.mapper.PermissionConfigMapper;\nimport im.zhaojun.zfile.module.permission.model.entity.PermissionConfig;\nimport im.zhaojun.zfile.module.storage.event.StorageSourceDeleteEvent;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.aop.framework.AopContext;\nimport org.springframework.cache.annotation.CacheConfig;\nimport org.springframework.cache.annotation.CacheEvict;\nimport org.springframework.cache.annotation.Cacheable;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.Comparator;\nimport java.util.List;\n\n/**\n * 存储源权限 Service\n *\n * @author zhaojun\n */\n@Service\n@Slf4j\n@CacheConfig(cacheNames = \"permissionConfig\")\n@Deprecated\npublic class PermissionConfigService {\n\n\t@Resource\n\tprivate PermissionConfigMapper permissionConfigMapper;\n\n\t/**\n\t * 根据存储源 ID 查询权限配置\n\t *\n\t * @param   storageId\n\t *          存储源ID\n\t *\n\t * @return  存储源权限配置列表\n\t */\n\t@Deprecated\n\tpublic synchronized List<PermissionConfig> findByStorageId(Integer storageId) {\n\t\treturn ((PermissionConfigService) AopContext.currentProxy()).findByStorageIdNotThreadSafe(storageId);\n\t}\n\n\n\t/**\n\t * 根据存储源 ID 查询权限配置\n\t * 提示：受 sqlite 限制, 多线程调用此方法会出现 \"[SQLITE_BUSY]  The database file is locked (database is locked)\" 错误)\n\t * 建议使用 {@link PermissionConfigService#findByStorageId(Integer)} 俩保证所有数据库都是现场安全的。\n\t *\n\t * @param   storageId\n\t *          存储源ID\n\t *\n\t * @return  存储源权限配置列表\n\t */\n\t@Transactional(rollbackFor = Exception.class)\n\t@Deprecated\n\t@Cacheable(key = \"#storageId\",\n\t\t\tcondition = \"#storageId != null\")\n\tpublic List<PermissionConfig> findByStorageIdNotThreadSafe(Integer storageId) {\n\t\t// 数据库查询所有权限配置\n\t\tList<PermissionConfig> dbResult = permissionConfigMapper.findByStorageId(storageId);\n\t\t// 按照权限枚举顺序排序结果\n\t\tdbResult.sort(Comparator.comparingInt(permissionConfig -> permissionConfig.getOperator().ordinal()));\n\t\treturn dbResult;\n\t}\n\n\n\t/**\n\t * 根据存储源 ID 删除权限配置\n\t *\n\t * @param \tstorageId\n\t * \t\t\t存储源 ID\n\t */\n\t@Deprecated\n\t@CacheEvict(key = \"#storageId\")\n\tpublic int deleteByStorageId(Integer storageId) {\n\t\tint deleteSize = permissionConfigMapper.deleteByStorageId(storageId);\n\t\tlog.info(\"删除存储源 ID 为 {} 的权限配置 {} 条\", storageId, deleteSize);\n\t\treturn deleteSize;\n\t}\n\n\n\t/**\n\t * 监听存储源删除事件，根据存储源 id 删除相关的权限设置\n\t *\n\t * @param   storageSourceDeleteEvent\n\t *          存储源删除事件\n\t */\n\t@EventListener\n\tpublic void onStorageSourceDelete(StorageSourceDeleteEvent storageSourceDeleteEvent) {\n\t\tInteger storageId = storageSourceDeleteEvent.getId();\n\t\tint updateRows = ((PermissionConfigService) AopContext.currentProxy()).deleteByStorageId(storageId);\n\t\tif (log.isDebugEnabled()) {\n\t\t\tlog.debug(\"删除存储源 [id {}, name: {}, type: {}] 时，关联删除存储源权限设置(老) {} 条\",\n\t\t\t\t\tstorageId,\n\t\t\t\t\tstorageSourceDeleteEvent.getName(),\n\t\t\t\t\tstorageSourceDeleteEvent.getType().getDescription(),\n\t\t\t\t\tupdateRows);\n\t\t}\n\t}\n\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/readme/controller/StorageSourceReadmeController.java",
    "content": "package im.zhaojun.zfile.module.readme.controller;\n\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport com.github.xiaoymin.knife4j.annotations.ApiSort;\nimport im.zhaojun.zfile.core.annotation.DemoDisable;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.module.readme.model.entity.ReadmeConfig;\nimport im.zhaojun.zfile.module.readme.service.ReadmeConfigService;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.Parameter;\nimport io.swagger.v3.oas.annotations.enums.ParameterIn;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.annotation.Resource;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.List;\n\n/**\n * 存储源文档模块维护接口\n *\n * @author zhaojun\n */\n@Tag(name = \"存储源模块-README\")\n@ApiSort(7)\n@RestController\n@RequestMapping(\"/admin\")\npublic class StorageSourceReadmeController {\n\n    @Resource\n    private ReadmeConfigService readmeConfigService;\n\n    @ApiOperationSupport(order = 1)\n    @Operation(summary = \"获取存储源文档文件夹列表\", description =\"根据存储源 ID 获取存储源设置的文档文件夹列表\")\n    @Parameter(in = ParameterIn.PATH, name = \"storageId\", description = \"存储源 id\", required = true, schema = @Schema(type = \"integer\"))\n    @GetMapping(\"/storage/{storageId}/readme\")\n    public AjaxJson<List<ReadmeConfig>> getReadmeList(@PathVariable Integer storageId) {\n        return AjaxJson.getSuccessData(readmeConfigService.findByStorageId(storageId));\n    }\n\n\n    @ApiOperationSupport(order = 2)\n    @Operation(summary = \"保存存储源文档文件夹列表\", description =\"保存指定存储源 ID 设置的文档文件夹列表\")\n    @Parameter(in = ParameterIn.PATH, name = \"storageId\", description = \"存储源 id\", required = true, schema = @Schema(type = \"integer\"))\n    @PostMapping(\"/storage/{storageId}/readme\")\n    @DemoDisable\n    public AjaxJson<Void> saveReadmeList(@PathVariable Integer storageId, @RequestBody List<ReadmeConfig> readme) {\n        readmeConfigService.batchSave(storageId, readme);\n        return AjaxJson.getSuccess();\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/readme/mapper/ReadmeConfigMapper.java",
    "content": "package im.zhaojun.zfile.module.readme.mapper;\n\nimport com.baomidou.mybatisplus.core.mapper.BaseMapper;\nimport im.zhaojun.zfile.module.readme.model.entity.ReadmeConfig;\nimport org.apache.ibatis.annotations.Mapper;\nimport org.apache.ibatis.annotations.Param;\n\nimport java.util.List;\n\n/**\n * 存储源文档配置表 Mapper 接口\n *\n * @author zhaojun\n */\n@Mapper\npublic interface ReadmeConfigMapper extends BaseMapper<ReadmeConfig> {\n\n\n\t/**\n\t * 根据存储源 ID 查询文档配置\n\t *\n\t * @param   storageId\n\t *          存储源ID\n\t *\n\t * @return  存储源文档配置列表\n\t */\n\tList<ReadmeConfig> findByStorageId(@Param(\"storageId\") Integer storageId);\n\n\n\t/**\n\t * 根据存储源 ID 删除文档配置\n\t *\n\t * @param   storageId\n\t *          存储源ID\n\t *\n\t * @return  删除记录数\n\t */\n\tint deleteByStorageId(@Param(\"storageId\") Integer storageId);\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/readme/model/entity/ReadmeConfig.java",
    "content": "package im.zhaojun.zfile.module.readme.model.entity;\n\nimport com.baomidou.mybatisplus.annotation.IdType;\nimport com.baomidou.mybatisplus.annotation.TableField;\nimport com.baomidou.mybatisplus.annotation.TableId;\nimport com.baomidou.mybatisplus.annotation.TableName;\nimport im.zhaojun.zfile.module.readme.model.enums.ReadmeDisplayModeEnum;\nimport im.zhaojun.zfile.module.readme.model.enums.ReadmePathModeEnum;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport java.io.Serializable;\n\n/**\n * readme 文档配置 entity\n *\n * @author zhaojun\n */\n@Data\n@Schema(title=\"readme 文档配置\")\n@TableName(value = \"`readme_config`\")\npublic class ReadmeConfig implements Serializable {\n\n    private static final long serialVersionUID = 1L;\n\n    @TableId(value = \"id\", type = IdType.INPUT)\n    @Schema(title = \"ID, 新增无需填写\", example = \"1\")\n    private Integer id;\n\n\n    @TableField(value = \"`storage_id`\")\n    @Schema(title=\"存储源 ID\")\n    private Integer storageId;\n\n\n    @TableField(value = \"`description`\")\n    @Schema(title = \"表达式描述\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"用来辅助记忆表达式\")\n    private String description;\n\n\n    @TableField(value = \"`expression`\")\n    @Schema(title=\"路径表达式\")\n    private String expression;\n\n\n    @TableField(value = \"`readme_text`\")\n    @Schema(title=\"readme 文本内容, 支持 md 语法.\")\n    private String readmeText;\n\n    @TableField(value = \"`path_mode`\")\n    @Schema(title = \"路径模式\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"相等路径或绝对路径\")\n    private ReadmePathModeEnum pathMode;\n\n    @TableField(value = \"`display_mode`\")\n    @Schema(title = \"显示模式\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"readme 显示模式，支持顶部显示: top, 底部显示:bottom, 弹窗显示: dialog\")\n    private ReadmeDisplayModeEnum displayMode;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/readme/model/enums/ReadmeDisplayModeEnum.java",
    "content": "package im.zhaojun.zfile.module.readme.model.enums;\n\nimport com.baomidou.mybatisplus.annotation.EnumValue;\nimport com.fasterxml.jackson.annotation.JsonValue;\nimport lombok.AllArgsConstructor;\nimport lombok.Getter;\n\n/**\n * Readme 展示模式枚举\n *\n * @author zhaojun\n */\n@Getter\n@AllArgsConstructor\npublic enum ReadmeDisplayModeEnum {\n\n\t/**\n\t * 顶部显示\n\t */\n\tTOP(\"top\"),\n\n\t/**\n\t * 底部显示\n\t */\n\tBOTTOM(\"bottom\"),\n\n\t/**\n\t * 弹窗显示\n\t */\n\tDIALOG(\"dialog\");\n\n\t@EnumValue\n\t@JsonValue\n\tprivate final String value;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/readme/model/enums/ReadmePathModeEnum.java",
    "content": "package im.zhaojun.zfile.module.readme.model.enums;\n\nimport com.baomidou.mybatisplus.annotation.EnumValue;\nimport com.fasterxml.jackson.annotation.JsonValue;\nimport lombok.AllArgsConstructor;\nimport lombok.Getter;\n\n/**\n * Readme 路径模式枚举\n *\n * @author zhaojun\n */\n@Getter\n@AllArgsConstructor\npublic enum ReadmePathModeEnum {\n\n\t/**\n\t * 相对路径\n\t */\n    RELATIVE(\"relative\"),\n\n\t/**\n\t * 绝对路径\n\t */\n    ABSOLUTE(\"absolute\");\n\n\t@EnumValue\n\t@JsonValue\n\tprivate final String value;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/readme/service/ReadmeConfigService.java",
    "content": "package im.zhaojun.zfile.module.readme.service;\n\nimport im.zhaojun.zfile.core.util.CollectionUtils;\nimport im.zhaojun.zfile.core.util.HttpUtil;\nimport im.zhaojun.zfile.core.util.PatternMatcherUtils;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.readme.mapper.ReadmeConfigMapper;\nimport im.zhaojun.zfile.module.readme.model.entity.ReadmeConfig;\nimport im.zhaojun.zfile.module.readme.model.enums.ReadmeDisplayModeEnum;\nimport im.zhaojun.zfile.module.readme.model.enums.ReadmePathModeEnum;\nimport im.zhaojun.zfile.module.storage.context.StorageSourceContext;\nimport im.zhaojun.zfile.module.storage.event.StorageSourceCopyEvent;\nimport im.zhaojun.zfile.module.storage.event.StorageSourceDeleteEvent;\nimport im.zhaojun.zfile.module.storage.model.param.IStorageParam;\nimport im.zhaojun.zfile.module.storage.model.result.FileItemResult;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.apache.commons.lang3.BooleanUtils;\nimport org.springframework.aop.framework.AopContext;\nimport org.springframework.beans.BeanUtils;\nimport org.springframework.cache.annotation.CacheConfig;\nimport org.springframework.cache.annotation.CacheEvict;\nimport org.springframework.cache.annotation.Cacheable;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.List;\n\n/**\n * 存储源 readme 配置 Service\n *\n * @author zhaojun\n */\n@Slf4j\n@Service\n@CacheConfig(cacheNames = \"readmeConfig\")\npublic class ReadmeConfigService {\n\n\t@Resource\n\tprivate ReadmeConfigMapper readmeConfigMapper;\n\n\t/**\n\t * 根据存储源 ID 查询文档配置\n\t *\n\t * @param   storageId\n\t *          存储源ID\n\t *\n\t * @return  存储源文档配置列表\n\t */\n\t@Cacheable(key = \"#storageId\",\n\t\t\t\tcondition = \"#storageId != null\")\n\tpublic List<ReadmeConfig> findByStorageId(Integer storageId){\n\t\treturn readmeConfigMapper.findByStorageId(storageId);\n\t}\n\n\n\t/**\n\t * 批量保存存储源 readme 配置, 会先删除之前的所有配置(在事务中运行)\n\t *\n\t * @param   storageId\n\t *          存储源 ID\n\t *\n\t * @param   readmeConfigList\n\t *          存储源 readme 配置列表\n\t */\n\t@Transactional(rollbackFor = Exception.class)\n\tpublic void batchSave(Integer storageId, List<ReadmeConfig> readmeConfigList) {\n\t\t((ReadmeConfigService) AopContext.currentProxy()).deleteByStorageId(storageId);\n\n\t\tlog.info(\"更新存储源 ID 为 {} 的目录文档配置 {} 条\", storageId, readmeConfigList.size());\n\n\t\treadmeConfigList.forEach(readmeConfig -> {\n\t\t\treadmeConfig.setId(null);\n\t\t\treadmeConfig.setStorageId(storageId);\n\t\t\treadmeConfigMapper.insert(readmeConfig);\n\n\t\t\tif (log.isDebugEnabled()) {\n\t\t\t\tlog.debug(\"新增目录文档, 存储源 ID: {}, 表达式: {}, 描述: {}, 显示模式: {}\",\n\t\t\t\t\t\treadmeConfig.getStorageId(), readmeConfig.getExpression(),\n\t\t\t\t\t\treadmeConfig.getDescription(), readmeConfig.getDisplayMode().getValue());\n\t\t\t}\n\t\t});\n\t}\n\n\n\t/**\n\t * 根据存储源 ID 删除存储源 readme 配置\n\t *\n\t * @param \tstorageId\n\t * \t\t\t存储源 ID\n\t */\n\t@CacheEvict(key = \"#storageId\")\n\tpublic int deleteByStorageId(Integer storageId) {\n\t\tint deleteSize = readmeConfigMapper.deleteByStorageId(storageId);\n\t\tlog.info(\"删除存储源 ID 为 {} 的目录文档配置 {} 条\", storageId, deleteSize);\n\t\treturn deleteSize;\n\t}\n\n\t/**\n\t * 监听存储源删除事件，根据存储源 id 删除相关的目录文档设置\n\t *\n\t * @param   storageSourceDeleteEvent\n\t *          存储源删除事件\n\t */\n\t@EventListener\n\tpublic void onStorageSourceDelete(StorageSourceDeleteEvent storageSourceDeleteEvent) {\n\t\tInteger storageId = storageSourceDeleteEvent.getId();\n\t\tint updateRows = ((ReadmeConfigService) AopContext.currentProxy()).deleteByStorageId(storageId);\n\t\tif (log.isDebugEnabled()) {\n\t\t\tlog.debug(\"删除存储源 [id {}, name: {}, type: {}] 时，关联删除存储源目录文档设置 {} 条\",\n\t\t\t\t\tstorageId,\n\t\t\t\t\tstorageSourceDeleteEvent.getName(),\n\t\t\t\t\tstorageSourceDeleteEvent.getType().getDescription(),\n\t\t\t\t\tupdateRows);\n\t\t}\n\t}\n\n\t/**\n\t * 根据存储源指定路径下的 readme 配置\n\t *\n\t * @param   storageId\n\t *          存储源ID\n\t *\n\t * @param   path\n\t *          文件夹路径\n\t *\n\t * @return  存储源 readme 配置列表\n\t */\n\tpublic ReadmeConfig findReadmeByPath(Integer storageId, String path) {\n\t\tList<ReadmeConfig> readmeConfigList = ((ReadmeConfigService) AopContext.currentProxy()).findByStorageId(storageId);\n\t\treturn getReadmeByTestPattern(storageId, readmeConfigList, path);\n\t}\n\n\n\t/**\n\t * 根据存储源指定路径下的 readme 配置，如果指定为兼容模式，则会读取指定目录下的 readme.md 文件.\n\t *\n\t * @param \tstorageId\n\t * \t\t\t存储源 ID\n\t *\n\t * @param \tpath\n\t * \t\t\t存储源路径\n\t *\n\t * @param \tcompatibilityReadme\n\t * \t\t\t是否兼容为读取 readme.md 文件\n\t *\n\t * @return  目录下存储源 readme 配置\n\t */\n\tpublic ReadmeConfig getByStorageAndPath(Integer storageId, String path, Boolean compatibilityReadme) {\n\t\tReadmeConfig readmeByPath = new ReadmeConfig();\n\t\treadmeByPath.setStorageId(storageId);\n\t\treadmeByPath.setDisplayMode(ReadmeDisplayModeEnum.BOTTOM);\n\t\tif (BooleanUtils.isTrue(compatibilityReadme)) {\n\t\t\ttry {\n\t\t\t\tAbstractBaseFileService<IStorageParam> abstractBaseFileService = StorageSourceContext.getByStorageId(storageId);\n\t\t\t\tString pathAndName = StringUtils.concat(path, \"readme.md\");\n\t\t\t\tFileItemResult fileItem = abstractBaseFileService.getFileItem(pathAndName);\n\t\t\t\tif (fileItem != null) {\n\t\t\t\t\tString url = fileItem.getUrl();\n\t\t\t\t\tString readmeText = HttpUtil.getTextContent(url);\n\t\t\t\t\tif (log.isDebugEnabled()) {\n\t\t\t\t\t\tlog.debug(\"存储源 {} 兼容获取目录 {} 下的 readme.md 文件成功, url: {}\", storageId, path, url);\n\t\t\t\t\t}\n\t\t\t\t\treadmeByPath.setReadmeText(readmeText);\n\t\t\t\t}\n\t\t\t} catch (Exception e) {\n\t\t\t\tlog.error(\"存储源 {} 兼容获取目录 {} 下的 readme.md 文件失败\", storageId, path, e);\n\t\t\t}\n\t\t} else {\n\t\t\t// 获取指定目录 readme 文件\n\t\t\tReadmeConfig dbReadmeConfig = ((ReadmeConfigService) AopContext.currentProxy()).findReadmeByPath(storageId, path);\n\t\t\tif (dbReadmeConfig != null) {\n\t\t\t\treadmeByPath = dbReadmeConfig;\n\t\t\t}\n\t\t}\n\n\t\treturn readmeByPath;\n\t}\n\n\n\t/**\n\t * 根据规则表达式和测试字符串进行匹配，如测试字符串和其中一个规则匹配上，则返回 true，反之返回 false。\n\t *\n\t * @param   patternList\n\t *          规则列表\n\t *\n\t * @param   test\n\t *          测试字符串\n\t *\n\t * @return  是否显示\n\t */\n\tprivate ReadmeConfig getReadmeByTestPattern(Integer storageId, List<ReadmeConfig> patternList, String test) {\n\t\t// 如果目录文档规则为空, 则可直接返回空.\n\t\tif (CollectionUtils.isEmpty(patternList)) {\n\t\t\tif (log.isDebugEnabled()) {\n\t\t\t\tlog.debug(\"目录文档规则列表为空, 存储源 ID: {}, 测试字符串: {}\", storageId, test);\n\t\t\t}\n\t\t\treturn null;\n\t\t}\n\n\t\tfor (ReadmeConfig readmeConfig : patternList) {\n\t\t\tString expression = readmeConfig.getExpression();\n\n\t\t\tif (StringUtils.isEmpty(expression)) {\n\t\t\t\tif (log.isDebugEnabled()) {\n\t\t\t\t\tlog.debug(\"存储源 {} 目录文档规则表达式为空: {}, 测试字符串: {}, 表达式为空，跳过该规则比对\", storageId, expression, test);\n\t\t\t\t}\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\ttry {\n                ReadmePathModeEnum pathMode = readmeConfig.getPathMode();\n                boolean match;\n\n                if (pathMode == ReadmePathModeEnum.ABSOLUTE) {\n                    AbstractBaseFileService<IStorageParam> abstractBaseFileService = StorageSourceContext.getByStorageId(storageId);\n                    String currentUserBasePath = abstractBaseFileService.getCurrentUserBasePath();\n                    match = PatternMatcherUtils.testCompatibilityGlobPattern(expression, StringUtils.concat(currentUserBasePath, test));\n                } else {\n                    match = PatternMatcherUtils.testCompatibilityGlobPattern(expression, test);\n                }\n\n\t\t\t\tif (log.isDebugEnabled()) {\n\t\t\t\t\tlog.debug(\"存储源 {} 目录文档规则表达式: {}, 测试字符串: {}, 匹配结果: {}\", storageId, expression, test, match);\n\t\t\t\t}\n\n\t\t\t\tif (match) {\n\t\t\t\t\treturn readmeConfig;\n\t\t\t\t}\n\t\t\t} catch (Exception e) {\n\t\t\t\tlog.error(\"存储源 {} 目录文档规则表达式: {}, 测试字符串: {}, 匹配异常，跳过该规则.\", storageId, expression, test, e);\n\t\t\t}\n\t\t}\n\n\t\treturn null;\n\t}\n\n\n\t/**\n\t * 监听存储源复制事件, 复制存储源时, 复制存储源目录文档设置\n\t *\n\t * @param   storageSourceCopyEvent\n\t *          存储源复制事件\n\t */\n\t@EventListener\n\tpublic void onStorageSourceCopy(StorageSourceCopyEvent storageSourceCopyEvent) {\n\t\tInteger fromId = storageSourceCopyEvent.getFromId();\n\t\tInteger newId = storageSourceCopyEvent.getNewId();\n\n\t\tList<ReadmeConfig> readmeConfigList = ((ReadmeConfigService) AopContext.currentProxy()).findByStorageId(fromId);\n\n\t\treadmeConfigList.forEach(readmeConfig -> {\n\t\t\tReadmeConfig newReadmeConfig = new ReadmeConfig();\n\t\t\tBeanUtils.copyProperties(readmeConfig, newReadmeConfig);\n\t\t\tnewReadmeConfig.setId(null);\n\t\t\tnewReadmeConfig.setStorageId(newId);\n\t\t\treadmeConfigMapper.insert(newReadmeConfig);\n\t\t});\n\n\t\tlog.info(\"复制存储源 ID 为 {} 的存储源目录文档设置到存储源 ID 为 {} 成功, 共 {} 条\", fromId, newId, readmeConfigList.size());\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/share/context/ShareAccessContext.java",
    "content": "package im.zhaojun.zfile.module.share.context;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Getter;\nimport org.springframework.stereotype.Component;\n\n/**\n * 分享访问上下文，用于在分享访问时传递相关信息\n * 解决分享访问时绕过用户基础路径限制的问题\n *\n * @author zhaojun\n */\n@Component\npublic class ShareAccessContext {\n    \n    private static final ThreadLocal<ShareAccessInfo> CONTEXT = new ThreadLocal<>();\n    \n    /**\n     * 分享访问信息\n     */\n    @Getter\n    @AllArgsConstructor\n    public static class ShareAccessInfo {\n        private boolean isShareAccess;\n        private String shareBasePath;\n        private String shareKey;\n        private Integer shareUserId;\n    }\n    \n    /**\n     * 设置分享访问上下文\n     *\n     * @param shareKey      分享链接 key\n     * @param shareBasePath 分享的基础路径\n     */\n    public static void setShareAccess(String shareKey, String shareBasePath) {\n        setShareAccess(shareKey, shareBasePath, null);\n    }\n\n    /**\n     * 设置分享访问上下文（带分享者用户ID）\n     *\n     * @param shareKey      分享链接 key\n     * @param shareBasePath 分享的基础路径\n     * @param shareUserId   分享者用户ID\n     */\n    public static void setShareAccess(String shareKey, String shareBasePath, Integer shareUserId) {\n        CONTEXT.set(new ShareAccessInfo(true, shareBasePath, shareKey, shareUserId));\n    }\n    \n    /**\n     * 检查当前是否为分享访问\n     *\n     * @return 是否为分享访问\n     */\n    public static boolean isShareAccess() {\n        ShareAccessInfo info = CONTEXT.get();\n        return info != null && info.isShareAccess;\n    }\n    \n    /**\n     * 获取分享的基础路径\n     *\n     * @return 分享基础路径，如果不是分享访问则返回 null\n     */\n    public static String getShareBasePath() {\n        ShareAccessInfo info = CONTEXT.get();\n        return info != null ? info.shareBasePath : null;\n    }\n    \n    /**\n     * 获取分享链接 key\n     *\n     * @return 分享链接 key，如果不是分享访问则返回 null\n     */\n    public static String getShareKey() {\n        ShareAccessInfo info = CONTEXT.get();\n        return info != null ? info.shareKey : null;\n    }\n\n    /**\n     * 获取分享者用户ID\n     *\n     * @return 分享者用户ID，如果不是分享访问则返回 null\n     */\n    public static Integer getShareUserId() {\n        ShareAccessInfo info = CONTEXT.get();\n        return info != null ? info.shareUserId : null;\n    }\n    \n    /**\n     * 清理当前线程的分享访问上下文\n     * 必须在分享访问结束后调用，防止内存泄漏\n     */\n    public static void clear() {\n        CONTEXT.remove();\n    }\n    \n    /**\n     * 获取当前分享访问信息\n     *\n     * @return 分享访问信息，如果不是分享访问则返回 null\n     */\n    public static ShareAccessInfo getCurrentInfo() {\n        return CONTEXT.get();\n    }\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/share/controller/ShareFileManagerController.java",
    "content": "package im.zhaojun.zfile.module.share.controller;\n\nimport com.baomidou.mybatisplus.extension.plugins.pagination.Page;\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport com.github.xiaoymin.knife4j.annotations.ApiSort;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.module.share.model.request.ShareLinkListRequest;\nimport im.zhaojun.zfile.module.share.model.result.ShareLinkResult;\nimport im.zhaojun.zfile.module.share.service.ShareLinkService;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.annotation.Resource;\nimport jakarta.validation.Valid;\nimport org.springframework.web.bind.annotation.DeleteMapping;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport java.util.List;\n\n/**\n * 管理员分享文件相关接口.\n */\n@Tag(name = \"分享管理模块\")\n@ApiSort(31)\n@RestController\n@RequestMapping(\"/admin/share\")\npublic class ShareFileManagerController {\n\n    @Resource\n    private ShareLinkService shareLinkService;\n\n    @ApiOperationSupport(order = 1)\n    @Operation(summary = \"分页查询分享列表\", description = \"管理员查看所有分享记录\")\n    @GetMapping(\"/list\")\n    public AjaxJson<List<ShareLinkResult>> getShareList(@Valid ShareLinkListRequest request) {\n        Page<ShareLinkResult> result = shareLinkService.getAdminShareList(request);\n        return AjaxJson.getPageData(result.getTotal(), result.getRecords());\n    }\n\n    @ApiOperationSupport(order = 2)\n    @Operation(summary = \"清理过期分享\", description = \"删除所有已过期的分享记录\")\n    @DeleteMapping(\"/expired\")\n    public AjaxJson<Integer> deleteExpiredShares() {\n        int deletedCount = shareLinkService.deleteExpiredLinks();\n        return AjaxJson.getSuccessData(deletedCount);\n    }\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/share/controller/ShareLinkController.java",
    "content": "package im.zhaojun.zfile.module.share.controller;\n\nimport cn.dev33.satoken.annotation.SaCheckLogin;\nimport com.baomidou.mybatisplus.extension.plugins.pagination.Page;\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport com.github.xiaoymin.knife4j.annotations.ApiSort;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.core.util.ZFileAuthUtil;\nimport im.zhaojun.zfile.module.share.model.request.CreateShareLinkRequest;\nimport im.zhaojun.zfile.module.share.model.request.ShareFileListRequest;\nimport im.zhaojun.zfile.module.share.model.request.ShareLinkListRequest;\nimport im.zhaojun.zfile.module.share.model.request.VerifySharePasswordRequest;\nimport im.zhaojun.zfile.module.share.model.result.CreateShareLinkResult;\nimport im.zhaojun.zfile.module.share.model.result.ShareFileInfoResult;\nimport im.zhaojun.zfile.module.share.model.result.ShareLinkResult;\nimport im.zhaojun.zfile.module.share.service.ShareLinkFileService;\nimport im.zhaojun.zfile.module.share.service.ShareLinkService;\nimport im.zhaojun.zfile.module.storage.annotation.StoragePermissionCheck;\nimport im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.Parameter;\nimport io.swagger.v3.oas.annotations.enums.ParameterIn;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.annotation.Resource;\nimport jakarta.validation.Valid;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.http.HttpHeaders;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.List;\n\n/**\n * 分享文件相关接口\n *\n * @author zhaojun\n */\n@Tag(name = \"分享文件模块\")\n@ApiSort(3)\n@Slf4j\n@RequestMapping(\"/api/share\")\n@RestController\npublic class ShareLinkController {\n\n    @Resource\n    private ShareLinkService shareLinkService;\n    \n    @Resource\n    private ShareLinkFileService shareLinkFileService;\n\n    @ApiOperationSupport(order = 1)\n    @Operation(summary = \"创建分享链接\", description = \"根据指定的文件或文件夹创建分享链接\")\n    @PostMapping(\"/create\")\n    @StoragePermissionCheck(action = FileOperatorTypeEnum.SHARE_LINK)\n    public AjaxJson<CreateShareLinkResult> createShareLink(@Valid @RequestBody CreateShareLinkRequest request) {\n        CreateShareLinkResult result = shareLinkService.createShareLink(request);\n        return AjaxJson.getSuccessData(result);\n    }\n\n    @ApiOperationSupport(order = 2)\n    @Operation(summary = \"获取分享信息\", description = \"根据分享链接key获取分享的基本信息，无需密码\")\n    @GetMapping(\"/info/{shareKey}\")\n    @Parameter(in = ParameterIn.PATH, name = \"shareKey\", description = \"分享链接key\", required = true, schema = @Schema(type = \"string\"))\n    public AjaxJson<ShareLinkResult> getShareInfo(@PathVariable String shareKey) {\n        ShareLinkResult result = shareLinkService.getShareLinkInfo(shareKey);\n        return AjaxJson.getSuccessData(result);\n    }\n\n    @ApiOperationSupport(order = 3)\n    @Operation(summary = \"验证分享密码\", description = \"验证分享链接的访问密码\")\n    @PostMapping(\"/verify\")\n    public AjaxJson<Boolean> verifyPassword(@Valid @RequestBody VerifySharePasswordRequest request) {\n        boolean isValid = shareLinkService.verifyPassword(request.getShareKey(), request.getPassword());\n        return AjaxJson.getSuccessData(isValid);\n    }\n\n    @ApiOperationSupport(order = 4)\n    @Operation(summary = \"获取分享文件列表\", description = \"获取分享链接中的文件和文件夹列表\")\n    @PostMapping(\"/files\")\n    public AjaxJson<ShareFileInfoResult> getShareFileList(@Valid @RequestBody ShareFileListRequest request) {\n        // 处理请求参数默认值\n        request.handleDefaultValue();\n\n        ShareFileInfoResult result = shareLinkFileService.getShareFileList(\n                request.getShareKey(),\n                request.getPath(),\n                request.getPassword(),\n                request.getFolderPassword(),\n                request.getOrderBy(),\n                request.getOrderDirection()\n        );\n        \n        return AjaxJson.getSuccessData(result);\n    }\n\n    @ApiOperationSupport(order = 5)\n    @Operation(summary = \"下载分享文件\", description = \"通过分享链接下载文件，返回302重定向到实际下载地址\")\n    @GetMapping(\"/download/{shareKey}\")\n    @Parameter(in = ParameterIn.PATH, name = \"shareKey\", description = \"分享链接key\", required = true, schema = @Schema(type = \"string\"))\n    @Parameter(in = ParameterIn.QUERY, name = \"path\", description = \"文件路径\", required = true, schema = @Schema(type = \"string\"))\n    @Parameter(in = ParameterIn.QUERY, name = \"password\", description = \"分享密码\", schema = @Schema(type = \"string\"))\n    public ResponseEntity<?> downloadShareFile(\n            @PathVariable String shareKey,\n            @RequestParam String path,\n            @RequestParam(required = false) String password) {\n        try {\n            // 获取实际下载地址\n            String actualDownloadUrl = shareLinkFileService.getShareFileDownloadUrl(shareKey, path, password);\n            \n            // 302重定向到实际地址\n            return ResponseEntity.status(302)\n                    .header(HttpHeaders.CACHE_CONTROL, \"no-cache, no-store, must-revalidate, private\")\n                    .header(HttpHeaders.PRAGMA, \"no-cache\")\n                    .header(HttpHeaders.EXPIRES, \"0\")\n                    .header(HttpHeaders.LOCATION, actualDownloadUrl)\n                    .build();\n                    \n        } catch (Exception e) {\n            log.error(\"分享文件下载失败, shareKey: {}, path: {}\", shareKey, path, e);\n            return ResponseEntity.status(400).body(AjaxJson.getError(e.getMessage()));\n        }\n    }\n\n    @ApiOperationSupport(order = 6)\n    @Operation(summary = \"取消分享\", description = \"删除指定的分享链接\")\n    @DeleteMapping(\"/{shareKey}\")\n    @Parameter(in = ParameterIn.PATH, name = \"shareKey\", description = \"分享链接key\", required = true, schema = @Schema(type = \"string\"))\n    @SaCheckLogin\n    public AjaxJson<Boolean> deleteShare(@PathVariable String shareKey) {\n        shareLinkService.deleteShareLink(shareKey);\n        return AjaxJson.getSuccessData(true);\n    }\n\n    @ApiOperationSupport(order = 7)\n    @Operation(summary = \"获取用户分享列表\", description = \"获取当前用户创建的分享链接（支持分页与筛选）\")\n    @GetMapping(\"/list\")\n    public AjaxJson<List<ShareLinkResult>> getUserShareList(@Valid ShareLinkListRequest request) {\n        Page<ShareLinkResult> result = shareLinkService.getUserShareList(request);\n        return AjaxJson.getPageData(result.getTotal(), result.getRecords());\n    }\n\n    @ApiOperationSupport(order = 8)\n    @Operation(summary = \"清理过期分享\", description = \"删除所有已过期的分享链接\")\n    @DeleteMapping(\"/expired\")\n    @SaCheckLogin\n    public AjaxJson<Integer> deleteExpiredShares() {\n        Integer currentUserId = ZFileAuthUtil.getCurrentUserId();\n        int deletedCount = shareLinkService.deleteExpiredLinksByUserId(currentUserId);\n        return AjaxJson.getSuccessData(deletedCount);\n    }\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/share/mapper/ShareLinkMapper.java",
    "content": "package im.zhaojun.zfile.module.share.mapper;\n\nimport com.baomidou.mybatisplus.core.mapper.BaseMapper;\nimport im.zhaojun.zfile.module.share.model.entity.ShareLink;\nimport org.apache.ibatis.annotations.Mapper;\nimport org.apache.ibatis.annotations.Param;\n\nimport java.util.Date;\nimport java.util.List;\n\n@Mapper\npublic interface ShareLinkMapper extends BaseMapper<ShareLink> {\n\n    /**\n     * 根据分享 key 获取分享链接信息\n     */\n    ShareLink getByShareKey(@Param(\"shareKey\") String shareKey);\n\n    /**\n     * 根据用户 ID 获取分享链接列表\n     */\n    List<ShareLink> getByUserId(@Param(\"userId\") Integer userId);\n\n    /**\n     * 更新访问次数\n     */\n    int incrementAccessCount(@Param(\"shareKey\") String shareKey, @Param(\"increment\") int increment);\n\n    /**\n     * 更新下载次数\n     */\n    int incrementDownloadCount(@Param(\"shareKey\") String shareKey, @Param(\"increment\") int increment);\n\n    /**\n     * 删除过期的分享链接\n     */\n    int deleteExpiredLinks(@Param(\"currentTime\") Date currentTime);\n\n    /**\n     * 删除指定用户的过期分享链接\n     */\n    int deleteExpiredLinksByUserId(@Param(\"userId\") Integer userId, @Param(\"currentTime\") Date currentTime);\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/share/model/dto/ShareEntryDTO.java",
    "content": "package im.zhaojun.zfile.module.share.model.dto;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport im.zhaojun.zfile.module.share.model.enums.ShareEntryTypeEnum;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotNull;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * 分享条目 DTO\n */\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\n@JsonIgnoreProperties(ignoreUnknown = true)\n@Schema(description = \"分享条目\")\npublic class ShareEntryDTO {\n\n    @Schema(title = \"条目名称\", example = \"document.pdf\")\n    @NotBlank(message = \"分享条目名称不能为空\")\n    private String name;\n\n    @Schema(title = \"条目类型\", description = \"FILE/FOLDER\")\n    @NotNull(message = \"分享条目类型不能为空\")\n    private ShareEntryTypeEnum type;\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/share/model/entity/ShareLink.java",
    "content": "package im.zhaojun.zfile.module.share.model.entity;\n\nimport com.baomidou.mybatisplus.annotation.IdType;\nimport com.baomidou.mybatisplus.annotation.TableField;\nimport com.baomidou.mybatisplus.annotation.TableId;\nimport com.baomidou.mybatisplus.annotation.TableName;\nimport im.zhaojun.zfile.module.share.model.enums.ShareTypeEnum;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport java.io.Serializable;\nimport java.util.Date;\n\n@Data\n@Schema(title=\"分享文件(夹)\")\n@TableName(value = \"share_link\")\npublic class ShareLink implements Serializable {\n\n    private static final long serialVersionUID = 1L;\n\n    @Schema(title = \"分享ID\")\n    @TableId(value = \"id\", type = IdType.AUTO)\n    private Integer id;\n\n    @Schema(title = \"分享链接 key\")\n    @TableField(value = \"share_key\")\n    private String shareKey;\n\n    @Schema(title = \"分享密码\")\n    @TableField(value = \"`password`\")\n    private String password;\n\n    @Schema(title = \"过期时间\")\n    @TableField(value = \"expire_date\")\n    private Date expireDate;\n\n    @Schema(title = \"存储源 key\")\n    @TableField(value = \"storage_key\")\n    private String storageKey;\n\n    @Schema(title = \"分享所在目录\")\n    @TableField(value = \"share_path\")\n    private String sharePath;\n\n    @Schema(title = \"分享项目(JSON格式存储文件或文件夹名称)\")\n    @TableField(value = \"share_item\")\n    private String shareItem;\n\n    @Schema(title = \"创建时间\")\n    @TableField(value = \"create_date\")\n    private Date createDate;\n\n    @Schema(title = \"分享类型\", description = \"FILE/FOLDER/MULTIPLE\")\n    @TableField(value = \"share_type\")\n    private ShareTypeEnum shareType;\n\n    @Schema(title = \"创建分享的用户ID\")\n    @TableField(value = \"user_id\")\n    private Integer userId;\n\n    @Schema(title = \"下载次数\")\n    @TableField(value = \"download_count\")\n    private Integer downloadCount;\n\n    @Schema(title = \"访问次数\")\n    @TableField(value = \"access_count\")\n    private Integer accessCount;\n\n    @Schema(title = \"是否已过期\")\n    @TableField(exist = false)\n    private Boolean expired;\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/share/model/enums/ShareEntryTypeEnum.java",
    "content": "package im.zhaojun.zfile.module.share.model.enums;\n\nimport com.baomidou.mybatisplus.annotation.EnumValue;\nimport com.fasterxml.jackson.annotation.JsonValue;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.AllArgsConstructor;\nimport lombok.Getter;\n\n/**\n * 分享条目类型\n */\n@Schema(description = \"分享条目类型\")\n@Getter\n@AllArgsConstructor\npublic enum ShareEntryTypeEnum {\n\n    @Schema(description = \"文件\")\n    FILE(\"FILE\"),\n\n    @Schema(description = \"文件夹\")\n    FOLDER(\"FOLDER\");\n\n    @EnumValue\n    @JsonValue\n    private final String value;\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/share/model/enums/ShareTypeEnum.java",
    "content": "package im.zhaojun.zfile.module.share.model.enums;\n\nimport com.baomidou.mybatisplus.annotation.EnumValue;\nimport com.fasterxml.jackson.annotation.JsonValue;\nimport lombok.AllArgsConstructor;\nimport lombok.Getter;\n\n@Getter\n@AllArgsConstructor\npublic enum ShareTypeEnum {\n\n    FILE(\"FILE\", \"单个文件\"),\n\n    FOLDER(\"FOLDER\", \"文件夹\"),\n\n    MULTIPLE(\"MULTIPLE\", \"多个文件或文件夹\");\n\n    @EnumValue\n    @JsonValue\n    private final String value;\n\n    private final String description;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/share/model/request/CreateShareLinkRequest.java",
    "content": "package im.zhaojun.zfile.module.share.model.request;\n\nimport im.zhaojun.zfile.module.share.model.dto.ShareEntryDTO;\nimport im.zhaojun.zfile.module.share.model.enums.ShareTypeEnum;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.NotNull;\nimport java.util.Date;\nimport java.util.List;\n\n/**\n * 创建分享链接请求\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"创建分享链接请求\")\npublic class CreateShareLinkRequest {\n\n    @Schema(title = \"存储源 key\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"local\")\n    @NotBlank(message = \"存储源 key 不能为空\")\n    private String storageKey;\n\n    @Schema(title = \"分享所在目录\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"/documents\")\n    @NotBlank(message = \"分享目录不能为空\")\n    private String sharePath;\n\n    @Schema(title = \"分享条目列表\", requiredMode = Schema.RequiredMode.REQUIRED)\n    @NotEmpty(message = \"分享条目不能为空\")\n    private List<ShareEntryDTO> shareEntries;\n\n    @Schema(title = \"分享密码（可选）\", example = \"123456\")\n    private String password;\n\n    @Schema(title = \"过期时间（可选）\", example = \"2024-12-31T23:59:59\")\n    private Date expireDate;\n\n    @Schema(title = \"分享类型\", example = \"FOLDER\")\n    @NotNull(message = \"分享类型不能为空\")\n    private ShareTypeEnum shareType;\n\n    @Schema(title = \"自定义分享 key（可选）\", description = \"如果不提供则自动生成\", example = \"my-custom-key\")\n    private String shareKey;\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/share/model/request/ShareFileListRequest.java",
    "content": "package im.zhaojun.zfile.module.share.model.request;\n\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport jakarta.validation.constraints.NotBlank;\n\n/**\n * 获取分享文件列表请求\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"获取分享文件列表请求\")\npublic class ShareFileListRequest {\n\n    @Schema(title = \"分享链接 key\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"abc12345\")\n    @NotBlank(message = \"分享链接 key 不能为空\")\n    private String shareKey;\n\n    @Schema(title = \"请求路径\", example = \"/\")\n    private String path;\n\n    @Schema(title = \"分享密码\", example = \"123456\")\n    private String password;\n\n    @Schema(title = \"目录密码\", example = \"123456\")\n    private String folderPassword;\n\n    @Schema(title = \"排序字段\", example = \"name\")\n    private String orderBy;\n\n    @Schema(title = \"排序方向\", example = \"asc\")\n    private String orderDirection;\n\n    public void handleDefaultValue() {\n        if (StringUtils.isEmpty(path)) {\n            path = \"/\";\n        }\n        if (StringUtils.isEmpty(orderBy)) {\n            orderBy = \"name\";\n        }\n        if (StringUtils.isEmpty(orderDirection)) {\n            orderDirection = \"asc\";\n        }\n\n        // 自动补全路径, 如 a 补全为 /a/\n        path = StringUtils.concat(path);\n    }\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/share/model/request/ShareLinkListRequest.java",
    "content": "package im.zhaojun.zfile.module.share.model.request;\n\nimport cn.hutool.core.util.StrUtil;\nimport im.zhaojun.zfile.core.model.request.PageQueryRequest;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\nimport lombok.EqualsAndHashCode;\nimport org.springframework.format.annotation.DateTimeFormat;\n\nimport java.util.Date;\n\n/**\n * 用户分享列表分页请求对象.\n */\n@EqualsAndHashCode(callSuper = true)\n@Data\n@Schema(description = \"用户分享列表分页请求\")\npublic class ShareLinkListRequest extends PageQueryRequest {\n\n    /**\n     * 关键词，支持分享 key、名称模糊搜索.\n     */\n    @Schema(title = \"搜索关键词\")\n    private String keyword;\n\n    /**\n     * 分享状态，支持 all / active / expired.\n     */\n    @Schema(title = \"分享状态\", allowableValues = {\"all\", \"active\", \"expired\"})\n    private String status;\n\n    /**\n     * 存储源 key，传值时仅查询对应存储源的分享记录.\n     */\n    @Schema(title = \"存储源 key\")\n    private String storageKey;\n\n    /**\n     * 创建时间起始.\n     */\n    @Schema(title = \"创建时间起始\", example = \"2024-01-01 00:00:00\")\n    @DateTimeFormat(pattern = \"yyyy-MM-dd HH:mm:ss\")\n    private Date createDateStart;\n\n    /**\n     * 创建时间结束.\n     */\n    @Schema(title = \"创建时间结束\", example = \"2024-01-31 23:59:59\")\n    @DateTimeFormat(pattern = \"yyyy-MM-dd HH:mm:ss\")\n    private Date createDateEnd;\n\n    public void handleDefaultValue() {\n        if (getPage() == null || getPage() < 1) {\n            setPage(1);\n        }\n        if (getLimit() == null || getLimit() < 1) {\n            setLimit(10);\n        }\n        if (getLimit() > 100) {\n            setLimit(100);\n        }\n        if (StrUtil.isBlank(getOrderBy())) {\n            setOrderBy(\"create_date\");\n        }\n        if (StrUtil.isBlank(getOrderDirection())) {\n            setOrderDirection(\"desc\");\n        }\n        if (StrUtil.isBlank(status)) {\n            status = \"all\";\n        }\n    }\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/share/model/request/VerifySharePasswordRequest.java",
    "content": "package im.zhaojun.zfile.module.share.model.request;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport jakarta.validation.constraints.NotBlank;\n\n/**\n * 验证分享密码请求\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"验证分享密码请求\")\npublic class VerifySharePasswordRequest {\n\n    @Schema(title = \"分享链接 key\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"abc12345\")\n    @NotBlank(message = \"分享链接 key 不能为空\")\n    private String shareKey;\n\n    @Schema(title = \"分享密码\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"123456\")\n    @NotBlank(message = \"分享密码不能为空\")\n    private String password;\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/share/model/result/CreateShareLinkResult.java",
    "content": "package im.zhaojun.zfile.module.share.model.result;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\n/**\n * 创建分享链接响应\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"创建分享链接响应\")\npublic class CreateShareLinkResult {\n\n    @Schema(title = \"分享链接 key\", example = \"abc12345\")\n    private String shareKey;\n\n    @Schema(title = \"完整分享链接\", example = \"https://example.com/s/abc12345\")\n    private String fullShareUrl;\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/share/model/result/ShareFileInfoResult.java",
    "content": "package im.zhaojun.zfile.module.share.model.result;\n\nimport im.zhaojun.zfile.module.storage.model.result.FileItemResult;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport java.util.List;\nimport java.util.Map;\n\n/**\n * 分享文件信息响应\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"分享文件信息响应\")\npublic class ShareFileInfoResult {\n\n    @Schema(title = \"文件列表\")\n    private List<FileItemResult> fileItemList;\n\n    @Schema(title = \"当前路径\")\n    private String currentPath;\n\n    @Schema(title = \"父路径\")\n    private String parentPath;\n\n    @Schema(title = \"分享信息\")\n    private ShareLinkResult shareLinkInfo;\n\n    @Schema(title = \"是否为根目录\")\n    private Boolean isRoot;\n\n    @Schema(title = \"分享者在该存储源的权限映射\")\n    private Map<String, Boolean> permission;\n\n    public ShareFileInfoResult() {\n    }\n\n    public ShareFileInfoResult(List<FileItemResult> fileItemList, String currentPath, ShareLinkResult shareLinkInfo) {\n        this.fileItemList = fileItemList;\n        this.currentPath = currentPath;\n        this.shareLinkInfo = shareLinkInfo;\n        this.isRoot = \"/\".equals(currentPath);\n        if (!isRoot && currentPath != null) {\n            int lastSlashIndex = currentPath.lastIndexOf('/');\n            if (lastSlashIndex > 0) {\n                this.parentPath = currentPath.substring(0, lastSlashIndex);\n            } else {\n                this.parentPath = \"/\";\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/share/model/result/ShareLinkResult.java",
    "content": "package im.zhaojun.zfile.module.share.model.result;\n\nimport com.fasterxml.jackson.annotation.JsonFormat;\nimport im.zhaojun.zfile.module.share.model.dto.ShareEntryDTO;\nimport im.zhaojun.zfile.module.share.model.enums.ShareTypeEnum;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport java.util.Date;\nimport java.util.List;\n\n/**\n * 分享链接详情响应\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"分享链接详情响应\")\npublic class ShareLinkResult {\n\n    @Schema(title = \"分享链接 key\")\n    private String shareKey;\n\n    @Schema(title = \"是否需要密码\")\n    private Boolean needPassword;\n\n    @Schema(title = \"是否已过期\")\n    private Boolean expired;\n\n    @Schema(title = \"过期时间\")\n    @JsonFormat(pattern = \"yyyy-MM-dd HH:mm:ss\", timezone = \"GMT+8\")\n    private Date expireDate;\n\n    @Schema(title = \"存储源 key\")\n    private String storageKey;\n\n    @Schema(title = \"存储源 ID\")\n    private Integer storageId;\n\n    @Schema(title = \"存储源名称\")\n    private String storageName;\n\n    @Schema(title = \"分享所在目录\")\n    private String sharePath;\n\n    @Schema(title = \"分享条目列表\")\n    private List<ShareEntryDTO> shareEntries;\n\n    @Schema(title = \"分享类型\")\n    private ShareTypeEnum shareType;\n\n    @Schema(title = \"创建时间\")\n    @JsonFormat(pattern = \"yyyy-MM-dd HH:mm:ss\", timezone = \"GMT+8\")\n    private Date createDate;\n\n    @Schema(title = \"访问次数\")\n    private Integer accessCount;\n\n    @Schema(title = \"下载次数\")\n    private Integer downloadCount;\n\n    @Schema(title = \"分享密码\")\n    private String password;\n\n    @Schema(title = \"分享创建者用户ID\")\n    private Integer userId;\n\n    @Schema(title = \"分享创建者用户名\")\n    private String username;\n\n    @Schema(title = \"分享创建者昵称\")\n    private String nickname;\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/share/service/ShareLinkFileService.java",
    "content": "package im.zhaojun.zfile.module.share.service;\n\nimport cn.hutool.core.util.StrUtil;\nimport com.alibaba.fastjson2.JSON;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.util.CollectionUtils;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.share.context.ShareAccessContext;\nimport im.zhaojun.zfile.module.share.model.dto.ShareEntryDTO;\nimport im.zhaojun.zfile.module.share.model.entity.ShareLink;\nimport im.zhaojun.zfile.module.share.model.enums.ShareEntryTypeEnum;\nimport im.zhaojun.zfile.module.share.model.result.ShareFileInfoResult;\nimport im.zhaojun.zfile.module.share.model.result.ShareLinkResult;\nimport im.zhaojun.zfile.module.storage.chain.FileChain;\nimport im.zhaojun.zfile.module.storage.chain.FileContext;\nimport im.zhaojun.zfile.module.storage.context.StorageSourceContext;\nimport im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.request.base.FileListRequest;\nimport im.zhaojun.zfile.module.storage.model.result.FileItemResult;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;\nimport im.zhaojun.zfile.module.user.service.UserStorageSourceService;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.stereotype.Service;\n\nimport java.util.List;\nimport java.util.Objects;\nimport java.util.Set;\nimport java.util.stream.Collectors;\n\n/**\n * 分享文件操作服务\n * 专门处理分享文件的访问逻辑，绕过用户基础路径限制\n *\n * @author zhaojun\n */\n@Slf4j\n@Service\npublic class ShareLinkFileService {\n\n\t@Resource\n\tprivate ShareLinkService shareLinkService;\n\n    @Resource\n    private FileChain fileChain;\n\n    @Resource\n    private UserStorageSourceService userStorageSourceService;\n\n\t/**\n\t * 获取分享文件列表\n\t *\n\t * @param shareKey     分享链接 key\n\t * @param relativePath 相对路径\n\t * @param password     分享密码\n\t * @return 分享文件信息\n\t */\n    public ShareFileInfoResult getShareFileList(String shareKey, String relativePath, String password, String folderPassword, String orderBy, String orderDirection) {\n\t\tShareLink shareLink = getValidShareLink(shareKey);\n\t\t\n\t\t// 验证密码\n\t\tvalidateSharePassword(shareLink, password);\n\t\t\n\t\ttry {\n\t\t\t// 设置分享访问上下文，getCurrentUserBasePath() 会返回分享的基础路径\n            ShareAccessContext.setShareAccess(shareKey, shareLink.getSharePath(), shareLink.getUserId());\n\t\t\t\n\t\t\tAbstractBaseFileService<?> fileService = StorageSourceContext.getByStorageKey(shareLink.getStorageKey());\n\t\t\tif (fileService == null) {\n\t\t\t\tthrow new BizException(ErrorCode.BIZ_STORAGE_NOT_FOUND);\n\t\t\t}\n\t\t\t\n\t\t// 根据分享条目获取文件列表\n\t\t\tList<FileItemResult> fileItemList = getFilteredFileList(shareLink, fileService, relativePath);\n\n            // 执行责任链\n            FileListRequest fileListRequest = new FileListRequest();\n            fileListRequest.setPath(relativePath);\n            // 目录密码（与分享密码独立）\n            fileListRequest.setPassword(folderPassword);\n            fileListRequest.setOrderBy(orderBy);\n            fileListRequest.setOrderDirection(orderDirection);\n            FileContext fileContext = FileContext.builder()\n                    .storageId(fileService.getStorageId())\n                    .fileListRequest(fileListRequest)\n                    .fileItemList(fileItemList)\n                    .fileService(fileService)\n                    .operatorUserId(shareLink.getUserId())\n                    .build();\n            fileChain.execute(fileContext);\n\n            // 构建分享链接信息\n\t\t\tShareLinkResult shareLinkResult = shareLinkService.getShareLinkInfo(shareKey);\n\t\t\t\n\t\t\t// 更新访问次数（只在访问根路径时更新）\n\t\t\tif (StrUtil.isBlank(relativePath) || \"/\".equals(relativePath)) {\n\t\t\t\tshareLinkService.incrementAccessCount(shareKey);\n\t\t\t}\n\t\t\t\n\t\t\tShareFileInfoResult shareFileInfoResult = new ShareFileInfoResult(fileContext.getFileItemList(), relativePath, shareLinkResult);\n\t\t\tInteger storageId = fileService.getStorageId();\n\t\t\tshareFileInfoResult.setPermission(userStorageSourceService.getPermissionMapByUserIdAndStorageId(shareLink.getUserId(), storageId));\n\t\t\treturn shareFileInfoResult;\n\n\t\t} catch (BizException e) {\n            throw e;\n        } catch (Exception e) {\n            throw new BizException(ErrorCode.BIZ_SHARE_FILE_LIST_ERROR.setMessage(ErrorCode.BIZ_SHARE_FILE_LIST_ERROR.getMessage() + \": \" + e.getMessage()));\n        } finally {\n\t\t\t// 确保清理上下文\n\t\t\tShareAccessContext.clear();\n\t\t}\n\t}\n\n\t/**\n\t * 获取分享文件的下载地址\n\t *\n\t * @param shareKey 分享链接 key\n\t * @param filePath 文件路径\n\t * @param password 分享密码\n\t * @return 下载地址\n\t */\n\tpublic String getShareFileDownloadUrl(String shareKey, String filePath, String password) {\n\t\tShareLink shareLink = getValidShareLink(shareKey);\n\t\t\n\t\t// 验证密码\n\t\tvalidateSharePassword(shareLink, password);\n\t\t\n\t\ttry {\n\t\t\tAbstractBaseFileService<?> fileService = StorageSourceContext.getByStorageKey(shareLink.getStorageKey());\n\t\t\tif (fileService == null) {\n\t\t\t\tthrow new BizException(ErrorCode.BIZ_STORAGE_NOT_FOUND);\n\t\t\t}\n\t\t\t\n\t\t\t// 更新下载次数\n\t\t\tshareLinkService.incrementDownloadCount(shareKey);\n\t\t\t\n\t\t\t// 获取下载地址，getCurrentUserBasePath() 会返回分享的基础路径\n\t\t\treturn fileService.getDownloadUrl(StringUtils.concat(shareLink.getSharePath(), filePath));\n\t\t\t\n\t\t} catch (Exception e) {\n\t\t\tthrow new BizException(ErrorCode.BIZ_SHARE_FILE_DOWNLOAD_ERROR.setMessage(ErrorCode.BIZ_SHARE_FILE_DOWNLOAD_ERROR.getMessage() + \": \" + e.getMessage()));\n\t\t} finally {\n\t\t\tShareAccessContext.clear();\n\t\t}\n\t}\n\n\t/**\n\t * 获取分享文件项信息\n\t *\n\t * @param shareKey 分享链接 key\n\t * @param filePath 文件路径\n\t * @param password 分享密码\n\t * @return 文件项信息\n\t */\n\tpublic FileItemResult getShareFileItem(String shareKey, String filePath, String password) {\n\t\tShareLink shareLink = getValidShareLink(shareKey);\n\t\t\n\t\t// 验证密码\n\t\tvalidateSharePassword(shareLink, password);\n\t\t\n\t\ttry {\n\t\t\t// 设置分享访问上下文\n            ShareAccessContext.setShareAccess(shareKey, shareLink.getSharePath(), shareLink.getUserId());\n\t\t\t\n\t\t\tAbstractBaseFileService<?> fileService = StorageSourceContext.getByStorageKey(shareLink.getStorageKey());\n\t\t\tif (fileService == null) {\n\t\t\t\tthrow new BizException(ErrorCode.BIZ_STORAGE_NOT_FOUND);\n\t\t\t}\n\t\t\t\n\t\t\t// 获取文件信息，getCurrentUserBasePath() 会返回分享的基础路径\n\t\t\treturn fileService.getFileItem(filePath);\n\t\t\t\n\t\t} catch (Exception e) {\n\t\t\tthrow new BizException(ErrorCode.BIZ_SHARE_FILE_INFO_ERROR.setMessage(ErrorCode.BIZ_SHARE_FILE_INFO_ERROR.getMessage() + \": \" + e.getMessage()));\n\t\t} finally {\n\t\t\tShareAccessContext.clear();\n\t\t}\n\t}\n\n\t/**\n\t * 获取有效的分享链接\n\t *\n\t * @param shareKey 分享链接 key\n\t * @return 分享链接\n\t */\n\tprivate ShareLink getValidShareLink(String shareKey) {\n\t\treturn shareLinkService.getValidShareLink(shareKey);\n\t}\n\n\t/**\n\t * 验证分享密码\n\t *\n\t * @param shareLink 分享链接\n\t * @param password  输入的密码\n\t */\n\tprivate void validateSharePassword(ShareLink shareLink, String password) {\n\t\t// 如果分享设置了密码，则需要验证\n\t\tif (StrUtil.isNotBlank(shareLink.getPassword())) {\n\t\t\tif (StrUtil.isBlank(password) || !Objects.equals(shareLink.getPassword(), password)) {\n\t\t\t\tthrow new BizException(ErrorCode.BIZ_SHARE_PASSWORD_ERROR);\n\t\t\t}\n\t\t}\n\t}\n\n\n\t/**\n\t * 根据分享条目获取过滤后的文件列表\n\t *\n\t * @param shareLink    分享链接\n\t * @param fileService  文件服务\n\t * @param relativePath 相对路径\n\t * @return 过滤后的文件列表\n\t */\n\tprivate List<FileItemResult> getFilteredFileList(ShareLink shareLink, AbstractBaseFileService<?> fileService, String relativePath) throws Exception {\n\t\t// 如果是访问根路径且指定了分享条目，需要进行过滤\n\t\tif (StrUtil.isBlank(relativePath) || \"/\".equals(relativePath)) {\n\t\t\treturn getFilteredRootFileList(shareLink, fileService);\n\t\t}\n\n\t\t// 如果是访问子路径，直接返回该路径下的所有文件\n\t\treturn fileService.fileList(relativePath);\n\t}\n\n\t/**\n\t * 获取过滤后的根路径文件列表\n\t *\n\t * @param shareLink   分享链接\n\t * @param fileService 文件服务\n\t * @return 过滤后的文件列表\n\t */\n\tprivate List<FileItemResult> getFilteredRootFileList(ShareLink shareLink, AbstractBaseFileService<?> fileService) throws Exception {\n\t\t// 获取分享路径下的所有文件\n\t\tList<FileItemResult> allFiles = fileService.fileList(\"/\");\n\t\t\n\t\t// 解析分享条目\n\t\tList<ShareEntryDTO> shareEntries = parseShareEntries(shareLink.getShareItem());\n\t\t\n\t\t// 如果没有指定分享项目，返回所有文件\n\t\tif (CollectionUtils.isEmpty(shareEntries)) {\n\t\t\treturn allFiles;\n\t\t}\n\t\t\n\t\treturn filterByShareEntries(allFiles, shareEntries);\n\t}\n\n\tprivate List<FileItemResult> filterByShareEntries(List<FileItemResult> allFiles, List<ShareEntryDTO> shareEntries) {\n\t\tif (CollectionUtils.isEmpty(shareEntries)) {\n\t\t\treturn allFiles;\n\t\t}\n\n\t\tSet<String> folderNames = shareEntries.stream()\n\t\t\t\t.filter(entry -> entry.getType() == ShareEntryTypeEnum.FOLDER)\n\t\t\t\t.map(ShareEntryDTO::getName)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\tSet<String> fileNames = shareEntries.stream()\n\t\t\t\t.filter(entry -> entry.getType() == ShareEntryTypeEnum.FILE)\n\t\t\t\t.map(ShareEntryDTO::getName)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\treturn allFiles.stream()\n\t\t\t\t.filter(file -> {\n\t\t\t\t\tif (file.getType() == FileTypeEnum.FOLDER) {\n\t\t\t\t\t\treturn folderNames.contains(file.getName());\n\t\t\t\t\t}\n\t\t\t\t\treturn fileNames.contains(file.getName());\n\t\t\t\t})\n\t\t\t\t.collect(Collectors.toList());\n\t}\n\n\tprivate List<ShareEntryDTO> parseShareEntries(String shareItemJson) {\n\t\tif (StrUtil.isBlank(shareItemJson)) {\n\t\t\treturn List.of();\n\t\t}\n\n\t\ttry {\n\t\t\treturn JSON.parseArray(shareItemJson, ShareEntryDTO.class);\n\t\t} catch (Exception e) {\n\t\t\treturn List.of();\n\t\t}\n\t}\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/share/service/ShareLinkService.java",
    "content": "package im.zhaojun.zfile.module.share.service;\n\nimport cn.hutool.core.util.IdUtil;\nimport cn.hutool.core.util.StrUtil;\nimport com.alibaba.fastjson2.JSON;\nimport com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;\nimport com.baomidou.mybatisplus.core.toolkit.Wrappers;\nimport com.baomidou.mybatisplus.extension.plugins.pagination.Page;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.core.util.ZFileAuthUtil;\nimport im.zhaojun.zfile.module.config.service.SystemConfigService;\nimport im.zhaojun.zfile.module.share.mapper.ShareLinkMapper;\nimport im.zhaojun.zfile.module.share.model.dto.ShareEntryDTO;\nimport im.zhaojun.zfile.module.share.model.entity.ShareLink;\nimport im.zhaojun.zfile.module.share.model.request.CreateShareLinkRequest;\nimport im.zhaojun.zfile.module.share.model.request.ShareLinkListRequest;\nimport im.zhaojun.zfile.module.share.model.result.CreateShareLinkResult;\nimport im.zhaojun.zfile.module.share.model.result.ShareLinkResult;\nimport im.zhaojun.zfile.module.storage.context.StorageSourceContext;\nimport im.zhaojun.zfile.module.storage.model.entity.StorageSource;\nimport im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;\nimport im.zhaojun.zfile.module.storage.service.StorageSourceService;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;\nimport im.zhaojun.zfile.module.user.model.constant.UserConstant;\nimport im.zhaojun.zfile.module.user.model.entity.User;\nimport im.zhaojun.zfile.module.user.model.entity.UserStorageSource;\nimport im.zhaojun.zfile.module.user.service.UserService;\nimport im.zhaojun.zfile.module.user.service.UserStorageSourceService;\nimport jakarta.annotation.PostConstruct;\nimport jakarta.annotation.PreDestroy;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.aop.framework.AopContext;\nimport org.springframework.beans.BeanUtils;\nimport org.springframework.cache.annotation.CacheConfig;\nimport org.springframework.cache.annotation.CacheEvict;\nimport org.springframework.cache.annotation.Cacheable;\nimport org.springframework.stereotype.Service;\n\nimport java.util.*;\nimport java.util.concurrent.*;\nimport java.util.concurrent.atomic.AtomicInteger;\nimport java.util.function.BiConsumer;\nimport java.util.stream.Collectors;\n\n@Slf4j\n@Service\n@CacheConfig(cacheNames = \"shareLink\")\npublic class ShareLinkService {\n\n    @Resource\n    private ShareLinkMapper shareLinkMapper;\n\n    @Resource\n    private StorageSourceService storageSourceService;\n\n    @Resource\n    private SystemConfigService systemConfigService;\n\n    @Resource\n    private UserStorageSourceService userStorageSourceService;\n\n    @Resource\n    private UserService userService;\n\n    /**\n     * 缓冲区刷新阈值（当某个分享链接的访问或下载次数在缓冲区累积达到该值时，触发刷新操作，将数据写入数据库）\n     */\n    private static final int BUFFER_FLUSH_THRESHOLD = 10;\n\n    /**\n     * 缓冲区刷新间隔（定时任务每隔多长时间触发一次刷新操作，单位：毫秒）\n     */\n    private static final long BUFFER_FLUSH_INTERVAL_MILLIS = 5000L;\n\n    private final ConcurrentMap<String, AtomicInteger> accessCountBuffer = new ConcurrentHashMap<>();\n\n    private final ConcurrentMap<String, AtomicInteger> downloadCountBuffer = new ConcurrentHashMap<>();\n\n    private ScheduledExecutorService bufferFlushScheduler;\n\n    @PostConstruct\n    public void initBufferFlushScheduler() {\n        bufferFlushScheduler = Executors.newSingleThreadScheduledExecutor(r -> {\n            Thread thread = new Thread(r, \"share-link-buffer-flusher\");\n            thread.setDaemon(true);\n            return thread;\n        });\n        bufferFlushScheduler.scheduleAtFixedRate(this::flushAllBuffersSafely,\n                BUFFER_FLUSH_INTERVAL_MILLIS,\n                BUFFER_FLUSH_INTERVAL_MILLIS,\n                TimeUnit.MILLISECONDS);\n    }\n\n    @PreDestroy\n    public void shutdownBufferFlushScheduler() {\n        if (bufferFlushScheduler != null) {\n            bufferFlushScheduler.shutdown();\n            try {\n                if (!bufferFlushScheduler.awaitTermination(1, TimeUnit.SECONDS)) {\n                    bufferFlushScheduler.shutdownNow();\n                }\n            } catch (InterruptedException ex) {\n                Thread.currentThread().interrupt();\n                bufferFlushScheduler.shutdownNow();\n            }\n        }\n        flushAllBuffersSafely();\n    }\n\n    /**\n     * 获取指定分享链接\n     */\n    @Cacheable(key = \"'shareKey:' + #shareKey\", condition = \"#shareKey != null\", unless = \"#result == null\")\n    public ShareLink getByShareKey(String shareKey) {\n        return shareLinkMapper.getByShareKey(shareKey);\n    }\n\n    /**\n     * 删除分享链接\n     */\n    @CacheEvict(key = \"'shareKey:' + #shareKey\")\n    public void deleteShareLink(String shareKey) {\n        ShareLink shareLink = ((ShareLinkService) AopContext.currentProxy()).getByShareKey(shareKey);\n        if (shareLink == null) {\n            throw new BizException(ErrorCode.BIZ_SHARE_LINK_NOT_EXIST);\n        }\n        Integer currentUserId = ZFileAuthUtil.getCurrentUserId();\n        boolean currentIsAdmin = Objects.equals(UserConstant.ADMIN_ID, currentUserId);\n        boolean deleteIsCurrentUser = Objects.equals(shareLink.getUserId(), currentUserId);\n        if (!deleteIsCurrentUser && !currentIsAdmin) {\n            throw new BizException(ErrorCode.BIZ_STORAGE_SOURCE_ILLEGAL_OPERATION);\n        }\n        shareLinkMapper.deleteById(shareLink.getId());\n    }\n\n    /**\n     * 删除过期的分享链接\n     */\n    @CacheEvict(allEntries = true)\n    public int deleteExpiredLinks() {\n        return shareLinkMapper.deleteExpiredLinks(new Date());\n    }\n\n    /**\n     * 删除指定用户的过期分享链接\n     */\n    @CacheEvict(allEntries = true)\n    public int deleteExpiredLinksByUserId(Integer userId) {\n        if (userId == null) {\n            return 0;\n        }\n        return shareLinkMapper.deleteExpiredLinksByUserId(userId, new Date());\n    }\n\n    /**\n     * 更新分享访问次数（先累加到缓冲区，满足条件后统一写入数据库）\n     */\n    public void incrementAccessCount(String shareKey) {\n        bufferIncrement(shareKey, accessCountBuffer,\n                (key, delta) -> shareLinkMapper.incrementAccessCount(key, delta), \"访问\");\n    }\n\n    /**\n     * 更新分享下载次数（先累加到缓冲区，满足条件后统一写入数据库）\n     */\n    public void incrementDownloadCount(String shareKey) {\n        bufferIncrement(shareKey, downloadCountBuffer,\n                (key, delta) -> shareLinkMapper.incrementDownloadCount(key, delta), \"下载\");\n    }\n\n    private void bufferIncrement(String shareKey,\n                                  ConcurrentMap<String, AtomicInteger> buffer,\n                                  BiConsumer<String, Integer> flusher,\n                                  String metricType) {\n        if (StrUtil.isBlank(shareKey)) {\n            return;\n        }\n        AtomicInteger counter = buffer.computeIfAbsent(shareKey, key -> new AtomicInteger());\n        int current = counter.incrementAndGet();\n        if (current >= BUFFER_FLUSH_THRESHOLD) {\n            drainSingleEntry(shareKey, buffer, counter, flusher, metricType);\n        }\n    }\n\n    private void flushAllBuffersSafely() {\n        try {\n            flushBuffer(accessCountBuffer,\n                    (key, delta) -> shareLinkMapper.incrementAccessCount(key, delta), \"访问\");\n            flushBuffer(downloadCountBuffer,\n                    (key, delta) -> shareLinkMapper.incrementDownloadCount(key, delta), \"下载\");\n        } catch (Exception ex) {\n            log.warn(\"刷新分享链接访问/下载次数缓冲区失败\", ex);\n        }\n    }\n\n    private void flushBuffer(ConcurrentMap<String, AtomicInteger> buffer,\n                              BiConsumer<String, Integer> flusher,\n                              String metricType) {\n        for (Map.Entry<String, AtomicInteger> entry : buffer.entrySet()) {\n            drainSingleEntry(entry.getKey(), buffer, entry.getValue(), flusher, metricType);\n        }\n    }\n\n    private void drainSingleEntry(String shareKey,\n                                  ConcurrentMap<String, AtomicInteger> buffer,\n                                  AtomicInteger counter,\n                                  BiConsumer<String, Integer> flusher,\n                                  String metricType) {\n        int delta = counter.getAndSet(0);\n        if (delta <= 0) {\n            return;\n        }\n        try {\n            flusher.accept(shareKey, delta);\n            if (log.isDebugEnabled()) {\n                log.debug(\"刷新分享链接 {} 的{}次数增量 {}\", shareKey, metricType, delta);\n            }\n        } catch (Exception ex) {\n            counter.addAndGet(delta);\n            log.warn(\"刷新分享链接 {} 的{}增量 {} 失败\", shareKey, metricType, delta, ex);\n        } finally {\n            if (counter.get() == 0) {\n                buffer.remove(shareKey, counter);\n            }\n        }\n    }\n\n    // ======================== 业务方法 ========================\n\n    /**\n     * 创建分享链接\n     */\n    public CreateShareLinkResult createShareLink(CreateShareLinkRequest request) {\n        // 生成或验证分享 key\n        String shareKey = generateOrValidateShareKey(request.getShareKey(), request.getStorageKey());\n\n        // 校验请求参数并获取文件服务\n        AbstractBaseFileService<?> fileService = validateAndGetFileService(request);\n\n        // 获取当前用户基础路径，存储分享路径，避免用户路径变更后分享链接失效的问题\n        String absoluteSharePath = StringUtils.concat(fileService.getCurrentUserBasePath(), request.getSharePath());\n\n        // 构建分享链接对象\n        ShareLink shareLink = new ShareLink();\n        shareLink.setShareKey(shareKey);\n        shareLink.setPassword(request.getPassword());\n        shareLink.setExpireDate(request.getExpireDate());\n        shareLink.setStorageKey(request.getStorageKey());\n        shareLink.setSharePath(absoluteSharePath);  // 存储绝对路径\n        List<ShareEntryDTO> normalizedEntries = request.getShareEntries().stream()\n                .map(entry -> {\n                    ShareEntryDTO dto = new ShareEntryDTO();\n                    String name = entry.getName() == null ? null : entry.getName().trim();\n                    dto.setName(name);\n                    dto.setType(entry.getType());\n                    return dto;\n                })\n                .collect(Collectors.toList());\n\n        shareLink.setShareItem(JSON.toJSONString(normalizedEntries));\n        shareLink.setShareType(request.getShareType());\n        shareLink.setUserId(ZFileAuthUtil.getCurrentUserId());\n        shareLink.setCreateDate(new Date());\n\n        // 保存到数据库\n        shareLinkMapper.insert(shareLink);\n\n        // 构建返回结果\n        CreateShareLinkResult result = new CreateShareLinkResult();\n        result.setShareKey(shareKey);\n        result.setFullShareUrl(StringUtils.removeDuplicateSlashes(systemConfigService.getAxiosFromDomainOrSetting() + \"/share/\" + shareKey));\n        return result;\n    }\n\n    /**\n     * 根据分享 key 获取分享信息\n     */\n    public ShareLinkResult getShareLinkInfo(String shareKey) {\n        ShareLink shareLink = getValidShareLink(shareKey);\n        return buildShareLinkResult(shareLink, false);\n    }\n\n    /**\n     * 验证分享密码\n     */\n    public boolean verifyPassword(String shareKey, String password) {\n        ShareLink shareLink = getValidShareLink(shareKey);\n\n        // 如果没有设置密码，则验证通过\n        if (StrUtil.isBlank(shareLink.getPassword())) {\n            return true;\n        }\n\n        return Objects.equals(shareLink.getPassword(), password);\n    }\n\n    /**\n     * 获取有效分享链接\n     *\n     * @param shareKey 分享链接 key\n     * @return 分享链接\n     */\n    public ShareLink getValidShareLink(String shareKey) {\n        ShareLink shareLink = ((ShareLinkService) AopContext.currentProxy()).getByShareKey(shareKey);\n        if (shareLink == null) {\n            throw new BizException(ErrorCode.BIZ_SHARE_LINK_NOT_EXIST);\n        }\n\n        // 检查是否过期\n        if (isExpired(shareLink)) {\n            throw new BizException(ErrorCode.BIZ_SHARE_LINK_EXPIRED);\n        }\n\n        return shareLink;\n    }\n\n\n    /**\n     * 获取用户创建的分享列表（分页）\n     */\n    public Page<ShareLinkResult> getUserShareList(ShareLinkListRequest request) {\n        request.handleDefaultValue();\n\n        Integer currentUserId = ZFileAuthUtil.getCurrentUserId();\n        Page<ShareLinkResult> emptyPage = new Page<>(request.getPage(), request.getLimit());\n        emptyPage.setRecords(Collections.emptyList());\n\n        if (currentUserId == null) {\n            emptyPage.setTotal(0);\n            return emptyPage;\n        }\n\n        LambdaQueryWrapper<ShareLink> queryWrapper = buildShareListQueryWrapper(request);\n        queryWrapper.eq(ShareLink::getUserId, currentUserId);\n\n        Page<ShareLink> page = shareLinkMapper.selectPage(new Page<ShareLink>(request.getPage(), request.getLimit()).addOrder(request.getOrderItem()), queryWrapper);\n        return buildShareResultPage(page, false);\n    }\n\n    /**\n     * 管理员查询全部分享列表（分页）\n     */\n    public Page<ShareLinkResult> getAdminShareList(ShareLinkListRequest request) {\n        request.handleDefaultValue();\n\n        LambdaQueryWrapper<ShareLink> queryWrapper = buildShareListQueryWrapper(request);\n        Page<ShareLink> page = shareLinkMapper.selectPage(new Page<ShareLink>(request.getPage(), request.getLimit()).addOrder(request.getOrderItem()), queryWrapper);\n        return buildShareResultPage(page, true);\n    }\n\n    private LambdaQueryWrapper<ShareLink> buildShareListQueryWrapper(ShareLinkListRequest request) {\n        LambdaQueryWrapper<ShareLink> queryWrapper = Wrappers.lambdaQuery();\n\n        if (StrUtil.isNotBlank(request.getStorageKey())) {\n            queryWrapper.eq(ShareLink::getStorageKey, request.getStorageKey().trim());\n        }\n\n        if (StrUtil.isNotBlank(request.getKeyword())) {\n            String keyword = request.getKeyword().trim();\n            queryWrapper.and(wrapper -> wrapper.like(ShareLink::getShareKey, keyword)\n                    .or().like(ShareLink::getShareItem, keyword)\n                    .or().like(ShareLink::getSharePath, keyword));\n        }\n\n        String status = StrUtil.blankToDefault(request.getStatus(), \"all\").toLowerCase(Locale.ROOT);\n        Date now = new Date();\n        switch (status) {\n            case \"expired\" -> {\n                queryWrapper.isNotNull(ShareLink::getExpireDate);\n                queryWrapper.le(ShareLink::getExpireDate, now);\n            }\n            case \"active\" -> queryWrapper.and(wrapper -> wrapper.isNull(ShareLink::getExpireDate)\n                    .or().gt(ShareLink::getExpireDate, now));\n            default -> {\n                // all, do nothing\n            }\n        }\n\n        if (request.getCreateDateStart() != null) {\n            queryWrapper.ge(ShareLink::getCreateDate, request.getCreateDateStart());\n        }\n        if (request.getCreateDateEnd() != null) {\n            queryWrapper.le(ShareLink::getCreateDate, request.getCreateDateEnd());\n        }\n\n        return queryWrapper;\n    }\n\n    private Page<ShareLinkResult> buildShareResultPage(Page<ShareLink> page, boolean includeUserInfo) {\n        Page<ShareLinkResult> resultPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());\n        List<ShareLink> shareLinks = page.getRecords();\n\n        Map<Integer, User> userMap;\n        if (includeUserInfo && !shareLinks.isEmpty()) {\n            userMap = new HashMap<>();\n            shareLinks.stream()\n                    .map(ShareLink::getUserId)\n                    .filter(Objects::nonNull)\n                    .distinct()\n                    .forEach(userId -> {\n                        User user = userService.getById(userId);\n                        if (user != null) {\n                            userMap.put(userId, user);\n                        }\n                    });\n        } else {\n            userMap = Collections.emptyMap();\n        }\n\n        List<ShareLinkResult> records = shareLinks.stream()\n                .map(item -> {\n                    ShareLinkResult result = buildShareLinkResult(item, true);\n                    if (includeUserInfo) {\n                        result.setUserId(item.getUserId());\n                        User user = userMap.get(item.getUserId());\n                        if (user != null) {\n                            result.setUsername(user.getUsername());\n                            result.setNickname(user.getNickname());\n                        }\n                    }\n                    return result;\n                })\n                .collect(Collectors.toList());\n\n        resultPage.setRecords(records);\n        return resultPage;\n    }\n\n\n\n    /**\n     * 判断分享链接是否过期\n     */\n    private boolean isExpired(ShareLink shareLink) {\n        if (shareLink.getExpireDate() == null) {\n            return false;\n        }\n        return new Date().after(shareLink.getExpireDate());\n    }\n\n    /**\n     * 构建分享链接结果\n     *\n     * @param shareLink 分享链接实体\n     * @return 分享链接结果\n     */\n    private ShareLinkResult buildShareLinkResult(ShareLink shareLink, boolean includeSensitive) {\n        ShareLinkResult result = new ShareLinkResult();\n        BeanUtils.copyProperties(shareLink, result);\n\n        if (includeSensitive) {\n            result.setPassword(shareLink.getPassword());\n        } else {\n            result.setPassword(null);\n        }\n\n        // 解析分享条目\n        if (StrUtil.isNotBlank(shareLink.getShareItem())) {\n            try {\n                List<ShareEntryDTO> shareEntries = JSON.parseArray(shareLink.getShareItem(), ShareEntryDTO.class);\n                result.setShareEntries(shareEntries == null ? List.of() : shareEntries);\n            } catch (Exception e) {\n                result.setShareEntries(List.of());\n            }\n        } else {\n            result.setShareEntries(List.of());\n        }\n\n        result.setNeedPassword(StrUtil.isNotBlank(shareLink.getPassword()));\n        result.setExpired(isExpired(shareLink));\n\n        if (StrUtil.isNotBlank(shareLink.getStorageKey())) {\n            StorageSource storageSource = storageSourceService.findByStorageKey(shareLink.getStorageKey());\n            if (storageSource != null) {\n                result.setStorageId(storageSource.getId());\n                result.setStorageName(storageSource.getName());\n            }\n        }\n\n        return result;\n    }\n\n    /**\n     * 校验创建分享链接请求参数并获取文件服务\n     *\n     * @param request 创建分享链接请求\n     * @return 文件服务实例\n     */\n    private AbstractBaseFileService<?> validateAndGetFileService(CreateShareLinkRequest request) {\n        String storageKey = request.getStorageKey();\n\n        // 验证存储源是否存在\n        if (!storageSourceService.existByStorageKey(storageKey)) {\n            throw new BizException(ErrorCode.BIZ_STORAGE_NOT_FOUND);\n        }\n\n        // 获取文件服务实例\n        AbstractBaseFileService<?> fileService = StorageSourceContext.getByStorageKey(storageKey);\n        if (fileService == null) {\n            throw new BizException(ErrorCode.BIZ_STORAGE_NOT_FOUND);\n        }\n\n        // 验证过期时间是否合法\n        if (request.getExpireDate() != null && request.getExpireDate().before(new Date())) {\n            throw new BizException(ErrorCode.BIZ_SHARE_LINK_EXPIRY_MUST_BE_FUTURE);\n        }\n\n        return fileService;\n    }\n\n    /**\n     * 生成或验证分享 key\n     *\n     * @param customShareKey 自定义分享 key，可以为空\n     * @return 有效的分享 key\n     */\n    private String generateOrValidateShareKey(String customShareKey, String storageKey) {\n        // 如果提供了自定义 key，则验证是否可用\n        if (StrUtil.isNotBlank(customShareKey)) {\n            return validateCustomShareKey(customShareKey, storageKey);\n        }\n\n        // 如果没有提供自定义 key，则自动生成\n        return generateShareKey();\n    }\n\n    /**\n     * 验证自定义分享 key\n     *\n     * @param customShareKey 自定义分享 key\n     * @param storageKey     存储源 key\n     * @return 验证通过的分享 key\n     */\n    private String validateCustomShareKey(String customShareKey, String storageKey) {\n        // 检查用户是否有使用自定义分享 key 的权限\n        Integer storageId = storageSourceService.findIdByKey(storageKey);\n        if (!hasCustomKeyPermission(storageId)) {\n            throw new BizException(ErrorCode.NO_CUSTOM_SHARE_LINK_KEY_PERMISSION);\n        }\n\n        // 验证格式：只能包含字母、数字、下划线和短横线，长度 3-8\n        if (!customShareKey.matches(\"^[a-zA-Z0-9_-]{3,8}$\")) {\n            throw new BizException(ErrorCode.BIZ_CUSTOM_SHARE_LINK_KEY_FORMAT_ILLEGAL);\n        }\n\n        // 验证是否已存在\n        if (((ShareLinkService) AopContext.currentProxy()).getByShareKey(customShareKey) != null) {\n            throw new BizException(ErrorCode.BIZ_SHARE_LINK_KEY_ALREADY_EXIST);\n        }\n\n        return customShareKey;\n    }\n\n    /**\n     * 生成分享 key\n     */\n    private String generateShareKey() {\n        String shareKey;\n        do {\n            shareKey = IdUtil.randomUUID().replace(\"-\", \"\").substring(0, 8);\n        } while (((ShareLinkService) AopContext.currentProxy()).getByShareKey(shareKey) != null);\n        return shareKey;\n    }\n\n    /**\n     * 检查用户是否有使用自定义分享 key 的权限\n     *\n     * @param storageId 存储源 ID\n     * @return 是否有权限\n     */\n    private boolean hasCustomKeyPermission(Integer storageId) {\n        Integer currentUserId = ZFileAuthUtil.getCurrentUserId();\n        if (currentUserId == null) {\n            return false;\n        }\n\n        UserStorageSource userStorageSource = userStorageSourceService.getByUserIdAndStorageId(currentUserId, storageId);\n        if (userStorageSource == null || !Boolean.TRUE.equals(userStorageSource.getEnable())) {\n            return false;\n        }\n\n        return userStorageSource.getPermissions().contains(FileOperatorTypeEnum.CUSTOM_SHARE_KEY.getValue());\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/sso/controller/SsoAPIController.java",
    "content": "package im.zhaojun.zfile.module.sso.controller;\n\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.module.sso.model.response.SsoLoginItemResponse;\nimport im.zhaojun.zfile.module.sso.service.SsoService;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport java.util.List;\n\n/**\n * 单点登录接口\n *\n * @author OnEvent\n */\n@Slf4j\n@Tag(name = \"单点登录接口\")\n@RestController\n@RequestMapping(\"/api/sso\")\n@RequiredArgsConstructor\npublic class SsoAPIController {\n\n    private final SsoService ssoService;\n\n    @GetMapping(\"/list\")\n    @Operation(summary = \"登录页面 SSO 服务商列表\")\n    public AjaxJson<List<SsoLoginItemResponse>> list() {\n        return AjaxJson.getSuccessData(ssoService.listAllLoginItems());\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/sso/controller/SsoController.java",
    "content": "package im.zhaojun.zfile.module.sso.controller;\n\nimport cn.hutool.core.util.IdUtil;\nimport cn.hutool.core.util.URLUtil;\nimport im.zhaojun.zfile.module.sso.service.SsoService;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.servlet.http.HttpSession;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.web.bind.annotation.*;\nimport org.springframework.web.servlet.view.RedirectView;\n\n/**\n * 单点登录接口\n *\n * @author OnEvent\n */\n@Slf4j\n@Tag(name = \"单点登录\")\n@RestController\n@RequestMapping(\"/sso\")\n@RequiredArgsConstructor\nclass SsoController {\n\n    private final SsoService ssoService;\n\n    @GetMapping(\"/{provider}/login\")\n    @Operation(summary = \"获取单点登录地址\")\n    public RedirectView login(@PathVariable String provider, HttpSession session) {\n        String state = IdUtil.fastSimpleUUID();\n        session.setAttribute(\"state\", state);\n\n        String url = ssoService.getAuthRedirectUrl(provider, state);\n\n        RedirectView redirect = new RedirectView();\n        redirect.setUrl(url);\n        redirect.setStatusCode(HttpStatus.SEE_OTHER);\n        return redirect;\n    }\n\n    @GetMapping(\"/{provider}/login/callback\")\n    @Operation(summary = \"单点登录回调接口\")\n    public RedirectView callback(@PathVariable(\"provider\") String provider, @RequestParam(\"code\") String code, @RequestParam(\"state\") String state, HttpSession session) {\n        Object expectedState = session.getAttribute(\"state\");\n        if (expectedState == null) {\n            String err = URLUtil.encode(\"当前会话中 state 为空，可能是请求地址和回调地址不一致\");\n            return new RedirectView(\"/sso/login/error?err=\" + err);\n        }\n        if (!expectedState.equals(state)) {\n            String err = URLUtil.encode(\"state 参数不一致，请检查请求地址和回调地址是否一致\");\n            return new RedirectView(\"/sso/login/error?err=\" + err);\n        }\n\n        String url = ssoService.callbackHandler(provider, code);\n\n        RedirectView redirect = new RedirectView();\n        redirect.setUrl(url);\n        redirect.setStatusCode(HttpStatus.SEE_OTHER);\n        return redirect;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/sso/controller/SsoManagerController.java",
    "content": "package im.zhaojun.zfile.module.sso.controller;\n\nimport im.zhaojun.zfile.core.annotation.DemoDisable;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.module.sso.model.entity.SsoConfig;\nimport im.zhaojun.zfile.module.sso.model.request.CheckProviderDuplicateRequest;\nimport im.zhaojun.zfile.module.sso.service.SsoService;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.validation.Valid;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.Collection;\nimport java.util.List;\n\n/**\n * 单点登录管理接口\n *\n * @author OnEvent\n */\n@Slf4j\n@Tag(name = \"单点登录管理接口\")\n@RestController\n@RequestMapping(\"/admin/sso\")\n@RequiredArgsConstructor\nclass SsoManagerController {\n\n    private final SsoService ssoService;\n\n    @GetMapping(\"/providers\")\n    @Operation(summary = \"SSO 服务商列表\")\n    public AjaxJson<Collection<SsoConfig>> list() {\n        List<SsoConfig> ssoConfigList = ssoService.list();\n        return AjaxJson.getSuccessData(ssoConfigList);\n    }\n\n    @PostMapping(\"/provider\")\n    @Operation(summary = \"保存 SSO 服务商\")\n    @DemoDisable\n    public AjaxJson<SsoConfig> saveOrUpdateProvider(@RequestBody @Valid SsoConfig ssoConfig) {\n        return AjaxJson.getSuccessData(ssoService.saveOrUpdate(ssoConfig));\n    }\n\n    @DeleteMapping(\"/provider/{provider}\")\n    @Operation(summary = \"删除 SSO 服务商\")\n    @DemoDisable\n    public AjaxJson<Void> deleteProvider(@PathVariable String provider) {\n        ssoService.deleteProvider(provider);\n        return AjaxJson.getSuccess();\n    }\n\n    @GetMapping(\"/provider/checkDuplicate\")\n    @Operation(summary = \"检查服务商简称是否重复\")\n    public AjaxJson<Boolean> checkDuplicate(CheckProviderDuplicateRequest request) {\n        Integer id = request.getId();\n        String provider = request.getProvider();\n        return AjaxJson.getSuccessData(ssoService.checkDuplicateProvider(id, provider));\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/sso/mapper/SsoConfigMapper.java",
    "content": "package im.zhaojun.zfile.module.sso.mapper;\n\nimport com.baomidou.mybatisplus.core.mapper.BaseMapper;\nimport im.zhaojun.zfile.module.sso.model.entity.SsoConfig;\nimport im.zhaojun.zfile.module.sso.model.response.SsoLoginItemResponse;\nimport org.apache.ibatis.annotations.Mapper;\nimport org.apache.ibatis.annotations.Param;\n\nimport java.util.List;\n\n/**\n * 单点登录配置表的 Mapper 接口\n *\n * @author OnEvent\n */\n@Mapper\npublic interface SsoConfigMapper extends BaseMapper<SsoConfig> {\n\n    List<SsoConfig> findAll();\n\n    List<SsoLoginItemResponse> findAllLoginItems();\n\n    SsoConfig findByProvider(@Param(\"provider\") String provider);\n\n    int countByProvider(@Param(\"provider\") String provider, @Param(\"ignoreId\") Integer ignoreId);\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/sso/model/entity/SsoConfig.java",
    "content": "package im.zhaojun.zfile.module.sso.model.entity;\n\nimport com.baomidou.mybatisplus.annotation.IdType;\nimport com.baomidou.mybatisplus.annotation.TableField;\nimport com.baomidou.mybatisplus.annotation.TableId;\nimport com.baomidou.mybatisplus.annotation.TableName;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotNull;\nimport lombok.Data;\n\nimport java.io.Serial;\nimport java.io.Serializable;\n\n/**\n * 单点登录厂商配置\n *\n * @author OnEvent\n */\n@Data\n@Schema(title = \"单点登录厂商配置\")\n@TableName(value = \"`sso_config`\")\npublic class SsoConfig implements Serializable {\n\n    @Serial\n    private static final long serialVersionUID = 1L;\n\n    @TableId(value = \"id\", type = IdType.AUTO)\n    private Integer id;\n\n    @TableField(value = \"provider\")\n    @Schema(title = \"OIDC/OAuth2 厂商名\", example = \"Logto\", description = \"简称，仅可包含数字、字母，-，_\")\n    @NotBlank(message = \"OIDC/OAuth2 厂商名不能为空\")\n    private String provider;\n\n    @TableField(value = \"`name`\")\n    @Schema(title = \"显示名称\", description = \"登录页悬浮到图标上的名称\")\n    @NotBlank(message = \"显示名称不能为空\")\n    private String name;\n\n    @TableField(value = \"`icon`\")\n    @Schema(title = \"ICON\", description = \"登录页显示的图标，支持 URL、SVG、Base64 格式\")\n    @NotBlank(message = \"ICON 不能为空\")\n    private String icon;\n\n    @TableField(value = \"`client_id`\")\n    @Schema(title = \"在 SSO 厂商处生成的 ID\")\n    @NotBlank(message = \"client_id 不能为空\")\n    private String clientId;\n\n    @TableField(value = \"`client_secret`\")\n    @Schema(title = \"在 SSO 厂商处生成的密钥\")\n    @NotBlank(message = \"client_secret 不能为空\")\n    private String clientSecret;\n\n    @TableField(value = \"`auth_url`\")\n    @Schema(title = \"SSO 厂商提供的授权端点\")\n    @NotBlank(message = \"auth_url 不能为空\")\n    private String authUrl;\n\n    @TableField(value = \"`token_url`\")\n    @Schema(title = \"SSO 厂商提供的 Token 端点\")\n    @NotBlank(message = \"token_url 不能为空\")\n    private String tokenUrl;\n\n    @TableField(value = \"`user_info_url`\")\n    @Schema(title = \"SSO 厂商提供的用户信息端点\")\n    @NotBlank(message = \"user_info_url 不能为空\")\n    private String userInfoUrl;\n\n    @TableField(value = \"`scope`\")\n    @Schema(title = \"SSO 厂商提供的授权范围\")\n    @NotBlank(message = \"scope 不能为空\")\n    private String scope;\n\n    @TableField(value = \"`binding_field`\")\n    @Schema(title = \"SSO 系统中用户与本系统中用户互相的绑定字段\")\n    @NotBlank(message = \"用户字段表达式不能为空\")\n    private String bindingField;\n\n    @TableField(value = \"`enabled`\")\n    @Schema(title = \"是否启用\")\n    @NotNull(message = \"启用状态不能为空\")\n    private Boolean enabled;\n\n    @TableField(value = \"`order_num`\")\n    @Schema(title = \"排序\", description = \"数字越小越靠前\")\n    private Integer orderNum;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/sso/model/request/CheckProviderDuplicateRequest.java",
    "content": "package im.zhaojun.zfile.module.sso.model.request;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\n@Data\npublic class CheckProviderDuplicateRequest {\n\n    @Schema(title=\"id\")\n    private Integer id;\n\n    @Schema(title=\"提供商\")\n    private String provider;\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/sso/model/response/SsoLoginItemResponse.java",
    "content": "package im.zhaojun.zfile.module.sso.model.response;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\n@Data\npublic class SsoLoginItemResponse {\n\n    @Schema(title = \"OIDC/OAuth2 厂商名\", example = \"Logto\", description = \"简称，仅可包含数字、字母，-，_\")\n    private String provider;\n\n    @Schema(title = \"显示名称\", description = \"登录页悬浮到图标上的名称\")\n    private String name;\n\n    @Schema(title = \"ICON\", description = \"登录页显示的图标，支持 URL、SVG、Base64 格式\")\n    private String icon;\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/sso/model/response/TokenResponse.java",
    "content": "package im.zhaojun.zfile.module.sso.model.response;\n\nimport lombok.Data;\n\n@Data\npublic class TokenResponse {\n\n    private String idToken;\n\n    private String accessToken;\n\n    private String refreshToken;\n\n    private String scope;\n\n    private String tokenType;\n\n    private Long expiresIn;\n\n    private String refreshTokenExpiresIn;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/sso/service/SsoService.java",
    "content": "package im.zhaojun.zfile.module.sso.service;\n\nimport cn.dev33.satoken.stp.StpUtil;\nimport cn.hutool.core.convert.Convert;\nimport cn.hutool.core.util.BooleanUtil;\nimport cn.hutool.core.util.ObjectUtil;\nimport cn.hutool.core.util.StrUtil;\nimport cn.hutool.http.Header;\nimport cn.hutool.http.HttpResponse;\nimport cn.hutool.http.HttpUtil;\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONReader;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.exception.core.ErrorPageBizException;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.config.service.SystemConfigService;\nimport im.zhaojun.zfile.module.sso.mapper.SsoConfigMapper;\nimport im.zhaojun.zfile.module.sso.model.entity.SsoConfig;\nimport im.zhaojun.zfile.module.sso.model.response.SsoLoginItemResponse;\nimport im.zhaojun.zfile.module.sso.model.response.TokenResponse;\nimport im.zhaojun.zfile.module.user.model.constant.UserConstant;\nimport im.zhaojun.zfile.module.user.model.entity.User;\nimport im.zhaojun.zfile.module.user.model.request.CopyUserRequest;\nimport im.zhaojun.zfile.module.user.service.UserService;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.aop.framework.AopContext;\nimport org.springframework.cache.annotation.CacheConfig;\nimport org.springframework.cache.annotation.CacheEvict;\nimport org.springframework.cache.annotation.Cacheable;\nimport org.springframework.stereotype.Service;\n\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\n\nimport static im.zhaojun.zfile.module.sso.service.SsoService.SSO_CONFIG_CACHE_KEY;\n\n/**\n * 单点登录服务\n *\n * @author OnEvent\n */\n@Slf4j\n@Service\n@RequiredArgsConstructor\n@CacheConfig(cacheNames = SSO_CONFIG_CACHE_KEY)\npublic class SsoService {\n\n    public static final String SSO_CONFIG_CACHE_KEY = \"ssoConfig\";\n\n    private static final String REDIRECT_URI = \"/sso/{}/login/callback\";\n\n    private final SsoConfigMapper ssoConfigMapper;\n\n    private final SystemConfigService systemConfigService;\n\n    private final UserService userService;\n\n    public List<SsoConfig> list() {\n        return ssoConfigMapper.findAll();\n    }\n\n    public List<SsoLoginItemResponse> listAllLoginItems() {\n        return ssoConfigMapper.findAllLoginItems();\n    }\n\n    @Cacheable(key = \"#provider\", unless = \"#result == null\", condition = \"#provider != null\")\n    public SsoConfig getProvider(String provider) {\n        return ssoConfigMapper.findByProvider(provider);\n    }\n\n    @CacheEvict(key = \"#result.provider\")\n    public SsoConfig saveOrUpdate(SsoConfig ssoConfig) {\n        boolean providerIsDuplicate = checkDuplicateProvider(ssoConfig.getId(), ssoConfig.getProvider());\n        if (providerIsDuplicate) {\n            throw new BizException(ErrorCode.BIZ_SSO_PROVIDER_EXIST);\n        }\n\n        if (ssoConfig.getId() == null) {\n            ssoConfigMapper.insert(ssoConfig);\n        } else {\n            ssoConfigMapper.updateById(ssoConfig);\n        }\n        return ssoConfig;\n    }\n\n    @CacheEvict(key = \"#provider\")\n    public void deleteProvider(String provider) {\n        ssoConfigMapper.deleteById(provider);\n    }\n\n    public boolean checkDuplicateProvider(Integer ignoreId, String provider) {\n        return ssoConfigMapper.countByProvider(provider, ignoreId) > 0;\n    }\n\n    /**\n     * 获取 OIDC/OAuth2 的授权地址<br/>\n     * 并由后端控制页面重定向到 OIDC/OAuth2 服务器的授权页面\n     *\n     * @param state 状态值\n     * @return 授权地址\n     */\n    public String getAuthRedirectUrl(String provider, String state) {\n        SsoConfig config = ((SsoService) AopContext.currentProxy()).getProvider(provider);\n        if (ObjectUtil.isNull(config)) {\n            throw new ErrorPageBizException(\"供应商: [\" + provider + \"] 不存在, 请检查配置\");\n        }\n\n        if (BooleanUtil.isFalse(config.getEnabled())) {\n            throw new ErrorPageBizException(ErrorCode.BIZ_SSO_PROVIDER_DISABLED);\n        }\n\n        Map<String, String> authParamsMap = new HashMap<>() {{\n            put(\"response_type\", \"code\");\n            put(\"client_id\", config.getClientId());\n            put(\"redirect_uri\", systemConfigService.getAxiosFromDomainOrSetting() + StrUtil.format(REDIRECT_URI, provider));\n            put(\"state\", state);\n            put(\"scope\", config.getScope());\n        }};\n        String authParamsStr = HttpUtil.toParams(authParamsMap);\n        return config.getAuthUrl() + \"?\" + authParamsStr;\n    }\n\n    /**\n     * 处理 OIDC/OAuth2 的回调，并同时获取用户信息，利用用户信息中的邮箱完成登录<br/>\n     * 若一切顺利则返回到成功页面<br/>\n     * 当在获取 AK 和用户信息时发生错误时，返回错误页面\n     *\n     * @param code 授权码\n     * @return 重定向的页面路径\n     */\n    public String callbackHandler(String provider, String code) {\n        SsoConfig config = ((SsoService) AopContext.currentProxy()).getProvider(provider);\n        if (log.isDebugEnabled()) {\n            log.debug(\"[Callback] 单点登录厂商 {} 配置信息: {}\", provider, config);\n        }\n\n        if (BooleanUtil.isFalse(config.getEnabled())) {\n            throw new ErrorPageBizException(ErrorCode.BIZ_SSO_PROVIDER_DISABLED);\n        }\n\n        // 获取 Access Token\n        Map<String, String> tokenParamsMap = new HashMap<>() {{\n            put(\"code\", code);\n            put(\"client_id\", config.getClientId());\n            put(\"client_secret\", config.getClientSecret());\n            put(\"redirect_uri\", systemConfigService.getAxiosFromDomainOrSetting() + StrUtil.format(REDIRECT_URI, provider));\n            put(\"grant_type\", \"authorization_code\");\n        }};\n\n        HttpResponse getTokenResponse = HttpUtil.createPost(config.getTokenUrl())\n                .header(Header.ACCEPT, \"application/json\")\n                .body(HttpUtil.toParams(tokenParamsMap))\n                .execute();\n        String tokenStr = getTokenResponse.body();\n        if (log.isDebugEnabled()) {\n            log.debug(\"[Token] 单点登录厂商返回的 Token 信息: {}\", tokenStr);\n        }\n        if (!getTokenResponse.isOk()) {\n            log.error(\"单点登录厂商 {} 返回错误: {}, 错误信息: {}\", provider, getTokenResponse.getStatus(), tokenStr);\n            throw new ErrorPageBizException(\"单点登录失败: \" + getTokenResponse.getStatus() + \", \" + tokenStr);\n        }\n\n        TokenResponse token = JSON.parseObject(tokenStr, TokenResponse.class, JSONReader.Feature.SupportSmartMatch);\n        if (!\"bearer\".equalsIgnoreCase(token.getTokenType())) {\n            throw new ErrorPageBizException(\"Access Token 类型错误, 需要 Bearer 类型, 请检查配置\");\n        }\n\n\n        // 获取用户信息\n        HttpResponse userInfoResponse = HttpUtil\n                .createGet(config.getUserInfoUrl())\n                .bearerAuth(token.getAccessToken())\n                .execute();\n        String userInfoStr = userInfoResponse.body();\n        if (log.isDebugEnabled()) {\n            log.debug(\"[UserInfo] 单点登录服务商处请求 {} 的用户信息: {}，将尝试通过 {} 表达式获取字段\", config.getUserInfoUrl(), userInfoStr, config.getBindingField());\n        }\n        if (!userInfoResponse.isOk()) {\n            log.error(\"单点登录服务商 {} 返回错误: {}, 错误信息: {}\", provider, userInfoResponse.getStatus(), userInfoStr);\n            throw new ErrorPageBizException(\"从单点登录服务商获取用户信息失败: \" + userInfoResponse.getStatus() + \", \" + userInfoStr);\n        }\n\n        Object bindingField = JSON.parseObject(userInfoStr, JSONReader.Feature.SupportSmartMatch).getByPath(config.getBindingField());\n        if (log.isDebugEnabled()) {\n            log.debug(\"[UserInfo] 通过表达式 [{}] 获取到字段: {}\", config.getBindingField(), bindingField);\n        }\n\n        if (StrUtil.isBlankIfStr(bindingField)) {\n            throw new ErrorPageBizException(\"解析用户信息失败, 请检查配置\");\n        }\n\n\n        String bindingFieldStr = Convert.toStr(bindingField);\n        User user = userService.getByUsername(bindingFieldStr);\n        if (user == null) {\n            User templateUser = userService.getById(UserConstant.NEW_USER_TEMPLATE_ID);\n            if (!BooleanUtil.isTrue(templateUser.getEnable())) {\n                throw new ErrorPageBizException(\"当前系统未启用新用户注册, 请联系管理员\");\n            }\n\n            CopyUserRequest copyUserRequest = new CopyUserRequest();\n            copyUserRequest.setFromId(UserConstant.NEW_USER_TEMPLATE_ID);\n            copyUserRequest.setToNickname(bindingFieldStr);\n            copyUserRequest.setToUsername(bindingFieldStr);\n            Integer newUserId = userService.copy(copyUserRequest);\n\n            log.info(\"新用户 {} 通过单点登录注册成功, ID: {}\", bindingFieldStr, newUserId);\n\n            user = new User();\n            user.setId(newUserId);\n        }\n\n        StpUtil.login(user.getId());\n        String axiosFromDomainOrSetting = systemConfigService.getAxiosFromDomainOrSetting();\n        String frontDomain = systemConfigService.getFrontDomain();\n\n        String redirectUrl;\n        if (StringUtils.isBlank(frontDomain)) {\n            redirectUrl = axiosFromDomainOrSetting;\n        } else {\n            // 如果配置了前端域名，则跳转到前端域名\n            redirectUrl = frontDomain + \"/sso?token=\" + StpUtil.getTokenValue();\n        }\n\n        if (log.isDebugEnabled()) {\n            log.debug(\"用户 {} 通过单点登录登录成功, ID: {}，将跳转到 {}, token 为 {}\", bindingFieldStr, user.getId(), redirectUrl, StpUtil.getTokenValue());\n        }\n        return redirectUrl;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/annotation/CheckPassword.java",
    "content": "package im.zhaojun.zfile.module.storage.annotation;\n\nimport java.lang.annotation.*;\n\n/**\n * 检查文件夹密码规则的注解, 判断是否有权限访问文件夹\n *\n * @author zhaojun\n */\n@Target(ElementType.METHOD)\n@Retention(RetentionPolicy.RUNTIME)\n@Repeatable(CheckPasswords.class)\npublic @interface CheckPassword {\n\t\n\t/**\n\t * 存储源 key 字段表达式\n\t */\n\tString storageKeyFieldExpression();\n\t\n\t/**\n\t * 路径字段名称\n\t */\n\tString pathFieldExpression();\n\t\n\t/**\n\t * 密码字段名称\n\t */\n\tString passwordFieldExpression();\n\t\n\t/**\n\t * 路径是否是文件夹, 如果为 false, 则会取路径的父目录作为路径\n\t */\n\tboolean pathIsDirectory() default true;\n\t\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/annotation/CheckPasswords.java",
    "content": "package im.zhaojun.zfile.module.storage.annotation;\n\nimport java.lang.annotation.ElementType;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.RetentionPolicy;\nimport java.lang.annotation.Target;\n\n@Target(ElementType.METHOD)\n@Retention(RetentionPolicy.RUNTIME)\npublic @interface CheckPasswords {\n\n    CheckPassword[] value();\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/annotation/LinkRateLimiter.java",
    "content": "package im.zhaojun.zfile.module.storage.annotation;\n\nimport java.lang.annotation.ElementType;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.RetentionPolicy;\nimport java.lang.annotation.Target;\n\n/**\n * 直链访问频率限制注解，标注了此注解的请求，会被校验访问频率是否符合要求.\n *\n * @author zhaojun\n */\n@Target(ElementType.METHOD)\n@Retention(RetentionPolicy.RUNTIME)\npublic @interface LinkRateLimiter {\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/annotation/ProCheck.java",
    "content": "package im.zhaojun.zfile.module.storage.annotation;\n\nimport java.lang.annotation.ElementType;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.RetentionPolicy;\nimport java.lang.annotation.Target;\n\n/**\n * 检测是否是合法的捐赠版\n *\n * @author zhaojun\n */\n@Target(ElementType.METHOD)\n@Retention(RetentionPolicy.RUNTIME)\npublic @interface ProCheck {\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/annotation/RefererCheck.java",
    "content": "package im.zhaojun.zfile.module.storage.annotation;\n\nimport java.lang.annotation.ElementType;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.RetentionPolicy;\nimport java.lang.annotation.Target;\n\n/**\n * Referer 校验注解，标注了此注解的请求，会被校验 Referer 是否符合要求.\n *\n * @author zhaojun\n */\n@Target(ElementType.METHOD)\n@Retention(RetentionPolicy.RUNTIME)\npublic @interface RefererCheck {\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/annotation/StorageParamItem.java",
    "content": "package im.zhaojun.zfile.module.storage.annotation;\n\nimport im.zhaojun.zfile.module.storage.enums.StorageParamItemAnnoEnum;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageParamTypeEnum;\n\nimport java.lang.annotation.ElementType;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.RetentionPolicy;\nimport java.lang.annotation.Target;\n\n/**\n * 标记存储类型参数名称\n *\n * @author zhaojun\n */\n@Target(ElementType.FIELD)\n@Retention(RetentionPolicy.RUNTIME)\npublic @interface StorageParamItem {\n\n\t/**\n\t * 是否是捐赠版功能.\n\t */\n\tboolean pro() default false;\n\n\t/**\n\t * 字段显示排序值, 值越小, 越靠前. 默认为 99\n\t */\n\tint order() default 99;\n\n\t/**\n\t * 参数键, 如果为空, 则使用字段名称.\n\t */\n\tString key() default \"\";\n\n\t/**\n\t * 参数名称, 用于网页上显示名称.\n\t */\n\tString name() default \"\";\n\n\t/**\n\t * 字段类型, 默认为 input, 可选值为: input, textarea, select, switch.\n\t */\n\tStorageParamTypeEnum type() default StorageParamTypeEnum.INPUT;\n\n\t/**\n\t * 当 {@link #type} 为 select 时, 选项的值.\n\t */\n\tStorageParamSelectOption[] options() default {};\n\n\t/**\n\t * 当 {@link #type} 为 select 时, 选项的值. 通过 {@link StorageParamSelect#getOptions)} 方法获取选项值.\n\t */\n\tClass<? extends StorageParamSelect> optionsClass() default StorageParamSelect.class;\n\n\t/**\n\t * 当 {@link #type} 为 select 时, 是否允许用户创建选项.\n\t */\n\tboolean optionAllowCreate() default false;\n\n\t/**\n\t * 参数值是否可以为空. 如不为空，则抛出异常.\n\t */\n\tboolean required() default true;\n\n\t/**\n\t * 如果填写值为空，则给予默认值.\n\t * 支持 ${xxx} 变量, 会读取配置文件中的值, 如获取失败, 会默认为空.\n\t */\n\tString defaultValue() default \"\";\n\n\t/**\n\t * 参数描述信息, 用户在用户填写时, 进行提示.\n\t */\n\tString description() default \"\";\n\n\t/**\n\t * 参数下方的提示链接, 如果为空, 则不显示.\n\t */\n\tString link() default \"\";\n\n\t/**\n\t * 参数下方的提示链接文件信息, 如果为空, 则默认为链接地址.\n\t */\n\tString linkName() default \"\";\n\n\t/**\n\t * 是否忽略参数不传递给前端，也不保存到数据库，一般是临时参数\n\t */\n\tboolean ignoreInput() default false;\n\n\t/**\n\t * 是否前端不显示该字段.\n\t */\n\tboolean hidden() default false;\n\n\t/**\n\t * 判断条件表达式，表达式结果为 true 时才显示该字段\n\t */\n\tString condition() default \"\";\n\n\t/**\n\t * 为了简略子类的注解只修改父类注解某些字段的情况, 直接全部复制的话，后期维护困难，容易不同步, 可以使用该字段描述哪些字段以子类的值为准，其他的从父类继承.\n\t */\n\tStorageParamItemAnnoEnum[] onlyOverwrite() default {};\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/annotation/StorageParamSelect.java",
    "content": "package im.zhaojun.zfile.module.storage.annotation;\n\nimport im.zhaojun.zfile.module.storage.model.param.IStorageParam;\nimport im.zhaojun.zfile.module.storage.model.bo.StorageSourceParamDef;\n\nimport java.util.List;\n\n/**\n * 存储源参数下拉值接口.\n *\n * @author zhaojun\n */\npublic interface StorageParamSelect {\n\n\t/**\n\t * 获取存储源参数下拉选项列表.\n\t *\n\t * @param   storageParamItem\n\t *          存储源下拉参数定义\n\t *\n\t * @param   targetParam\n\t *          存储源参数\n\t *\n\t * @return  存储源参数下拉选项列表\n\t */\n\tList<StorageSourceParamDef.Options> getOptions(StorageParamItem storageParamItem, IStorageParam targetParam);\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/annotation/StorageParamSelectOption.java",
    "content": "package im.zhaojun.zfile.module.storage.annotation;\n\nimport java.lang.annotation.ElementType;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.RetentionPolicy;\nimport java.lang.annotation.Target;\n\n/**\n * 标记存储类型参数类型为 select 时, 数据的下拉值.\n *\n * @author zhaojun\n */\n@Target(ElementType.ANNOTATION_TYPE)\n@Retention(RetentionPolicy.RUNTIME)\npublic @interface StorageParamSelectOption {\n\n\t/**\n\t * 选项显示值\n\t */\n\tString label() default \"\";\n\n\t/**\n\t * 选项存储值\n\t */\n\tString value();\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/annotation/StoragePermissionCheck.java",
    "content": "package im.zhaojun.zfile.module.storage.annotation;\n\nimport im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;\n\nimport java.lang.annotation.ElementType;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.RetentionPolicy;\nimport java.lang.annotation.Target;\n\n/**\n * 存储源权限检查\n *\n * @author zhaojun\n */\n@Target(ElementType.METHOD)\n@Retention(RetentionPolicy.RUNTIME)\npublic @interface StoragePermissionCheck {\n\n\t/**\n\t * 文件操作类型枚举\n\t */\n\tFileOperatorTypeEnum action();\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/annotation/impl/EncodingStorageParamSelect.java",
    "content": "package im.zhaojun.zfile.module.storage.annotation.impl;\n\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamItem;\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamSelect;\nimport im.zhaojun.zfile.module.storage.model.param.IStorageParam;\nimport im.zhaojun.zfile.module.storage.model.bo.StorageSourceParamDef;\n\nimport java.nio.charset.Charset;\nimport java.util.ArrayList;\nimport java.util.List;\n\n/**\n * 编码格式动态参数.\n *\n * @author zhaojun\n */\npublic class EncodingStorageParamSelect implements StorageParamSelect {\n\n\t@Override\n\tpublic List<StorageSourceParamDef.Options> getOptions(StorageParamItem storageParamItem, IStorageParam targetParam) {\n\t\tList<StorageSourceParamDef.Options> options = new ArrayList<>();\n\n\t\tfor (String name : Charset.availableCharsets().keySet()) {\n\t\t\tStorageSourceParamDef.Options option = new StorageSourceParamDef.Options(name);\n\t\t\toptions.add(option);\n\t\t}\n\t\treturn options;\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/aspect/CheckPasswordAspect.java",
    "content": "package im.zhaojun.zfile.module.storage.aspect;\n\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.util.CollectionUtils;\nimport im.zhaojun.zfile.core.util.FileUtils;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.password.model.dto.VerifyResultDTO;\nimport im.zhaojun.zfile.module.password.service.PasswordConfigService;\nimport im.zhaojun.zfile.module.storage.annotation.CheckPassword;\nimport im.zhaojun.zfile.module.storage.annotation.CheckPasswords;\nimport im.zhaojun.zfile.module.storage.context.StorageSourceContext;\nimport im.zhaojun.zfile.module.storage.service.StorageSourceService;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.aspectj.lang.ProceedingJoinPoint;\nimport org.aspectj.lang.Signature;\nimport org.aspectj.lang.annotation.Around;\nimport org.aspectj.lang.annotation.Aspect;\nimport org.aspectj.lang.reflect.MethodSignature;\nimport org.springframework.expression.Expression;\nimport org.springframework.expression.spel.standard.SpelExpressionParser;\nimport org.springframework.stereotype.Component;\n\nimport java.lang.reflect.Method;\nimport java.util.ArrayList;\nimport java.util.List;\n\n/**\n * 检查密码切面\n *\n * @author zhaojun\n */\n@Aspect\n@Component\n@Slf4j\npublic class CheckPasswordAspect {\n\t\n\t@Resource\n\tprivate PasswordConfigService passwordConfigService;\n\t\n\t@Resource\n\tprivate StorageSourceService storageSourceService;\n\t\n\t/**\n\t * 校验密码\n\t *\n\t * @param   point\n\t *          连接点\n\t *\n\t * @return  方法运行结果\n\t */\n\t@Around(value = \"@annotation(im.zhaojun.zfile.module.storage.annotation.CheckPassword) || @annotation(im.zhaojun.zfile.module.storage.annotation.CheckPasswords)\")\n\tpublic Object around(ProceedingJoinPoint point) throws Throwable {\n\t\tSignature s = point.getSignature();\n\t\tMethodSignature ms = (MethodSignature) s;\n\t\tMethod method = ms.getMethod();\n\t\tList<CheckPassword> checkPasswordList = new ArrayList<>();\n\n\t\tCheckPasswords checkPasswords = method.getAnnotation(CheckPasswords.class);\n\t\tCheckPassword checkPassword = method.getAnnotation(CheckPassword.class);\n\t\tif (checkPasswords != null) {\n\t\t\tCollectionUtils.addAll(checkPasswordList, checkPasswords.value());\n\t\t} else if (checkPassword != null) {\n\t\t\tcheckPasswordList.add(checkPassword);\n\t\t} else {\n\t\t\treturn point.proceed();\n\t\t}\n\n\n\t\tfor (CheckPassword item : checkPasswordList) {\n\t\t\tboolean pathIsDirectory = item.pathIsDirectory();\n\t\t\tString storageKeyFieldExpression = item.storageKeyFieldExpression();\n\t\t\tString passwordFieldExpression = item.passwordFieldExpression();\n\t\t\tString pathFieldExpression = item.pathFieldExpression();\n\n\t\t\tObject[] args = point.getArgs();\n\n\t\t\tString storageKeyFieldValue = getFieldValue(args, storageKeyFieldExpression);\n\t\t\tString passwordFieldValue = getFieldValue(args, passwordFieldExpression);\n\t\t\tString pathFieldValue = getFieldValue(args, pathFieldExpression);\n\n\t\t\tif (!pathIsDirectory) {\n\t\t\t\tpathFieldValue = FileUtils.getParentPath(pathFieldValue);\n\t\t\t}\n\n\t\t\tInteger storageId = storageSourceService.findIdByKey(storageKeyFieldValue);\n\n\t\t\tif (storageId == null) {\n\t\t\t\tthrow new BizException(ErrorCode.BIZ_STORAGE_NOT_FOUND);\n\t\t\t}\n\n\t\t\tAbstractBaseFileService<?> targetService = StorageSourceContext.getByStorageId(storageId);\n\t\t\tString fullPath = StringUtils.concat(targetService.getCurrentUserBasePath(), pathFieldValue);\n\t\t\tVerifyResultDTO verifyResultDTO = passwordConfigService.verifyPassword(storageId, fullPath, passwordFieldValue);\n\t\t\tif (!verifyResultDTO.isPassed()) {\n\t\t\t\tthrow new BizException(verifyResultDTO.getErrorCode());\n\t\t\t}\n\t\t}\n\t\treturn point.proceed();\n\t}\n\t\n\t\n\tpublic String getFieldValue(Object target, String expression) {\n\t\tSpelExpressionParser parser = new SpelExpressionParser();\n\t\tExpression exp = parser.parseExpression(expression);\n\t\treturn (String) exp.getValue(target);\n\t}\n\t\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/aspect/FileOperatorCheckAspect.java",
    "content": "package im.zhaojun.zfile.module.storage.aspect;\n\nimport im.zhaojun.zfile.core.exception.biz.StorageSourceIllegalOperationBizException;\nimport im.zhaojun.zfile.core.util.CollectionUtils;\nimport im.zhaojun.zfile.core.util.OnlyOfficeKeyCacheUtils;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.core.util.ZFileAuthUtil;\nimport im.zhaojun.zfile.module.link.model.request.BatchGenerateLinkRequest;\nimport im.zhaojun.zfile.module.onlyoffice.model.OnlyOfficeFile;\nimport im.zhaojun.zfile.module.storage.annotation.StoragePermissionCheck;\nimport im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;\nimport im.zhaojun.zfile.module.storage.service.StorageSourceService;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;\nimport im.zhaojun.zfile.module.user.model.entity.User;\nimport im.zhaojun.zfile.module.user.model.entity.UserStorageSource;\nimport im.zhaojun.zfile.module.user.service.UserStorageSourceService;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.apache.commons.lang3.BooleanUtils;\nimport org.aspectj.lang.ProceedingJoinPoint;\nimport org.aspectj.lang.annotation.Around;\nimport org.aspectj.lang.annotation.Aspect;\nimport org.springframework.stereotype.Component;\n\nimport java.util.List;\nimport java.util.Objects;\nimport java.util.Set;\n\n/**\n * 根据权限设置, 校验文件操作权限\n *\n * @author zhaojun\n */\n@Aspect\n@Component\n@Slf4j\npublic class FileOperatorCheckAspect {\n\n\t@Resource\n\tprivate StorageSourceService storageSourceService;\n\n\t@Resource\n\tprivate UserStorageSourceService userStorageSourceService;\n\n\t/**\n\t * 生成直链权限校验\n\t *\n\t * @param   point\n\t *          连接点\n\t *\n\t * @return  方法运行结果\n\t */\n\t@Around(\"@annotation(storagePermissionCheck)\")\n\tpublic Object annotationCheck(ProceedingJoinPoint point, StoragePermissionCheck storagePermissionCheck) throws Throwable {\n\t\tFileOperatorTypeEnum action = storagePermissionCheck.action();\n\t\tif (action == FileOperatorTypeEnum.LINK) {\n\t\t\treturn linkActionCheck(point);\n\t\t}\n\n\t\treturn point.proceed();\n\t}\n\n\tpublic Object linkActionCheck(ProceedingJoinPoint point) throws Throwable {\n\t\tFileOperatorTypeEnum action = FileOperatorTypeEnum.LINK;\n\t\tObject arg = point.getArgs()[0];\n\t\tString storageKey = (arg instanceof BatchGenerateLinkRequest) ?((BatchGenerateLinkRequest) arg).getStorageKey() : (String) arg;\n\t\tInteger storageId = storageSourceService.findIdByKey(storageKey);\n\n\t\tboolean allowAccess = allowAccess(storageId, action);\n\t\tif (allowAccess) {\n\t\t\treturn point.proceed();\n\t\t} else {\n\t\t\tthrow new StorageSourceIllegalOperationBizException(storageId, action);\n\t\t}\n\t}\n\n\t/**\n\t * 存储源是否可用权限校验\n\t *\n\t * @param   point\n\t *          连接点\n\t *\n\t * @return  方法运行结果\n\t */\n\t@Around(\"execution(public * im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService.fileList(..)) || \" +\n\t\t\t\"execution(public * im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService.getFileItem(..))\")\n\tpublic Object availableAround(ProceedingJoinPoint point) throws Throwable {\n\t\tcheckPermission(point, FileOperatorTypeEnum.AVAILABLE);\n\t\treturn point.proceed();\n\t}\n\n\t/**\n\t * 新建文件/文件夹权限校验\n\t *\n\t * @param   point\n\t *          连接点\n\t *\n\t * @return  方法运行结果\n\t */\n\t@Around(\"execution(public * im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService.newFolder(..))\")\n\tpublic Object newFolderAround(ProceedingJoinPoint point) throws Throwable {\n\t\tcheckPermission(point, FileOperatorTypeEnum.NEW_FOLDER);\n\t\treturn point.proceed();\n\t}\n\n\t/**\n\t * 删除文件/文件夹权限校验\n\t *\n\t * @param   point\n\t *          连接点\n\t *\n\t * @return  方法运行结果\n\t */\n\t@Around(\"execution(public * im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService.delete*(..))\")\n\tpublic Object deleteAround(ProceedingJoinPoint point) throws Throwable {\n\t\tcheckPermission(point, FileOperatorTypeEnum.DELETE);\n\n\t\tObject result = point.proceed();\n\n\t\tboolean isFolder = point.getSignature().getName().equals(\"deleteFolder\");\n\t\tAbstractBaseFileService<?> targetService = (AbstractBaseFileService<?>) point.getTarget();\n\t\tString path = (String) point.getArgs()[0];\n\t\tString name = (String) point.getArgs()[1];\n\t\tString currentUserBasePath = targetService.getCurrentUserBasePath();\n\t\tString fullPath = StringUtils.concat(currentUserBasePath, path, name);\n\t\tclearOnlyOfficeCache(fullPath, targetService.storageId, isFolder);\n\n\t\treturn result;\n\t}\n\n\t/**\n\t * 获取文件上传地址校验\n\t *\n\t * @param   point\n\t *          连接点\n\t *\n\t * @return  方法运行结果\n\t */\n\t@Around(\"execution(public * im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService.getUploadUrl(..)) || \" +\n\t\t\t\"execution(public * im.zhaojun.zfile.module.storage.service.base.AbstractProxyTransferService.uploadFile(..))\")\n\tpublic Object uploadAround(ProceedingJoinPoint point) throws Throwable {\n\t\tcheckPermission(point, FileOperatorTypeEnum.UPLOAD);\n\n\t\tObject[] args = point.getArgs();\n\t\tAbstractBaseFileService<?> targetService = (AbstractBaseFileService<?>) point.getTarget();\n\t\tString currentUserBasePath = targetService.getCurrentUserBasePath();\n\n\t\tString fullPath;\n\t\tString methodName = point.getSignature().getName();\n\t\tif (Objects.equals(methodName, \"getUploadUrl\")) {\n\t\t\tfullPath = StringUtils.concat(currentUserBasePath, (String) args[0], (String) args[1]);\n\t\t} else if (Objects.equals(methodName, \"uploadFile\")) {\n\t\t\tfullPath = StringUtils.concat(currentUserBasePath, (String) args[0]);\n\t\t} else {\n\t\t\tthrow new IllegalArgumentException(\"上传校验异常.\");\n\t\t}\n\n\t\tObject result = point.proceed();\n\t\tclearOnlyOfficeCache(fullPath, targetService.storageId, false);\n\n\t\treturn result;\n\t}\n\n\t/**\n\t * 重命名文件/文件夹权限校验\n\t *\n\t * @param   point\n\t *          连接点\n\t *\n\t * @return  方法运行结果\n\t */\n\t@Around(\"execution(public * im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService.rename*(..))\")\n\tpublic Object renameAround(ProceedingJoinPoint point) throws Throwable {\n\t\tcheckPermission(point, FileOperatorTypeEnum.RENAME);\n\n\t\tAbstractBaseFileService<?> targetService = (AbstractBaseFileService<?>) point.getTarget();\n\t\tString currentUserBasePath = targetService.getCurrentUserBasePath();\n\n\t\tObject[] args = point.getArgs();\n\t\tString path = (String) args[0];\n\t\tString name = (String) args[1];\n\t\tString newName = (String) args[2];\n\t\tString sourceFullPath = StringUtils.concat(currentUserBasePath, path, name);\n\t\tString targetFullPath = StringUtils.concat(currentUserBasePath, path, newName);\n\n\t\tObject result = point.proceed();\n\t\tclearOnlyOfficeCache(sourceFullPath, targetService.storageId, Objects.equals(point.getSignature().getName(), \"renameFolder\"));\n\n\t\treturn result;\n\t}\n\n\t/**\n\t * 移动权限校验\n\t *\n\t * @param   point\n\t *          连接点\n\t *\n\t * @return  方法运行结果\n\t */\n\t@Around(\"execution(public * im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService.move*(..))\")\n\tpublic Object moveAround(ProceedingJoinPoint point) throws Throwable {\n\t\tcheckPermission(point, FileOperatorTypeEnum.MOVE);\n\t\tObject result = point.proceed();\n\n\t\tAbstractBaseFileService<?> targetService = (AbstractBaseFileService<?>) point.getTarget();\n\t\tString path = (String) point.getArgs()[0];\n\t\tString name = (String) point.getArgs()[1];\n\t\tString currentUserBasePath = targetService.getCurrentUserBasePath();\n\t\tString fullPath = StringUtils.concat(currentUserBasePath, path, name);\n\t\tclearOnlyOfficeCache(fullPath, targetService.storageId, Objects.equals(point.getSignature().getName(), \"moveFolder\"));\n\n\t\treturn result;\n\t}\n\n\t/**\n\t * 复制权限校验\n\t *\n\t * @param   point\n\t *          连接点\n\t *\n\t * @return  方法运行结果\n\t */\n\t@Around(\"execution(public * im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService.copy*(..))\")\n\tpublic Object copyAround(ProceedingJoinPoint point) throws Throwable {\n\t\tcheckPermission(point, FileOperatorTypeEnum.COPY);\n\t\treturn point.proceed();\n\t}\n\n\t/**\n\t * 校验是否有此文件操作的权限\n\t *\n\t * @param   point\n\t *          连接点\n\t *\n\t * @param   fileOperatorType\n\t *          文件操作类型\n\t */\n\tprivate void checkPermission(ProceedingJoinPoint point, FileOperatorTypeEnum fileOperatorType) {\n\t\t// 获取对应的存储源 service\n\t\tAbstractBaseFileService<?> targetService = (AbstractBaseFileService<?>) point.getTarget();\n\t\tInteger storageId = targetService.storageId;\n\n\t\tboolean allowAccess = allowAccess(storageId, fileOperatorType);\n\n\t\tif (BooleanUtils.isFalse(allowAccess)) {\n\t\t\tthrow new StorageSourceIllegalOperationBizException(storageId, fileOperatorType);\n\t\t}\n\t}\n\n\n\tprivate boolean allowAccess(Integer storageId, FileOperatorTypeEnum fileOperatorType) {\n\t\tUser currentUser = ZFileAuthUtil.getCurrentUser();\n\t\tif (BooleanUtils.isNotTrue(currentUser.getEnable())) {\n\t\t\treturn false;\n\t\t}\n\n\t\tUserStorageSource userStorageSource = userStorageSourceService.getByUserIdAndStorageId(ZFileAuthUtil.getCurrentUserId(), storageId);\n\n\t\t// 如果未授权该存储源，则默认禁止所有类型的操作\n\t\tBoolean enable = userStorageSource.getEnable();\n\t\tif (enable == null || !enable) {\n\t\t\treturn false;\n\t\t}\n\n\t\tif (fileOperatorType == FileOperatorTypeEnum.AVAILABLE) {\n\t\t\treturn true;\n\t\t}\n\n\t\tSet<String> permissions = userStorageSource.getPermissions();\n\t\treturn permissions.contains(fileOperatorType.getValue());\n\t}\n\n\t/**\n\t * 清除 OnlyOffice 缓存\n\t *\n\t * @param \tfullPath\n\t * \t\t\t文件全路径(包含用户路径)\n\t *\n\t * @param \tstorageId\n\t * \t\t\t存储源 ID\n\t */\n\tprivate void clearOnlyOfficeCache(String fullPath, Integer storageId, boolean isFolder) {\n\t\ttry {\n\t\t\tString storageKey = storageSourceService.findStorageKeyById(storageId);\n\t\t\tif (isFolder) {\n\t\t\t\tList<OnlyOfficeFile> caches = OnlyOfficeKeyCacheUtils.removeByFolder(new OnlyOfficeFile(storageKey, fullPath));\n\t\t\t\tif (CollectionUtils.isNotEmpty(caches)) {\n\t\t\t\t\tlog.debug(\"删除/重命名文件夹时, 清除 OnlyOffice 缓存 {} 个\", caches);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tOnlyOfficeFile onlyOfficeFile = OnlyOfficeKeyCacheUtils.removeByFile(new OnlyOfficeFile(storageKey, fullPath));\n\t\t\t\tif (onlyOfficeFile != null) {\n\t\t\t\t\tlog.debug(\"删除/重命名文件时, 清除 OnlyOffice 缓存: {}\", onlyOfficeFile);\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (Exception e) {\n\t\t\tlog.error(\"清除 OnlyOffice 缓存失败\", e);\n\t\t}\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/chain/FileChain.java",
    "content": "package im.zhaojun.zfile.module.storage.chain;\n\nimport im.zhaojun.zfile.module.storage.chain.command.*;\nimport lombok.extern.slf4j.Slf4j;\nimport org.apache.commons.chain.impl.ChainBase;\nimport org.springframework.stereotype.Service;\n\nimport jakarta.annotation.PostConstruct;\nimport jakarta.annotation.Resource;\n\n/**\n * 文件处理责任链定义\n *\n * @author zhaojun\n */\n@Service\n@Slf4j\npublic class FileChain extends ChainBase {\n\n\t@Resource\n\tprivate FileAccessPermissionVerifyCommand fileAccessPermissionVerifyCommand;\n\n\t@Resource\n\tprivate FolderPasswordVerifyCommand folderPasswordVerifyCommand;\n\n\t@Resource\n\tprivate FileHiddenCommand fileHiddenCommand;\n\n\t@Resource\n\tprivate FileSortCommand fileSortCommand;\n\n\t@Resource\n\tprivate FileDownloadPermissionCommand fileDownloadPermissionCommand;\n\n\t/**\n\t * 初始化责任链\n\t */\n\t@PostConstruct\n\tpublic void init() {\n\t\tthis.addCommand(fileAccessPermissionVerifyCommand);\n\t\tthis.addCommand(folderPasswordVerifyCommand);\n\t\tthis.addCommand(fileHiddenCommand);\n\t\tthis.addCommand(fileSortCommand);\n\t\tthis.addCommand(fileDownloadPermissionCommand);\n\t}\n\n\t/**\n\t * 执行文件处理责任链\n\t *\n\t * @param   content\n\t *          文件上下文\n\t *\n\t * @return  是否执行成功\n\t */\n\tpublic FileContext execute(FileContext content) throws Exception {\n\t\tsuper.execute(content);\n\t\treturn content;\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/chain/FileContext.java",
    "content": "package im.zhaojun.zfile.module.storage.chain;\n\nimport im.zhaojun.zfile.module.storage.model.request.base.FileListRequest;\nimport im.zhaojun.zfile.module.storage.model.result.FileItemResult;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.EqualsAndHashCode;\nimport org.apache.commons.chain.impl.ContextBase;\n\nimport java.util.List;\n\n/**\n * 文件处理责任链上下文\n *\n * @author zhaojun\n */\n@EqualsAndHashCode(callSuper = true)\n@Data\n@AllArgsConstructor\n@Builder\npublic class FileContext extends ContextBase {\n\n\t/**\n\t * 存储源 id\n\t */\n\tprivate Integer storageId;\n\n\t/**\n\t * 存储源请求\n\t */\n\tprivate FileListRequest fileListRequest;\n\n\t/**\n\t * 根据存储源请求获取到的文件列表\n\t */\n\tprivate List<FileItemResult> fileItemList;\n\n\t/**\n\t * 当前目录密码路径表达式\n\t */\n\tprivate String passwordPattern;\n\n\t/**\n\t * 存储源 Service\n\t */\n    private AbstractBaseFileService<?> fileService;\n\n    /**\n     * 操作者用户ID（在分享模式下为分享者的用户ID）\n     */\n    private Integer operatorUserId;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/chain/command/FileAccessPermissionVerifyCommand.java",
    "content": "package im.zhaojun.zfile.module.storage.chain.command;\n\nimport im.zhaojun.zfile.core.exception.biz.StorageSourceFileForbiddenAccessBizException;\nimport im.zhaojun.zfile.module.filter.service.FilterConfigService;\nimport im.zhaojun.zfile.module.storage.chain.FileContext;\nimport im.zhaojun.zfile.module.storage.model.request.base.FileListRequest;\nimport org.apache.commons.chain.Command;\nimport org.apache.commons.chain.Context;\nimport org.springframework.stereotype.Service;\n\nimport jakarta.annotation.Resource;\n\n/**\n * 目录访问权限责任链 command 命令\n *      检查请求的目录是否有访问权限\n *\n * @author zhaojun\n */\n@Service\npublic class FileAccessPermissionVerifyCommand implements Command {\n\n\t@Resource\n\tprivate FilterConfigService filterConfigService;\n\n\t/**\n\t * 校验是否有权限访问此目录\n\t *\n\t * @param   context\n\t *          文件处理责任链上下文\n\t *\n\t * @return  是否停止执行责任链, true: 停止执行责任链, false: 继续执行责任链\n\t */\n\t@Override\n\tpublic boolean execute(Context context) throws Exception {\n\t\tFileContext fileContext = (FileContext) context;\n\t\tInteger storageId = fileContext.getStorageId();\n\t\tFileListRequest fileListRequest = fileContext.getFileListRequest();\n\t\t\n\t\t// 检查文件目录是否是不可访问的, 如果是则抛出异常\n\t\tboolean isInaccessible = filterConfigService.checkFileIsInaccessible(storageId, fileListRequest.getPath());\n\t\t\n\t\tif (isInaccessible) {\n\t\t\tthrow new StorageSourceFileForbiddenAccessBizException(storageId, fileListRequest.getPath());\n\t\t}\n\t\t\n\t\treturn false;\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/chain/command/FileDownloadPermissionCommand.java",
    "content": "package im.zhaojun.zfile.module.storage.chain.command;\n\nimport im.zhaojun.zfile.module.storage.chain.FileContext;\nimport im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;\nimport im.zhaojun.zfile.module.user.service.UserStorageSourceService;\nimport org.apache.commons.chain.Command;\nimport org.apache.commons.chain.Context;\nimport org.springframework.stereotype.Service;\n\nimport jakarta.annotation.Resource;\n\n/**\n * 检查是否有权限下载 command 命令\n *      检查是否有权限下载和预览文件，如果都没有权限，则去除返回结果中的下载地址\n *\n * @author zhaojun\n */\n@Service\npublic class FileDownloadPermissionCommand implements Command {\n\n    @Resource\n    private UserStorageSourceService userStorageSourceService;\n\n    /**\n     * 当没有预览和下载权限时，去除返回结果中的 url 字段.\n     *\n     * @param   context\n     *          文件处理责任链上下文\n     *\n     * @return  是否停止执行责任链, true: 停止执行责任链, false: 继续执行责任链\n     */\n    @Override\n    public boolean execute(Context context) throws Exception {\n        FileContext fileContext = (FileContext) context;\n        Integer storageId = fileContext.getStorageId();\n        Integer operatorUserId = fileContext.getOperatorUserId();\n\n        boolean hasDownloadPermission = userStorageSourceService.hasUserStorageOperatorPermission(operatorUserId, storageId, FileOperatorTypeEnum.DOWNLOAD);\n        boolean hasPreviewPermission = userStorageSourceService.hasUserStorageOperatorPermission(operatorUserId, storageId, FileOperatorTypeEnum.PREVIEW);\n\n        if (hasDownloadPermission || hasPreviewPermission) {\n            return false;\n        }\n        fileContext.getFileItemList().forEach(file -> { file.setUrl(null);});\n        return false;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/chain/command/FileHiddenCommand.java",
    "content": "package im.zhaojun.zfile.module.storage.chain.command;\n\nimport im.zhaojun.zfile.core.util.CollectionUtils;\nimport im.zhaojun.zfile.module.filter.service.FilterConfigService;\nimport im.zhaojun.zfile.module.storage.chain.FileContext;\nimport im.zhaojun.zfile.module.storage.model.result.FileItemResult;\nimport org.apache.commons.chain.Command;\nimport org.apache.commons.chain.Context;\nimport org.springframework.stereotype.Service;\n\nimport jakarta.annotation.Resource;\nimport java.util.List;\nimport java.util.stream.Collectors;\n\n/**\n * 文件隐藏责任链 command 命令\n *      过滤此存储源通过规则隐藏的文件.\n *\n * @author zhaojun\n */\n@Service\npublic class FileHiddenCommand implements Command {\n\n\t@Resource\n\tprivate FilterConfigService filterConfigService;\n\n\t/**\n\t * 隐藏存储源规律规则匹配到的文件.\n\t *\n\t * @param   context\n\t *          文件处理责任链上下文\n\t *\n\t * @return  是否停止执行责任链, true: 停止执行责任链, false: 继续执行责任链\n\t */\n\t@Override\n\tpublic boolean execute(Context context) throws Exception {\n\t\tFileContext fileContext = (FileContext) context;\n\t\tInteger storageId = fileContext.getStorageId();\n\t\t\n\t\tList<FileItemResult> fileItemList = fileContext.getFileItemList();\n\t\tif (CollectionUtils.isEmpty(fileItemList)) {\n\t\t\treturn false;\n\t\t}\n\n\t\tList<FileItemResult> result = fileItemList.stream()\n\t\t\t\t.filter(fileItem -> !filterConfigService.checkFileIsHidden(storageId, fileItem.getFullPath()))\n\t\t\t\t.collect(Collectors.toList());\n\t\t\n\t\tfileContext.setFileItemList(result);\n\t\treturn false;\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/chain/command/FileSortCommand.java",
    "content": "package im.zhaojun.zfile.module.storage.chain.command;\n\nimport im.zhaojun.zfile.module.storage.chain.FileContext;\nimport im.zhaojun.zfile.module.storage.model.request.base.FileListRequest;\nimport im.zhaojun.zfile.module.storage.model.result.FileItemResult;\nimport im.zhaojun.zfile.core.util.FileComparator;\nimport org.apache.commons.chain.Command;\nimport org.apache.commons.chain.Context;\nimport org.springframework.stereotype.Service;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n/**\n * 文件排序责任链 command 命令\n *      根据请求类中的排序参数，进行文件排序.\n *\n * @author zhaojun\n */\n@Service\npublic class FileSortCommand implements Command {\n\n\t/**\n\t * 按照请求的排序字段和方向进行文件排序.\n\t *\n\t * @param   context\n\t *          文件处理责任链上下文\n\t *\n\t * @return  是否停止执行责任链, true: 停止执行责任链, false: 继续执行责任链\n\t */\n\t@Override\n\tpublic boolean execute(Context context) throws Exception {\n\t\tFileContext fileContext = (FileContext) context;\n\n\t\tList<FileItemResult> fileItemList = fileContext.getFileItemList();\n\t\tFileListRequest fileListRequest = fileContext.getFileListRequest();\n\n\t\tif (fileListRequest.getOrderBy() == null || fileListRequest.getOrderDirection() == null) {\n\t\t\treturn false;\n\t\t}\n\n\t\t// 创建副本, 防止排序和过滤对原数据产生影响\n\t\tList<FileItemResult> copyList = new ArrayList<>(fileItemList);\n\n\t\t// 按照自然排序\n\t\tcopyList.sort(new FileComparator(fileListRequest.getOrderBy(), fileListRequest.getOrderDirection()));\n\t\tfileContext.setFileItemList(copyList);\n\t\treturn false;\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/chain/command/FolderPasswordVerifyCommand.java",
    "content": "package im.zhaojun.zfile.module.storage.chain.command;\n\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.password.model.dto.VerifyResultDTO;\nimport im.zhaojun.zfile.module.user.service.UserStorageSourceService;\nimport im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;\nimport im.zhaojun.zfile.module.password.service.PasswordConfigService;\nimport im.zhaojun.zfile.module.storage.chain.FileContext;\nimport im.zhaojun.zfile.module.storage.model.request.base.FileListRequest;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;\nimport org.apache.commons.chain.Command;\nimport org.apache.commons.chain.Context;\nimport org.springframework.stereotype.Service;\n\nimport jakarta.annotation.Resource;\n\n/**\n * 校验文件夹密码责任链 command 命令\n *      校验当前请求的文件夹是否需要密码校验，如果需求则校验密码，密码不正确则抛出异常\n *\n * @author zhaojun\n */\n@Service\npublic class FolderPasswordVerifyCommand implements Command {\n\n\t@Resource\n\tprivate PasswordConfigService passwordConfigService;\n\n    @Resource\n    private UserStorageSourceService userStorageSourceService;\n\t\n\t/**\n\t * 校验当前文件是否需要密码.\n\t *\n\t * @param   context\n\t *          文件处理责任链上下文\n\t *\n\t * @return  是否停止执行责任链, true: 停止执行责任链, false: 继续执行责任链\n\t */\n\t@Override\n\tpublic boolean execute(Context context) throws Exception {\n\t\tFileContext fileContext = (FileContext) context;\n\t\tInteger storageId = fileContext.getStorageId();\n\t\t\n\t\tFileListRequest fileListRequest = fileContext.getFileListRequest();\n\t\tString path = fileListRequest.getPath();\n\t\tString password = fileListRequest.getPassword();\n\n\t\tAbstractBaseFileService<?> fileService = fileContext.getFileService();\n\t\tString fullPath = StringUtils.concat(fileService.getCurrentUserBasePath(), path);\n\n        // 分享模式下，如果分享者拥有忽略密码权限，则跳过目录密码校验\n        Integer operatorUserId = fileContext.getOperatorUserId();\n        if (operatorUserId != null) {\n            boolean ignorePwd = userStorageSourceService.hasUserStorageOperatorPermission(operatorUserId, storageId, FileOperatorTypeEnum.IGNORE_PASSWORD);\n            if (ignorePwd) {\n                return false;\n            }\n        }\n\n\t\t// 校验密码, 如果校验不通过, 则返回错误消息\n\t\tVerifyResultDTO verifyResultDTO = passwordConfigService.verifyPassword(storageId, fullPath, password);\n\t\tif (!verifyResultDTO.isPassed()) {\n\t\t\tthrow new BizException(verifyResultDTO.getErrorCode());\n\t\t}\n\n\t\t// 设置当前文件夹所对应的文件夹路径表达式.\n\t\tfileContext.setPasswordPattern(verifyResultDTO.getPattern());;\n\t\treturn false;\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/constant/S3SignerTypeConstant.java",
    "content": "package im.zhaojun.zfile.module.storage.constant;\n\n/**\n * S3 签名类型\n */\npublic class S3SignerTypeConstant {\n\n    public static final String DEFAULT = \" \";\n\n    public static final String AWSS3V4SignerType = \"AWSS3V4SignerType\";\n\n    public static final String S3SignerType = \"S3SignerType\";\n\n    public static final String QueryStringSignerType = \"QueryStringSignerType\";\n\n    public static final String AWS3SignerType = \"AWS3SignerType\";\n\n    public static final String AWS4SignerType = \"AWS4SignerType\";\n\n    public static final String NoOpSignerType = \"NoOpSignerType\";\n\n    public static final String AWS4UnsignedPayloadSignerType = \"AWS4UnsignedPayloadSignerType\";\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/constant/StorageConfigConstant.java",
    "content": "package im.zhaojun.zfile.module.storage.constant;\n\n/**\n * 存储源设置字段常量.\n *\n * @author zhaojun\n */\npublic class StorageConfigConstant {\n\n    public static final String ACCESS_TOKEN_KEY = \"accessToken\";\n\n    public static final String REFRESH_TOKEN_KEY = \"refreshToken\";\n\n    public static final String REFRESH_TOKEN_EXPIRED_AT_KEY = \"refreshTokenExpiredAt\";\n\n    public static final String PROXY_DOWNLOAD_KEY = \"enableProxyDownload\";\n\n    public static final String PROXY_UPLOAD_KEY = \"enableProxyUpload\";\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/constant/StorageSourceConnectionProperties.java",
    "content": "package im.zhaojun.zfile.module.storage.constant;\n\npublic class StorageSourceConnectionProperties {\n\n    /**\n     * 默认连接超时时间(秒)\n     */\n    public static final int DEFAULT_CONNECTION_TIMEOUT_SECONDS = 5;\n\n    /**\n     * 默认连接超时时间(毫秒)\n     */\n    public static final int DEFAULT_CONNECTION_TIMEOUT_MILLIS = DEFAULT_CONNECTION_TIMEOUT_SECONDS * 1000;\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/context/StorageSourceContext.java",
    "content": "package im.zhaojun.zfile.module.storage.context;\n\nimport cn.hutool.core.util.ReflectUtil;\nimport cn.hutool.extra.spring.SpringUtil;\nimport im.zhaojun.zfile.core.exception.biz.InvalidStorageSourceBizException;\nimport im.zhaojun.zfile.core.util.ClassUtils;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamItem;\nimport im.zhaojun.zfile.module.storage.model.bo.StorageSourceParamDef;\nimport im.zhaojun.zfile.module.storage.model.dto.StorageSourceInitDTO;\nimport im.zhaojun.zfile.module.storage.model.entity.StorageSource;\nimport im.zhaojun.zfile.module.storage.model.entity.StorageSourceConfig;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.param.IStorageParam;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;\nimport im.zhaojun.zfile.module.storage.service.base.RefreshTokenService;\nimport im.zhaojun.zfile.module.storage.support.StorageSourceSupport;\nimport lombok.extern.slf4j.Slf4j;\nimport org.apache.commons.lang3.BooleanUtils;\nimport org.springframework.aop.support.AopUtils;\n\nimport java.lang.reflect.Field;\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.function.Function;\nimport java.util.stream.Collectors;\n\n/**\n * 每个存储源对应一个 Service, 其中初始化好了与对象存储的配置信息.\n * 此存储源上下文环境用户缓存每个 Service, 避免重复初始化.\n * <br>\n *\n * @author zhaojun\n */\n@Slf4j\npublic class StorageSourceContext {\n\n    /**\n     * Map<Integer, AbstractBaseFileService>\n     * Map<存储源 ID, 存储源 Service>\n     */\n    private static final Map<Integer, AbstractBaseFileService<IStorageParam>> DRIVES_SERVICE_MAP = new ConcurrentHashMap<>();\n\n\n    /**\n     * Map<String, Integer>\n     * Map<存储源 Key, 存储源 ID>\n     */\n    private static final Map<String, Integer> STORAGE_KEY_ID_MAP = new ConcurrentHashMap<>();\n\n\n    /**\n     * Map<存储源类型的bean名称, 存储源 Service>\n     */\n    private static Map<String, AbstractBaseFileService> storageTypeServiceNameMap;\n\n    /**\n     * Map<存储源枚举类型, 存储源 Service>\n     */\n    private static Map<StorageTypeEnum, AbstractBaseFileService> storageTypeEnumFileServiceMap = new HashMap<>();\n\n    /**\n     * 缓存每个存储源参数的字段列表.\n     */\n    private static final Map<Class<?>, Map<String, Field>> PARAM_CLASS_FIELD_NAME_MAP_CACHE = new HashMap<>();\n\n    /**\n     * 项目启动时, 自动调用数据库已存储的所有存储源进行初始化.\n     */\n    static void load(Map<String, AbstractBaseFileService> storageTypeServiceNameMap) {\n        StorageSourceContext.storageTypeServiceNameMap = storageTypeServiceNameMap;\n        for (AbstractBaseFileService value : storageTypeServiceNameMap.values()) {\n            storageTypeEnumFileServiceMap.put(value.getStorageTypeEnum(), value);\n        }\n    }\n\n\n    /**\n     * 根据存储源 id 获取对应的 Service.\n     *\n     * @param   storageId\n     *          存储源 ID\n     *\n     * @return  存储源对应的 Service\n     */\n    public static AbstractBaseFileService<IStorageParam> getByStorageId(Integer storageId) {\n        AbstractBaseFileService<IStorageParam> abstractBaseFileService = DRIVES_SERVICE_MAP.get(storageId);\n        if (abstractBaseFileService == null) {\n            throw new InvalidStorageSourceBizException(storageId);\n        }\n        return abstractBaseFileService;\n    }\n\n\n    /**\n     * 根据存储源 key 获取对应的 Service.\n     *\n     * @param   key\n     *          存储源 key\n     *\n     * @return  存储源对应的 Service\n     */\n    public static AbstractBaseFileService<?> getByStorageKey(String key) {\n        Integer storageId = STORAGE_KEY_ID_MAP.get(key);\n        if (storageId == null) {\n            return null;\n        }\n        return getByStorageId(storageId);\n    }\n\n\n    /**\n     * 根据存储源类型获取对应的 Service.\n     *\n     * @param   storageTypeEnum\n     *          存储源类型(枚举)\n     *\n     * @return  存储源对应的 Service\n     */\n    public static AbstractBaseFileService<?> getByStorageTypeEnum(StorageTypeEnum storageTypeEnum) {\n        return storageTypeEnumFileServiceMap.get(storageTypeEnum);\n    }\n\n\n    /**\n     * 根据存储类型获取对应的存储源的参数列表.\n     *\n     * @param   type\n     *          存储类型: {@link StorageTypeEnum}\n     *\n     * @return  指定类型存储源的参数列表. {@link StorageSourceSupport#getStorageSourceParamList(AbstractBaseFileService)} )}}\n     */\n    public static List<StorageSourceParamDef> getStorageSourceParamListByType(StorageTypeEnum type) {\n        return storageTypeServiceNameMap.values().stream()\n                // 根据存储源类型找到第一个匹配的 Service\n                .filter(fileService -> fileService.getStorageTypeEnum() == type)\n                .findFirst()\n                // 获取该 Service 的参数列表\n                .map(StorageSourceSupport::getStorageSourceParamList)\n                // 如果没有找到, 则返回空列表\n                .orElse(Collections.emptyList());\n    }\n\n\n    /**\n     * 初始化指定存储源的 Service, 添加到上下文环境中.\n     *\n     * @param   storageSourceInitDTO\n     *          存储源初始化对象\n     */\n    public static void init(StorageSourceInitDTO storageSourceInitDTO) {\n        Integer storageId = storageSourceInitDTO.getId();\n        String storageName = storageSourceInitDTO.getName();\n        String key = storageSourceInitDTO.getKey();\n        StorageTypeEnum storageTypeEnum = storageSourceInitDTO.getType();\n\n        AbstractBaseFileService<IStorageParam> baseFileService = getInitStorageBeanByStorageType(storageTypeEnum);\n        if (baseFileService == null) {\n            throw new InvalidStorageSourceBizException(storageId);\n        }\n\n        // 填充初始化参数\n        IStorageParam initParam = getInitParam(baseFileService, storageSourceInitDTO.getStorageSourceConfigList());\n\n        // 进行初始化并测试连接\n        baseFileService.init(storageName, storageId, initParam);\n        baseFileService.testConnection();\n\n        DRIVES_SERVICE_MAP.put(storageId, baseFileService);\n        STORAGE_KEY_ID_MAP.put(key, storageId);\n    }\n\n\n    /**\n     * 获取指定存储源初始状态的 Service.\n     *\n     * @param   storageTypeEnum\n     *          存储源类型\n     *\n     * @return  存储源对应未初始化的 Service\n     */\n    private static AbstractBaseFileService<IStorageParam> getInitStorageBeanByStorageType(StorageTypeEnum storageTypeEnum) {\n        for (AbstractBaseFileService<?> value : storageTypeServiceNameMap.values()) {\n            if (Objects.equals(value.getStorageTypeEnum(), storageTypeEnum)) {\n                return SpringUtil.getBean(value.getClass());\n            }\n        }\n        return null;\n    }\n\n\n    /**\n     * 获取指定存储源的初始化参数.\n     */\n    private static IStorageParam getInitParam(AbstractBaseFileService<?> baseFileService, List<StorageSourceConfig> storageSourceConfigList) {\n        // 获取存储源实现类的实际 Class\n        Class<?> beanTargetClass = AopUtils.getTargetClass(baseFileService);\n        // 获取存储源实现类的实际 Class 的泛型参数类型\n        Class<?> paramClass = ClassUtils.getClassFirstGenericsParam(beanTargetClass);\n\n        // 获取存储器参数 key -> 存储器 field 对照关系，如果缓存中有，则从缓存中取.\n        Map<String, Field> fieldMap = new HashMap<>();\n        if (PARAM_CLASS_FIELD_NAME_MAP_CACHE.containsKey(paramClass)) {\n            fieldMap = PARAM_CLASS_FIELD_NAME_MAP_CACHE.get(paramClass);\n        } else {\n            Field[] fields = ReflectUtil.getFieldsDirectly(paramClass, true);\n            List<String> ignoreFieldNameList = new ArrayList<>();\n            for (Field field : fields) {\n                String key;\n\n                StorageParamItem storageParamItem = field.getDeclaredAnnotation(StorageParamItem.class);\n                // 没有注解或注解中没有配置 key 则使用字段名.\n                if (storageParamItem == null || StringUtils.isEmpty(storageParamItem.key())) {\n                    key = field.getName();\n                } else {\n                    key = storageParamItem.key();\n                }\n\n                if (storageParamItem != null && storageParamItem.ignoreInput()) {\n                    ignoreFieldNameList.add(key);\n                }\n\n                // 如果 map 中包含此 key, 则是父类的, 跳过.\n                if (fieldMap.containsKey(key)) {\n                    continue;\n                }\n\n                if (!ignoreFieldNameList.contains(key)) {\n                    fieldMap.put(key, field);\n                }\n            }\n            PARAM_CLASS_FIELD_NAME_MAP_CACHE.put(paramClass, fieldMap);\n        }\n\n        // 实例化参数对象\n        IStorageParam iStorageParam = ReflectUtil.newInstance(paramClass.getName());\n\n        Map<String, Field> fieldMapCopy = new HashMap<>(fieldMap);\n\n        // 给所有字段填充值\n        for (StorageSourceConfig storageSourceConfig : storageSourceConfigList) {\n            String name = storageSourceConfig.getName();\n            String value = storageSourceConfig.getValue();\n            try {\n                Field field = fieldMap.get(name);\n                ReflectUtil.setFieldValue(iStorageParam, field, value);\n                fieldMapCopy.remove(name);\n            } catch (Exception e) {\n                log.warn(\"存储源 {} 从数据库获取存储源参数进行初始化时为字段 {} 初始化值 {} 失败\", baseFileService, name, value, e);\n            }\n        }\n\n        if (!fieldMapCopy.isEmpty()) {\n            List<StorageSourceParamDef> storageSourceParamList = StorageSourceSupport.getStorageSourceParamList(baseFileService);\n            Map<String, StorageSourceParamDef> storageSourceParamDefMap = storageSourceParamList.stream()\n                    .collect(Collectors.toMap(StorageSourceParamDef::getKey, Function.identity()));\n\n            // 如果还有字段没有填充值, 则使用默认值填充.\n            for (Map.Entry<String, Field> entry : fieldMapCopy.entrySet()) {\n                Field field = entry.getValue();\n                StorageSourceParamDef storageSourceParamDef = storageSourceParamDefMap.get(entry.getKey());\n                if (storageSourceParamDef == null) {\n                    continue;\n                }\n\n                String defaultValue = storageSourceParamDef.getDefaultValue();\n                if (StringUtils.isBlank(defaultValue)) {\n                    continue;\n                }\n                ReflectUtil.setFieldValue(iStorageParam, field, defaultValue);\n                if (log.isDebugEnabled()) {\n                    log.debug(\"存储源 {} 数据库未设置字段 {} 值，使用默认值 {}\", baseFileService, entry.getKey(), defaultValue);\n                }\n            }\n        }\n\n        return iStorageParam;\n    }\n\n\n    /**\n     * 获取所有 AccessToken 机制的存储源, 这些存储源都继承类 {@link RefreshTokenService}.\n     *\n     * @return  获取所有需要刷新 AccessToken 的存储源.\n     */\n    public static Map<Integer, RefreshTokenService> getAllRefreshTokenStorageSource() {\n        Map<Integer, RefreshTokenService> result = new HashMap<>();\n\n        for (Map.Entry<Integer, AbstractBaseFileService<IStorageParam>> baseFileServiceEntry : DRIVES_SERVICE_MAP.entrySet()) {\n            Integer storageId = baseFileServiceEntry.getKey();\n            AbstractBaseFileService<?> baseFileService = baseFileServiceEntry.getValue();\n            // 如果未初始化成功, 则直接跳过\n            if (BooleanUtils.isNotTrue(baseFileService.isInitialized())) {\n                continue;\n            }\n\n            if (baseFileService instanceof RefreshTokenService) {\n                result.put(storageId, (RefreshTokenService) baseFileService);\n            }\n        }\n\n        return result;\n    }\n\n\n    /**\n     * 销毁指定存储源的 Service.\n     *\n     * @param   storageSource\n     *          存储源类\n     */\n    public static void destroy(StorageSource storageSource) {\n        Integer id = storageSource.getId();\n        String key = storageSource.getKey();\n        log.info(\"清理存储源上下文对象, storageId: {}, storageKey: {}\", id, key);\n        AbstractBaseFileService<IStorageParam> abstractBaseFileService = DRIVES_SERVICE_MAP.remove(id);\n        if (abstractBaseFileService != null) {\n            abstractBaseFileService.destroy();\n        }\n\n        STORAGE_KEY_ID_MAP.remove(key);\n    }\n\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/context/StorageSourceInitializer.java",
    "content": "package im.zhaojun.zfile.module.storage.context;\n\nimport im.zhaojun.zfile.module.storage.model.dto.StorageSourceInitDTO;\nimport im.zhaojun.zfile.module.storage.model.entity.StorageSource;\nimport im.zhaojun.zfile.module.storage.model.entity.StorageSourceConfig;\nimport im.zhaojun.zfile.module.storage.service.StorageSourceConfigService;\nimport im.zhaojun.zfile.module.storage.service.StorageSourceService;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.BeansException;\nimport org.springframework.context.ApplicationContext;\nimport org.springframework.context.ApplicationContextAware;\nimport org.springframework.context.annotation.DependsOn;\nimport org.springframework.core.annotation.Order;\nimport org.springframework.stereotype.Component;\n\nimport java.util.List;\nimport java.util.Map;\n\n/**\n * @author zhaojun\n */\n@Slf4j\n@Component\n@Order(100)\n@DependsOn(value = {\"storageSourceService\"})\npublic class StorageSourceInitializer implements ApplicationContextAware {\n\n    @Resource\n    private StorageSourceService storageSourceService;\n\n    @Resource\n    private StorageSourceConfigService storageSourceConfigService;\n\n    @Override\n    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {\n        try {\n            Map<String, AbstractBaseFileService> abstractBaseFileServiceMap = applicationContext.getBeansOfType(AbstractBaseFileService.class);\n            StorageSourceContext.load(abstractBaseFileServiceMap);\n        } catch (Exception e) {\n            log.error(\"初始化存储源 Bean 失败.\", e);\n            return;\n        }\n\n        List<StorageSource> list = storageSourceService.findAllOrderByOrderNum();\n        for (StorageSource storageSource : list) {\n            try {\n                List<StorageSourceConfig> storageSourceConfigList = storageSourceConfigService.selectStorageConfigByStorageId(storageSource.getId());\n                StorageSourceInitDTO storageSourceInitDTO = StorageSourceInitDTO.convert(storageSource, storageSourceConfigList);\n                StorageSourceContext.init(storageSourceInitDTO);\n                log.info(\"启动时初始化存储源成功, 存储源 id: [{}], 存储源类型: [{}], 存储源名称: [{}]\",\n                        storageSource.getId(), storageSource.getType().getDescription(), storageSource.getName());\n            } catch (Exception e) {\n                log.error(\"启动时初始化存储源失败, 存储源 id: {}, 存储源类型: {}, 存储源名称: {}\",\n                        storageSource.getId(), storageSource.getType().getDescription(), storageSource.getName(), e);\n            }\n        }\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/controller/base/StorageMetaDataController.java",
    "content": "package im.zhaojun.zfile.module.storage.controller.base;\n\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport com.github.xiaoymin.knife4j.annotations.ApiSort;\nimport im.zhaojun.zfile.module.storage.context.StorageSourceContext;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.bo.StorageSourceParamDef;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport io.swagger.v3.oas.annotations.Operation;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport java.util.List;\n\n/**\n * 系统元数据接口\n *\n * @author zhaojun\n */\n@Tag(name = \"存储源模块-元数据\")\n@ApiSort(4)\n@RestController\n@RequestMapping(\"/admin\")\npublic class StorageMetaDataController {\n\n    @GetMapping(\"/support-storage\")\n    @ApiOperationSupport(order = 1)\n    @Operation(summary = \"获取支持的存储源类型\", description = \"获取系统支持的存储源类型\")\n    public AjaxJson<StorageTypeEnum[]> supportStorage() {\n        return AjaxJson.getSuccessData(StorageTypeEnum.values());\n    }\n\n\n    @GetMapping(\"/storage-params\")\n    @ApiOperationSupport(order = 2)\n    @Operation(summary = \"获取指定存储源类型的所有参数信息\", description = \"获取指定存储源类型的参数，如本地存储只需要填路径地址，而对象存储需要填 AccessKey, SecretKey 等信息.\")\n    public AjaxJson<List<StorageSourceParamDef>> getFormByStorageType(StorageTypeEnum storageType) {\n        List<StorageSourceParamDef> storageSourceConfigList = StorageSourceContext.getStorageSourceParamListByType(storageType);\n        return AjaxJson.getSuccessData(storageSourceConfigList);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/controller/base/StorageSourceController.java",
    "content": "package im.zhaojun.zfile.module.storage.controller.base;\n\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport com.github.xiaoymin.knife4j.annotations.ApiSort;\nimport im.zhaojun.zfile.core.annotation.DemoDisable;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.module.storage.convert.StorageSourceConvert;\nimport im.zhaojun.zfile.module.storage.model.bo.RefreshTokenCacheBO;\nimport im.zhaojun.zfile.module.storage.model.dto.StorageSourceDTO;\nimport im.zhaojun.zfile.module.storage.model.entity.StorageSource;\nimport im.zhaojun.zfile.module.storage.model.request.admin.CopyStorageSourceRequest;\nimport im.zhaojun.zfile.module.storage.model.request.admin.UpdateStorageSortRequest;\nimport im.zhaojun.zfile.module.storage.model.request.base.SaveStorageSourceRequest;\nimport im.zhaojun.zfile.module.storage.model.result.StorageSourceAdminResult;\nimport im.zhaojun.zfile.module.storage.service.StorageSourceService;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.Parameter;\nimport io.swagger.v3.oas.annotations.Parameters;\nimport io.swagger.v3.oas.annotations.enums.ParameterIn;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.annotation.Resource;\nimport jakarta.validation.Valid;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.List;\n\n/**\n * 存储源基础设置模块接口\n *\n * @author zhaojun\n */\n@Tag(name = \"存储源模块-基础\")\n@ApiSort(3)\n@RestController\n@RequestMapping(\"/admin\")\npublic class StorageSourceController {\n\n    @Resource\n    private StorageSourceService storageSourceService;\n\n    @Resource\n    private StorageSourceConvert storageSourceConvert;\n\n\n    @ApiOperationSupport(order = 1)\n    @Operation(summary = \"获取所有存储源列表\", description =\"获取所有添加的存储源列表，按照排序值由小到大排序\")\n    @GetMapping(\"/storages\")\n    public AjaxJson<List<StorageSourceAdminResult>> storageList() {\n        List<StorageSource> list = storageSourceService.findAllOrderByOrderNum();\n\n        List<StorageSourceAdminResult> storageSourceAdminResults = storageSourceConvert.entityToAdminResultList(list);\n\n        storageSourceAdminResults.forEach(storageSourceAdminResult -> {\n            RefreshTokenCacheBO.RefreshTokenInfo refreshTokenInfo = RefreshTokenCacheBO.getRefreshTokenInfo(storageSourceAdminResult.getId());\n            storageSourceAdminResult.setRefreshTokenInfo(refreshTokenInfo);\n        });\n\n        return AjaxJson.getSuccessData(storageSourceAdminResults);\n    }\n\n\n    @ApiOperationSupport(order = 2)\n    @Operation(summary = \"获取指定存储源参数\", description =\"获取指定存储源基本信息及其参数\")\n    @Parameter(in = ParameterIn.PATH, name = \"storageId\", description = \"存储源 id\", required = true, schema = @Schema(type = \"integer\"))\n    @GetMapping(\"/storage/{storageId}\")\n    public AjaxJson<StorageSourceDTO> storageItem(@PathVariable Integer storageId) {\n        StorageSourceDTO storageSourceDTO = storageSourceService.findDTOById(storageId);\n        return AjaxJson.getSuccessData(storageSourceDTO);\n    }\n\n\n    @ApiOperationSupport(order = 3)\n    @Operation(summary = \"保存存储源参数\", description =\"保存存储源的所有参数\")\n    @PostMapping(\"/storage\")\n    @DemoDisable\n    public AjaxJson<Integer> saveStorageItem(@RequestBody SaveStorageSourceRequest saveStorageSourceRequest) {\n        Integer id = storageSourceService.saveStorageSource(saveStorageSourceRequest);\n        return AjaxJson.getSuccessData(id);\n    }\n\n\n    @ApiOperationSupport(order = 4)\n    @Operation(summary = \"删除存储源\", description =\"删除存储源基本设置和拓展设置\")\n    @Parameter(in = ParameterIn.PATH, name = \"storageId\", description = \"存储源 id\", required = true, schema = @Schema(type = \"integer\"))\n    @DeleteMapping(\"/storage/{storageId}\")\n    @DemoDisable\n    public AjaxJson<Void> deleteStorageItem(@PathVariable Integer storageId) {\n        storageSourceService.deleteById(storageId);\n        return AjaxJson.getSuccess();\n    }\n\n\n    @ApiOperationSupport(order = 5)\n    @Operation(summary = \"启用存储源\", description =\"开启存储源后可在前台显示\")\n    @Parameter(in = ParameterIn.PATH, name = \"storageId\", description = \"存储源 id\", required = true, schema = @Schema(type = \"integer\"))\n    @PostMapping(\"/storage/{storageId}/enable\")\n    @DemoDisable\n    public AjaxJson<Void> enable(@PathVariable Integer storageId) {\n        StorageSource storageSource = storageSourceService.findById(storageId);\n        storageSource.setEnable(true);\n        storageSourceService.updateById(storageSource);\n        return AjaxJson.getSuccess();\n    }\n\n\n    @ApiOperationSupport(order = 6)\n    @Operation(summary = \"停止存储源\", description =\"停用存储源后不在前台显示\")\n    @Parameter(in = ParameterIn.PATH, name = \"storageId\", description = \"存储源 id\", required = true, schema = @Schema(type = \"integer\"))\n    @PostMapping(\"/storage/{storageId}/disable\")\n    @DemoDisable\n    public AjaxJson<Void> disable(@PathVariable Integer storageId) {\n        StorageSource storageSource = storageSourceService.findById(storageId);\n        storageSource.setEnable(false);\n        storageSourceService.updateById(storageSource);\n        return AjaxJson.getSuccess();\n    }\n\n\n    @ApiOperationSupport(order = 7)\n    @Operation(summary = \"更新存储源顺序\")\n    @PostMapping(\"/storage/sort\")\n    public AjaxJson<Void> updateStorageSort(@RequestBody List<UpdateStorageSortRequest> updateStorageSortRequestList) {\n        storageSourceService.updateStorageSort(updateStorageSortRequestList);\n        return AjaxJson.getSuccess();\n    }\n\n\n    @ApiOperationSupport(order = 8)\n    @Operation(summary = \"校验存储源 key 是否重复\")\n    @Parameter(in = ParameterIn.QUERY, name = \"storageKey\", description = \"存储源 key\", required = true, schema = @Schema(type = \"string\"))\n    @GetMapping(\"/storage/exist/key\")\n    public AjaxJson<Boolean> existKey(String storageKey) {\n        boolean exist = storageSourceService.existByStorageKey(storageKey);\n        return AjaxJson.getSuccessData(exist);\n    }\n\n\n    @ApiOperationSupport(order = 9)\n    @Operation(summary = \"修改 readme 兼容模式\", description =\"修改 readme 兼容模式是否启用\")\n    @Parameters({\n        @Parameter(in = ParameterIn.PATH, name = \"storageId\", description = \"存储源 id\", required = true, schema = @Schema(type = \"integer\")),\n        @Parameter(in = ParameterIn.PATH, name = \"status\", description = \"存储源兼容模式状态\", required = true, schema = @Schema(type = \"boolean\"))\n    })\n    @PostMapping(\"/storage/{storageId}/compatibility_readme/{status}\")\n    public AjaxJson<Void> changeCompatibilityReadme(@PathVariable Integer storageId, @PathVariable Boolean status) {\n        StorageSource storageSource = storageSourceService.findById(storageId);\n        storageSource.setCompatibilityReadme(status);\n        storageSourceService.updateById(storageSource);\n        return AjaxJson.getSuccess();\n    }\n\n\n    @ApiOperationSupport(order = 10)\n    @Operation(summary = \"复制存储源\", description =\"复制存储源配置\")\n    @PostMapping(\"/storage/copy\")\n    @DemoDisable\n    public AjaxJson<Integer> copyStorage(@RequestBody @Valid CopyStorageSourceRequest copyStorageSourceRequest) {\n        Integer id = storageSourceService.copy(copyStorageSourceRequest);\n        return AjaxJson.getSuccessData(id);\n    }\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/controller/callback/GoogleDriveCallbackController.java",
    "content": "package im.zhaojun.zfile.module.storage.controller.callback;\n\nimport cn.hutool.core.codec.Base64;\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.storage.model.dto.OAuth2TokenDTO;\nimport im.zhaojun.zfile.module.storage.oauth2.service.IOAuth2Service;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.stereotype.Controller;\nimport org.springframework.ui.Model;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RequestMapping;\n\n/**\n * @author zhaojun\n */\n@Tag(name = \"Google Drive 认证回调模块\")\n@Controller\n@Slf4j\n@RequestMapping(value = {\"/gd\"})\npublic class GoogleDriveCallbackController {\n\t\n\t@Resource\n\tprivate IOAuth2Service googleDriveOAuth2ServiceImpl;\n\t\n\t@GetMapping(\"/authorize\")\n\t@ApiOperationSupport(order = 1)\n\t@Operation(summary = \"生成 OAuth2 登陆 URL\", description = \"生成 OneDrive OAuth2 登陆 URL，用户国际版，家庭版等非世纪互联运营的 OneDrive.\")\n\tpublic String authorize(String clientId, String clientSecret, String redirectUri) {\n\t\tString authorizeUrl = googleDriveOAuth2ServiceImpl.generateAuthorizationUrl(clientId, clientSecret, redirectUri);\n\t\treturn \"redirect:\" + authorizeUrl;\n\t}\n\t\n\t@GetMapping(\"/callback\")\n\tpublic String googleDriveCallback(String code, String state, Model model) {\n\t\tif (log.isDebugEnabled()) {\n\t\t\tlog.debug(\"Google Drive 授权回调参数信息： code: {}, state: {}\", code, state);\n\t\t}\n\n\t\tString clientId = null, clientSecret = null, redirectUri = null;\n\t\tif (StringUtils.isNotEmpty(state)) {\n\t\t\tString stateDecode = Base64.decodeStr(state);\n\t\t\tString[] stateArr = stateDecode.split(\"::\");\n\t\t\tclientId = stateArr[0];\n\t\t\tclientSecret = stateArr[1];\n\t\t\tredirectUri = stateArr[2];\n\t\t}\n\n\t\tOAuth2TokenDTO oAuth2TokenDTO = googleDriveOAuth2ServiceImpl.getTokenByCode(code, clientId, clientSecret, redirectUri);\n\t\tmodel.addAttribute(\"oauth2Token\", oAuth2TokenDTO);\n\t\tmodel.addAttribute(\"type\", \"Google Drive\");\n\t\treturn \"callback\";\n\t}\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/controller/callback/OneDriveCallbackController.java",
    "content": "package im.zhaojun.zfile.module.storage.controller.callback;\n\nimport cn.hutool.core.codec.Base64;\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.storage.model.dto.OAuth2TokenDTO;\nimport im.zhaojun.zfile.module.storage.oauth2.service.OneDriveChinaOAuth2ServiceImpl;\nimport im.zhaojun.zfile.module.storage.oauth2.service.OneDriveOAuth2ServiceImpl;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.stereotype.Controller;\nimport org.springframework.ui.Model;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RequestMapping;\n\n/**\n * OneDrive 授权回调\n *\n * @author zhaojun\n */\n@Tag(name = \"OneDrive 认证回调模块\")\n@Controller\n@Slf4j\n@RequestMapping(value = {\"/onedrive\", \"/onedirve\"})\npublic class OneDriveCallbackController {\n    \n    @Resource\n    private OneDriveOAuth2ServiceImpl oneDriveOAuth2Service;\n\n    @Resource\n    private OneDriveChinaOAuth2ServiceImpl oneDriveChinaOAuth2Service;\n\n    @GetMapping(\"/authorize\")\n    @ApiOperationSupport(order = 1)\n    @Operation(summary = \"生成 OAuth2 登陆 URL\", description = \"生成 OneDrive OAuth2 登陆 URL，用户国际版，家庭版等非世纪互联运营的 OneDrive.\")\n    public String authorize(String clientId, String clientSecret, String redirectUri) {\n        String authorizeUrl = oneDriveOAuth2Service.generateAuthorizationUrl(clientId, clientSecret, redirectUri);\n        return \"redirect:\" + authorizeUrl;\n    }\n    \n    \n    @GetMapping(\"/callback\")\n    @ApiOperationSupport(order = 2)\n    @Operation(summary = \"OAuth2 回调地址\", description = \"根据 OAuth2 协议，登录成功后，会返回给网站一个 code，用此 code 去换取 accessToken 和 refreshToken.（oneDrive 会回调此接口）\")\n    public String oneDriveCallback(String code, String state, Model model) {\n        if (log.isDebugEnabled()) {\n            log.debug(\"onedrive 国际版授权回调参数信息： code: {}, state: {}\", code, state);\n        }\n\n        String clientId = null, clientSecret = null, redirectUri = null;\n        if (StringUtils.isNotEmpty(state)) {\n            String stateDecode = Base64.decodeStr(state);\n            String[] stateArr = stateDecode.split(\"::\");\n            clientId = stateArr[0];\n            clientSecret = stateArr[1];\n            redirectUri = stateArr[2];\n        }\n\n        OAuth2TokenDTO oAuth2TokenDTO = oneDriveOAuth2Service.getTokenByCode(code, clientId, clientSecret, redirectUri);\n        model.addAttribute(\"oauth2Token\", oAuth2TokenDTO);\n        model.addAttribute(\"type\", \"OneDrive 国际版\");\n        return \"callback\";\n    }\n    \n    \n    @GetMapping(\"/china-authorize\")\n    @ApiOperationSupport(order = 3)\n    @Operation(summary = \"生成 OAuth2 登陆 URL(世纪互联)\", description = \"生成 OneDrive OAuth2 登陆 URL，用于世纪互联版本.\")\n    public String authorizeChina(String clientId, String clientSecret, String redirectUri) {\n        String authorizeUrl = oneDriveChinaOAuth2Service.generateAuthorizationUrl(clientId, clientSecret, redirectUri);\n        return \"redirect:\" + authorizeUrl;\n    }\n    \n    \n    @GetMapping(\"/china-callback\")\n    @ApiOperationSupport(order = 4)\n    @Operation(summary = \"OAuth2 回调地址(世纪互联)\", description = \"根据 OAuth2 协议，登录成功后，会返回给网站一个 code，用此 code 去换取 accessToken 和 refreshToken.（oneDrive 会回调此接口）\")\n    public String oneDriveChinaCallback(String code, String state, Model model) {\n        if (log.isDebugEnabled()) {\n            log.debug(\"onedrive 世纪互联授权回调参数信息： code: {}, state: {}\", code, state);\n        }\n\n        String clientId = null, clientSecret = null, redirectUri = null;\n        if (StringUtils.isNotEmpty(state)) {\n            String stateDecode = Base64.decodeStr(state);\n            String[] stateArr = stateDecode.split(\"::\");\n            clientId = stateArr[0];\n            clientSecret = stateArr[1];\n            redirectUri = stateArr[2];\n        }\n\n        OAuth2TokenDTO OAuth2TokenDTO = oneDriveChinaOAuth2Service.getTokenByCode(code, clientId, clientSecret, redirectUri);\n        model.addAttribute(\"oauth2Token\", OAuth2TokenDTO);\n        model.addAttribute(\"type\", \"OneDrive 世纪互联\");\n        return \"callback\";\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/controller/file/FileController.java",
    "content": "package im.zhaojun.zfile.module.storage.controller.file;\n\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport com.github.xiaoymin.knife4j.annotations.ApiSort;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.biz.InvalidStorageSourceBizException;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.core.util.ZFileAuthUtil;\nimport im.zhaojun.zfile.module.storage.annotation.CheckPassword;\nimport im.zhaojun.zfile.module.storage.annotation.ProCheck;\nimport im.zhaojun.zfile.module.storage.chain.FileChain;\nimport im.zhaojun.zfile.module.storage.chain.FileContext;\nimport im.zhaojun.zfile.module.storage.context.StorageSourceContext;\nimport im.zhaojun.zfile.module.storage.convert.StorageSourceConvert;\nimport im.zhaojun.zfile.module.storage.model.entity.StorageSource;\nimport im.zhaojun.zfile.module.storage.model.request.base.FileItemRequest;\nimport im.zhaojun.zfile.module.storage.model.request.base.FileListRequest;\nimport im.zhaojun.zfile.module.storage.model.request.base.SearchStorageRequest;\nimport im.zhaojun.zfile.module.storage.model.result.FileInfoResult;\nimport im.zhaojun.zfile.module.storage.model.result.FileItemResult;\nimport im.zhaojun.zfile.module.storage.model.result.StorageSourceResult;\nimport im.zhaojun.zfile.module.storage.service.StorageSourceService;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.annotation.Resource;\nimport jakarta.validation.Valid;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.List;\n\n/**\n * 文件列表相关接口, 如展示存储源列表, 展示文件列表, 搜索文件列表等.\n *\n * @author zhaojun\n */\n@Tag(name = \"文件列表模块\")\n@ApiSort(2)\n@Slf4j\n@RequestMapping(\"/api/storage\")\n@RestController\npublic class FileController {\n\t\n\t@Resource\n\tprivate StorageSourceService storageSourceService;\n\t\n\t@Resource\n\tprivate FileChain fileChain;\n\t\n\t@Resource\n\tprivate StorageSourceConvert storageSourceConvert;\n\t\n\t\n\t@ApiOperationSupport(order = 1)\n\t@Operation(summary = \"获取存储源列表\", description = \"获取所有已启用的存储源, 并且按照后台顺序排序\")\n\t@GetMapping(\"/list\")\n\t@ProCheck\n\tpublic AjaxJson<List<StorageSourceResult>> storageList() {\n\t\tList<StorageSource> storageList = storageSourceService.findAllEnableOrderByOrderNum(ZFileAuthUtil.getCurrentUserId());\n\t\tList<StorageSourceResult> storageSourceResultList =\n\t\t\t\tstorageSourceConvert.entityToResultList(storageList);\n\t\treturn AjaxJson.getSuccessData(storageSourceResultList);\n\t}\n\t\n\t\n\t@ApiOperationSupport(order = 2)\n\t@Operation(summary = \"获取文件列表\", description = \"获取某个存储源下, 指定路径的文件&文件夹列表\")\n\t@PostMapping(\"/files\")\n\tpublic AjaxJson<FileInfoResult> list(@Valid @RequestBody FileListRequest fileListRequest) throws Exception {\n\t\tString storageKey = fileListRequest.getStorageKey();\n\t\tInteger storageId = storageSourceService.findIdByKey(storageKey);\n\t\tif (storageId == null) {\n\t\t\tthrow new InvalidStorageSourceBizException(storageKey);\n\t\t}\n\t\t\n\t\t// 处理请求参数默认值\n\t\tfileListRequest.handleDefaultValue();\n\t\t\n\t\t// 获取文件列表\n\t\tAbstractBaseFileService<?> fileService = StorageSourceContext.getByStorageId(storageId);\n\t\tList<FileItemResult> fileItemList = fileService.fileList(fileListRequest.getPath());\n\t\t\n\t\t// 执行责任链\n\t\tFileContext fileContext = FileContext.builder()\n\t\t\t\t.storageId(storageId)\n\t\t\t\t.fileListRequest(fileListRequest)\n\t\t\t\t.fileItemList(fileItemList)\n\t\t\t\t.fileService(fileService)\n\t\t\t\t.build();\n\t\tfileChain.execute(fileContext);\n\t\t\n\t\treturn AjaxJson.getSuccessData(new FileInfoResult(fileContext.getFileItemList(), fileContext.getPasswordPattern()));\n\t}\n\t\n\t\n\t@ApiOperationSupport(order = 3)\n\t@Operation(summary = \"获取单个文件信息\", description = \"获取某个存储源下, 单个文件的信息\")\n\t@PostMapping(\"/file/item\")\n\t@CheckPassword(storageKeyFieldExpression = \"[0].storageKey\",\n\t\t\t\t\tpathFieldExpression = \"[0].path\",\n\t\t\t\t\tpathIsDirectory = false,\n\t\t\t\t\tpasswordFieldExpression = \"[0].password\")\n\tpublic AjaxJson<FileItemResult> fileItem(@Valid @RequestBody FileItemRequest fileItemRequest) {\n\t\tString storageKey = fileItemRequest.getStorageKey();\n\t\tInteger storageId = storageSourceService.findIdByKey(storageKey);\n\t\tif (storageId == null) {\n\t\t\tthrow new InvalidStorageSourceBizException(storageKey);\n\t\t}\n\t\t\n\t\t// 处理请求参数默认值\n\t\tfileItemRequest.handleDefaultValue();\n\t\t\n\t\t// 获取文件列表\n\t\tAbstractBaseFileService<?> fileService = StorageSourceContext.getByStorageId(storageId);\n\t\t\n\t\tFileItemResult fileItemResult;\n\t\ttry {\n\t\t\tfileItemResult = fileService.getFileItem(fileItemRequest.getPath());\n\t\t} catch (Exception e) {\n\t\t\tthrow new BizException(ErrorCode.BIZ_ERROR.getCode(), \"获取文件信息失败: \" + e.getMessage());\n\t\t}\n\t\t\n\t\treturn AjaxJson.getSuccessData(fileItemResult);\n\t}\n\t\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/controller/file/FileOperatorController.java",
    "content": "package im.zhaojun.zfile.module.storage.controller.file;\n\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport com.github.xiaoymin.knife4j.annotations.ApiSort;\nimport im.zhaojun.zfile.core.annotation.DemoDisable;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.password.model.dto.VerifyResultDTO;\nimport im.zhaojun.zfile.module.password.service.PasswordConfigService;\nimport im.zhaojun.zfile.module.storage.annotation.CheckPassword;\nimport im.zhaojun.zfile.module.storage.context.StorageSourceContext;\nimport im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.request.operator.*;\nimport im.zhaojun.zfile.module.storage.model.result.operator.BatchOperatorResult;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.annotation.Resource;\nimport jakarta.validation.Valid;\nimport lombok.extern.slf4j.Slf4j;\nimport org.apache.commons.lang3.BooleanUtils;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.*;\n\n/**\n * 文件操作相关接口, 如新建文件夹, 上传文件, 删除文件, 移动文件等.\n *\n * @author zhaojun\n */\n@Tag(name = \"文件操作模块\")\n@ApiSort(3)\n@Slf4j\n@RestController\n@RequestMapping(\"/api/file/operator\")\npublic class FileOperatorController {\n\n\t@Resource\n\tprivate PasswordConfigService passwordConfigService;\n\n\t@ApiOperationSupport(order = 1)\n\t@Operation(summary = \"创建文件夹\")\n\t@PostMapping(\"/mkdir\")\n\t@CheckPassword(storageKeyFieldExpression = \"[0].storageKey\",\n\t\t\tpathFieldExpression = \"[0].path\",\n\t\t\tpasswordFieldExpression = \"[0].password\")\n\t@DemoDisable\n\tpublic AjaxJson<Void> mkdir(@Valid @RequestBody NewFolderRequest newFolderRequest) {\n\t\tAbstractBaseFileService<?> fileService = StorageSourceContext.getByStorageKey(newFolderRequest.getStorageKey());\n\t\tboolean flag = fileService.newFolder(newFolderRequest.getPath(), newFolderRequest.getName());\n\t\tif (flag) {\n\t\t\treturn AjaxJson.getSuccess(\"创建成功\");\n\t\t} else {\n\t\t\treturn AjaxJson.getError(\"创建失败\");\n\t\t}\n\t}\n\n\n\t@ApiOperationSupport(order = 2)\n\t@Operation(summary = \"批量删除文件/文件夹\")\n\t@PostMapping(\"/delete/batch\")\n\t@DemoDisable\n\tpublic AjaxJson<List<BatchOperatorResult>> deleteFile(@Valid @RequestBody BatchDeleteRequest batchDeleteRequest) {\n\t\tAbstractBaseFileService<?> fileService = StorageSourceContext.getByStorageKey(batchDeleteRequest.getStorageKey());\n\t\tif (fileService == null) {\n\t\t\tthrow new BizException(ErrorCode.BIZ_STORAGE_NOT_FOUND);\n\t\t}\n\n\t\tList<BatchDeleteRequest.DeleteItem> deleteItems = batchDeleteRequest.getDeleteItems();\n\n\t\tMap<String, Boolean> pathCheckCache = new HashMap<>();\n\n\t\tList<BatchOperatorResult> batchOperatorResults = new ArrayList<>();\n\n\t\tfor (BatchDeleteRequest.DeleteItem deleteItem : deleteItems) {\n\t\t\t// 检查权限\n\t\t\tString deletePath = deleteItem.getPath();\n\t\t\tString deleteName = deleteItem.getName();\n\t\t\tFileTypeEnum deleteType = deleteItem.getType();\n\n\t\t\tBoolean pathCheckResult = pathCheckCache.get(deletePath);\n\n\t\t\t// 缓存值为 false, 则即为失败, 直接跳过此删除的文件\n\t\t\tif (BooleanUtils.isFalse(pathCheckResult)) {\n\t\t\t\tbatchOperatorResults.add(BatchOperatorResult.fail(deletePath, deleteName, \"密码错误\"));\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// 缓存没有, 则进行校验\n\t\t\tif (pathCheckResult == null) {\n\t\t\t\tString fullPath = StringUtils.concat(fileService.getCurrentUserBasePath(), deletePath);\n\t\t\t\tVerifyResultDTO verifyResultDTO = passwordConfigService.verifyPassword(fileService.getStorageId(), fullPath, deleteItem.getPassword());\n\t\t\t\t// 校验不通过, 则跳过此删除的文件\n\t\t\t\tif (!verifyResultDTO.isPassed()) {\n\t\t\t\t\tlog.warn(\"因密码原因删除失败, 类型: {}, 路径: {}, 名称: {}, 原因: {}\", deleteType, deletePath, deleteName, verifyResultDTO.getErrorCode());\n\t\t\t\t\tpathCheckCache.put(deletePath, false);\n\t\t\t\t\tbatchOperatorResults.add(BatchOperatorResult.fail(deletePath, deleteName, \"密码错误\"));\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tpathCheckCache.put(deletePath, true);\n\t\t\t}\n\n\t\t\tboolean flag = false;\n\t\t\ttry {\n\t\t\t\tif (deleteType == FileTypeEnum.FILE) {\n\t\t\t\t\tflag = fileService.deleteFile(deletePath, deleteName);\n\t\t\t\t} else if (deleteType == FileTypeEnum.FOLDER) {\n\t\t\t\t\tflag = fileService.deleteFolder(deletePath, deleteName);\n\t\t\t\t}\n\n\t\t\t\tif (flag) {\n\t\t\t\t\tbatchOperatorResults.add(BatchOperatorResult.success(deletePath, deleteName));\n\t\t\t\t} else {\n\t\t\t\t\tbatchOperatorResults.add(BatchOperatorResult.fail(deletePath, deleteName, \"操作失败\"));\n\t\t\t\t}\n\t\t\t} catch (Exception e) {\n\t\t\t\tlog.error(\"删除文件/文件夹失败, 文件路径: {}, 文件名称: {}\", deletePath, deleteName, e);\n\t\t\t\tbatchOperatorResults.add(BatchOperatorResult.fail(deletePath, deleteName, e.getMessage()));\n\t\t\t}\n\t\t}\n\n\t\treturn AjaxJson.getSuccessData(batchOperatorResults);\n\t}\n\n\n\t@ApiOperationSupport(order = 3)\n\t@Operation(summary = \"重命名文件\")\n\t@PostMapping(\"/rename/file\")\n\t@CheckPassword(storageKeyFieldExpression = \"[0].storageKey\",\n\t\t\tpathFieldExpression = \"[0].path\",\n\t\t\tpasswordFieldExpression = \"[0].password\")\n\t@DemoDisable\n\tpublic AjaxJson<Void> rename(@Valid @RequestBody RenameFileRequest renameFileRequest) {\n\t\tAbstractBaseFileService<?> fileService = StorageSourceContext.getByStorageKey(renameFileRequest.getStorageKey());\n\t\tboolean flag = fileService.renameFile(renameFileRequest.getPath(), renameFileRequest.getName(), renameFileRequest.getNewName());\n\t\tif (flag) {\n\t\t\treturn AjaxJson.getSuccess(\"重命名成功\");\n\t\t} else {\n\t\t\treturn AjaxJson.getError(\"重命名失败\");\n\t\t}\n\t}\n\n\n\t@ApiOperationSupport(order = 4)\n\t@Operation(summary = \"重命名文件夹\")\n\t@PostMapping(\"/rename/folder\")\n\t@CheckPassword(storageKeyFieldExpression = \"[0].storageKey\",\n\t\t\tpathFieldExpression = \"[0].path\",\n\t\t\tpasswordFieldExpression = \"[0].password\")\n\t@DemoDisable\n\tpublic AjaxJson<Void> renameFolder(@Valid @RequestBody RenameFolderRequest renameFolderRequest) {\n\t\tAbstractBaseFileService<?> fileService = StorageSourceContext.getByStorageKey(renameFolderRequest.getStorageKey());\n\t\tboolean flag = fileService.renameFolder(renameFolderRequest.getPath(), renameFolderRequest.getName(), renameFolderRequest.getNewName());\n\t\tif (flag) {\n\t\t\treturn AjaxJson.getSuccess(\"重命名成功\");\n\t\t} else {\n\t\t\treturn AjaxJson.getError(\"重命名失败\");\n\t\t}\n\t}\n\n\t@ApiOperationSupport(order = 5)\n\t@Operation(summary = \"上传文件\")\n\t@PostMapping(\"/upload/file\")\n\t@CheckPassword(storageKeyFieldExpression = \"[0].storageKey\",\n\t\t\tpathFieldExpression = \"[0].path\",\n\t\t\tpasswordFieldExpression = \"[0].password\")\n\t@DemoDisable\n\tpublic AjaxJson<String> getUploadFileUrl(@Valid @RequestBody UploadFileRequest uploadFileRequest) {\n\t\tAbstractBaseFileService<?> fileService = StorageSourceContext.getByStorageKey(uploadFileRequest.getStorageKey());\n\t\tString uploadUrl = fileService.getUploadUrl(uploadFileRequest.getPath(),\n\t\t\t\tuploadFileRequest.getName(), uploadFileRequest.getSize());\n\t\treturn AjaxJson.getSuccessData(uploadUrl);\n\t}\n\n\t@ApiOperationSupport(order = 6)\n\t@Operation(summary = \"(移动/复制)(文件/文件夹)\")\n\t@PostMapping(\"/{action:move|copy}/{type:file|folder}\")\n\t@CheckPassword(storageKeyFieldExpression = \"[0].storageKey\",\n\t\t\tpathFieldExpression = \"[0].path\",\n\t\t\tpasswordFieldExpression = \"[0].srcPathPassword\")\n\t@CheckPassword(storageKeyFieldExpression = \"[0].storageKey\",\n\t\t\tpathFieldExpression = \"[0].targetPath\",\n\t\t\tpasswordFieldExpression = \"[0].targetPathPassword\")\n\t@DemoDisable\n\tpublic AjaxJson<List<BatchOperatorResult>> moveFile(@Valid @RequestBody BatchMoveOrCopyFileRequest batchMoveOrCopyFileRequest,\n\t\t\t\t\t\t\t\t@PathVariable(\"action\") String action,\n\t\t\t\t\t\t\t\t@PathVariable(\"type\") String type) {\n\t\tif (batchMoveOrCopyFileRequest.getNameList().size() != batchMoveOrCopyFileRequest.getTargetNameList().size()) {\n\t\t\tthrow new BizException(ErrorCode.BIZ_BAD_REQUEST);\n\t\t}\n\n\t\tString storageKey = batchMoveOrCopyFileRequest.getStorageKey();\n\t\tAbstractBaseFileService<?> fileService = StorageSourceContext.getByStorageKey(storageKey);\n\n\t\tMap<String, String> dictMap = new HashMap<>() {{\n            put(\"move\", \"移动\");\n            put(\"copy\", \"复制\");\n            put(\"file\", \"文件\");\n            put(\"folder\", \"文件夹\");\n        }};\n\n\t\tList<BatchOperatorResult> batchOperatorResults = new ArrayList<>();\n\n\t\tList<String> targetNameList = batchMoveOrCopyFileRequest.getTargetNameList();\n\t\tString srcPath = batchMoveOrCopyFileRequest.getPath();\n\t\tString targetPath = batchMoveOrCopyFileRequest.getTargetPath();\n\n\t\tfor (int i = 0; i < targetNameList.size(); i++) {\n\t\t\tString srcName = batchMoveOrCopyFileRequest.getNameList().get(i);\n\t\t\tString targetName = batchMoveOrCopyFileRequest.getTargetNameList().get(i);\n\n\t\t\tif (StringUtils.isBlank(srcName) || StringUtils.isBlank(targetName) || StringUtils.isBlank(srcPath) || StringUtils.isBlank(targetPath)) {\n\t\t\t\tbatchOperatorResults.add(BatchOperatorResult.fail(srcPath, srcName, \"参数错误\"));\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// 判断不能移动/复制到自己的子文件夹下\n\t\t\tString srcFullPath = StringUtils.concat(srcPath, srcName);\n\t\t\tif (targetPath.startsWith(srcFullPath)) {\n\t\t\t\tbatchOperatorResults.add(BatchOperatorResult.fail(srcPath, srcName, \"不能\" + dictMap.get(action) + dictMap.get(type) + \"到自己的子文件夹下\"));\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tboolean flag = false;\n\t\t\ttry {\n\t\t\t\tif (\"move\".equals(action)) {\n\t\t\t\t\tif (\"file\".equals(type)) {\n\t\t\t\t\t\tflag = fileService.moveFile(srcPath, srcName, targetPath, targetName);\n\t\t\t\t\t} else if (\"folder\".equals(type)) {\n\t\t\t\t\t\tflag = fileService.moveFolder(srcPath, srcName, targetPath, targetName);\n\t\t\t\t\t}\n\t\t\t\t} else if (\"copy\".equals(action)) {\n\t\t\t\t\tif (\"file\".equals(type)) {\n\t\t\t\t\t\tflag = fileService.copyFile(srcPath, srcName, targetPath, targetName);\n\t\t\t\t\t} else if (\"folder\".equals(type)) {\n\t\t\t\t\t\tflag = fileService.copyFolder(srcPath, srcName, targetPath, targetName);\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif (flag) {\n\t\t\t\t\tbatchOperatorResults.add(BatchOperatorResult.success(srcPath, srcName));\n\t\t\t\t} else {\n\t\t\t\t\tbatchOperatorResults.add(BatchOperatorResult.fail(srcPath, srcName, \"操作失败\"));\n\t\t\t\t}\n\t\t\t} catch (Exception e) {\n\t\t\t\tif (e instanceof BizException bizException) {\n\t\t\t\t\tif (!Objects.equals(bizException.getCode(), ErrorCode.BIZ_UNSUPPORTED_OPERATION.getCode())) {\n\t\t\t\t\t\tlog.warn(\"批量{}{}失败，源文件路径: {}, 源文件名称: {}, 目标文件路径: {}, 目标文件名称: {}, err: {}\", dictMap.get(action), dictMap.get(type), srcPath, srcName, targetPath, targetName, e.getMessage());\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tlog.error(\"批量{}{}失败，源文件路径: {}, 源文件名称: {}, 目标文件路径: {}, 目标文件名称: {}\", dictMap.get(action), dictMap.get(type), srcPath, srcName, targetPath, targetName, e);\n\t\t\t\t}\n\t\t\t\tbatchOperatorResults.add(BatchOperatorResult.fail(srcPath, srcName, e.getMessage()));\n\t\t\t}\n\t\t}\n\n\t\treturn AjaxJson.getSuccessData(batchOperatorResults);\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/controller/helper/GoogleDriveHelperController.java",
    "content": "package im.zhaojun.zfile.module.storage.controller.helper;\n\nimport cn.hutool.http.HttpRequest;\nimport cn.hutool.http.HttpResponse;\nimport cn.hutool.http.HttpUtil;\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONArray;\nimport com.alibaba.fastjson2.JSONObject;\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport im.zhaojun.zfile.module.storage.model.request.GetGoogleDriveListRequest;\nimport im.zhaojun.zfile.module.storage.model.result.GoogleDriveInfoResult;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport io.swagger.v3.oas.annotations.Operation;\nimport org.springframework.stereotype.Controller;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.ResponseBody;\n\nimport jakarta.validation.Valid;\nimport java.util.ArrayList;\nimport java.util.List;\n\n/**\n * @author zhaojun\n */\n@Tag(name = \"gd 工具辅助模块\")\n@Controller\n@RequestMapping(\"/gd\")\npublic class GoogleDriveHelperController {\n\t\n\t@PostMapping(\"/drives\")\n\t@ResponseBody\n\t@ApiOperationSupport(order = 1)\n\t@Operation(summary = \"获取 gd drives 列表\")\n\tpublic AjaxJson<List<GoogleDriveInfoResult>> getDrives(@Valid @RequestBody GetGoogleDriveListRequest googleDriveListRequest) {\n\t\tList<GoogleDriveInfoResult> bucketNameList = new ArrayList<>();\n\t\tString accessToken = googleDriveListRequest.getAccessToken();\n\t\t\n\t\tHttpRequest httpRequest = HttpUtil.createGet(\"https://www.googleapis.com/drive/v3/drives\");\n\t\thttpRequest.header(\"Authorization\", \"Bearer \" + accessToken);\n\t\t\n\t\tHttpResponse httpResponse = httpRequest.execute();\n\t\t\n\t\tString body = httpResponse.body();\n\t\tJSONObject jsonObject = JSON.parseObject(body);\n\t\tJSONArray drives = jsonObject.getJSONArray(\"drives\");\n\t\t\n\t\tfor (int i = 0; i < drives.size(); i++) {\n\t\t\tJSONObject drive = drives.getJSONObject(i);\n\t\t\tString id = drive.getString(\"id\");\n\t\t\tString name = drive.getString(\"name\");\n\t\t\tbucketNameList.add(new GoogleDriveInfoResult(id, name));\n\t\t}\n\t\t\n\t\treturn AjaxJson.getSuccessData(bucketNameList);\n\t}\n\t\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/controller/helper/Open115HelperController.java",
    "content": "package im.zhaojun.zfile.module.storage.controller.helper;\n\nimport cn.hutool.core.codec.Base64;\nimport cn.hutool.core.util.RandomUtil;\nimport cn.hutool.crypto.SecureUtil;\nimport cn.hutool.http.HttpRequest;\nimport cn.hutool.http.HttpResponse;\nimport cn.hutool.http.HttpUtil;\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport im.zhaojun.zfile.core.exception.core.SystemException;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.module.storage.model.result.Open115AuthDeviceCodeResult;\nimport im.zhaojun.zfile.module.storage.model.result.Open115GetStatusResult;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport org.springframework.stereotype.Controller;\nimport org.springframework.web.bind.annotation.*;\n\n@Tag(name = \"115 工具辅助模块\")\n@Controller\n@RequestMapping(\"/115\")\npublic class Open115HelperController {\n\n    @GetMapping(\"/qrcode\")\n    @ResponseBody\n    @ApiOperationSupport(order = 1)\n    @Operation(summary = \"获取二维码\")\n    public AjaxJson<Open115AuthDeviceCodeResult> generateQrCode(String appId) {\n        String codeVerifier = RandomUtil.randomString(128);\n        String codeChallenge = Base64.encode(SecureUtil.md5().digest(codeVerifier));\n\n        // https://www.yuque.com/115yun/open/shtpzfhewv5nag11\n        HttpRequest httpRequest = HttpUtil.createPost(\"https://passportapi.115.com/open/authDeviceCode\")\n                .form(\"client_id\", appId)\n                .form(\"code_challenge\", codeChallenge)\n                .form(\"code_challenge_method\", \"md5\");\n\n        HttpResponse execute = httpRequest.execute();\n        String body = execute.body();\n\n        JSONObject jsonObject = JSON.parseObject(body);\n        if (jsonObject.getInteger(\"state\") == 0) {\n            throw new SystemException(jsonObject.getString(\"error\"));\n        }\n\n        Open115AuthDeviceCodeResult open115AuthDeviceCodeResult = JSON.parseObject(body).getObject(\"data\", Open115AuthDeviceCodeResult.class);\n        open115AuthDeviceCodeResult.setCodeVerifier(codeVerifier);\n        return AjaxJson.getSuccessData(open115AuthDeviceCodeResult);\n    }\n\n    @PostMapping(\"/qrCodeStatus\")\n    @ResponseBody\n    @ApiOperationSupport(order = 2)\n    @Operation(summary = \"获取二维码状态\")\n    public AjaxJson<Open115GetStatusResult> getQrCodeStatus(@RequestBody Open115AuthDeviceCodeResult open115AuthDeviceCodeResult) {\n\n        // https://www.yuque.com/115yun/open/shtpzfhewv5nag11#6d33298a\n        HttpRequest httpRequest = HttpUtil.createGet(\"https://qrcodeapi.115.com/get/status/\")\n                .form(\"uid\", open115AuthDeviceCodeResult.getUid())\n                .form(\"time\", open115AuthDeviceCodeResult.getTime())\n                .form(\"sign\", open115AuthDeviceCodeResult.getSign());\n\n        httpRequest.setReadTimeout(0);\n        HttpResponse execute = httpRequest.execute();\n        String body = execute.body();\n\n        JSONObject jsonObject = JSON.parseObject(body);\n        if (jsonObject.getInteger(\"state\") == 0) {\n            return AjaxJson.getSuccessData(Open115GetStatusResult.error(jsonObject.getString(\"error\")));\n        }\n\n        if (jsonObject.getInteger(\"state\") == 1 && !jsonObject.getJSONObject(\"data\").containsKey(\"status\")) {\n            return AjaxJson.getSuccessData(Open115GetStatusResult.waiting());\n        }\n\n        if (jsonObject.getInteger(\"state\") == 1 && jsonObject.getJSONObject(\"data\").getInteger(\"status\") == 1) {\n            return AjaxJson.getSuccessData(Open115GetStatusResult.scanning(jsonObject.getJSONObject(\"data\").getString(\"msg\")));\n        }\n\n\n        // https://www.yuque.com/115yun/open/shtpzfhewv5nag11#QCCVQ\n        HttpRequest deviceCodeToTokenHttpRequest = HttpUtil.createPost(\"https://passportapi.115.com/open/deviceCodeToToken\")\n                .form(\"uid\", open115AuthDeviceCodeResult.getUid())\n                .form(\"code_verifier\", open115AuthDeviceCodeResult.getCodeVerifier());\n        String deviceCodeToTokenBody = deviceCodeToTokenHttpRequest.execute().body();\n        JSONObject deviceCodeToTokenJsonObject = JSON.parseObject(deviceCodeToTokenBody).getJSONObject(\"data\");\n        String accessToken = deviceCodeToTokenJsonObject.getString(\"access_token\");\n        String refreshToken = deviceCodeToTokenJsonObject.getString(\"refresh_token\");\n        Integer expiresIn = deviceCodeToTokenJsonObject.getInteger(\"expires_in\");\n\n            // 否则认为 expiredAt 是过期时间(单位: 秒)\n        Integer expiredAt = expiresIn + (int) (System.currentTimeMillis() / 1000);\n\n        return AjaxJson.getSuccessData(Open115GetStatusResult.success(accessToken, refreshToken, expiredAt));\n    }\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/controller/helper/Open115UploadUtils.java",
    "content": "package im.zhaojun.zfile.module.storage.controller.helper;\n\nimport cn.hutool.cache.Cache;\nimport cn.hutool.cache.CacheUtil;\nimport cn.hutool.core.codec.Base64;\nimport cn.hutool.core.lang.func.Func0;\nimport cn.hutool.http.HttpRequest;\nimport cn.hutool.http.HttpResponse;\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport com.alibaba.fastjson2.annotation.JSONField;\nimport lombok.extern.slf4j.Slf4j;\nimport software.amazon.awssdk.auth.credentials.AwsSessionCredentials;\nimport software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;\nimport software.amazon.awssdk.core.interceptor.Context;\nimport software.amazon.awssdk.core.interceptor.ExecutionAttributes;\nimport software.amazon.awssdk.core.interceptor.ExecutionInterceptor;\nimport software.amazon.awssdk.core.sync.RequestBody;\nimport software.amazon.awssdk.http.SdkHttpRequest;\nimport software.amazon.awssdk.regions.Region;\nimport software.amazon.awssdk.services.s3.S3Client;\nimport software.amazon.awssdk.services.s3.model.PutObjectRequest;\n\nimport java.io.File;\nimport java.io.IOException;\nimport java.io.RandomAccessFile;\nimport java.net.URI;\nimport java.security.MessageDigest;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.*;\nimport java.util.function.Supplier;\n\n@Slf4j\npublic class Open115UploadUtils {\n\n    private static final String BASE_URL = \"https://proapi.115.com\";\n\n    private static final String INIT_UPLOAD_PATH = \"/open/upload/init\";\n\n    private static final String GET_TOKEN_PATH = \"/open/upload/get_token\";\n\n    private static final Integer FAST_UPLOAD_STATUS = 2;\n\n    private static final Integer NORMAL_UPLOAD_STATUS = 1;\n\n    private static final MessageDigest digest;\n\n    static {\n        try {\n            digest = MessageDigest.getInstance(\"SHA-1\");\n        } catch (NoSuchAlgorithmException e) {\n            throw new RuntimeException(e);\n        }\n    }\n\n    /**\n     * S3 临时上传凭证有效时间为 1 小时，设置为 55 分钟的缓存时间\n     */\n    private static final Cache<String, UploadTokenResponse> UPLOAD_TOKEN_CACHE = CacheUtil.newTimedCache(55 * 60 * 1000);\n\n    /**\n     * 上传文件到 115，自动适应秒传，非秒传场景.\n     *\n     * @param   file\n     *          要上传的文件\n     *\n     * @param   targetDirId\n     *          目标文件夹 ID，0 代表根目录\n     *\n     * @param   accessTokenSupplier\n     *          115 OPEN API 的访问令牌提供者，通常是一个 lambda 表达式或方法引用，这里使用 supplier 是为了防止上传过程中访问令牌过期导致的错误。\n     *\n     * @return  文件上传成功后返回的 pick_code，如果是秒传则返回对应的 pick_code。\n     */\n    public static String uploadFile(File file, String fileName, String targetDirId, Supplier<String> accessTokenSupplier) throws Exception {\n        InitUploadResponse initResponse = initUploadWithAuthHandling(file, fileName, targetDirId, accessTokenSupplier);\n        InitUploadResponse.Data initData = initResponse.getData();\n\n        if (initData.status == FAST_UPLOAD_STATUS) {\n            log.info(\"文件 {} 秒传成功，pick_code: {}\", fileName, initData.pickCode);\n            return initData.pickCode;\n        }\n\n        if (initData.status == NORMAL_UPLOAD_STATUS) {\n            log.info(\"文件 {} 需要正常上传，pick_code: {}\", fileName, initData.pickCode);\n            UploadTokenResponse tokenResponse = getUploadToken(accessTokenSupplier);\n            uploadToObjectStorage(file, initData, tokenResponse.getData());\n            log.info(\"文件 {} 上传到对象存储成功...\", fileName);\n            return initData.pickCode;\n        }\n\n        throw new Exception(\"上传初始化后出现未处理的上传状态: \" + initData.status);\n    }\n\n    /**\n     * 调用初始化接口，并内置了二次验证的处理逻辑。\n     */\n    private static InitUploadResponse initUploadWithAuthHandling(File file, String fileName, String targetDirId, Supplier<String> accessToken) throws Exception {\n        String fileSha1 = calculateSha1(file, 0, file.length());\n        String target = \"U_1_\" + targetDirId;\n\n        String signKey = null;\n        String signVal = null;\n\n        while (true) {\n            Map<String, Object> formMap = new HashMap<>();\n            formMap.put(\"file_name\", fileName);\n            formMap.put(\"file_size\", file.length());\n            formMap.put(\"target\", target);\n            formMap.put(\"fileid\", fileSha1);\n\n            if (signKey != null && signVal != null) {\n                formMap.put(\"sign_key\", signKey);\n                formMap.put(\"sign_val\", signVal);\n            }\n\n            // https://www.yuque.com/115yun/open/ul4mrauo5i2uza0q\n            HttpResponse response = HttpRequest.post(BASE_URL + INIT_UPLOAD_PATH)\n                    .bearerAuth(accessToken.get())\n                    .form(formMap)\n                    .execute();\n\n            String responseBody = response.body();\n            InitUploadResponse initResponse = JSON.parseObject(responseBody, InitUploadResponse.class);\n\n            if (!initResponse.state && initResponse.code != 0) {\n                throw new Exception(\"初始化上传接口返回错误: \" + initResponse.message);\n            }\n\n            InitUploadResponse.Data data = initResponse.getData();\n            if (data.status == 7 && data.code == 701) {\n                signKey = data.signKey;\n\n                String[] range = data.signCheck.split(\"-\");\n                long start = Long.parseLong(range[0]);\n                long end = Long.parseLong(range[1]);\n                signVal = calculateSha1(file, start, (end - start + 1));\n                continue;\n            }\n\n            return initResponse;\n        }\n    }\n\n    /**\n     * 直传文件到 open115 提供的对象存储。\n     */\n    private static void uploadToObjectStorage(File file, InitUploadResponse.Data initData, UploadTokenResponse.Data tokenData) throws Exception {\n        CallbackInfo callbackInfo = initData.getCallback();\n        try (S3Client s3Client = S3Client.builder()\n                .region(Region.of(\"auto\"))\n                .endpointOverride(new URI(tokenData.endpoint))\n                .credentialsProvider(StaticCredentialsProvider.create(AwsSessionCredentials.create(tokenData.accessKeyId, tokenData.accessKeySecret, tokenData.securityToken)))\n                .overrideConfiguration(c -> c.addExecutionInterceptor(new OssHeaderInterceptor()))\n                .build()) {\n\n            Map<String, String> metadata = new HashMap<>();\n            metadata.put(\"x-oss-callback\", Base64.encode(callbackInfo.callback));\n            metadata.put(\"x-oss-callback-var\", Base64.encode(callbackInfo.callbackVar));\n            PutObjectRequest putObjectRequest = PutObjectRequest.builder()\n                    .bucket(initData.bucket)\n                    .key(initData.object)\n                    .metadata(metadata)\n                    .build();\n\n            s3Client.putObject(putObjectRequest, RequestBody.fromFile(file));\n        }\n    }\n\n    /**\n     * 调用接口获取上传凭证，会使用缓存来避免频繁请求。\n     */\n    private static UploadTokenResponse getUploadToken(Supplier<String> accessToken) {\n        String accessTokenStr = accessToken.get();\n        return UPLOAD_TOKEN_CACHE.get(accessTokenStr, false, (Func0<UploadTokenResponse>) () -> {\n            // https://www.yuque.com/115yun/open/ul4mrauo5i2uza0q\n            HttpResponse response = HttpRequest.get(BASE_URL + GET_TOKEN_PATH)\n                    .bearerAuth(accessTokenStr)\n                    .execute();\n\n            String responseBody = response.body();\n            UploadTokenResponse tokenResponse = JSON.parseObject(responseBody, UploadTokenResponse.class);\n            if (!tokenResponse.state) {\n                throw new Exception(\"获取上传凭证接口返回错误: \" + tokenResponse.message);\n            }\n            return tokenResponse;\n        });\n    }\n\n    /**\n     * SHA-1 计算工具方法，支持全文件或文件局部范围计算。\n     *\n     * @param   file   文件对象\n     * @param   offset 开始位置\n     * @param   length 要计算的长度\n     * @return  大写的 SHA-1 字符串\n     */\n    private static String calculateSha1(File file, long offset, long length) throws IOException {\n        try (RandomAccessFile raf = new RandomAccessFile(file, \"r\")) {\n            raf.seek(offset);\n            byte[] buffer = new byte[8192];\n            int bytesRead;\n            long remaining = length;\n            while (remaining > 0 && (bytesRead = raf.read(buffer, 0, (int) Math.min(buffer.length, remaining))) != -1) {\n                digest.update(buffer, 0, bytesRead);\n                remaining -= bytesRead;\n            }\n        }\n        byte[] bytes = digest.digest();\n        StringBuilder sb = new StringBuilder();\n        for (byte b : bytes) {\n            sb.append(String.format(\"%02x\", b));\n        }\n        return sb.toString().toUpperCase();\n    }\n\n    public static class InitUploadResponse {\n        public boolean state;\n        public String message;\n        public int code;\n        public Object data;\n\n        public InitUploadResponse.Data getData() {\n            if (data instanceof JSONObject jsonObject) {\n                return jsonObject.toJavaObject(InitUploadResponse.Data.class);\n            }\n            return null;\n        }\n\n        public static class Data {\n\n            public int status;\n\n            public int code;\n\n            @JSONField(name = \"pick_code\")\n            public String pickCode;\n\n            public String bucket;\n\n            public String object;\n\n            // Object 类型以兼容对象和数组两种情况\n            public Object callback;\n\n            @JSONField(name = \"sign_key\")\n            public String signKey;\n\n            @JSONField(name = \"sign_check\")\n            public String signCheck;\n\n            @JSONField(name = \"file_id\")\n            public String fileId;\n\n            public CallbackInfo getCallback() {\n                if (callback instanceof JSONObject jsonObject) {\n                    return jsonObject.toJavaObject(CallbackInfo.class);\n                }\n                return null;\n            }\n        }\n    }\n\n    // 这个类只在 status=1 时被使用，此时API返回的是一个对象\n    public static class CallbackInfo {\n\n        public String callback;\n\n        @JSONField(name = \"callback_var\")\n        public String callbackVar;\n\n    }\n\n    public static class UploadTokenResponse {\n\n        public boolean state;\n\n        public String message;\n\n        public int code;\n\n        public Object data;\n\n        public UploadTokenResponse.Data getData() {\n            if (data instanceof JSONObject jsonObject) {\n                return jsonObject.toJavaObject(UploadTokenResponse.Data.class);\n            }\n            return null;\n        }\n\n        public static class Data {\n\n            public String endpoint;\n\n            @JSONField(name = \"AccessKeyId\")\n            public String accessKeyId;\n\n            @JSONField(name = \"AccessKeySecret\")\n            public String accessKeySecret;\n\n            @JSONField(name = \"SecurityToken\")\n            public String securityToken;\n\n            @JSONField(name = \"Expiration\")\n            public String expiration;\n        }\n    }\n\n    /**\n     * 自定义的 S3 执行拦截器，用于处理请求头的转换。去除 amazon s3 sdk 在请求头上自动添加的 `x-amz-meta-` 前缀。\n     */\n    static class OssHeaderInterceptor implements ExecutionInterceptor {\n\n        @Override\n        public SdkHttpRequest modifyHttpRequest(Context.ModifyHttpRequest context, ExecutionAttributes executionAttributes) {\n            SdkHttpRequest request = context.httpRequest();\n\n            // 检查是否存在 x-amz-meta-x-oss-callback 头\n            Optional<String> callbackHeader = request.firstMatchingHeader(\"x-amz-meta-x-oss-callback\");\n            Optional<String> callbackVarHeader = request.firstMatchingHeader(\"x-amz-meta-x-oss-callback-var\");\n\n            // 如果不存在任何一个相关的头，则不进行任何操作\n            if (callbackHeader.isEmpty() && callbackVarHeader.isEmpty()) {\n                return request;\n            }\n\n            SdkHttpRequest.Builder newRequestBuilder = request.toBuilder();\n\n            // 存放需要移除的旧头\n            List<String> headersToRemove = new ArrayList<>();\n\n            // 处理 x-oss-callback\n            callbackHeader.ifPresent(value -> {\n                newRequestBuilder.putHeader(\"x-oss-callback\", value);\n                headersToRemove.add(\"x-amz-meta-x-oss-callback\");\n            });\n\n            // 处理 x-oss-callback-var\n            callbackVarHeader.ifPresent(value -> {\n                newRequestBuilder.putHeader(\"x-oss-callback-var\", value);\n                headersToRemove.add(\"x-amz-meta-x-oss-callback-var\");\n            });\n\n            // 移除旧的 x-amz-meta-* 头\n            for (String header : headersToRemove) {\n                newRequestBuilder.removeHeader(header);\n            }\n\n            return newRequestBuilder.build();\n        }\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/controller/helper/S3HelperController.java",
    "content": "package im.zhaojun.zfile.module.storage.controller.helper;\n\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport im.zhaojun.zfile.core.exception.core.SystemException;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.core.util.UrlUtils;\nimport im.zhaojun.zfile.module.storage.model.dto.ZFileCORSRule;\nimport im.zhaojun.zfile.module.storage.model.request.GetS3BucketListRequest;\nimport im.zhaojun.zfile.module.storage.model.request.GetS3CorsListRequest;\nimport im.zhaojun.zfile.module.storage.model.result.S3BucketNameResult;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.validation.Valid;\nimport org.springframework.stereotype.Controller;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.ResponseBody;\nimport software.amazon.awssdk.auth.credentials.AwsBasicCredentials;\nimport software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;\nimport software.amazon.awssdk.regions.Region;\nimport software.amazon.awssdk.services.s3.S3Client;\nimport software.amazon.awssdk.services.s3.model.Bucket;\nimport software.amazon.awssdk.services.s3.model.CORSRule;\nimport software.amazon.awssdk.services.s3.model.GetBucketCorsResponse;\nimport software.amazon.awssdk.services.s3.model.S3Exception;\n\nimport java.net.URI;\nimport java.sql.Date;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\n/**\n * S3 工具辅助\n *\n * @author zhaojun\n */\n@Tag(name = \"S3 工具辅助模块\")\n@Controller\n@RequestMapping(\"/s3\")\npublic class S3HelperController {\n\n\t@PostMapping(\"/getBuckets\")\n\t@ResponseBody\n\t@ApiOperationSupport(order = 1)\n\t@Operation(summary = \"获取 S3 存储器列表\")\n\tpublic AjaxJson<List<S3BucketNameResult>> getBucketNames(@Valid @RequestBody GetS3BucketListRequest getS3BucketListRequest) {\n\t\tList<S3BucketNameResult> bucketNameList = new ArrayList<>();\n\t\tString accessKey = getS3BucketListRequest.getAccessKey();\n\t\tString secretKey = getS3BucketListRequest.getSecretKey();\n\t\tString endPoint = getS3BucketListRequest.getEndPoint();\n\t\tif (!UrlUtils.hasScheme(endPoint)) {\n\t\t\tendPoint = \"http://\" + endPoint;\n\t\t}\n\t\tString region = getS3BucketListRequest.getRegion();\n\n\t\tif (StringUtils.isEmpty(region) && StringUtils.contains(endPoint, StringUtils.DOT)) {\n\t\t\tregion = endPoint.split(\"\\\\.\")[1];\n\t\t}\n\n\t\tif (StringUtils.isEmpty(region)) {\n\t\t\tregion = \"us-east-1\";\n\t\t}\n\n\t\tList<Bucket> buckets;\n\t\tS3Client s3Client = null;\n\t\ttry {\n\t\t\tRegion oss = Region.of(region);\n\t\t\tURI endpointOverride = URI.create(endPoint);\n\t\t\tStaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey));\n\n\t\t\ts3Client = S3Client.builder()\n\t\t\t\t\t.region(oss)\n\t\t\t\t\t.endpointOverride(endpointOverride)\n\t\t\t\t\t.credentialsProvider(credentialsProvider)\n\t\t\t\t\t.build();\n\t\t\tbuckets = s3Client.listBuckets().buckets();\n\t\t} catch (Exception e) {\n\t\t\te.printStackTrace();\n\t\t\tthrow new SystemException(\"S3 工具辅助模块获取 Bucket 列表失败\", e);\n\t\t} finally {\n\t\t\tif (s3Client != null) {\n\t\t\t\ttry {\n\t\t\t\t\ts3Client.close();\n\t\t\t\t} catch (Exception e) {\n\t\t\t\t\t// ignore\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tfor (Bucket bucket : buckets) {\n\t\t\tS3BucketNameResult s3BucketNameResult = new S3BucketNameResult(bucket.name(), Date.from(bucket.creationDate()));\n\t\t\tbucketNameList.add(s3BucketNameResult);\n\t\t}\n\n\t\treturn AjaxJson.getSuccessData(bucketNameList);\n\t}\n\n\t@PostMapping(\"/getCorsConfig\")\n\t@ResponseBody\n\t@ApiOperationSupport(order = 1)\n\t@Operation(summary = \"获取 S3 跨域设置\")\n\tpublic AjaxJson<List<ZFileCORSRule>> getCorsConfig(@Valid @RequestBody GetS3CorsListRequest getS3CorsListRequest) {\n\t\tString accessKey = getS3CorsListRequest.getAccessKey();\n\t\tString secretKey = getS3CorsListRequest.getSecretKey();\n\t\tString endPoint = getS3CorsListRequest.getEndPoint();\n\t\tif (!UrlUtils.hasScheme(endPoint)) {\n\t\t\tendPoint = \"http://\" + endPoint;\n\t\t}\n\t\tString region = getS3CorsListRequest.getRegion();\n\t\tString bucketName = getS3CorsListRequest.getBucketName();\n\n\t\tif (StringUtils.isEmpty(region) && StringUtils.contains(endPoint, StringUtils.DOT)) {\n\t\t\tregion = endPoint.split(\"\\\\.\")[1];\n\t\t}\n\n\t\tif (StringUtils.isEmpty(region)) {\n\t\t\tregion = \"us-east-1\";\n\t\t}\n\n\t\tS3Client s3Client = null;\n\t\ttry {\n\t\t\tRegion oss = Region.of(region);\n\t\t\tURI endpointOverride = URI.create(endPoint);\n\t\t\tStaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey));\n\n\t\t\ts3Client = S3Client.builder()\n\t\t\t\t\t.region(oss)\n\t\t\t\t\t.endpointOverride(endpointOverride)\n\t\t\t\t\t.credentialsProvider(credentialsProvider)\n\t\t\t\t\t.build();\n\t\t\tGetBucketCorsResponse getBucketCorsResponse = s3Client.getBucketCors(builder -> builder.bucket(bucketName));\n\t\t\tList<CORSRule> rules = getBucketCorsResponse.corsRules();\n\t\t\tList<ZFileCORSRule> rulesList = ZFileCORSRule.fromCORSRule(rules);\n\t\t\treturn AjaxJson.getSuccessData(rulesList);\n\t\t} catch (S3Exception s3Exception) {\n\t\t\tif (s3Exception.statusCode() == 404) {\n\t\t\t\treturn AjaxJson.getSuccessData(Collections.emptyList());\n\t\t\t} else {\n\t\t\t\tthrow new SystemException(\"获取跨域设置失败: \" + s3Exception.getMessage(), s3Exception);\n\t\t\t}\n\t\t} catch (Exception e) {\n\t\t\tthrow new SystemException(\"自动获取跨域设置失败: \" + e.getMessage(), e);\n\t\t} finally {\n\t\t\tif (s3Client != null) {\n\t\t\t\ttry {\n\t\t\t\t\ts3Client.close();\n\t\t\t\t} catch (Exception e) {\n\t\t\t\t\t// ignore\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/controller/helper/SharePointHelperController.java",
    "content": "package im.zhaojun.zfile.module.storage.controller.helper;\n\nimport cn.hutool.http.HttpRequest;\nimport cn.hutool.http.HttpResponse;\nimport cn.hutool.http.HttpUtil;\nimport com.alibaba.fastjson2.JSONArray;\nimport com.alibaba.fastjson2.JSONObject;\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.biz.APIHttpRequestBizException;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.storage.model.request.SharePointInfoRequest;\nimport im.zhaojun.zfile.module.storage.model.request.SharePointSearchSitesRequest;\nimport im.zhaojun.zfile.module.storage.model.request.SharePointSiteListsRequest;\nimport im.zhaojun.zfile.module.storage.model.result.SharepointSiteListResult;\nimport im.zhaojun.zfile.module.storage.model.result.SharepointSiteResult;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.validation.Valid;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.stereotype.Controller;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.ResponseBody;\n\nimport java.util.*;\n\n/**\n * SharePoint 工具类\n *\n * @author zhaojun\n */\n@Tag(name = \"SharePoint 工具辅助模块\")\n@Controller\n@RequestMapping(\"/sharepoint\")\npublic class SharePointHelperController {\n\n    private static final String SHAREPOINT_LIST_TYPE_EVENT = \"事件\";\n\n    private static final String SHAREPOINT_LIST_TYPE_DOCUMENT = \"文档\";\n\n\n    @PostMapping(\"/getSites\")\n    @ResponseBody\n    @ApiOperationSupport(order = 1)\n    @Operation(summary = \"获取网站列表\")\n    public AjaxJson<List<SharepointSiteResult>> getSites(@Valid @RequestBody SharePointSearchSitesRequest searchSitesRequest) {\n        List<SharepointSiteResult> sites = new ArrayList<>();\n\n        String requestUrl = getSearchSiteUrlByType(searchSitesRequest.getType());\n\n        // 构建请求认证 Token 信息\n        String tokenValue = getBearer(searchSitesRequest.getAccessToken());\n        HashMap<String, String> headers = new HashMap<>();\n        headers.put(\"Authorization\", tokenValue);\n\n        // 请求接口\n        HttpRequest getRequest = HttpUtil.createGet(requestUrl);\n        getRequest.form(\"search\", \" \");\n        HttpResponse execute = getRequest.addHeaders(headers).execute();\n        String body = execute.body();\n        if (execute.getStatus() != HttpStatus.OK.value()) {\n            throw new APIHttpRequestBizException(ErrorCode.BIZ_AUTO_GET_SHARE_POINT_SITES_ERROR, requestUrl, execute.getStatus(), body);\n        }\n\n        // 解析前缀\n        JSONObject rootObject = JSONObject.parseObject(body);\n        JSONArray valueArray = rootObject.getJSONArray(\"value\");\n        for (int i = 0; i < valueArray.size(); i++) {\n            SharepointSiteResult sharepointSiteResult = valueArray.getObject(i, SharepointSiteResult.class);\n            sites.add(sharepointSiteResult);\n        }\n\n        return AjaxJson.getSuccessData(sites);\n    }\n\n\n    @PostMapping(\"/getSiteLists\")\n    @ResponseBody\n    @ApiOperationSupport(order = 2)\n    @Operation(summary = \"获取网站下的子目录\")\n    public AjaxJson<List<SharepointSiteListResult>> getSites(@Valid @RequestBody SharePointSiteListsRequest sharePointSiteListsRequest) {\n        List<SharepointSiteListResult> sites = new ArrayList<>();\n\n        String siteId = sharePointSiteListsRequest.getSiteId();\n\n        String[] siteIdSplit = siteId.split(\",\");\n        if (siteIdSplit.length > 1) {\n            siteId = siteIdSplit[1];\n        }\n\n        String requestUrl = getSiteListsUrlByType(sharePointSiteListsRequest.getType(), siteId);\n\n        // 构建请求认证 Token 信息\n        String tokenValue = getBearer(sharePointSiteListsRequest.getAccessToken());\n        HashMap<String, String> headers = new HashMap<>();\n        headers.put(\"Authorization\", tokenValue);\n\n        // 请求接口\n        HttpRequest getRequest = HttpUtil.createGet(requestUrl);\n        HttpResponse execute = getRequest.addHeaders(headers).execute();\n        String body = execute.body();\n        if (execute.getStatus() != HttpStatus.OK.value()) {\n            throw new APIHttpRequestBizException(ErrorCode.BIZ_AUTO_GET_SHARE_POINT_SITES_ERROR, requestUrl, execute.getStatus(), body);\n        }\n\n        // 解析前缀\n        JSONObject rootObject = JSONObject.parseObject(body);\n        JSONArray valueArray = rootObject.getJSONArray(\"value\");\n        for (int i = 0; i < valueArray.size(); i++) {\n            SharepointSiteListResult sharepointSiteListResult = valueArray.getObject(i, SharepointSiteListResult.class);\n            // 如果是事件目录，则跳过\n            if (Objects.equals(SHAREPOINT_LIST_TYPE_EVENT, sharepointSiteListResult.getDisplayName())) {\n                continue;\n            }\n\n            // 如果是文档类型，则改名为\"默认文档\"\n            if (Objects.equals(SHAREPOINT_LIST_TYPE_DOCUMENT, sharepointSiteListResult.getDisplayName())) {\n                sharepointSiteListResult.setDisplayName(\"默认文档\");\n            }\n            sites.add(sharepointSiteListResult);\n        }\n        sites.sort(Comparator.comparing(SharepointSiteListResult::getCreatedDateTime));\n        return AjaxJson.getSuccessData(sites);\n    }\n\n    @PostMapping(\"/getDomainPrefix\")\n    @ApiOperationSupport(order = 3)\n    @Operation(summary = \"获取域名前缀\")\n    @ResponseBody\n    public AjaxJson<String> getDomainPrefix(@RequestBody SharePointInfoRequest sharePointInfoRequest) {\n        // 请求 URL\n        String requestUrl = getSiteRootUrlByType(sharePointInfoRequest.getType());\n\n        // 构建请求认证 Token 信息\n        String tokenValue = getBearer(sharePointInfoRequest.getAccessToken());\n        HashMap<String, String> headers = new HashMap<>();\n        headers.put(\"Authorization\", tokenValue);\n\n        // 请求接口\n        HttpRequest getRequest = HttpUtil.createGet(requestUrl);\n        HttpResponse execute = getRequest.addHeaders(headers).execute();\n        String body = execute.body();\n        if (execute.getStatus() != HttpStatus.OK.value()) {\n            throw new BizException(body);\n        }\n\n        // 解析前缀\n        JSONObject jsonObject = JSONObject.parseObject(body);\n        String hostname = jsonObject.getJSONObject(\"siteCollection\").getString(\"hostname\");\n        String domainPrefix = StringUtils.subBefore(hostname, \".sharepoint\", false);\n        return AjaxJson.getSuccessData(domainPrefix);\n    }\n\n\n    @PostMapping(\"/getSiteId\")\n    @ApiOperationSupport(order = 4)\n    @Operation(summary = \"获取 SiteId\")\n    @ResponseBody\n    public AjaxJson<String> getSiteId(@RequestBody SharePointInfoRequest sharePointInfoRequest) {\n\n        // 判断必填参数\n        if (sharePointInfoRequest == null || sharePointInfoRequest.getAccessToken() == null || sharePointInfoRequest.getSiteName() == null) {\n            throw new BizException(ErrorCode.BIZ_BAD_REQUEST);\n        }\n\n        // 构建请求认证 Token 信息\n        String tokenValue = getBearer(sharePointInfoRequest.getAccessToken());\n        HashMap<String, String> authorizationHeaders = new HashMap<>();\n        authorizationHeaders.put(\"Authorization\", tokenValue);\n\n\n        // 如果没有域名前缀, 则先获取\n        if (sharePointInfoRequest.getDomainPrefix() == null || sharePointInfoRequest.getDomainType() == null) {\n            String requestUrl = getSiteRootUrlByType(sharePointInfoRequest.getType());\n            HttpRequest getRequest = HttpUtil.createGet(requestUrl);\n            HttpResponse execute = getRequest.addHeaders(authorizationHeaders).execute();\n            String body = execute.body();\n            if (execute.getStatus() != HttpStatus.OK.value()) {\n                throw new BizException(body);\n            }\n            JSONObject jsonObject = JSONObject.parseObject(body);\n            String hostname = jsonObject.getJSONObject(\"siteCollection\").getString(\"hostname\");\n            String domainPrefix = StringUtils.subBefore(hostname, \".sharepoint\", false);\n            sharePointInfoRequest.setDomainPrefix(domainPrefix);\n        }\n\n\n        if (StringUtils.isEmpty(sharePointInfoRequest.getSiteType())) {\n            sharePointInfoRequest.setSiteType(\"/sites/\");\n        }\n\n        // 请求接口\n        String host = getHostByType(sharePointInfoRequest.getType());\n        String requestUrl = String.format(\"https://%s/v1.0/sites/%s.sharepoint.%s:/%s/%s\", host,\n                sharePointInfoRequest.getDomainPrefix(),\n                sharePointInfoRequest.getDomainType(),\n                sharePointInfoRequest.getSiteType(),\n                sharePointInfoRequest.getSiteName());\n        HttpRequest getRequest = HttpUtil.createGet(requestUrl);\n        HttpResponse execute = getRequest.addHeaders(authorizationHeaders).execute();\n        String body = execute.body();\n\n        // 解析数据\n        if (execute.getStatus() != HttpStatus.OK.value()) {\n            throw new BizException(body);\n        }\n        JSONObject jsonObject = JSONObject.parseObject(body);\n        return AjaxJson.getSuccessData(jsonObject.getString(\"id\"));\n    }\n\n\n    /**\n     * 根据类型获取 API 地址\n     *\n     * @param   type\n     *          网站类型：\n     *              Standard：标准版\n     *              China：世纪互联版\n     *\n     * @return  API 地址\n     */\n    private String getHostByType(String type) {\n        // 判断是标准版还是世纪互联版\n        if (Objects.equals(type, \"Standard\")) {\n            return \"graph.microsoft.com\";\n        } else if (Objects.equals(type, \"China\")) {\n            return \"microsoftgraph.chinacloudapi.cn\";\n        } else {\n            throw new BizException(ErrorCode.BIZ_UNSUPPORTED_STORAGE_TYPE);\n        }\n    }\n\n\n    /**\n     * 获取搜索网站请求 URL\n     *\n     * @param   type\n     *          网站类型：\n     *              Standard：标准版\n     *              China：世纪互联版\n     *\n     * @return  搜索网站请求 URL\n     */\n    private String getSearchSiteUrlByType(String type) {\n        String hostByType = getHostByType(type);\n        return String.format(\"https://%s/v1.0/sites\", hostByType);\n    }\n\n\n    /**\n     * 获取搜索网站请求 URL\n     *\n     * @param   type\n     *          网站类型：\n     *              Standard：标准版\n     *              China：世纪互联版\n     *\n     * @return  搜索网站请求 URL\n     */\n    private String getSiteListsUrlByType(String type, String siteId) {\n        String hostByType = getHostByType(type);\n        return String.format(\"https://%s/v1.0/sites/%s/lists\",hostByType, siteId);\n    }\n\n\n    /**\n     * 获取网站根目录请求 URL\n     *\n     * @param   type\n     *          网站类型：\n     *              Standard：标准版\n     *              China：世纪互联版\n     *\n     * @return  搜索网站请求 URL\n     */\n    private String getSiteRootUrlByType(String type) {\n        String hostByType = getHostByType(type);\n        return String.format(\"https://%s/v1.0/sites/root\", hostByType);\n    }\n\n\n    /**\n     * 获取 Bearer 格式的 Token\n     *\n     * @param   accessToken\n     *          访问令牌\n     *\n     * @return  Bearer 格式的 Token\n     */\n    private static String getBearer(String accessToken) {\n        return String.format(\"%s %s\", \"Bearer\", accessToken);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/controller/proxy/Open115UrlController.java",
    "content": "package im.zhaojun.zfile.module.storage.controller.proxy;\n\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.module.storage.context.StorageSourceContext;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;\nimport im.zhaojun.zfile.module.storage.service.impl.Open115ServiceImpl;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.Parameter;\nimport io.swagger.v3.oas.annotations.enums.ParameterIn;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport org.springframework.http.HttpHeaders;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.ResponseBody;\nimport org.springframework.web.bind.annotation.RestController;\n\n@RestController\npublic class Open115UrlController {\n\n    public static final String PROXY_DOWNLOAD_LINK_PREFIX = \"/open115/url/\";\n\n    @GetMapping(PROXY_DOWNLOAD_LINK_PREFIX + \"{storageId}/{pickCode}\")\n    @ResponseBody\n    @ApiOperationSupport(order = 1)\n    @Operation(summary = \"跳转 115 网盘实际下载地址\", description =\"根据 115 文件提取码跳转（302 重定向）到实际下载地址.\")\n    @Parameter(in = ParameterIn.PATH, name = \"pickCode\", description = \"文件提取码\", required = true, schema = @Schema(type = \"string\"))\n    public ResponseEntity<?> redirectTo115DownloadUrl(@PathVariable Integer storageId, @PathVariable String pickCode) {\n        AbstractBaseFileService<?> fileService = StorageSourceContext.getByStorageId(storageId);\n        if (fileService instanceof Open115ServiceImpl open115Service) {\n            String downloadUrlByPickCode = open115Service.getOpen115DownloadUrlByPickCode(pickCode);\n            return ResponseEntity.status(302)\n                    .header(HttpHeaders.CACHE_CONTROL, \"no-cache, no-store, must-revalidate, private\")\n                    .header(HttpHeaders.PRAGMA, \"no-cache\")\n                    .header(HttpHeaders.EXPIRES, \"0\")\n                    .header(HttpHeaders.LOCATION, downloadUrlByPickCode)\n                    .build();\n        } else {\n            throw new BizException(ErrorCode.BIZ_UNSUPPORTED_OPERATION_TYPE);\n        }\n    }\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/controller/proxy/ProxyDownloadController.java",
    "content": "package im.zhaojun.zfile.module.storage.controller.proxy;\n\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport com.github.xiaoymin.knife4j.annotations.ApiSort;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.ErrorPageBizException;\nimport im.zhaojun.zfile.core.util.ProxyDownloadUrlUtils;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.core.util.SpringMvcUtils;\nimport im.zhaojun.zfile.module.storage.context.StorageSourceContext;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractProxyTransferService;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.Parameter;\nimport io.swagger.v3.oas.annotations.Parameters;\nimport io.swagger.v3.oas.annotations.enums.ParameterIn;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport org.springframework.core.io.Resource;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.stereotype.Controller;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.RequestParam;\nimport org.springframework.web.bind.annotation.ResponseBody;\n\nimport java.beans.Beans;\n\n/**\n * 服务端代理下载 Controller\n *\n * @author zhaojun\n */\n@Tag(name = \"服务端代理下载\")\n@ApiSort(6)\n@Controller\npublic class ProxyDownloadController {\n\n    @GetMapping(\"/pd/{storageKey}/**\")\n    @ApiOperationSupport(order = 1)\n    @Operation(summary = \"下载本地存储源的文件\", description =\"因第三方存储源都有下载地址，本接口提供本地存储的下载地址的处理, 返回文件流进行下载.\")\n    @Parameters({\n            @Parameter(in = ParameterIn.PATH, name = \"storageKey\", description = \"存储源 key\", required = true, schema = @Schema(type = \"string\")),\n            @Parameter(in = ParameterIn.QUERY, name = \"type\", description = \"下载类型\", required = true, example = \"download\", schema = @Schema(type = \"string\")) // 下载类型: download(不论什么格式的文件都进行下载操作), default(使用浏览器默认处理，浏览器支持预览的格式，则进行预览，不支持的则进行下载)\n    })\n    @ResponseBody\n    public ResponseEntity<Resource> downAttachment(@PathVariable(\"storageKey\") String storageKey, String signature, @RequestParam(value = \"filename\", required = false) String filename) throws Exception {\n        // 获取下载文件路径\n        String filePath = SpringMvcUtils.getExtractPathWithinPattern();\n        filePath = filename != null ? filePath + StringUtils.SLASH + filename : filePath;\n\n        if (StringUtils.isNotEmpty(filename) && filename.contains(StringUtils.SLASH)) {\n            throw new ErrorPageBizException(ErrorCode.BIZ_INVALID_FILE_NAME);\n        }\n\n        AbstractBaseFileService<?> storageServiceByKey = StorageSourceContext.getByStorageKey(storageKey);\n\n        // 如果不是 ProxyTransferService, 则返回错误信息.\n        // todo 判断是否支持代理下载的方式应该是根据存储源设置\n        if (!Beans.isInstanceOf(storageServiceByKey, AbstractProxyTransferService.class)) {\n            throw new ErrorPageBizException(ErrorCode.BIZ_UNSUPPORTED_PROXY_DOWNLOAD);\n        }\n\n        // 进行上传.\n        AbstractProxyTransferService<?> proxyDownloadService = (AbstractProxyTransferService<?>) storageServiceByKey;\n\n        // 如果是私有空间才校验签名.\n        boolean privateStorage = proxyDownloadService.getParam().isProxyPrivate();\n        if (privateStorage) {\n            Integer storageId = proxyDownloadService.getStorageId();\n            boolean valid = ProxyDownloadUrlUtils.validSignatureExpired(storageId, filePath, signature);\n            if (!valid) {\n                throw new ErrorPageBizException(ErrorCode.BIZ_INVALID_SIGNATURE);\n            }\n        }\n\n        return proxyDownloadService.downloadToStream(filePath);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/controller/proxy/ProxyUploadController.java",
    "content": "package im.zhaojun.zfile.module.storage.controller.proxy;\n\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.core.util.SpringMvcUtils;\nimport im.zhaojun.zfile.module.storage.context.StorageSourceContext;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractProxyTransferService;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport org.springframework.web.bind.annotation.*;\nimport org.springframework.web.multipart.MultipartFile;\n\nimport java.beans.Beans;\n\n/**\n * 服务端代理上传 Controller\n *\n * @author zhaojun\n */\n@Tag(name = \"服务端代理上传\")\n@RestController\npublic class ProxyUploadController {\n\n\t@PutMapping(\"/file/upload/{storageKey}/**\")\n\t@ResponseBody\n\tpublic AjaxJson<?> upload(@RequestParam MultipartFile file, @PathVariable(\"storageKey\") String storageKey, @RequestParam(value = \"filename\", required = false) String filename) throws Exception {\n\t\tif (file == null) {\n\t\t\tthrow new BizException(ErrorCode.BIZ_UPLOAD_FILE_NOT_EMPTY);\n\t\t}\n\n\t\t// 获取上传路径\n\t\tString filePath = SpringMvcUtils.getExtractPathWithinPattern();\n\t\tfilePath = filename != null ? filePath + StringUtils.SLASH + filename : filePath;\n\n\t\tAbstractBaseFileService<?> storageServiceByKey = StorageSourceContext.getByStorageKey(storageKey);\n\n\t\t// 如果不是 ProxyTransferService, 则返回错误信息.\n\t\tif (storageServiceByKey == null || !Beans.isInstanceOf(storageServiceByKey, AbstractProxyTransferService.class)) {\n\t\t\treturn AjaxJson.getError(\"存储类型异常，不支持上传.\");\n\t\t}\n\n\t\t// 进行上传.\n\t\tAbstractProxyTransferService<?> proxyUploadService = (AbstractProxyTransferService<?>) storageServiceByKey;\n\t\tproxyUploadService.uploadFile(filePath, file.getInputStream(), file.getSize());\n\t\treturn AjaxJson.getSuccess();\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/convert/StorageSourceConvert.java",
    "content": "package im.zhaojun.zfile.module.storage.convert;\n\nimport im.zhaojun.zfile.module.readme.model.entity.ReadmeConfig;\nimport im.zhaojun.zfile.module.storage.model.request.base.SaveStorageSourceRequest;\nimport im.zhaojun.zfile.module.storage.model.result.StorageSourceAdminResult;\nimport im.zhaojun.zfile.module.storage.model.dto.StorageSourceAllParamDTO;\nimport im.zhaojun.zfile.module.storage.model.dto.StorageSourceDTO;\nimport im.zhaojun.zfile.module.storage.model.result.StorageSourceConfigResult;\nimport im.zhaojun.zfile.module.storage.model.result.StorageSourceResult;\nimport im.zhaojun.zfile.module.storage.model.entity.StorageSource;\nimport org.mapstruct.Mapper;\nimport org.mapstruct.Mapping;\nimport org.mapstruct.ReportingPolicy;\nimport org.springframework.stereotype.Component;\n\nimport java.util.List;\n\n/**\n * StorageSource 转换器\n *\n * @author zhaojun\n */\n@Component\n@Mapper(componentModel = \"spring\", unmappedTargetPolicy = ReportingPolicy.IGNORE)\npublic interface StorageSourceConvert {\n\n\n\t/**\n\t * 将 StorageSource 转换为 StorageSourceResult\n\t *\n\t * @param   list\n\t *          StorageSource 列表\n\t *\n\t * @return  StorageSourceResult 列表\n\t */\n\tList<StorageSourceResult> entityToResultList(List<StorageSource> list);\n\n\n\t/**\n\t * 将 StorageSource 转换为 StorageSourceConfigResult\n\t *\n\t * @param   storageSource\n\t *          StorageSource 实体\n\t *\n\t * @return  StorageSourceConfigResult 实体\n\t */\n\t@Mapping(source = \"readmeConfig.displayMode\", target = \"readmeDisplayMode\")\n\tStorageSourceConfigResult entityToConfigResult(StorageSource storageSource, ReadmeConfig readmeConfig);\n\n\n\t/**\n\t * 将 StorageSource 转换为 StorageSourceAdminResult\n\t *\n\t * @param   list\n\t *          StorageSource 列表\n\t *\n\t * @return  StorageSourceAdminResult 列表\n\t */\n\tList<StorageSourceAdminResult> entityToAdminResultList(List<StorageSource> list);\n\n\n\tStorageSourceDTO entityToDTO(StorageSource storageSource, StorageSourceAllParamDTO storageSourceAllParam);\n\t\n\t\n\t/**\n\t * 将 SaveStorageSourceRequest 转换为 StorageSource\n\t *\n\t * @param   saveStorageSourceRequest\n\t *          SaveStorageSourceRequest 实体\n\t *\n\t * @return  StorageSource 实体\n\t */\n\tStorageSource saveRequestToEntity(SaveStorageSourceRequest saveStorageSourceRequest);\n\t\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/enums/StorageParamItemAnnoEnum.java",
    "content": "package im.zhaojun.zfile.module.storage.enums;\n\n\npublic enum StorageParamItemAnnoEnum {\n\n    PRO,\n    ORDER,\n    KEY,\n    NAME,\n    TYPE,\n    OPTIONS,\n    OPTIONS_CLASS,\n    OPTION_ALLOW_CREATE,\n    REQUIRED,\n    DEFAULT_VALUE,\n    DESCRIPTION,\n    LINK,\n    LINK_NAME,\n    IGNORE_INPUT,\n    CONDITION,\n    HIDDEN\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/event/StorageSourceCopyEvent.java",
    "content": "package im.zhaojun.zfile.module.storage.event;\n\nimport lombok.Data;\n\n/**\n * @author zhaojun\n */\n@Data\npublic class StorageSourceCopyEvent {\n\n    private Integer fromId;\n\n    private Integer newId;\n\n    public StorageSourceCopyEvent(Integer fromId, Integer newId) {\n        this.fromId = fromId;\n        this.newId = newId;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/event/StorageSourceDeleteEvent.java",
    "content": "package im.zhaojun.zfile.module.storage.event;\n\nimport im.zhaojun.zfile.module.storage.model.entity.StorageSource;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport lombok.Data;\n\n/**\n * @author zhaojun\n */\n@Data\npublic class StorageSourceDeleteEvent {\n\n    private Integer id;\n\n    private String key;\n\n    private String name;\n\n    private StorageTypeEnum type;\n\n    public StorageSourceDeleteEvent(StorageSource storageSource) {\n        this.id = storageSource.getId();\n        this.key = storageSource.getKey();\n        this.name = storageSource.getName();\n        this.type = storageSource.getType();\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/function/AllowAdminFileOperatorTypeEnumDefaultValueFunc.java",
    "content": "package im.zhaojun.zfile.module.storage.function;\n\nimport im.zhaojun.zfile.module.storage.model.dto.FileOperatorTypeDefaultValueDTO;\n\nimport java.util.function.Function;\n\n/**\n * 默认允许管理员操作.\n *\n * @author zhaojun\n */\npublic class AllowAdminFileOperatorTypeEnumDefaultValueFunc implements Function<Integer, FileOperatorTypeDefaultValueDTO> {\n\t\n\t/**\n\t * 默认允许管理员操作\n\t *\n\t * @param \tstorageId\n\t * \t\t\t存储源 ID\n\t *\n\t * @return\t文件操作类型默认值\n\t */\n\t@Override\n\tpublic FileOperatorTypeDefaultValueDTO apply(Integer storageId) {\n\t\treturn new FileOperatorTypeDefaultValueDTO(true, false);\n\t}\n\t\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/function/AllowAllFileOperatorTypeEnumDefaultValueFunc.java",
    "content": "package im.zhaojun.zfile.module.storage.function;\n\nimport im.zhaojun.zfile.module.storage.model.dto.FileOperatorTypeDefaultValueDTO;\n\nimport java.util.function.Function;\n\n/**\n * 默认允许所有操作.\n *\n * @author zhaojun\n */\npublic class AllowAllFileOperatorTypeEnumDefaultValueFunc implements Function<Integer, FileOperatorTypeDefaultValueDTO> {\n\t\n\t/**\n\t * 默认允许所有操作.\n\t *\n\t * @param \tstorageId\n\t * \t\t\t存储源 ID\n\t *\n\t * @return\t文件操作类型默认值\n\t */\n\t@Override\n\tpublic FileOperatorTypeDefaultValueDTO apply(Integer storageId) {\n\t\treturn new FileOperatorTypeDefaultValueDTO(true, true);\n\t}\n\t\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/function/BasicFileOperatorTypeEnumDefaultValueFunc.java",
    "content": "package im.zhaojun.zfile.module.storage.function;\n\nimport org.apache.commons.lang3.BooleanUtils;\nimport cn.hutool.extra.spring.SpringUtil;\nimport im.zhaojun.zfile.module.storage.model.dto.FileOperatorTypeDefaultValueDTO;\nimport im.zhaojun.zfile.module.storage.model.entity.StorageSource;\nimport im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;\nimport im.zhaojun.zfile.module.storage.service.StorageSourceService;\n\nimport java.util.function.Function;\n\n/**\n * 根据存储源是否允许文件操作来获取权限默认值.\n *\n * @see FileOperatorTypeEnum#NEW_FOLDER\n * @see FileOperatorTypeEnum#DELETE\n * ...\n *\n * @author zhaojun\n */\npublic class BasicFileOperatorTypeEnumDefaultValueFunc implements Function<Integer, FileOperatorTypeDefaultValueDTO> {\n\t\n\tprivate static StorageSourceService storageSourceService;\n\t\n\t/**\n\t * 取存储源是否允许文件操作和是否允许文件匿名操作.\n\t * \t\t如果允许文件操作, 则管理员有权限\n\t * \t\t如果允许文件操作, 且允许文件匿名操作, 则匿名用户有权限.\n\t *\n\t * @param \tstorageId\n\t * \t\t\t存储源 ID\n\t *\n\t * @return\t文件操作类型默认值\n\t */\n\t@Override\n\tpublic FileOperatorTypeDefaultValueDTO apply(Integer storageId) {\n\t\tif (storageSourceService == null) {\n\t\t\tstorageSourceService = SpringUtil.getBean(StorageSourceService.class);\n\t\t}\n\t\tStorageSource storageSource = storageSourceService.findById(storageId);\n\t\tBoolean enableFileOperator = BooleanUtils.isTrue(storageSource.getEnableFileOperator());\n\t\tBoolean enableFileAnnoOperator = BooleanUtils.isTrue(storageSource.getEnableFileAnnoOperator());\n\t\t\n\t\tboolean allowAdmin = enableFileOperator;\n\t\tboolean allowAnonymous = enableFileOperator && enableFileAnnoOperator;\n\t\treturn new FileOperatorTypeDefaultValueDTO(allowAdmin, allowAnonymous);\n\t}\n\t\n\t\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/function/DisableAllFileOperatorTypeEnumDefaultValueFunc.java",
    "content": "package im.zhaojun.zfile.module.storage.function;\n\nimport im.zhaojun.zfile.module.storage.model.dto.FileOperatorTypeDefaultValueDTO;\n\nimport java.util.function.Function;\n\n/**\n * 禁用所有操作.\n *\n * @author zhaojun\n */\npublic class DisableAllFileOperatorTypeEnumDefaultValueFunc implements Function<Integer, FileOperatorTypeDefaultValueDTO> {\n\t\n\t/**\n\t * 禁用所有操作.\n\t *\n\t * @param \tstorageId\n\t * \t\t\t存储源 ID\n\t *\n\t * @return\t文件操作类型默认值\n\t */\n\t@Override\n\tpublic FileOperatorTypeDefaultValueDTO apply(Integer storageId) {\n\t\treturn new FileOperatorTypeDefaultValueDTO(false, false);\n\t}\n\t\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/function/LinkFileOperatorTypeEnumDefaultValueFunc.java",
    "content": "package im.zhaojun.zfile.module.storage.function;\n\nimport cn.hutool.extra.spring.SpringUtil;\nimport im.zhaojun.zfile.module.storage.model.dto.FileOperatorTypeDefaultValueDTO;\nimport im.zhaojun.zfile.module.config.service.SystemConfigService;\nimport im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;\n\nimport java.util.function.Function;\n\n/**\n * 根据全局站点设置是否允许使用直链控制权限.\n *\n * @author zhaojun\n */\npublic class LinkFileOperatorTypeEnumDefaultValueFunc implements Function<Integer, FileOperatorTypeDefaultValueDTO> {\n\t\n\tprivate static SystemConfigService systemConfigService;\n\t\n\t/**\n\t * 根据全局站点设置是否允许使用直链控制权限.\n\t *\n\t * @param \tstorageId\n\t * \t\t\t存储源 ID\n\t *\n\t * @return\t文件操作类型默认值\n\t */\n\t@Override\n\tpublic FileOperatorTypeDefaultValueDTO apply(Integer storageId) {\n\t\tif (systemConfigService == null) {\n\t\t\tsystemConfigService = SpringUtil.getBean(SystemConfigService.class);\n\t\t}\n\t\t\n\t\tSystemConfigDTO systemConfig = systemConfigService.getSystemConfig();\n\t\t\n\t\tBoolean showPathLink = systemConfig.getShowPathLink();\n\t\treturn new FileOperatorTypeDefaultValueDTO(showPathLink, showPathLink);\n\t}\n\t\n\t\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/function/SearchFileOperatorTypeEnumDefaultValueFunc.java",
    "content": "package im.zhaojun.zfile.module.storage.function;\n\nimport cn.hutool.extra.spring.SpringUtil;\nimport im.zhaojun.zfile.module.storage.model.dto.FileOperatorTypeDefaultValueDTO;\nimport im.zhaojun.zfile.module.storage.model.entity.StorageSource;\nimport im.zhaojun.zfile.module.storage.service.StorageSourceService;\n\nimport java.util.function.Function;\n\n/**\n * @author zhaojun\n */\npublic class SearchFileOperatorTypeEnumDefaultValueFunc implements Function<Integer, FileOperatorTypeDefaultValueDTO> {\n\t\n\tprivate StorageSourceService storageSourceService;\n\t\n\t@Override\n\tpublic FileOperatorTypeDefaultValueDTO apply(Integer storageId) {\n\t\tif (storageSourceService == null) {\n\t\t\tstorageSourceService = SpringUtil.getBean(StorageSourceService.class);\n\t\t}\n\t\t\n\t\tStorageSource storageSource = storageSourceService.findById(storageId);\n\t\treturn new FileOperatorTypeDefaultValueDTO(storageSource.getSearchEnable(), false);\n\t}\n\t\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/function/ShortLinkFileOperatorTypeEnumDefaultValueFunc.java",
    "content": "package im.zhaojun.zfile.module.storage.function;\n\nimport cn.hutool.extra.spring.SpringUtil;\nimport im.zhaojun.zfile.module.storage.model.dto.FileOperatorTypeDefaultValueDTO;\nimport im.zhaojun.zfile.module.config.service.SystemConfigService;\nimport im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;\n\nimport java.util.function.Function;\n\n/**\n * 根据全局站点设置是否允许使用短链控制权限.\n *\n * @author zhaojun\n */\npublic class ShortLinkFileOperatorTypeEnumDefaultValueFunc implements Function<Integer, FileOperatorTypeDefaultValueDTO> {\n\t\n\tprivate static SystemConfigService systemConfigService;\n\t\n\t/**\n\t * 根据全局站点设置是否允许使用短链控制权限.\n\t *\n\t * @param \tstorageId\n\t * \t\t\t存储源 ID\n\t *\n\t * @return\t文件操作类型默认值\n\t */\n\t@Override\n\tpublic FileOperatorTypeDefaultValueDTO apply(Integer storageId) {\n\t\tif (systemConfigService == null) {\n\t\t\tsystemConfigService = SpringUtil.getBean(SystemConfigService.class);\n\t\t}\n\t\t\n\t\tSystemConfigDTO systemConfig = systemConfigService.getSystemConfig();\n\t\t\n\t\tBoolean showPathLink = systemConfig.getShowPathLink();\n\t\treturn new FileOperatorTypeDefaultValueDTO(showPathLink, showPathLink);\n\t}\n\t\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/mapper/StorageSourceConfigMapper.java",
    "content": "package im.zhaojun.zfile.module.storage.mapper;\n\nimport com.baomidou.mybatisplus.core.mapper.BaseMapper;\nimport im.zhaojun.zfile.module.storage.model.entity.StorageSourceConfig;\nimport org.apache.ibatis.annotations.Mapper;\nimport org.apache.ibatis.annotations.Param;\n\nimport java.util.List;\n\n\n/**\n * 存储源拓展设置 Mapper 接口\n *\n * @author zhaojun\n */\n@Mapper\npublic interface StorageSourceConfigMapper extends BaseMapper<StorageSourceConfig> {\n\n    /**\n     * 根据存储源 ID 查询存储源拓展配置, 并按照存储源 id 排序\n     *\n     * @param   storageId\n     *          存储源 ID\n     *\n     * @return  存储源拓展配置列表\n     */\n    List<StorageSourceConfig> findByStorageIdOrderById(@Param(\"storageId\") Integer storageId);\n    \n\n    /**\n     * 根据存储源 ID 删除存储源拓展配置\n     *\n     * @param   storageId\n     *          存储源 ID\n     *\n     * @return  删除记录数\n     */\n    int deleteByStorageId(@Param(\"storageId\") Integer storageId);\n\n\n    /**\n     * 批量插入存储源拓展配置\n     *\n     * @param   list\n     *          存储源拓展配置列表\n     *\n     * @return  插入记录数\n     */\n    int insertList(@Param(\"list\") List<StorageSourceConfig> list);\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/mapper/StorageSourceMapper.java",
    "content": "package im.zhaojun.zfile.module.storage.mapper;\n\nimport com.baomidou.mybatisplus.core.mapper.BaseMapper;\nimport im.zhaojun.zfile.module.storage.model.entity.StorageSource;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport org.apache.ibatis.annotations.Mapper;\nimport org.apache.ibatis.annotations.Param;\n\nimport java.util.List;\n\n/**\n * 存储源基本配置 Mapper 接口\n *\n * @author zhaojun\n */\n@Mapper\npublic interface StorageSourceMapper extends BaseMapper<StorageSource> {\n\n    /**\n     * 获取所有已启用的存储源, 并按照存储源排序值排序\n     *\n     * @return  存储源列表\n     */\n    List<StorageSource> findUserEnableList(Integer userId);\n\n\n    /**\n     * 获取所有存储源, 并按照存储源排序值排序\n     *\n     * @return  存储源列表\n     */\n    List<StorageSource> findAllOrderByOrderNum();\n\n\n    /**\n     * 获取存储源 ID 最大值\n     *\n     * @return 存储源 ID 最大值\n     */\n    Integer selectMaxId();\n\n\n    /**\n     * 根据存储源类型获取存储源列表\n     *\n     * @param   type\n     *          存储源类型\n     *\n     * @return  存储源列表\n     */\n    List<StorageSource> findByType(@Param(\"type\") StorageTypeEnum type);\n\n\n    /**\n     * 根据存储源 ID 设置排序值\n     *\n     * @param   orderNum\n     *          排序值\n     *\n     * @param   id\n     *          存储源 ID\n     */\n    void updateSetOrderNumById(@Param(\"orderNum\") int orderNum, @Param(\"id\") Integer id);\n\n\n    /**\n     * 根据存储源 key 获取存储源\n     *\n     * @param   storageKey\n     *          存储源 key\n     *\n     * @return  存储源信息\n     */\n\tStorageSource findByStorageKey(@Param(\"storageKey\") String storageKey);\n\n\n    /**\n     * 根据存储源 key 获取存储源 id\n     *\n     * @param   storageKey\n     *          存储源 key\n     *\n     * @return  存储源 id\n     */\n    Integer findIdByStorageKey(@Param(\"storageKey\") String storageKey);\n\n\n    /**\n     * 根据存储源 id 获取存储源 key\n     *\n     * @param   id\n     *          存储源 id\n     *\n     * @return  存储源 key\n     */\n    String findKeyById(@Param(\"id\")Integer id);\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/bo/AuthModel.java",
    "content": "\npackage im.zhaojun.zfile.module.storage.model.bo;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\n\n/**\n * 又拍云上传认证信息 model\n *\n * @author zhaojun\n */\n@Data\n@AllArgsConstructor\npublic class AuthModel {\n\n    /**\n     * 上传 url\n     */\n    private String url;\n\n    /**\n     * 上传签名\n     */\n    private String signature;\n\n    /**\n     * 上传策略 base64\n     */\n    private String policy;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/bo/RefreshTokenCacheBO.java",
    "content": "package im.zhaojun.zfile.module.storage.model.bo;\n\nimport cn.hutool.cache.Cache;\nimport cn.hutool.cache.CacheUtil;\nimport im.zhaojun.zfile.module.storage.model.dto.RefreshTokenInfoDTO;\nimport lombok.Data;\nimport lombok.ToString;\n\nimport java.util.Date;\n\n/**\n * 用于存储刷新 Token 信息的缓存\n *\n * @author zhaojun\n */\n@ToString\npublic class RefreshTokenCacheBO {\n\n\tprivate static final Cache<Integer, RefreshTokenInfo> REFRESH_TOKEN_INFO_CACHE = CacheUtil.newFIFOCache(1024);\n\n\tpublic static void putRefreshTokenInfo(Integer storageId, RefreshTokenInfo refreshTokenInfo) {\n\t\trefreshTokenInfo.setStorageId(storageId);\n\t\tREFRESH_TOKEN_INFO_CACHE.put(storageId, refreshTokenInfo);\n\t}\n\n\tpublic static RefreshTokenInfo getRefreshTokenInfo(Integer storageId) {\n\t\treturn REFRESH_TOKEN_INFO_CACHE.get(storageId);\n\t}\n\n\t@Data\n\tpublic static class RefreshTokenInfo {\n\n\t\tprivate Integer storageId;\n\n\t\tprivate boolean success;\n\n\t\tprivate Date lastRefreshTime;\n\n\t\tprivate String msg;\n\n\t\tprivate RefreshTokenInfoDTO data;\n\n\t\tpublic static RefreshTokenInfo success(RefreshTokenInfoDTO data) {\n\t\t\tRefreshTokenInfo info = new RefreshTokenInfo();\n\t\t\tinfo.setSuccess(true);\n\t\t\tinfo.setLastRefreshTime(new Date());\n\t\t\tinfo.setData(data);\n\t\t\treturn info;\n\t\t}\n\n\t\tpublic static RefreshTokenInfo fail(String msg) {\n\t\t\tRefreshTokenInfo info = new RefreshTokenInfo();\n\t\t\tinfo.setSuccess(false);\n\t\t\tinfo.setMsg(msg);\n\t\t\tinfo.setLastRefreshTime(new Date());\n\t\t\treturn info;\n\t\t}\n\n\t\tpublic boolean isExpired() {\n\t\t\tif (!success) {\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tif (lastRefreshTime == null) {\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tif (data == null || data.getExpiredAt() == null) {\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tlong expireTime = data.getExpiredAt() * 1000L;\n\t\t\tlong currentTime = System.currentTimeMillis();\n\t\t\tlong timeDiff = expireTime - currentTime;\n\t\t\treturn timeDiff < 5 * 60 * 1000L;\n\t\t}\n\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/bo/StorageSourceMetadata.java",
    "content": "package im.zhaojun.zfile.module.storage.model.bo;\n\nimport lombok.Data;\n\n/**\n * 存储源的元数据配置，用于指示页面功能，下面给的默认值是常用默认值，所以仅需配置与默认不同的参数。\n */\n@Data\npublic class StorageSourceMetadata {\n\n    /**\n     * 上传使用的类型\n     */\n    private UploadType uploadType;\n\n    /**\n     * 是否支持重命名文件夹\n     */\n    private boolean supportRenameFolder = true;\n\n    /**\n     * 是否支持移动文件夹\n     */\n    private boolean supportMoveFolder = true;\n\n    /**\n     * 是否支持复制文件夹\n     */\n    private boolean supportCopyFolder = true;\n\n    /**\n     * 是否支持删除非空文件夹\n     */\n    private boolean supportDeleteNotEmptyFolder = true;\n\n    /**\n     * 是否需要在上传文件前创建文件夹\n     */\n    private boolean needCreateFolderBeforeUpload = true;\n\n    public enum UploadType {\n\n        /**\n         * 微软系上传，onedrive, sharepoint，包含国际版、国内版\n         */\n        MICROSOFT,\n\n        /**\n         * 使用 ZFile 服务端中转传输\n         */\n        PROXY,\n\n        /**\n         * 亚马逊 S3 上传\n         */\n        S3,\n\n        /**\n         * 又拍云上传\n         */\n        UPYUN\n\n\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/bo/StorageSourceParamDef.java",
    "content": "package im.zhaojun.zfile.module.storage.model.bo;\n\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamSelectOption;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageParamTypeEnum;\nimport lombok.Data;\nimport lombok.Getter;\n\nimport java.util.List;\n\n/**\n * 存储源参数定义, 包含参数名称、描述、必填、默认值等信息.\n *\n * @author zhaojun\n */\n@Data\npublic class StorageSourceParamDef {\n\n\t/**\n\t * 字段显示排序值, 值越小, 越靠前.\n\t */\n\tprivate int order;\n\n\t/**\n\t * 参数 key\n\t */\n\tprivate String key;\n\n\t/**\n\t * 参数名称\n\t */\n\tprivate String name;\n\n\t/**\n\t * 参数描述\n\t */\n\tprivate String description;\n\n\t/**\n\t * 是否必填\n\t */\n\tprivate boolean required;\n\n\t/**\n\t * 默认值\n\t */\n\tprivate String defaultValue;\n\n\t/**\n\t * 链接地址\n\t */\n\tprivate String link;\n\n\t/**\n\t * 链接名称\n\t */\n\tprivate String linkName;\n\n\t/**\n\t * 是否是 pro 功能\n\t */\n\tprivate boolean pro;\n\n\t/**\n\t * 字段类型, 默认为 input, 可选值为: input, select, switch.\n\t */\n\tprivate StorageParamTypeEnum type;\n\n\t/**\n\t * 当 {@link #type} 为 select 时, 选项的值.\n\t */\n\tprivate List<Options> options;\n\n\t/**\n\t * 当 {@link #type} 为 select 时, 是否允许用户创建选项.\n\t */\n\tprivate boolean optionAllowCreate;\n\n\t/**\n\t * 判断条件表达式，表达式结果为 true 时才显示该字段\n\t */\n\tprivate String condition;\n\n\t/**\n\t * 是否隐藏该字段, 默认为 false.\n\t */\n\tprivate boolean hidden;\n\n\t@Getter\n\tpublic static class Options {\n\n\t\tprivate final String label;\n\n\t\tprivate final String value;\n\n\t\tpublic Options(String value) {\n\t\t\tthis.label = value;\n\t\t\tthis.value = value;\n\t\t}\n\n\t\tpublic Options(String label, String value) {\n\t\t\tthis.label = label;\n\t\t\tthis.value = value;\n\t\t}\n\t\tpublic Options(StorageParamSelectOption storageParamSelectOption) {\n\t\t\tthis.value = storageParamSelectOption.value();\n\t\t\tthis.label = StringUtils.firstNonNull(storageParamSelectOption.label(), storageParamSelectOption.value());\n\t\t}\n\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/bo/UploadSignParam.java",
    "content": "package im.zhaojun.zfile.module.storage.model.bo;\n\nimport lombok.Data;\n\n/**\n * 又拍云上传参数 model\n *\n * @author zhaojun\n */\n@Data\npublic class UploadSignParam {\n\n\t/**\n\t * 上传路径\n\t */\n\tprivate String path;\n\n\t/**\n\t * 文件大小\n\t */\n\tprivate Long size;\n\n\t/**\n\t * 文件名\n\t */\n\tprivate String name;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/dto/FileOperatorTypeDefaultValueDTO.java",
    "content": "package im.zhaojun.zfile.module.storage.model.dto;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\n\n/**\n * 文件操作类型默认结果\n *\n * @author zhaojun\n */\n@Data\n@AllArgsConstructor\npublic class FileOperatorTypeDefaultValueDTO {\n\t\n\tprivate boolean allowAdmin;\n\t\n\tprivate boolean allowAnonymous;\n\t\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/dto/OAuth2TokenDTO.java",
    "content": "package im.zhaojun.zfile.module.storage.model.dto;\n\nimport lombok.Data;\nimport lombok.ToString;\n\n/**\n * OneDrive Token DTO\n *\n * @author zhaojun\n */\n@ToString\n@Data\npublic class OAuth2TokenDTO {\n    \n    private String clientId;\n    \n    private String clientSecret;\n    \n    private String redirectUri;\n    \n    private String accessToken;\n    \n    private String refreshToken;\n    \n    private boolean success;\n    \n    private String body;\n\n    /**\n     * 令牌到期时间，时间戳，单位毫秒\n     */\n    private Integer expiredAt;\n    \n    public static OAuth2TokenDTO success(String clientId, String clientSecret, String redirectUri, String accessToken, String refreshToken, String body, Integer expiredAt) {\n        OAuth2TokenDTO token = new OAuth2TokenDTO();\n        token.setClientId(clientId);\n        token.setClientSecret(clientSecret);\n        token.setRedirectUri(redirectUri);\n        token.setSuccess(true);\n        token.setBody(body);\n        token.setAccessToken(accessToken);\n        token.setRefreshToken(refreshToken);\n        token.setExpiredAt(expiredAt);\n        return token;\n    }\n    \n    public static OAuth2TokenDTO fail(String clientId, String clientSecret, String redirectUri, String body) {\n        OAuth2TokenDTO token = new OAuth2TokenDTO();\n        token.setClientId(clientId);\n        token.setClientSecret(clientSecret);\n        token.setRedirectUri(redirectUri);\n        token.setSuccess(false);\n        token.setBody(body);\n        return token;\n    }\n    \n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/dto/RefreshTokenInfoDTO.java",
    "content": "package im.zhaojun.zfile.module.storage.model.dto;\n\nimport lombok.Data;\n\nimport java.util.Date;\n\n@Data\npublic class RefreshTokenInfoDTO {\n\n    /**\n     * 访问令牌，用于访问受保护的资源\n     */\n    private String accessToken;\n\n    /**\n     * 刷新令牌，用于获取新的访问令牌\n     */\n    private String refreshToken;\n\n    /**\n     * 会话令牌，通常用于 AWS 等云存储服务的临时凭证\n     */\n    private String sessionToken;\n\n    /**\n     * 过期时间戳(单位: 秒)\n     */\n    private Integer expiredAt;\n\n    public Date getExpiredAtDate() {\n        if (expiredAt == null) {\n            return null;\n        }\n        // 如果 expiredAt 是 10 位时间戳(秒)\n        if (expiredAt > 1_000_000_000) {\n            return new Date(expiredAt * 1000L);\n        } else {\n            // 否则认为 expiredAt 是过期时间(单位: 秒)\n            return new Date((expiredAt + System.currentTimeMillis() / 1000) * 1000L);\n        }\n    }\n\n    public static RefreshTokenInfoDTO success(String accessToken, String refreshToken, String sessionToken, Integer expiredAt) {\n        RefreshTokenInfoDTO token = new RefreshTokenInfoDTO();\n        token.setAccessToken(accessToken);\n        token.setRefreshToken(refreshToken);\n        token.setSessionToken(sessionToken);\n\n        if (expiredAt != null) {\n            // 如果 expiredAt 是 10 位时间戳(秒)\n            if (expiredAt > 1_000_000_000) {\n                token.setExpiredAt(expiredAt);\n            } else {\n                // 否则认为 expiredAt 是过期时间(单位: 秒)\n                token.setExpiredAt(expiredAt + (int) (System.currentTimeMillis() / 1000));\n            }\n        }\n        return token;\n    }\n\n    public static RefreshTokenInfoDTO success(String accessToken, String refreshToken, Integer expiredAt) {\n        return success(accessToken, refreshToken, null, expiredAt);\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/dto/StorageSourceAllParamDTO.java",
    "content": "package im.zhaojun.zfile.module.storage.model.dto;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport im.zhaojun.zfile.core.config.jackson.JSONStringDeserializer;\nimport im.zhaojun.zfile.core.config.jackson.JSONStringSerializer;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport java.io.Serializable;\n\n/**\n * 所有存储源的全部参数\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"存储源所有拓展参数\")\npublic class StorageSourceAllParamDTO implements Serializable {\n\n    private static final long serialVersionUID = 1L;\n\n    @Schema(title = \"Endpoint 接入点\", example = \"oss-cn-beijing.aliyuncs.com\")\n    private String endPoint;\n\n    @Schema(title = \"Endpoint 接入点协议\", example = \"http\")\n    private String endPointScheme;\n\n    @Schema(title = \"路径风格\", example = \"path-style\")\n    private String pathStyle;\n\n    @Schema(title = \"是否是私有空间\", example = \"true\")\n    private Boolean isPrivate;\n\n    @Schema(title = \"代理下载生成签名链接\", example = \"true\")\n    private boolean proxyPrivate;\n\n    @Schema(title = \"accessKey\", example = \"LTAI4FjfXqXxQZQZ\")\n    private String accessKey;\n\n    @Schema(title = \"secretKey\", example = \"QJIO19ASJIKL10ZL\")\n    private String secretKey;\n\n    @Schema(title = \"bucket 名称\", example = \"zfile-test\")\n    private String bucketName;\n\n    @Schema(title = \"原 bucket 名称\", example = \"zfile-test\")\n    private String originBucketName;\n\n    @Schema(title = \"域名或 IP\", example = \"127.0.0.1\")\n    private String host;\n\n    @Schema(title = \"端口\", example = \"8080\")\n    private String port;\n\n    @Schema(title = \"访问令牌\", example = \"2.a6b7dbd428f731035f771b8d15063f61.86400.12929220\")\n    private String accessToken;\n\n    @Schema(title = \"刷新令牌\", example = \"15063f61.86400.1292922000-2346678-1243281asd-1asa\")\n    private String refreshToken;\n\n    @Schema(title = \"刷新令牌到期时间(秒)\", example = \"1752994685\")\n    private Integer refreshTokenExpiredAt;\n\n    @Schema(title = \"接口请求频率限制\", example = \"1.5\")\n    private Double qps;\n\n    @Schema(title = \"secretId\", example = \"LTAI4FjfXqXxQZQZ\")\n    private String secretId;\n\n    @Schema(title = \"文件路径\", example = \"/root/\")\n    private String filePath;\n\n    @Schema(title = \"用户名\", example = \"admin\")\n    private String username;\n\n    @Schema(title = \"密码\", example = \"123456\")\n    private String password;\n\n    @Schema(title = \"密钥\", example = \"-----BEGIN OPENSSH PRIVATE KEY-----\\nxxxx\\n-----END OPENSSH PRIVATE KEY-----\")\n    private String privateKey;\n\n    @Schema(title = \"密钥 passphrase\", example = \"123456\")\n    private String passphrase;\n\n    @Schema(title = \"域名\", example = \"http://zfile-test.oss-cn-beijing.aliyuncs.com\")\n    private String domain;\n\n    @Schema(title = \"基路径\", example = \"/root/\")\n    private String basePath;\n\n    @Schema(title = \"token\", example = \"12e34awsde12\")\n    private String token;\n\n    @Schema(title = \"token 有效期\", example = \"1800\")\n    private Integer tokenTime;\n\n    @Schema(title = \"token 有效期\", example = \"1800\")\n    private Integer proxyTokenTime;\n\n    @Schema(title = \"siteId\", example = \"ltzx124yu54z\")\n    private String siteId;\n\n    @Schema(title = \"listId\", example = \"nbmyuoya12sz\")\n    private String listId;\n\n    @Schema(title = \"站点名称\", example = \"test\")\n    private String siteName;\n\n    @Schema(title = \"站点类型\", example = \"sites\")\n    private String siteType;\n\n    @Schema(title = \"下载反代域名\", example = \"http://zfile-oroxy.zfile.vip\")\n    private String proxyDomain;\n\n    @Schema(title = \"下载链接类型\", example = \"basic\")\n    private String downloadLinkType;\n\n    @Schema(title = \"clientId\", example = \"4a72d927-1917-418d-9eb2-1b365c53c1c5\")\n    private String clientId;\n\n    @Schema(title = \"clientSecret\", example = \"l:zI-_yrW75lV8M61K@z.I2K@B/On6Q1a\")\n    private String clientSecret;\n\n    @Schema(title = \"回调地址\", example = \"https://zfile.jun6.net/onedrive/callback\")\n    private String redirectUri;\n\n    @Schema(title = \"区域\", example = \"cn-beijing\")\n    private String region;\n\n    @Schema(title = \"url\", example = \"url 链接\")\n    private String url;\n\n    @Schema(title = \"编码格式\", example = \"UTF-8\")\n    private String encoding;\n\n    @Schema(title = \"存储源 ID\", example = \"0AGrY0xF1D7PEUk9PV2\")\n    private String driveId;\n\n    @Schema(title = \"启用代理上传\", example = \"true\")\n    private boolean enableProxyUpload;\n\n    @Schema(title = \"启用代理下载\", example = \"true\")\n    private boolean enableProxyDownload;\n\n    @Schema(title = \"下载重定向模式\", example = \"true\")\n    private boolean redirectMode;\n\n    @Schema(title = \"FTP 模式\", example = \"passive\")\n    private String ftpMode;\n\n    @Schema(title = \"代理上传超时时间(秒)\", example = \"300\")\n    private Integer proxyUploadTimeoutSecond;\n\n    @Schema(title = \"最大连接数\", example = \"8\")\n    private Integer maxConnections;\n\n    @Schema(title = \"下载链接强制下载\", example = \"true\")\n    private boolean proxyLinkForceDownload;\n\n    @Schema(title = \"S3 跨域配置\", example = \"[]\")\n    @JsonSerialize(using = JSONStringSerializer.class)\n    @JsonDeserialize(using = JSONStringDeserializer.class)\n    private String corsConfigList;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/dto/StorageSourceDTO.java",
    "content": "package im.zhaojun.zfile.module.storage.model.dto;\n\nimport com.baomidou.mybatisplus.annotation.TableField;\nimport im.zhaojun.zfile.module.storage.model.enums.SearchModeEnum;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport java.io.Serializable;\n\n/**\n * @author zhaojun\n */\n@Data\n@Schema(description = \"存储源基本参数\")\npublic class StorageSourceDTO implements Serializable {\n\n    private static final long serialVersionUID = 1L;\n\n    @Schema(title = \"ID, 新增无需填写\", example = \"1\")\n    private Integer id;\n\n    @Schema(title = \"存储源名称\", example = \"阿里云 OSS 存储\")\n    private String name;\n\n    @Schema(title = \"存储源别名\", example = \"存储源别名，用于 URL 中展示, 如 http://ip:port/{存储源别名}\")\n    private String key;\n\n    @Schema(title = \"存储源备注\", example = \"这是一个备注信息, 用于管理员区分不同的存储源, 此字段仅管理员可见\")\n    private String remark;\n\n    @Schema(title = \"存储源类型\", example = \"ftp\")\n    private StorageTypeEnum type;\n\n    @Schema(title = \"是否启用\", example = \"true\")\n    private boolean enable;\n\n    @Schema(title = \"是否启用文件操作功能\", example = \"true\", description =\"是否启用文件上传，编辑，删除等操作.\")\n    private Boolean enableFileOperator;\n\n    @Schema(title = \"是否允许匿名进行文件操作\", example = \"true\", description =\"是否允许匿名进行文件上传，编辑，删除等操作.\")\n    private Boolean enableFileAnnoOperator;\n\n    @Schema(title = \"是否开启缓存\", example = \"true\")\n    private boolean enableCache;\n\n    @Schema(title = \"是否开启缓存自动刷新\", example = \"true\")\n    private boolean autoRefreshCache;\n\n    @Schema(title = \"是否开启搜索\", example = \"true\")\n    private boolean searchEnable;\n\n    @Schema(title = \"搜索是否忽略大小写\", example = \"true\")\n    private boolean searchIgnoreCase;\n\n    @TableField(value = \"`search_mode`\")\n    @Schema(title = \"搜索模式\", example = \"SEARCH_CACHE\", description =\"仅从缓存中搜索或直接全量搜索\")\n    private SearchModeEnum searchMode;\n\n    @Schema(title = \"排序值\", example = \"1\")\n    private Integer orderNum;\n\n    @Schema(title = \"存储源拓展属性\")\n    private StorageSourceAllParamDTO storageSourceAllParam;\n\n    @Schema(title = \"是否默认开启图片模式\", example = \"true\")\n    private boolean defaultSwitchToImgMode;\n    \n    @Schema(title = \"兼容 readme 模式\", example = \"true\", description =\"兼容模式, 目录文档读取 readme.md 文件\")\n    private Boolean compatibilityReadme;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/dto/StorageSourceInitDTO.java",
    "content": "package im.zhaojun.zfile.module.storage.model.dto;\n\nimport im.zhaojun.zfile.module.storage.model.entity.StorageSource;\nimport im.zhaojun.zfile.module.storage.model.entity.StorageSourceConfig;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport java.util.List;\n\n@Data\npublic class StorageSourceInitDTO {\n\n    @Schema(title = \"ID, 新增无需填写\", example = \"1\")\n    private Integer id;\n\n    @Schema(title = \"存储源名称\", example = \"阿里云 OSS 存储\")\n    private String name;\n\n    @Schema(title = \"存储源别名\", example = \"存储源别名，用于 URL 中展示, 如 http://ip:port/{存储源别名}\")\n    private String key;\n\n    @Schema(title = \"存储源类型\", example = \"ftp\")\n    private StorageTypeEnum type;\n\n    @Schema(title = \"存储源参数\")\n    List<StorageSourceConfig> storageSourceConfigList;\n\n    public static StorageSourceInitDTO convert(StorageSource storageSource, List<StorageSourceConfig> storageSourceConfigList) {\n        StorageSourceInitDTO storageSourceInitDTO = new StorageSourceInitDTO();\n        storageSourceInitDTO.setId(storageSource.getId());\n        storageSourceInitDTO.setType(storageSource.getType());\n        storageSourceInitDTO.setName(storageSource.getName());\n        storageSourceInitDTO.setKey(storageSource.getKey());\n        storageSourceInitDTO.setStorageSourceConfigList(storageSourceConfigList);\n        return storageSourceInitDTO;\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/dto/ZFileCORSRule.java",
    "content": "package im.zhaojun.zfile.module.storage.model.dto;\n\nimport cn.hutool.core.collection.CollUtil;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport lombok.Data;\nimport software.amazon.awssdk.services.s3.model.CORSRule;\n\nimport java.io.Serializable;\nimport java.util.*;\n\n@Data\npublic class ZFileCORSRule implements Serializable {\n\n    private String id;\n\n    private List<String> allowedMethods;\n\n    private List<String> allowedOrigins;\n\n    private Integer maxAgeSeconds;\n\n    private List<String> exposedHeaders;\n\n    private List<String> allowedHeaders;\n\n    public static List<ZFileCORSRule> fromCORSRule(List<CORSRule> corsRules) {\n        List<ZFileCORSRule> zFileCORSRules = new ArrayList<>();\n        for (CORSRule corsRule : corsRules) {\n            ZFileCORSRule zFileCORSRule = new ZFileCORSRule();\n            zFileCORSRule.setId(StringUtils.isEmpty(corsRule.id()) ? String.valueOf(corsRule.hashCode()) : corsRule.id());\n            zFileCORSRule.setAllowedMethods(new ArrayList<>(corsRule.allowedMethods()));\n            zFileCORSRule.setAllowedOrigins(new ArrayList<>(corsRule.allowedOrigins()));\n            zFileCORSRule.setAllowedHeaders(new ArrayList<>(corsRule.allowedHeaders()));\n            zFileCORSRule.setExposedHeaders(new ArrayList<>(corsRule.exposeHeaders()));\n            zFileCORSRule.setMaxAgeSeconds(corsRule.maxAgeSeconds());\n            zFileCORSRules.add(zFileCORSRule);\n        }\n        return sortAndDistinct(zFileCORSRules);\n    }\n\n    public static Set<CORSRule> toCORSRule(List<ZFileCORSRule> zFileCORSRules) {\n        Set<CORSRule> corsRules = new HashSet<>();\n        for (ZFileCORSRule zFileCORSRule : sortAndDistinct(zFileCORSRules)) {\n            CORSRule.Builder builder = CORSRule.builder();\n            builder.id(zFileCORSRule.getId());\n            builder.allowedMethods(zFileCORSRule.getAllowedMethods());\n            builder.allowedOrigins(zFileCORSRule.getAllowedOrigins());\n            builder.allowedHeaders(zFileCORSRule.getAllowedHeaders());\n            builder.exposeHeaders(zFileCORSRule.getExposedHeaders());\n            builder.maxAgeSeconds(zFileCORSRule.getMaxAgeSeconds());\n            corsRules.add(builder.build());\n        }\n        return corsRules;\n    }\n\n    public static List<ZFileCORSRule> sortAndDistinct(List<ZFileCORSRule> zFileCORSRules) {\n        for (ZFileCORSRule zFileCORSRule : zFileCORSRules) {\n            if (zFileCORSRule.getAllowedMethods() != null) {\n                Collections.sort(zFileCORSRule.getAllowedMethods());\n            }\n            if (zFileCORSRule.getAllowedHeaders() != null) {\n                Collections.sort(zFileCORSRule.getAllowedHeaders());\n            }\n            if (zFileCORSRule.getAllowedOrigins() != null) {\n                Collections.sort(zFileCORSRule.getAllowedOrigins());\n            }\n            if (zFileCORSRule.getExposedHeaders() != null) {\n                Collections.sort(zFileCORSRule.getExposedHeaders());\n            }\n        }\n        return CollUtil.distinct(zFileCORSRules);\n    }\n\n\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) return true;\n        if (o == null || getClass() != o.getClass()) return false;\n        ZFileCORSRule that = (ZFileCORSRule) o;\n        return Objects.equals(allowedMethods, that.allowedMethods) &&\n                Objects.equals(allowedOrigins, that.allowedOrigins) &&\n                Objects.equals(maxAgeSeconds, that.maxAgeSeconds) &&\n                Objects.equals(exposedHeaders, that.exposedHeaders) &&\n                Objects.equals(allowedHeaders, that.allowedHeaders);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(allowedMethods, allowedOrigins, maxAgeSeconds, exposedHeaders, allowedHeaders);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/entity/StorageSource.java",
    "content": "package im.zhaojun.zfile.module.storage.model.entity;\n\nimport com.baomidou.mybatisplus.annotation.IdType;\nimport com.baomidou.mybatisplus.annotation.TableField;\nimport com.baomidou.mybatisplus.annotation.TableId;\nimport com.baomidou.mybatisplus.annotation.TableName;\nimport im.zhaojun.zfile.module.storage.model.enums.SearchModeEnum;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport java.io.Serializable;\n\n/**\n * 存储源基本属性 entity\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"存储源基本属性\")\n@TableName(value = \"storage_source\")\npublic class StorageSource implements Serializable {\n\n    private static final long serialVersionUID = 1L;\n\n    @TableId(value = \"id\", type = IdType.AUTO)\n    @Schema(title = \"ID, 新增无需填写\", example = \"1\")\n    private Integer id;\n\n\n    @TableField(value = \"`enable`\")\n    @Schema(title = \"是否启用\", example = \"true\")\n    private Boolean enable;\n\n\n    @TableField(value = \"`enable_file_operator`\")\n    @Schema(title = \"是否启用文件操作功能\", example = \"true\", description =\"是否启用文件上传，编辑，删除等操作.\")\n    @Deprecated\n    private Boolean enableFileOperator;\n\n\n    @TableField(value = \"`enable_file_anno_operator`\")\n    @Schema(title = \"是否允许匿名进行文件操作\", example = \"true\", description =\"是否允许匿名进行文件上传，编辑，删除等操作.\")\n    @Deprecated\n    private Boolean enableFileAnnoOperator;\n\n\n    @TableField(value = \"`enable_cache`\")\n    @Schema(title = \"是否开启缓存\", example = \"true\")\n    private Boolean enableCache;\n\n\n    @TableField(value = \"`name`\")\n    @Schema(title = \"存储源名称\", example = \"阿里云 OSS 存储\")\n    private String name;\n\n\n    @TableField(value = \"`key`\")\n    @Schema(title = \"存储源别名\", example = \"存储源别名，用于 URL 中展示, 如 http://ip:port/{存储源别名}\")\n    private String key;\n\n\n    @TableField(value = \"`remark`\")\n    @Schema(title = \"存储源备注\", example = \"这是一个备注信息, 用于管理员区分不同的存储源, 此字段仅管理员可见\")\n    private String remark;\n\n\n    @TableField(value = \"auto_refresh_cache\")\n    @Schema(title = \"是否开启缓存自动刷新\", example = \"true\")\n    private Boolean autoRefreshCache;\n\n\n    @TableField(value = \"`type`\")\n    @Schema(title = \"存储源类型\")\n    private StorageTypeEnum type;\n\n\n    @TableField(value = \"search_enable\")\n    @Schema(title = \"是否开启搜索\", example = \"true\")\n    private Boolean searchEnable;\n\n\n    @TableField(value = \"search_ignore_case\")\n    @Schema(title = \"搜索是否忽略大小写\", example = \"true\")\n    private Boolean searchIgnoreCase;\n\n\n    @TableField(value = \"`search_mode`\")\n    @Schema(title = \"搜索模式\", example = \"SEARCH_CACHE\", description =\"仅从缓存中搜索或直接全量搜索\")\n    private SearchModeEnum searchMode;\n\n\n    @TableField(value = \"order_num\")\n    @Schema(title = \"排序值\", example = \"1\")\n    private Integer orderNum;\n\n\n    @TableField(value = \"default_switch_to_img_mode\")\n    @Schema(title = \"是否默认开启图片模式\", example = \"true\")\n    private Boolean defaultSwitchToImgMode;\n    \n    \n    @TableField(value = \"compatibility_readme\")\n    @Schema(title = \"兼容 readme 模式\", example = \"true\", description =\"兼容模式, 目录文档读取 readme.md 文件\")\n    private Boolean compatibilityReadme;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/entity/StorageSourceConfig.java",
    "content": "package im.zhaojun.zfile.module.storage.model.entity;\n\nimport com.baomidou.mybatisplus.annotation.IdType;\nimport com.baomidou.mybatisplus.annotation.TableField;\nimport com.baomidou.mybatisplus.annotation.TableId;\nimport com.baomidou.mybatisplus.annotation.TableName;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport java.io.Serializable;\n\n/**\n * 存储源拓展属性 entity\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"存储源拓展属性\")\n@TableName(value = \"storage_source_config\")\npublic class StorageSourceConfig implements Serializable {\n\n    private static final long serialVersionUID = 1L;\n\n    @TableId(value = \"id\", type = IdType.AUTO)\n    @Schema(title = \"ID, 新增无需填写\", example = \"1\")\n    private Integer id;\n\n\n    @TableField(value = \"`name`\")\n    @Schema(title = \"存储源属性名称 name\", example = \"bucketName\")\n    private String name;\n\n\n    @TableField(value = \"`type`\")\n    @Schema(title = \"存储源类型\")\n    private StorageTypeEnum type;\n\n\n    @TableField(value = \"title\")\n    @Schema(title = \"存储源属性名称\", example = \"Bucket 名称\")\n    private String title;\n\n\n    @TableField(value = \"storage_id\")\n    @Schema(title = \"存储源 id\", example = \"1\")\n    private Integer storageId;\n\n\n    @TableField(value = \"`value`\")\n    @Schema(title = \"存储源对应的值\", example = \"my-bucket\")\n    private String value;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/enums/FileOperatorTypeEnum.java",
    "content": "package im.zhaojun.zfile.module.storage.model.enums;\n\nimport cn.hutool.core.util.ReflectUtil;\nimport com.baomidou.mybatisplus.annotation.EnumValue;\nimport com.fasterxml.jackson.annotation.JsonValue;\nimport im.zhaojun.zfile.core.exception.core.SystemException;\nimport im.zhaojun.zfile.module.storage.function.*;\nimport im.zhaojun.zfile.module.storage.model.dto.FileOperatorTypeDefaultValueDTO;\nimport lombok.AccessLevel;\nimport lombok.AllArgsConstructor;\nimport lombok.Getter;\nimport lombok.extern.slf4j.Slf4j;\n\nimport java.lang.reflect.Field;\nimport java.util.function.Function;\n\n/**\n * 文件操作类型枚举\n *\n * @author zhaojun\n */\n@Slf4j\n@Getter\n@AllArgsConstructor\npublic enum FileOperatorTypeEnum {\n\n\t/**\n\t * 是否可用\n\t */\n\t@Deprecated\n\tAVAILABLE(\"是否可用\", \"available\",\n\t\t\t\"显示且可访问存储源，匿名用户通过 URL 也无法访问. 但不影响直链/短链下载.\", AllowAllFileOperatorTypeEnumDefaultValueFunc.class),\n\n\t/**\n\t * 新建文件夹操作\n\t */\n\tNEW_FOLDER(\"新建文件夹\", \"newFolder\",\n\t\t\t\"关闭此权限会同时关闭上传文件夹的功能\", BasicFileOperatorTypeEnumDefaultValueFunc.class),\n\n\t/**\n\t * 获取文件上传链接操作\n\t */\n\tUPLOAD(\"上传\", \"upload\",\n\t\t\tnull, BasicFileOperatorTypeEnumDefaultValueFunc.class),\n\n\t/**\n\t * 预览操作\n\t */\n\tPREVIEW(\"预览\", \"preview\",\n\t\t\t\"控制视频、音频、Office 等格式是否支持预览\", AllowAllFileOperatorTypeEnumDefaultValueFunc.class),\n\n\t/**\n\t * 下载操作（如果允许预览，但不允许下载，则只是隐藏下载按钮而已，无法真正防止下载）\n\t */\n\tDOWNLOAD(\"下载\", \"download\",\n\t\t\t\"如允许预览，但不允许下载，则只是隐藏下载按钮而已，无法真正防止下载。且不会限制直链下载。\", AllowAllFileOperatorTypeEnumDefaultValueFunc.class),\n\n\t/**\n\t * 批量下载\n\t */\n\t@Deprecated\n\tBATCH_DOWNLOAD(\"批量下载\", \"batchDownload\", null, AllowAllFileOperatorTypeEnumDefaultValueFunc.class),\n\n\t/**\n\t * 打包下载\n\t */\n\t@Deprecated\n\tPACKAGE_DOWNLOAD(\"打包下载\", \"packageDownload\", null, AllowAllFileOperatorTypeEnumDefaultValueFunc.class),\n\n\t/**\n\t * 复制文件下载链接\n\t */\n\tCOPY_DOWNLOAD_LINK(\"复制下载链接\", \"copyDownloadLink\", \"如允许则右键菜单会增加复制文件下载链接功能（不是直链和短链，而是存储源本身的下载链接）\", AllowAllFileOperatorTypeEnumDefaultValueFunc.class),\n\n\t/**\n\t * 重命名文件&文件夹操作\n\t */\n\tRENAME(\"重命名\", \"rename\",\n\t\t\tnull, BasicFileOperatorTypeEnumDefaultValueFunc.class),\n\n\t/**\n\t * 复制文件&文件夹操作\n\t */\n\tCOPY(\"复制\", \"copy\",\n\t\t\tnull, BasicFileOperatorTypeEnumDefaultValueFunc.class),\n\n\t/**\n\t * 移动文件&文件夹操作\n\t */\n\tMOVE(\"移动\", \"move\",\n\t\t\tnull, BasicFileOperatorTypeEnumDefaultValueFunc.class),\n\n\t/**\n\t * 删除文件&文件夹操作\n\t */\n\tDELETE(\"删除\", \"delete\",\n\t\t\tnull, BasicFileOperatorTypeEnumDefaultValueFunc.class),\n\n\t/**\n\t * 搜索操作\n\t */\n\t@Deprecated\n\tSEARCH(\"搜索\", \"search\",\n\t\t\t\"如您未启用存储源搜索功能, 此处授权也不会生效.\", SearchFileOperatorTypeEnumDefaultValueFunc.class),\n\n\t/**\n\t * 获取直链\n\t */\n\tLINK(\"获取直链\", \"generateLink\",\n\t\t\tnull, LinkFileOperatorTypeEnumDefaultValueFunc.class),\n\n\t/**\n\t * 生成短链\n\t */\n\tSHORT_LINK(\"生成短链\", \"generateShortLink\",\n\t\t\tnull, ShortLinkFileOperatorTypeEnumDefaultValueFunc.class),\n\n    /**\n     * 创建分享链接\n     */\n    SHARE_LINK(\"创建分享链接\", \"createShareLink\",\n            \"允许用户创建分享链接\", AllowAdminFileOperatorTypeEnumDefaultValueFunc.class),\n\n\t/**\n\t * 分享自定义 key\n\t */\n\tCUSTOM_SHARE_KEY(\"自定义分享链接 key\", \"customShareKey\",\n\t\t\t\"允许用户在创建分享链接时使用自定义 key，而不是系统自动生成\", AllowAdminFileOperatorTypeEnumDefaultValueFunc.class),\n\n\t/**\n\t * 忽略密码\n\t */\n\tIGNORE_PASSWORD(\"忽略密码\", \"ignorePassword\",\n\t\t\t\"勾选上，则会忽略所有密码规则，免输入密码\", DisableAllFileOperatorTypeEnumDefaultValueFunc.class),\n\n\t/**\n\t * 忽略隐藏\n\t */\n\tIGNORE_HIDDEN(\"忽略隐藏\", \"ignoreHidden\",\n\t\t\t\"勾选上，则会忽略所有隐藏规则，可查看所有文件夹和文件\", DisableAllFileOperatorTypeEnumDefaultValueFunc.class);\n\n\n\t/**\n\t * 操作类型描述\n\t */\n\tprivate final String name;\n\n\t/**\n\t * 操作类型值\n\t */\n\t@EnumValue\n\t@JsonValue\n\tprivate final String value;\n\n\t/**\n\t * 辅助提示信息\n\t */\n\tprivate final String tips;\n\n\t@Getter(AccessLevel.NONE)\n\tprivate final Class<? extends Function<Integer, FileOperatorTypeDefaultValueDTO>> defaultValueFuncClazz;\n\n\tpublic FileOperatorTypeDefaultValueDTO getDefaultValue(Integer storageId) {\n\t\ttry {\n\t\t\treturn ReflectUtil.newInstance(defaultValueFuncClazz).apply(storageId);\n\t\t} catch (Exception e) {\n\t\t\tthrow new SystemException(e);\n\t\t}\n\t}\n\n\tpublic boolean isDeprecated() {\n\t\ttry {\n\t\t\tField field = getClass().getField(name());\n\t\t\treturn field.isAnnotationPresent(Deprecated.class);\n\t\t} catch (NoSuchFieldException e) {\n\t\t\tlog.error(\"获取枚举类注解失败\", e);\n\t\t}\n\n\t\treturn false;\n\t}\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/enums/FileTypeEnum.java",
    "content": "package im.zhaojun.zfile.module.storage.model.enums;\n\nimport com.baomidou.mybatisplus.annotation.EnumValue;\nimport com.fasterxml.jackson.annotation.JsonValue;\nimport lombok.AllArgsConstructor;\nimport lombok.Getter;\n\n/**\n * 文件类型枚举\n *\n * @author zhaojun\n */\n@Getter\n@AllArgsConstructor\npublic enum FileTypeEnum {\n\n    /**\n     * 文件\n     */\n    FILE(\"FILE\"),\n\n    /**\n     * 文件夹\n     */\n    FOLDER(\"FOLDER\");\n\n    @EnumValue\n    @JsonValue\n    private final String value;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/enums/SearchFolderModeEnum.java",
    "content": "package im.zhaojun.zfile.module.storage.model.enums;\n\nimport com.baomidou.mybatisplus.annotation.EnumValue;\nimport com.fasterxml.jackson.annotation.JsonValue;\nimport lombok.AllArgsConstructor;\nimport lombok.Getter;\n\n/**\n * 搜索模式枚举\n *\n * @author zhaojun\n */\n@Getter\n@AllArgsConstructor\npublic enum SearchFolderModeEnum {\n\n\t/**\n\t * 搜索当前文件夹\n\t */\n\tSEARCH_CURRENT_FOLDER(\"search_current_folder\"),\n\n\t/**\n\t * 当前文件夹及所有子文件夹\n\t */\n\tSEARCH_CURRENT_FOLDER_AND_CHILD(\"search_current_folder_and_child\"),\n\n\t/**\n\t * 当前所有文件夹\n\t */\n\tSEARCH_ALL(\"search_all\");\n\n\t@EnumValue\n\t@JsonValue\n\tprivate final String value;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/enums/SearchModeEnum.java",
    "content": "package im.zhaojun.zfile.module.storage.model.enums;\n\nimport com.baomidou.mybatisplus.annotation.EnumValue;\nimport com.fasterxml.jackson.annotation.JsonValue;\nimport lombok.AllArgsConstructor;\nimport lombok.Getter;\n\n/**\n * 文件搜索模式枚举\n *\n * @author zhaojun\n */\n@Getter\n@AllArgsConstructor\npublic enum SearchModeEnum {\n\n\t/**\n\t * 仅搜索缓存\n\t */\n\tSEARCH_CACHE_MODE(\"SEARCH_CACHE\"),\n\n\t/**\n\t * 搜索全部\n\t */\n\tSEARCH_ALL_MODE(\"SEARCH_ALL\");\n\n\t@EnumValue\n\t@JsonValue\n\tprivate final String value;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/enums/StorageParamTypeEnum.java",
    "content": "package im.zhaojun.zfile.module.storage.model.enums;\n\nimport com.baomidou.mybatisplus.annotation.EnumValue;\nimport com.fasterxml.jackson.annotation.JsonValue;\nimport lombok.AllArgsConstructor;\nimport lombok.Getter;\n\n/**\n * 存储源参数类型枚举\n *\n * @author zhaojun\n */\n@Getter\n@AllArgsConstructor\npublic enum StorageParamTypeEnum {\n\n\t/**\n\t * 输入框\n\t */\n\tINPUT(\"input\"),\n\n\t/**\n\t * 数字输入框\n\t */\n\tNUMBER(\"number\"),\n\n\t/**\n\t * 多行文本输入框\n\t */\n\tTEXTAREA(\"textarea\"),\n\n\t/**\n\t * 下拉框\n\t */\n\tSELECT(\"select\"),\n\n\t/**\n\t * 开关\n\t */\n\tSWITCH(\"switch\");\n\n\t@EnumValue\n\t@JsonValue\n\tprivate final String value;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/enums/StorageTypeEnum.java",
    "content": "package im.zhaojun.zfile.module.storage.model.enums;\n\nimport com.baomidou.mybatisplus.annotation.EnumValue;\nimport com.baomidou.mybatisplus.annotation.IEnum;\nimport com.fasterxml.jackson.annotation.JsonFormat;\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport io.swagger.v3.oas.annotations.media.Schema;\n\nimport java.util.HashMap;\nimport java.util.Map;\n\n/**\n * 存储源类型枚举\n *\n * @author zhaojun\n */\n@JsonFormat(shape = JsonFormat.Shape.OBJECT)\npublic enum StorageTypeEnum implements IEnum {\n\n    /**\n     * 当前系统支持的所有存储源类型\n     */\n    LOCAL(\"local\", \"本地存储\"),\n    ALIYUN(\"aliyun\", \"阿里云 OSS\"),\n    WEBDAV(\"webdav\", \"WebDAV\"),\n    TENCENT(\"tencent\", \"腾讯云 COS\"),\n    UPYUN(\"upyun\", \"又拍云 USS\"),\n    FTP(\"ftp\", \"FTP\"),\n    SFTP(\"sftp\", \"SFTP\"),\n    HUAWEI(\"huawei\", \"华为云 OBS\"),\n    MINIO(\"minio\", \"MINIO\"),\n    S3(\"s3\", \"S3通用协议\"),\n    ONE_DRIVE(\"onedrive\", \"OneDrive\"),\n    ONE_DRIVE_CHINA(\"onedrive-china\", \"OneDrive 世纪互联\"),\n    SHAREPOINT_DRIVE(\"sharepoint\", \"SharePoint\"),\n    SHAREPOINT_DRIVE_CHINA(\"sharepoint-china\", \"SharePoint 世纪互联\"),\n    GOOGLE_DRIVE(\"google-drive\", \"Google Drive\"),\n    QINIU(\"qiniu\", \"七牛云 KODO\"),\n    DOGE_CLOUD(\"doge-cloud\", \"多吉云\"),\n    OPEN115(\"open115\", \"115\");\n\n    private static final Map<String, StorageTypeEnum> ENUM_MAP = new HashMap<>();\n\n    static {\n        for (StorageTypeEnum type : StorageTypeEnum.values()) {\n            ENUM_MAP.put(type.getKey(), type);\n        }\n    }\n\n    @Schema(title = \"存储源类型枚举 Key\", example = \"aliyun\")\n    @EnumValue\n    private final String key;\n\n    @Schema(title = \"存储源类型枚举描述\", example = \"阿里云 OSS\")\n    private final String description;\n\n    StorageTypeEnum(String key, String description) {\n        this.key = key;\n        this.description = description;\n    }\n\n    public String getKey() {\n        return key;\n    }\n\n    public String getDescription() {\n        return description;\n    }\n\n    @JsonIgnore\n    public String getValue() {\n        return key;\n    }\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/param/AliyunParam.java",
    "content": "package im.zhaojun.zfile.module.storage.model.param;\n\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamItem;\nimport lombok.Getter;\n\n/**\n * 阿里云初始化参数\n *\n * @author zhaojun\n */\n@Getter\npublic class AliyunParam extends S3BaseParam {\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/param/DogeCloudParam.java",
    "content": "package im.zhaojun.zfile.module.storage.model.param;\n\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamItem;\nimport im.zhaojun.zfile.module.storage.enums.StorageParamItemAnnoEnum;\nimport lombok.Getter;\nimport lombok.Setter;\n\n/**\n * @author zhaojun\n */\n@Getter\n@Setter\npublic class DogeCloudParam extends S3BaseParam {\n\n    @StorageParamItem(ignoreInput = true, onlyOverwrite = { StorageParamItemAnnoEnum.IGNORE_INPUT })\n    private String endPoint;\n\n    @StorageParamItem(ignoreInput = true, onlyOverwrite = { StorageParamItemAnnoEnum.IGNORE_INPUT })\n    private String endPointScheme;\n\n    @StorageParamItem(ignoreInput = true, onlyOverwrite = { StorageParamItemAnnoEnum.IGNORE_INPUT })\n    private String bucketName;\n\n    @StorageParamItem(name = \"存储空间名称\", order = 40)\n    private String originBucketName;\n\n    @StorageParamItem(ignoreInput = true, onlyOverwrite = { StorageParamItemAnnoEnum.IGNORE_INPUT })\n    private boolean isPrivate;\n\n    @StorageParamItem(ignoreInput = true, onlyOverwrite = { StorageParamItemAnnoEnum.IGNORE_INPUT })\n    private Integer tokenTime;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/param/FtpParam.java",
    "content": "package im.zhaojun.zfile.module.storage.model.param;\n\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamItem;\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamSelectOption;\nimport im.zhaojun.zfile.module.storage.annotation.impl.EncodingStorageParamSelect;\nimport im.zhaojun.zfile.module.storage.enums.StorageParamItemAnnoEnum;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageParamTypeEnum;\nimport im.zhaojun.zfile.module.storage.service.impl.FtpServiceImpl;\nimport lombok.Getter;\n\n/**\n * 本地存储初始化参数\n *\n * @author zhaojun\n */\n@Getter\npublic class FtpParam extends ProxyTransferParam {\n\n\t@StorageParamItem(name = \"域名或 IP\", order = 1)\n\tprivate String host;\n\n\t@StorageParamItem(name = \"端口\", order = 2)\n\tprivate int port;\n\n\t@StorageParamItem(name = \"编码格式\",\n\t\t\tdefaultValue = \"UTF-8\",\n\t\t\ttype = StorageParamTypeEnum.SELECT,\n\t\t\toptionsClass = EncodingStorageParamSelect.class,\n\t\t\tdescription = \"表示文件夹及文件名称的编码格式，不表示文本内容的编码格式.\", order = 3)\n\tprivate String encoding;\n\n\t@StorageParamItem(name = \"用户名\", required = false, order = 4, description = \"如果是匿名访问，不填写保存失败的话，可能用户名需要写 anonymous\")\n\tprivate String username;\n\n\t@StorageParamItem(name = \"密码\", required = false, order = 5)\n\tprivate String password;\n\n\t@StorageParamItem(name = \"基路径\", defaultValue = \"/\", description = \"基路径表示该存储源哪个目录在 ZFile 中作为根目录，如： '/'，'/文件夹1'\", order = 6)\n\tprivate String basePath;\n\n\t@StorageParamItem(order = 7, onlyOverwrite = { StorageParamItemAnnoEnum.ORDER })\n\tprivate String domain;\n\n\t@StorageParamItem(name = \"FTP 模式\",\n\t\t\t\tcondition = \"domain==\",\n\t\t\t\ttype = StorageParamTypeEnum.SELECT,\n\t\t\t\toptions = {\n\t\t\t\t\t@StorageParamSelectOption(value = FtpServiceImpl.FTP_MODE_ACTIVE, label = \"主动模式\"),\n\t\t\t\t\t@StorageParamSelectOption(value = FtpServiceImpl.FTP_MODE_PASSIVE, label = \"被动模式\")\n\t\t\t\t},\n\t\t\t\tdefaultValue = \"passive\",\n\t\t\t\tdescription = \"主动模式为 FTP 服务端主动连接客户端(随机开放端口，需保证防火墙无限制端口)，被动模式为 FTP 服务端被动等待客户端连接.\",\n\t\t\torder = 8)\n\tprivate String ftpMode;\n\n\t@StorageParamItem(name = \"支持 Range\", condition = \"domain==\", type = StorageParamTypeEnum.SWITCH, defaultValue = \"false\", description = \"启用后会支持多线程下载、断点续传、下载显示进度，但会对 FTP 服务端带来更大压力\", order = 9)\n\tprivate boolean enableRange;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/param/GoogleDriveParam.java",
    "content": "package im.zhaojun.zfile.module.storage.model.param;\n\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamItem;\nimport lombok.Getter;\nimport lombok.Setter;\nimport lombok.ToString;\n\n/**\n * Google Drive 初始化参数\n *\n * @author zhaojun\n */\n@Getter\n@ToString\npublic class GoogleDriveParam extends ProxyTransferParam {\n\n\t@StorageParamItem(name = \"clientId\", defaultValue = \"${zfile.gd.clientId}\", order = 1, description = \"<font color=\\\"red\\\">默认 API 仅用作示例，因审核原因，目前不可用，请自行申请 API</font>\", link = \"https://docs.zfile.vip/advanced#google-drive-api\", linkName = \"自定义 API 文档\")\n\tprivate String clientId;\n\n\t@StorageParamItem(name = \"SecretKey\", defaultValue = \"${zfile.gd.clientSecret}\", order = 2)\n\tprivate String clientSecret;\n\n\t@StorageParamItem(name = \"回调地址\", description = \"这里要修改为自己的域名\", defaultValue = \"${zfile.gd.redirectUri}\", order = 3)\n\tprivate String redirectUri;\n\n\t@Setter\n\t@StorageParamItem(name = \"访问令牌\", link = \"/gd/authorize\", linkName = \"前往获取令牌\", order = 4)\n\tprivate String accessToken;\n\n\t@Setter\n\t@StorageParamItem(name = \"刷新令牌\", order = 5)\n\tprivate String refreshToken;\n\n\t@StorageParamItem(name = \"刷新令牌到期时间戳(秒)\", hidden = true, required = false)\n\tprivate Integer refreshTokenExpiredAt;\n\n\t@StorageParamItem(name = \"网盘\", order = 6, required = false)\n\tprivate String driveId;\n\n\t@StorageParamItem(name = \"基路径\", defaultValue = \"/\", order = 7, description = \"基路径表示该存储源哪个目录在 ZFile 中作为根目录，如： '/'，'/文件夹1'\")\n\tprivate String basePath;\n\n\t@StorageParamItem(name = \"加速域名\", required = false, description = \"可使用 cf worker index 程序的链接，会使用 cf 中转下载，教程自行查询. 不填写则使用服务器中转下载.\")\n\tprivate String domain;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/param/HuaweiParam.java",
    "content": "package im.zhaojun.zfile.module.storage.model.param;\n\nimport lombok.Getter;\n\n/**\n * 华为云初始化参数\n *\n * @author zhaojun\n */\n@Getter\npublic class HuaweiParam extends S3BaseParam {\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/param/IStorageParam.java",
    "content": "package im.zhaojun.zfile.module.storage.model.param;\n\n/**\n * @author zhaojun\n */\npublic interface IStorageParam {\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/param/LocalParam.java",
    "content": "package im.zhaojun.zfile.module.storage.model.param;\n\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamItem;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageParamTypeEnum;\nimport lombok.Getter;\n\n/**\n * 本地存储初始化参数\n *\n * @author zhaojun\n */\n@Getter\npublic class LocalParam extends ProxyTransferParam {\n\n\t@StorageParamItem(name = \"文件路径\", order = 1, description = \"只支持绝对路径<br>Docker 方式部署的话需提前映射宿主机路径！ \" +\n\t\t\t\"(<a class='link' target='_blank' href='https://docs.docker.com/engine/reference/run/#volume-shared-filesystems'>配置文档</a>)\")\n\tprivate String filePath;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/param/MicrosoftDriveParam.java",
    "content": "package im.zhaojun.zfile.module.storage.model.param;\n\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamItem;\nimport im.zhaojun.zfile.module.storage.enums.StorageParamItemAnnoEnum;\nimport lombok.Getter;\n\n/**\n * 微软云初始化参数\n *\n * @author zhaojun\n */\n@Getter\npublic class MicrosoftDriveParam extends OptionalProxyTransferParam {\n\n\t@StorageParamItem(name = \"clientId\", defaultValue = \"${zfile.onedrive.clientId}\", order = 1)\n\tprivate String clientId;\n\n\t@StorageParamItem(name = \"SecretKey\", defaultValue = \"${zfile.onedrive.clientSecret}\", order = 2)\n\tprivate String clientSecret;\n\t\n\t@StorageParamItem(name = \"回调地址\", description = \"如使用自定义 api, 需将此处默认的域名修改为您的域名, 且需在 api 中配置为回调域名.\", defaultValue = \"${zfile.onedrive.redirectUri}\", order = 3)\n\tprivate String redirectUri;\n\n\t@StorageParamItem(name = \"访问令牌\", link = \"/onedrive/authorize\", linkName = \"前往获取令牌\", order = 4)\n\tprivate String accessToken;\n\n\t@StorageParamItem(name = \"刷新令牌\", order = 5)\n\tprivate String refreshToken;\n\n\t@StorageParamItem(name = \"刷新令牌到期时间戳(秒)\", hidden = true, required = false)\n\tprivate Integer refreshTokenExpiredAt;\n\n\t@StorageParamItem(name = \"基路径\", defaultValue = \"/\", order = 6, description = \"基路径表示该存储源哪个目录在 ZFile 中作为根目录，如： '/'，'/文件夹1'\")\n\tprivate String basePath;\n\n\t@StorageParamItem(name = \"加速域名\", required = false, order = 7, description = \"使用 CF 或自建反代下载地址时填写，否则请留空。\")\n\tprivate String proxyDomain;\n\n\t@StorageParamItem(name = \"加速域名\", ignoreInput = true)\n\tprivate String domain;\n\n\t@StorageParamItem(name = \"代理上传超时时间\", condition = \"enableProxyUpload==true\", defaultValue = \"300\", description = \"服务器代理上传至微软云的超时时间, 单位为秒, 默认为 300 秒. 请自行根据服务器带宽大小、上传文件调整，为 0 则不限制.\", order = 101)\n\tprivate Integer proxyUploadTimeoutSecond;\n\n\t@StorageParamItem(condition = \"proxyDomain==\", onlyOverwrite = {StorageParamItemAnnoEnum.CONDITION, StorageParamItemAnnoEnum.ORDER}, order = 101)\n\tprivate boolean enableProxyDownload;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/param/MinIOParam.java",
    "content": "package im.zhaojun.zfile.module.storage.model.param;\n\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamItem;\nimport im.zhaojun.zfile.module.storage.enums.StorageParamItemAnnoEnum;\nimport lombok.Getter;\n\n/**\n * MinIO 初始化参数\n *\n * @author zhaojun\n */\n@Getter\npublic class MinIOParam extends S3BaseParam {\n\n\t@StorageParamItem(name = \"服务地址\", order = 30, description = \"为 minio 的服务地址，非 web 访问地址，需包含协议，如 http://ip:9000\")\n\tprivate String endPoint;\n\n\t@StorageParamItem(ignoreInput = true, onlyOverwrite = { StorageParamItemAnnoEnum.IGNORE_INPUT })\n\tprivate String endPointScheme;\n\n\t@StorageParamItem(name = \"地域\", defaultValue = \"minio\", order = 45)\n\tprivate String region;\n\n\t@StorageParamItem(description = \"为 minio 的服务地址，非 web 访问地址，一般为 http://ip:9000\", order = 65, onlyOverwrite = { StorageParamItemAnnoEnum.DESCRIPTION, StorageParamItemAnnoEnum.ORDER })\n\tprivate String domain;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/param/OneDriveChinaParam.java",
    "content": "package im.zhaojun.zfile.module.storage.model.param;\n\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamItem;\nimport lombok.Getter;\n\n/**\n * OneDrive 初始化参数\n *\n * @author zhaojun\n */\n@Getter\npublic class OneDriveChinaParam extends OneDriveParam {\n\n\t@StorageParamItem(name = \"clientId\", defaultValue = \"${zfile.onedrive-china.clientId}\", order = 1)\n\tprivate String clientId;\n\n\t@StorageParamItem(name = \"SecretKey\", defaultValue = \"${zfile.onedrive-china.clientSecret}\", order = 2)\n\tprivate String clientSecret;\n\t\n\t@StorageParamItem(name = \"回调地址\", description = \"如使用自定义 api, 需将此处默认的域名修改为您的域名, 且需在 api 中配置为回调域名.\",\n\t\t\tdefaultValue = \"${zfile.onedrive-china.redirectUri}\", order = 3)\n\tprivate String redirectUri;\n\n\t@StorageParamItem(name = \"访问令牌\", link = \"/onedrive/china-authorize\", linkName = \"前往获取令牌\", order = 3)\n\tprivate String accessToken;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/param/OneDriveParam.java",
    "content": "package im.zhaojun.zfile.module.storage.model.param;\n\nimport lombok.Getter;\n\n/**\n * OneDrive 初始化参数\n *\n * @author zhaojun\n */\n@Getter\npublic class OneDriveParam extends MicrosoftDriveParam {\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/param/Open115Param.java",
    "content": "package im.zhaojun.zfile.module.storage.model.param;\n\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamItem;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageParamTypeEnum;\nimport lombok.Getter;\n\n@Getter\npublic class Open115Param extends OptionalProxyTransferParam {\n\n    @StorageParamItem(name = \"AppID\", description = \"也可自行去 https://open.115.com 申请\", defaultValue = \"${zfile.open115.appId}\", order = 1)\n    private String clientId;\n\n    @StorageParamItem(name = \"访问令牌\", order = 2)\n    private String accessToken;\n\n    @StorageParamItem(name = \"刷新令牌\", order = 3)\n    private String refreshToken;\n\n    @StorageParamItem(name = \"接口请求速率(秒)\", order = 3, defaultValue = \"1\", type = StorageParamTypeEnum.NUMBER, description = \"表示每秒最多允许几个请求，<font color='#e6a23c'>建议最多为 1，过高可能会被风控。且 115 网盘严禁共享使用，多 IP 下载也可能会导致风控/封号，详见：</font><a class='link' target='_blank' href='https://www.yuque.com/115yun/open/vq62qwp8ia2efoli'>https://www.yuque.com/115yun/open/vq62qwp8ia2efoli</a>\")\n    private Double qps;\n\n    @StorageParamItem(name = \"刷新令牌到期时间戳(秒)\", hidden = true, required = false)\n    private Integer refreshTokenExpiredAt;\n\n    @StorageParamItem(name = \"基路径\", defaultValue = \"/\", description = \"基路径表示该存储源哪个目录在 ZFile 中作为根目录，如： '/'，'/文件夹1'\", order = 6)\n    private String basePath;\n\n    @StorageParamItem(hidden = true)\n    private boolean enableProxyUpload;\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/param/OptionalProxyTransferParam.java",
    "content": "package im.zhaojun.zfile.module.storage.model.param;\n\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamItem;\nimport im.zhaojun.zfile.module.storage.enums.StorageParamItemAnnoEnum;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageParamTypeEnum;\nimport lombok.Getter;\n\n@Getter\npublic class OptionalProxyTransferParam extends ProxyTransferParam {\n\n    @StorageParamItem(name = \"代理上传\", defaultValue = \"false\", type = StorageParamTypeEnum.SWITCH, description = \"启用该功能后，上传会先上传到服务器，完成后服务器再上传至目标存储源，强依赖服务器带宽大小，请确认必要后开启。\", order = 100)\n    private boolean enableProxyUpload;\n\n    @StorageParamItem(name = \"代理下载\", condition = \"domain==\", defaultValue = \"false\", type = StorageParamTypeEnum.SWITCH, description = \"启用该功能后，下载会先下载到服务器，完成后服务器再下载返回给下载用户，强依赖服务器带宽大小，请确认必要后开启。\", order = 101)\n    private boolean enableProxyDownload;\n\n    @StorageParamItem(name = \"代理下载生成签名链接\", condition = \"enableProxyDownload==true\", onlyOverwrite = { StorageParamItemAnnoEnum.NAME, StorageParamItemAnnoEnum.CONDITION, StorageParamItemAnnoEnum.ORDER }, order = 102)\n    private boolean proxyPrivate;\n\n    @StorageParamItem(name = \"代理下载签名有效期\", condition = \"proxyPrivate==true\", required = false, defaultValue = \"1800\", description = \"用于下载签名的有效期, 单位为秒, 如不配置则默认为 1800 秒.\", order = 103)\n    private Integer proxyTokenTime;\n\n    @StorageParamItem(name = \"代理下载链接强制下载\", condition = \"enableProxyDownload==true\", type = StorageParamTypeEnum.SWITCH, defaultValue = \"true\", description = \"控制代理下载时的下载行为：关闭则使用浏览器默认行为，启用则强制下载\", order = 105)\n    private boolean proxyLinkForceDownload;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/param/ProxyTransferParam.java",
    "content": "package im.zhaojun.zfile.module.storage.model.param;\n\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamItem;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageParamTypeEnum;\nimport lombok.Getter;\n\n/**\n * 代理上传下载参数\n *\n * @author zhaojun\n */\n@Getter\npublic class ProxyTransferParam implements IStorageParam {\n\n\t@StorageParamItem(name = \"加速域名\", required = false, description = \"如配置加速域名，则会使用你指定的域名+文件路径生成下载链接，不写则默认使用服务器中转下载(除非你知道你在做什么，不然一般不用填写该值).\", order = 10)\n\tprivate String domain;\n\n\t@StorageParamItem(name = \"生成签名链接\", condition = \"domain==\", type = StorageParamTypeEnum.SWITCH, defaultValue = \"true\", description = \"代理下载会生成带签名的链接, 如不想对外开放直链, 可以防止被当做直链使用（类似于对象存储的私有空间）.\", order = 20)\n\tprivate boolean proxyPrivate;\n\n\t@StorageParamItem(name = \"下载签名有效期\", condition = \"proxyPrivate==true\", required = false, defaultValue = \"1800\", description = \"用于下载签名的有效期, 单位为秒, 如不配置则默认为 1800 秒.\", order = 30)\n\tprivate Integer proxyTokenTime;\n\n\t@StorageParamItem(name = \"下载链接强制下载\", type = StorageParamTypeEnum.SWITCH, defaultValue = \"true\", description = \"关闭则使用浏览器默认行为，启用则强制下载\", order = 50)\n\tprivate boolean proxyLinkForceDownload;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/param/QiniuParam.java",
    "content": "package im.zhaojun.zfile.module.storage.model.param;\n\nimport lombok.Getter;\n\n/**\n * 七牛云初始化参数\n *\n * @author zhaojun\n */\n@Getter\npublic class QiniuParam extends S3BaseParam {\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/param/S3BaseParam.java",
    "content": "package im.zhaojun.zfile.module.storage.model.param;\n\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamItem;\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamSelectOption;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageParamTypeEnum;\nimport lombok.Getter;\n\n/**\n * S3 通用参数\n *\n * @author zhaojun\n */\n@Getter\npublic class S3BaseParam extends OptionalProxyTransferParam {\n\n\t@StorageParamItem(name = \"AccessKey\", order = 10)\n\tprivate String accessKey;\n\n\t@StorageParamItem(name = \"SecretKey\", order = 20)\n\tprivate String secretKey;\n\n\t@StorageParamItem(name = \"区域\", order = 30, description = \"如下拉列表中没有的区域，或想使用内网地址，可直接输入后回车，如: xxx-cn-beijing.example.com\")\n\tprivate String endPoint;\n\n\t@StorageParamItem(name = \"EndPoint 协议\", order = 31, description = \"指定 EndPoint 使用的协议, 默认为 http\",\n\t\t\ttype = StorageParamTypeEnum.SELECT,\n\t\t\toptions = {\n\t\t\t\t@StorageParamSelectOption(label = \"http\", value = \"http\"),\n\t\t\t\t@StorageParamSelectOption(label = \"https\", value = \"https\")\n\t\t\t},\n\t\t\tdefaultValue = \"http\")\n\tprivate String endPointScheme;\n\n\t@StorageParamItem(name = \"存储空间名称\", order = 40)\n\tprivate String bucketName;\n\n\t@StorageParamItem(name = \"基路径\", order = 50, required = false, defaultValue = \"/\", description = \"基路径表示该存储源哪个目录在 ZFile 中作为根目录，如： '/'，'/文件夹1'\")\n\tprivate String basePath;\n\n\t@StorageParamItem(name = \"Bucket 域名 / CDN 加速域名\", required = false, order = 60)\n\tprivate String domain;\n\n\t@StorageParamItem(name = \"是否是私有空间\", order = 70, type = StorageParamTypeEnum.SWITCH, defaultValue = \"true\", description = \"私有空间会生成带签名的下载链接\")\n\tprivate boolean isPrivate;\n\n\t@StorageParamItem(name = \"下载签名有效期\", order = 80, condition = \"isPrivate==true\", required = false, defaultValue = \"1800\", description = \"当为私有空间时, 用于下载签名的有效期, 单位为秒, 如不配置则默认为 1800 秒.\")\n\tprivate Integer tokenTime;\n\n\t@StorageParamItem(name = \"跨域配置\", order = 200, defaultValue = \"[]\")\n\tprivate String corsConfigList;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/param/S3Param.java",
    "content": "package im.zhaojun.zfile.module.storage.model.param;\n\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamItem;\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamSelectOption;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageParamTypeEnum;\nimport lombok.Getter;\n\n/**\n * S3 初始化参数\n *\n * @author zhaojun\n */\n@Getter\npublic class S3Param extends S3BaseParam {\n\n\t@StorageParamItem(name = \"EndPoint\", order = 30)\n\tprivate String endPoint;\n\n\t@StorageParamItem(name = \"地域\", order = 45)\n\tprivate String region;\n\n\t@StorageParamItem(name = \"域名风格\", type = StorageParamTypeEnum.SELECT,\n\t\t\toptions = { @StorageParamSelectOption(value = \"path-style\", label = \"路径风格\"),\n\t\t\t\t\t\t@StorageParamSelectOption(value = \"bucket-virtual-hosting\", label = \"虚拟主机风格\") },\n\t\t\tlinkName = \"查看 S3 API 说明文档\", link = \"https://docs.aws.amazon.com/zh_cn/AmazonS3/latest/userguide/VirtualHosting.html#path-style-access\",\n\t\t\tdescription = \"当使用域名访问时, 域名风格只支持使用路径模式\",\n\t\t\torder = 65)\n\tprivate String pathStyle;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/param/SftpParam.java",
    "content": "package im.zhaojun.zfile.module.storage.model.param;\n\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamItem;\nimport im.zhaojun.zfile.module.storage.annotation.impl.EncodingStorageParamSelect;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageParamTypeEnum;\nimport lombok.Getter;\n\n/**\n * SFTP 初始化参数\n *\n * @author zhaojun\n */\n@Getter\npublic class SftpParam extends ProxyTransferParam {\n\n    @StorageParamItem(name = \"域名或 IP\", order = 1)\n    private String host;\n\n    @StorageParamItem(name = \"端口\", order = 2)\n    private int port;\n\n    @StorageParamItem(name = \"编码格式\",\n            defaultValue = \"UTF-8\",\n            type = StorageParamTypeEnum.SELECT,\n            optionsClass = EncodingStorageParamSelect.class,\n            description = \"表示文件夹及文件名称的编码格式，不表示文本内容的编码格式.\", order = 3)\n    private String encoding;\n\n    @StorageParamItem(name = \"用户名\", required = false, order = 4)\n    private String username;\n\n    @StorageParamItem(name = \"密码\", required = false, order = 5)\n    private String password;\n\n    @StorageParamItem(name = \"密钥\", type = StorageParamTypeEnum.TEXTAREA, required = false, order = 6)\n    private String privateKey;\n\n    @StorageParamItem(name = \"密钥 passphrase\", required = false, order = 7)\n    private String passphrase;\n\n    @StorageParamItem(name = \"基路径\", defaultValue = \"/\", description = \"基路径表示该存储源哪个目录在 ZFile 中作为根目录，如： '/'，'/文件夹1'\", order = 8)\n    private String basePath;\n\n    @StorageParamItem(name = \"最大连接数\", defaultValue = \"8\", description = \"要确保你服务器 SSH 的可用连接数大于这个值，不然可能会报错 channel is not opened.\", order = 9)\n    private Integer maxConnections;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/param/SharePointChinaParam.java",
    "content": "package im.zhaojun.zfile.module.storage.model.param;\n\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamItem;\nimport lombok.Getter;\n\n/**\n * SharePoint 世纪互联初始化参数\n *\n * @author zhaojun\n */\n@Getter\npublic class SharePointChinaParam extends SharePointParam {\n\n\t@StorageParamItem(name = \"clientId\", defaultValue = \"${zfile.onedrive-china.clientId}\", order = 1)\n\tprivate String clientId;\n\n\t@StorageParamItem(name = \"SecretKey\", defaultValue = \"${zfile.onedrive-china.clientSecret}\", order = 2)\n\tprivate String clientSecret;\n\t\n\t@StorageParamItem(name = \"回调地址\", description = \"如使用自定义 api, 需将此处默认的域名修改为您的域名, 且需在 api 中配置为回调域名.\",\n\t\t\tdefaultValue = \"${zfile.onedrive-china.redirectUri}\", order = 3)\n\tprivate String redirectUri;\n\n\t@StorageParamItem(name = \"访问令牌\", link = \"/onedrive/china-authorize\", linkName = \"前往获取令牌\", order = 3)\n\tprivate String accessToken;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/param/SharePointParam.java",
    "content": "package im.zhaojun.zfile.module.storage.model.param;\n\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamItem;\nimport lombok.Getter;\n\n/**\n * SharePoint 初始化参数\n *\n * @author zhaojun\n */\n@Getter\npublic class SharePointParam extends MicrosoftDriveParam {\n\n\t@StorageParamItem(name = \"clientId\", defaultValue = \"${zfile.onedrive.clientId}\", order = 1)\n\tprivate String clientId;\n\n\t@StorageParamItem(name = \"SecretKey\", defaultValue = \"${zfile.onedrive.clientSecret}\", order = 2)\n\tprivate String clientSecret;\n\n\t@StorageParamItem(name = \"网站\", order = 5, description = \"如此处选择不到网站，请检查世纪互联中网站隐私设置是否为\\\"公用-组织中的任何人都可访问此站点\\\"\")\n\tprivate String siteId;\n\n\t@StorageParamItem(name = \"子目录\", order = 6, description = \"表示 SharePoint 子列表/子网站，在世纪互联网站 Tab 卡中 \\\"网站内容\\\" 新增.\")\n\tprivate String listId;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/param/TencentParam.java",
    "content": "package im.zhaojun.zfile.module.storage.model.param;\n\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamItem;\nimport im.zhaojun.zfile.module.storage.enums.StorageParamItemAnnoEnum;\nimport lombok.Getter;\n\n/**\n * 腾讯云初始化参数\n *\n * @author zhaojun\n */\n@Getter\npublic class TencentParam extends S3BaseParam {\n\n\t@StorageParamItem(key = \"secretId\", name = \"SecretId\", order = 1)\n\tprivate String accessKey;\n\n\t@StorageParamItem(name = \"SecretKey\", order = 2)\n\tprivate String secretKey;\n\n\t@StorageParamItem(description = \"如果使用自定义加速域名，请在腾讯云控制台关闭回源鉴权功能，否则同时勾选下面的私有空间时会冲突导致下载失败.\", onlyOverwrite = { StorageParamItemAnnoEnum.DESCRIPTION })\n\tprivate String domain;\n\n\t@StorageParamItem(description = \"私有空间会生成带签名的下载链接. <font color=\\\"red\\\">如您使用自定义CDN域名，且在腾讯云开启了回源鉴权，请务必关闭此选项。</font>\", onlyOverwrite = { StorageParamItemAnnoEnum.DESCRIPTION })\n\tprivate boolean isPrivate;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/param/UpYunParam.java",
    "content": "package im.zhaojun.zfile.module.storage.model.param;\n\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamItem;\nimport lombok.Getter;\n\n/**\n * 又拍云初始化参数\n *\n * @author zhaojun\n */\n@Getter\npublic class UpYunParam extends OptionalProxyTransferParam {\n\n\t@StorageParamItem(name = \"存储空间名称\", order = 1)\n\tprivate String bucketName;\n\n\t@StorageParamItem(name = \"操作员名称\", order = 2)\n\tprivate String username;\n\n\t@StorageParamItem(name = \"操作员密码\", order = 3)\n\tprivate String password;\n\n\t@StorageParamItem(name = \"下载域名\", description = \"填写您在又拍云绑定的域名.\", required = false, order = 4)\n\tprivate String domain;\n\n\t@StorageParamItem(name = \"基路径\", defaultValue = \"/\", description = \"基路径表示该存储源哪个目录在 ZFile 中作为根目录，如： '/'，'/文件夹1'\", order = 5)\n\tprivate String basePath;\n\n\t@StorageParamItem(name = \"Token\", required = false,\n\t\t\tcondition = \"enableProxyDownload==false\",\n\t\t\tlink = \"https://help.upyun.com/knowledge-base/cdn-token-limite/\", linkName = \"官方配置文档\",\n\t\t\tdescription = \"可在又拍云后台开启 \\\"访问控制\\\" -> \\\"Token 防盗链\\\"，控制资源内容的访问时限，即时间戳防盗链。\", order = 6)\n\tprivate String token;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/param/WebdavParam.java",
    "content": "package im.zhaojun.zfile.module.storage.model.param;\n\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamItem;\nimport im.zhaojun.zfile.module.storage.enums.StorageParamItemAnnoEnum;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageParamTypeEnum;\nimport lombok.Getter;\n\n/**\n * WebDav 初始化参数\n *\n * @author zhaojun\n */\n@Getter\npublic class WebdavParam extends ProxyTransferParam {\n\n\t@StorageParamItem(key = \"url\", name = \"WebDAV地址\", order = 1)\n\tprivate String url;\n\n\t@StorageParamItem(key = \"username\", name = \"用户名\", required = false, order = 2)\n\tprivate String username;\n\n\t@StorageParamItem(key = \"password\", name = \"密码\", required = false, order = 3)\n\tprivate String password;\n\n\t@StorageParamItem(name = \"基路径\", required = false, defaultValue = \"/\", description = \"基路径表示该存储源哪个目录在 ZFile 中作为根目录，如： '/'，'/文件夹1'\", order = 4)\n\tprivate String basePath;\n\n\t@StorageParamItem(key = \"redirectMode\", name = \"重定向模式\", condition = \"domain?==\", type = StorageParamTypeEnum.SWITCH, required = false, defaultValue = \"false\", description = \"启用后下载会直接重定向到 Webdav 原地址，即<font style=\\\"font-weight: bold\\\">WebDAV地址/文件路径/文件名</font>，而不是中转下载.（此功能需 WebDAV 服务端支持匿名下载，因为中转下载时会携带认证信息）\", order = 5)\n\tprivate boolean redirectMode;\n\n\t@StorageParamItem(condition = \"redirectMode?==false\", description = \"类似于重定向模式，只不过使用的不是上面配置的 WebDAV 地址，而是该字段的地址(请确认你配置的这个地址支持拼接路径后匿名下载).\", onlyOverwrite = { StorageParamItemAnnoEnum.CONDITION, StorageParamItemAnnoEnum.DESCRIPTION })\n\tprivate String domain;\n\n\t@StorageParamItem(condition = \"domain==&&redirectMode==false\", onlyOverwrite = StorageParamItemAnnoEnum.CONDITION)\n\tprivate boolean proxyPrivate;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/request/GetGoogleDriveListRequest.java",
    "content": "package im.zhaojun.zfile.module.storage.model.request;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport jakarta.validation.constraints.NotBlank;\n\n/**\n * @author zhaojun\n */\n@Data\n@Schema(title=\"gd drive 列表请求类\")\npublic class GetGoogleDriveListRequest {\n\t\n\t@NotBlank(message = \"accessToken 不能为空\")\n\t@Schema(title = \"accessToken\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"v7LtfjIbnxLCTj0R3riwhyxcbv4KVH5HuPWHWrrewHMEwjJyUlYXV6D4m1MLJ2dP__GX_7CKCc-HudUetPXWS2wwbfkNs6ydLq3xrk1gHA7wcD_pmt6oNuRXw5mnFzfdLkH5wIG1suQp3p0eHJurzIaCgYKATASATASFQE65dr8hO725r41QtZc9RJVUg12cA0163\")\n\tprivate String accessToken;\n\t\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/request/GetS3BucketListRequest.java",
    "content": "package im.zhaojun.zfile.module.storage.model.request;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport jakarta.validation.constraints.NotBlank;\n\n/**\n * 获取 S3 bucket 列表请求类\n *\n * @author zhaojun\n */\n@Data\n@Schema(title=\"S3 bucket 列表请求类\")\npublic class GetS3BucketListRequest {\n\n\t@NotBlank(message = \"accessKey 不能为空\")\n\t@Schema(title = \"accessKey\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"XQEWQJI129JAS12\")\n\tprivate String accessKey;\n\n\t@NotBlank(message = \"secretKey 不能为空\")\n\t@Schema(title = \"secretKey\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"EWQJI129JAS11AE2\")\n\tprivate String secretKey;\n\n\t@NotBlank(message = \"EndPoint 不能为空\")\n\t@Schema(title = \"Endpoint 接入点\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"oss-cn-beijing.aliyuncs.com\")\n\tprivate String endPoint;\n\n\t@Schema(title = \"Endpoint 接入点\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"cn-beijing\")\n\tprivate String region;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/request/GetS3CorsListRequest.java",
    "content": "package im.zhaojun.zfile.module.storage.model.request;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotBlank;\nimport lombok.Data;\n\n/**\n * 获取 S3 bucket 列表请求类\n *\n * @author zhaojun\n */\n@Data\n@Schema(title=\"S3 bucket 列表请求类\")\npublic class GetS3CorsListRequest {\n\n\t@NotBlank(message = \"accessKey 不能为空\")\n\t@Schema(title = \"accessKey\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"XQEWQJI129JAS12\")\n\tprivate String accessKey;\n\n\t@NotBlank(message = \"secretKey 不能为空\")\n\t@Schema(title = \"secretKey\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"EWQJI129JAS11AE2\")\n\tprivate String secretKey;\n\n\t@NotBlank(message = \"EndPoint 不能为空\")\n\t@Schema(title = \"Endpoint 接入点\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"oss-cn-beijing.aliyuncs.com\")\n\tprivate String endPoint;\n\n\t@Schema(title = \"Endpoint 接入点\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"cn-beijing\")\n\tprivate String region;\n\n\t@Schema(title = \"存储桶名称\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"zfile-bucket\")\n\tprivate String bucketName;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/request/SharePointInfoRequest.java",
    "content": "package im.zhaojun.zfile.module.storage.model.request;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\n\n/**\n * SharePoint 信息请求参数\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"SharePoint 信息请求类\")\npublic class SharePointInfoRequest {\n\n    @Schema(title = \"SharePoint 类型\", description =\"Standard(国际版、个人版等) 或 China(世纪互联)\", required = true, example = \"Standard\")\n    private String type;\n\n    @Schema(title = \"访问令牌 (accessToken)\", required = true, example = \"EwBoxxxxxxxxxxxxxxxbAI=\")\n    private String accessToken;\n\n    @Schema(title = \"域名前缀\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"zfile\")\n    private String domainPrefix;\n\n    @Schema(title = \"站点类型\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"/sites/\")\n    private String siteType;\n\n    @Schema(title = \"站点名称\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"zfile\")\n    private String siteName;\n\n    @Schema(title = \"域名类型\", description =\"com 或 cn\", example = \"com\")\n    private String domainType;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/request/SharePointSearchSitesRequest.java",
    "content": "package im.zhaojun.zfile.module.storage.model.request;\n\nimport im.zhaojun.zfile.core.validation.StringListValue;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport jakarta.validation.constraints.NotBlank;\n\n/**\n * SharePoint 搜索网站列表请求\n *\n * @author zhaojun\n */\n@Data\npublic class SharePointSearchSitesRequest {\n\n\t@StringListValue(message = \"账号类型只能是 Standard（标准版、国际版）或 China（世纪互联）\", vals = {\"Standard\", \"China\"})\n\tprivate String type;\n\n\t@Schema(title = \"访问令牌 (accessToken)\", required = true, example = \"EwBoxxxxxxxxxxxxxxxbAI=\")\n\t@NotBlank(message = \"访问令牌不能为空\")\n\tprivate String accessToken;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/request/SharePointSiteListsRequest.java",
    "content": "package im.zhaojun.zfile.module.storage.model.request;\n\nimport im.zhaojun.zfile.core.validation.StringListValue;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport jakarta.validation.constraints.NotBlank;\n\n/**\n * 获取 SharePoint 网站下目录请求\n *\n * @author zhaojun\n */\n@Data\npublic class SharePointSiteListsRequest {\n\n\t@StringListValue(message = \"账号类型只能是 Standard（标准版、国际版）或 China（世纪互联）\", vals = {\"Standard\", \"China\"})\n\tprivate String type;\n\n\t@Schema(title = \"访问令牌 (accessToken)\", required = true, example = \"EwBoxxxxxxxxxxxxxxxbAI=\")\n\t@NotBlank(message = \"访问令牌不能为空\")\n\tprivate String accessToken;\n\n\t@Schema(title = \"站点 ID (siteId)\", required = true, example = \"a046ac3a-ea74-13c5-8b8f-233599507d96 或 xxx.sharepoint.cn,a046ac3a-ea74-13c5-8b8f-233599507d96,ec7e71ed-9065-4190-b471-b91c28c30bb1  如果是后者，则会自动截取中间那部分，结果和前者相同\")\n\t@NotBlank(message = \"siteId 不能为空\")\n\tprivate String siteId;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/request/admin/CopyStorageSourceRequest.java",
    "content": "package im.zhaojun.zfile.module.storage.model.request.admin;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotNull;\n\n/**\n * 复制存储源请求参数\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"复制存储源请求请求类\")\npublic class CopyStorageSourceRequest {\n\n    @Schema(title = \"存储源 ID\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"1\")\n    @NotNull(message = \"存储源 id 不能为空\")\n    private Integer fromId;\n\n    @Schema(title = \"复制后存储源名称\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"1\")\n    @NotBlank(message = \"复制后存储源名称不能为空\")\n    private String toName;\n\n    @Schema(title = \"复制后存储源别名\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"1\")\n    @NotBlank(message = \"复制后存储源别名不能为空\")\n    private String toKey;\n\n\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/request/admin/UpdateStorageIdRequest.java",
    "content": "package im.zhaojun.zfile.module.storage.model.request.admin;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport jakarta.validation.constraints.NotBlank;\n\n/**\n * 更新存储源参数请求参数\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"更新存储源 id 请求类\")\npublic class UpdateStorageIdRequest {\n\n    @Schema(title = \"存储源原 ID\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"1\")\n    @NotBlank(message = \"源 id 不能为空\")\n    private Integer updateId;\n\n\n    @Schema(title = \"存储源新 ID\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"2\")\n    @NotBlank(message = \"修改后的 id 不能为空\")\n    private Integer newId;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/request/admin/UpdateStorageSortRequest.java",
    "content": "package im.zhaojun.zfile.module.storage.model.request.admin;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport jakarta.validation.constraints.NotBlank;\n\n/**\n * 更新存储源排序值请求参数\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"更新存储源排序值请求类\")\npublic class UpdateStorageSortRequest {\n\n    @Schema(title = \"存储源 ID\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"1\")\n    @NotBlank(message = \"存储源 id 不能为空\")\n    private Integer id;\n\n\n    @Schema(title = \"排序值，值越小越靠前\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"5\")\n    @NotBlank(message = \"排序值不能为空\")\n    private Integer orderNum;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/request/base/FileItemRequest.java",
    "content": "package im.zhaojun.zfile.module.storage.model.request.base;\n\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport jakarta.validation.constraints.NotBlank;\n\n/**\n * 获取指定文件信息的请求参数\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"获取指定文件信息的请求类\")\npublic class FileItemRequest {\n\n    @Schema(title = \"存储源 key\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"local\")\n    @NotBlank(message = \"存储源 key 不能为空\")\n    private String storageKey;\n\n    @Schema(title = \"请求路径\", example = \"/\")\n    private String path;\n\n\t@Schema(title = \"文件夹密码, 如果文件夹需要密码才能访问，则支持请求密码\", example = \"123456\")\n\tprivate String password;\n\n    public void handleDefaultValue() {\n        if (StringUtils.isEmpty(path)) {\n            path = \"/\";\n        }\n\t\t// 自动补全路径, 如 a 补全为 /a/\n        path = StringUtils.concat(path);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/request/base/FileListConfigRequest.java",
    "content": "package im.zhaojun.zfile.module.storage.model.request.base;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotBlank;\nimport lombok.Data;\n\n/**\n * 获取文件夹参数请求参数\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"获取文件夹参数请求类\")\npublic class FileListConfigRequest {\n\n\t@Schema(title = \"存储源 key\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"local\")\n\t@NotBlank(message = \"存储源 key 不能为空\")\n\tprivate String storageKey;\n\n\t@Schema(title = \"请求路径\", example = \"/\")\n\tprivate String path = \"/\";\n\t\n\t@Schema(title = \"文件夹密码\", example = \"123456\")\n\tprivate String password;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/request/base/FileListRequest.java",
    "content": "package im.zhaojun.zfile.module.storage.model.request.base;\n\nimport cn.hutool.core.util.StrUtil;\nimport im.zhaojun.zfile.core.validation.StringListValue;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport jakarta.validation.constraints.NotBlank;\n\n/**\n * 获取文件夹下文件列表请求参数\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"获取文件夹下文件列表请求类\")\npublic class FileListRequest {\n\n    @Schema(title = \"存储源 key\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"local\")\n    @NotBlank(message = \"存储源 key 不能为空\")\n    private String storageKey;\n\n    @Schema(title = \"请求路径\", example = \"/\")\n    private String path;\n\n\t@Schema(title = \"文件夹密码, 如果文件夹需要密码才能访问，则支持请求密码\", example = \"123456\")\n\tprivate String password;\n\n\t@StringListValue(message = \"排序字段参数异常，只能是 name、size、time\", vals = {\"name\", \"size\", \"time\"})\n\tprivate String orderBy;\n\n\t@StringListValue(message = \"排序顺序参数异常，只能是 asc 或 desc\", vals = {\"asc\", \"desc\"})\n\tprivate String orderDirection;\n\n    public void handleDefaultValue() {\n        if (StringUtils.isEmpty(path)) {\n            path = \"/\";\n        }\n    \tif (StringUtils.isEmpty(orderBy)) {\n    \t\torderBy = \"name\";\n    \t}\n        if (StringUtils.isEmpty(orderDirection)) {\n    \t\torderDirection = \"asc\";\n    \t}\n\n\t\t// 自动补全路径, 如 a 补全为 /a/\n        path = StringUtils.concat(path);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/request/base/SaveStorageSourceRequest.java",
    "content": "package im.zhaojun.zfile.module.storage.model.request.base;\n\nimport com.baomidou.mybatisplus.annotation.TableField;\nimport im.zhaojun.zfile.module.storage.model.enums.SearchModeEnum;\nimport im.zhaojun.zfile.module.storage.model.dto.StorageSourceAllParamDTO;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\n/**\n * 保存存储源信息请求类\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"存储源基本参数\")\npublic class SaveStorageSourceRequest {\n\n    @Schema(title = \"ID, 新增无需填写\", example = \"1\")\n    private Integer id;\n\n    @Schema(title = \"存储源名称\", example = \"阿里云 OSS 存储\")\n    private String name;\n\n    @Schema(title = \"存储源别名\", example = \"存储源别名，用于 URL 中展示, 如 http://ip:port/{存储源别名}\")\n    private String key;\n\n    @Schema(title = \"存储源备注\", example = \"这是一个备注信息, 用于管理员区分不同的存储源, 此字段仅管理员可见\")\n    private String remark;\n\n    @Schema(title = \"存储源类型\", example = \"ftp\")\n    private StorageTypeEnum type;\n\n    @Schema(title = \"是否启用\", example = \"true\")\n    private Boolean enable;\n\n    @Schema(title = \"是否启用文件操作功能\", example = \"true\", description =\"是否启用文件上传，编辑，删除等操作.\")\n    private Boolean enableFileOperator;\n\n    @Schema(title = \"是否允许匿名进行文件操作\", example = \"true\", description =\"是否允许匿名进行文件上传，编辑，删除等操作.\")\n    private Boolean enableFileAnnoOperator;\n\n    @Schema(title = \"是否开启缓存\", example = \"true\")\n    private boolean enableCache;\n\n    @Schema(title = \"是否开启缓存自动刷新\", example = \"true\")\n    private boolean autoRefreshCache;\n\n    @Schema(title = \"是否开启搜索\", example = \"true\")\n    private boolean searchEnable;\n\n    @Schema(title = \"搜索是否忽略大小写\", example = \"true\")\n    private boolean searchIgnoreCase;\n\n    @TableField(value = \"`search_mode`\")\n    @Schema(title = \"搜索模式\", example = \"SEARCH_CACHE\", description =\"仅从缓存中搜索或直接全量搜索\")\n    private SearchModeEnum searchMode;\n\n    @Schema(title = \"排序值\", example = \"1\")\n    private Integer orderNum;\n\n    @Schema(title = \"存储源拓展属性\")\n    private StorageSourceAllParamDTO storageSourceAllParam;\n\n    @Schema(title = \"是否默认开启图片模式\", example = \"true\")\n    private boolean defaultSwitchToImgMode;\n    \n    @Schema(title = \"兼容 readme 模式\", example = \"true\", description =\"兼容模式, 目录文档读取 readme.md 文件\")\n    private boolean compatibilityReadme;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/request/base/SearchStorageRequest.java",
    "content": "package im.zhaojun.zfile.module.storage.model.request.base;\n\nimport im.zhaojun.zfile.core.util.PatternMatcherUtils;\nimport im.zhaojun.zfile.module.storage.model.enums.SearchFolderModeEnum;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport jakarta.validation.constraints.NotBlank;\nimport java.util.Map;\n\n/**\n * 搜索存储源中文件请求参数\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"搜索存储源中文件请求类\")\npublic class SearchStorageRequest {\n\n    @Schema(title = \"存储源 key\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"local\")\n    @NotBlank(message = \"存储源 key 不能为空\")\n    private String storageKey;\n\n    @Schema(title = \"搜索关键字\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"png\")\n    private String searchKeyword;\n\n    @Schema(title = \"搜索模式\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"search_all\")\n    private SearchFolderModeEnum searchMode;\n\n    @Schema(title = \"搜索路径\", example = \"/\")\n    private String path;\n\n    @Schema(title = \"密码缓存\")\n    private Map<String, String> passwordCache;\n\n\n    /**\n     * 根据路径获取缓存的密码\n     *\n     * @param   path\n     *          文件夹路径\n     *\n     * @return  密码, 没找到则返回 null.\n     */\n    public String getPathPasswordCache(String path) {\n        if (passwordCache == null) {\n            return null;\n        }\n        for (Map.Entry<String, String> entry : passwordCache.entrySet()) {\n            String key = entry.getKey();\n            String value = entry.getValue();\n    \n            // 判断当前请求路径是否和规则路径表达式匹配\n            boolean match = PatternMatcherUtils.testCompatibilityGlobPattern(key, path);\n            if (match) {\n                return value;\n            }\n        }\n\n        return null;\n    }\n\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/request/operator/BatchDeleteRequest.java",
    "content": "package im.zhaojun.zfile.module.storage.model.request.operator;\n\nimport im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotEmpty;\nimport java.util.List;\n\n/**\n * 删除文件夹请求参数\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"删除文件夹请求类\")\npublic class BatchDeleteRequest {\n\n    @Schema(title = \"存储源 key\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"local\")\n    @NotBlank(message = \"存储源 key 不能为空\")\n    private String storageKey;\n    \n    @Schema(title = \"删除的文件详情\")\n    @NotEmpty(message = \"要删除的文件/文件夹不能为空\")\n    private List<DeleteItem> deleteItems;\n    \n    @Data\n    public static class DeleteItem {\n\n        private String path;\n\n        private String name;\n\n        private FileTypeEnum type;\n        \n        private String password;\n        \n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/request/operator/BatchMoveOrCopyFileRequest.java",
    "content": "package im.zhaojun.zfile.module.storage.model.request.operator;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotEmpty;\nimport java.util.List;\n\n/**\n * 批量(移动/复制)(文件/文件夹)请求，不支持跨存储策略操作，也不支持批量时源路径不相同或目标路径不相同的操作。\n *\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"(移动/复制)(文件/文件夹)请求\")\npublic class BatchMoveOrCopyFileRequest {\n\n    @Schema(title = \"存储源 key\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"local\")\n    @NotBlank(message = \"存储源 key 不能为空\")\n    private String storageKey;\n\n    @Schema(title = \"请求路径\", example = \"/\", description =\"表示要移动的文件夹所在的文件夹\")\n    @NotBlank\n    private String path;\n\n    @Schema(title = \"文件夹名称\", example = \"movie\", description =\"表示要移动的文件夹名称，支持多个\")\n    @NotEmpty\n    private List<String> nameList;\n\n    @Schema(title = \"目标路径\", example = \"/\", description =\"表示要移动到的文件夹\")\n    @NotBlank\n    private String targetPath;\n\n    @Schema(title = \"目标文件夹名称\", example = \"电影\", description =\"表示要移动到的文件夹名称，支持多个\")\n    @NotEmpty\n    private List<String> targetNameList;\n\n    @Schema(title = \"源文件夹密码, 如果文件夹需要密码才能访问，则支持请求密码\", example = \"123456\")\n    private String srcPathPassword;\n\n    @Schema(title = \"目标文件夹密码, 如果文件夹需要密码才能访问，则支持请求密码\", example = \"123456\")\n    private String targetPathPassword;\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/request/operator/NewFolderRequest.java",
    "content": "package im.zhaojun.zfile.module.storage.model.request.operator;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotBlank;\nimport lombok.Data;\n\n/**\n * 新建文件夹请求参数\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"新建文件夹请求类\")\npublic class NewFolderRequest {\n\n    @Schema(title = \"存储源 key\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"local\")\n    @NotBlank(message = \"存储源 key 不能为空\")\n    private String storageKey;\n\n    @Schema(title = \"请求路径\", example = \"/\", description =\"表示在哪个文件夹下创建文件夹\")\n    private String path = \"/\";\n\n    @Schema(title = \"新建的文件夹名称\", example = \"/a/b/c\", description =\"文件夹名称支持多级，如：/a/b/c\")\n    @NotBlank(message = \"新建的文件夹名称不能为空\")\n    private String name;\n    \n    @Schema(title = \"文件夹密码, 如果文件夹需要密码才能访问，则支持请求密码\", example = \"123456\")\n    private String password;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/request/operator/RenameFileRequest.java",
    "content": "package im.zhaojun.zfile.module.storage.model.request.operator;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotBlank;\nimport lombok.Data;\n\n/**\n * 重命名文件请求参数\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"重命名文件请求类\")\npublic class RenameFileRequest {\n\n    @Schema(title = \"存储源 key\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"local\")\n    @NotBlank(message = \"存储源 key 不能为空\")\n    private String storageKey;\n\n    @Schema(title = \"请求路径\", example = \"/\", description =\"表示在哪个文件夹下重命名文件\")\n    private String path = \"/\";\n\n    @Schema(title = \"重命名的原文件名称\", example = \"test.txt\")\n    @NotBlank(message = \"原文件名不能为空\")\n    private String name;\n\n    @Schema(title = \"重命名后的文件名称\", example = \"text-1.txt\")\n    @NotBlank(message = \"新文件名不能为空\")\n    private String newName;\n    \n    @Schema(title = \"文件夹密码, 如果文件夹需要密码才能访问，则支持请求密码\", example = \"123456\")\n    private String password;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/request/operator/RenameFolderRequest.java",
    "content": "package im.zhaojun.zfile.module.storage.model.request.operator;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotBlank;\nimport lombok.Data;\n\n/**\n * 重命名文件夹请求参数\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"重命名文件夹请求类\")\npublic class RenameFolderRequest {\n\n    @Schema(title = \"存储源 key\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"local\")\n    @NotBlank(message = \"存储源 key 不能为空\")\n    private String storageKey;\n\n    @Schema(title = \"请求路径\", example = \"/\", description =\"表示在哪个文件夹下重命名文件夹\")\n    private String path = \"/\";\n\n    @Schema(title = \"重命名的原文件夹名称\", example = \"movie\")\n    @NotBlank(message = \"原文件夹名称不能为空\")\n    private String name;\n\n    @Schema(title = \"重命名后的文件名称\", example = \"music\")\n    @NotBlank(message = \"新文件夹名称不能为空\")\n    private String newName;\n    \n    @Schema(title = \"文件夹密码, 如果文件夹需要密码才能访问，则支持请求密码\", example = \"123456\")\n    private String password;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/request/operator/UploadFileRequest.java",
    "content": "package im.zhaojun.zfile.module.storage.model.request.operator;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotBlank;\nimport lombok.Data;\n\n/**\n * 上传文件请求参数\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"上传文件请求类\")\npublic class UploadFileRequest {\n\n    @Schema(title = \"存储源 key\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"local\")\n    @NotBlank(message = \"存储源 key 不能为空\")\n    private String storageKey;\n\n    @Schema(title = \"上传路径\", example = \"/movie\", description =\"表示上传文件到哪个路径\")\n    private String path = \"/\";\n\n    @Schema(title = \"上传的文件名\", example = \"test.mp4\")\n    @NotBlank(message = \"上传的文件名不能为空\")\n    private String name;\n\n    @Schema(title = \"文件大小\", example = \"129102\")\n    private Long size;\n    \n    @Schema(title = \"文件夹密码, 如果文件夹需要密码才能访问，则支持请求密码\", example = \"123456\")\n    private String password;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/result/FileInfoResult.java",
    "content": "package im.zhaojun.zfile.module.storage.model.result;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\n\nimport java.util.List;\n\n/**\n * 文件列表信息结果类\n *\n * @author zhaojun\n */\n@Data\n@Schema(title=\"文件列表信息结果类\")\n@AllArgsConstructor\npublic class FileInfoResult {\n\n\t@Schema(title=\"文件列表\")\n\tprivate List<FileItemResult> files;\n\n\t@Schema(title=\"当前目录密码路径表达式\")\n\tprivate String passwordPattern;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/result/FileItemResult.java",
    "content": "package im.zhaojun.zfile.module.storage.model.result;\n\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport java.io.Serializable;\nimport java.util.Date;\n\n/**\n * 文件信息结果类\n * \n * @author zhaojun\n */\n@Data\n@Schema(title=\"文件列表信息结果类\")\npublic class FileItemResult implements Serializable {\n\n    @Schema(title = \"文件名\", example = \"a.mp4\")\n    private String name;\n    \n    @Schema(title = \"时间\", example = \"2020-01-01 15:22\")\n    private Date time;\n    \n    @Schema(title = \"大小\", example = \"1024\")\n    private Long size;\n    \n    @Schema(title = \"类型\", example = \"file\")\n    private FileTypeEnum type;\n    \n    @Schema(title = \"所在路径\", example = \"/home/\")\n    private String path;\n    \n    @Schema(title = \"下载地址\", example = \"http://www.example.com/a.mp4\")\n    private String url;\n    \n    /**\n     * 获取路径和名称的组合, 并移除重复的路径分隔符 /.\n     *\n     * @return  路径和名称的组合\n     */\n    @JsonIgnore\n    public String getFullPath() {\n        return StringUtils.concat(path, name);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/result/GoogleDriveInfoResult.java",
    "content": "package im.zhaojun.zfile.module.storage.model.result;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\n\n/**\n * gd drive 基本信息结果类\n *\n * @author zhaojun\n */\n@Data\n@AllArgsConstructor\n@Schema(title=\"gd drive 基本信息结果类\")\npublic class GoogleDriveInfoResult {\n\t\n\t@Schema(title = \"drive id\", example = \"0AGrY0xF1D7PEUk9PVB\")\n\tprivate String id;\n\t\n\t@Schema(title = \"drive 名称\", example = \"zfile\")\n\tprivate String name;\n\t\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/result/Open115AuthDeviceCodeResult.java",
    "content": "package im.zhaojun.zfile.module.storage.model.result;\n\nimport lombok.Data;\n\n@Data\npublic class Open115AuthDeviceCodeResult {\n\n    private String uid;\n\n    private Integer time;\n\n    private String sign;\n\n    private String codeVerifier;\n\n    private String qrcode;\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/result/Open115GetStatusResult.java",
    "content": "package im.zhaojun.zfile.module.storage.model.result;\n\nimport lombok.Data;\n\n@Data\npublic class Open115GetStatusResult {\n\n    private String status;\n\n    private String msg;\n\n    private String accessToken;\n\n    private String refreshToken;\n\n    private Integer expiredAt;\n\n    public static Open115GetStatusResult error(String msg) {\n        Open115GetStatusResult open115GetStatusResult = new Open115GetStatusResult();\n        open115GetStatusResult.setStatus(\"error\");\n        open115GetStatusResult.setMsg(msg);\n        return open115GetStatusResult;\n    }\n\n    public static Open115GetStatusResult waiting() {\n        Open115GetStatusResult open115GetStatusResult = new Open115GetStatusResult();\n        open115GetStatusResult.setStatus(\"waiting\");\n        return open115GetStatusResult;\n    }\n\n    public static Open115GetStatusResult scanning(String msg) {\n        Open115GetStatusResult open115GetStatusResult = new Open115GetStatusResult();\n        open115GetStatusResult.setStatus(\"scanning\");\n        open115GetStatusResult.setMsg(msg);\n        return open115GetStatusResult;\n    }\n\n    public static Open115GetStatusResult success(String accessToken, String refreshToken, Integer expiredAt) {\n        Open115GetStatusResult open115GetStatusResult = new Open115GetStatusResult();\n        open115GetStatusResult.setStatus(\"success\");\n        open115GetStatusResult.setAccessToken(accessToken);\n        open115GetStatusResult.setRefreshToken(refreshToken);\n        open115GetStatusResult.setExpiredAt(expiredAt);\n        return open115GetStatusResult;\n    }\n\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/result/S3BucketNameResult.java",
    "content": "package im.zhaojun.zfile.module.storage.model.result;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\n\nimport java.util.Date;\n\n/**\n * S3 bucket 名称结果类\n * \n * @author zhaojun\n */\n@Data\n@AllArgsConstructor\n@Schema(title=\"S3 bucket 名称结果类\")\npublic class S3BucketNameResult {\n\n\t@Schema(title = \"bucket 名称\", example = \"zfile\")\n\tprivate String name;\n\n\t@Schema(title = \"bucket 创建时间\", example = \"2022-01-01 15:22\")\n\tprivate Date date;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/result/SharepointSiteListResult.java",
    "content": "package im.zhaojun.zfile.module.storage.model.result;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport java.util.Date;\n\n/**\n * Sharepoint 网站 list 列表\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"Sharepoint 网站 list 列表\")\npublic class SharepointSiteListResult {\n\n\t@Schema(title=\"站点目录 id\")\n\tprivate String id;\n\n\t@Schema(title=\"站点目录名称\")\n\tprivate String displayName;\n\n\t@Schema(title=\"站点目录创建时间\")\n\tprivate Date createdDateTime;\n\n\t@Schema(title=\"站点目录地址\")\n\tprivate String webUrl;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/result/SharepointSiteResult.java",
    "content": "package im.zhaojun.zfile.module.storage.model.result;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\n/**\n * Sharepoint 站点信息\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"SharePoint 站点结果类\")\npublic class SharepointSiteResult {\n\n\t@Schema(title=\"站点 id\")\n\tprivate String id;\n\n\t@Schema(title=\"站点名称\")\n\tprivate String displayName;\n\n\t@Schema(title=\"站点地址\")\n\tprivate String webUrl;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/result/StorageSourceAdminResult.java",
    "content": "package im.zhaojun.zfile.module.storage.model.result;\n\nimport im.zhaojun.zfile.module.storage.model.enums.SearchModeEnum;\nimport im.zhaojun.zfile.module.storage.model.bo.RefreshTokenCacheBO;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\n/**\n * 存储源设置后台管理 Result\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"存储源设置后台管理 Result\")\npublic class StorageSourceAdminResult {\n\n\t@Schema(title = \"ID, 新增无需填写\", example = \"1\")\n\tprivate Integer id;\n\n\n\t@Schema(title = \"是否启用\", example = \"true\")\n\tprivate Boolean enable;\n\n\n\t@Schema(title = \"是否启用文件操作功能\", example = \"true\", description =\"是否启用文件上传，编辑，删除等操作.\")\n\tprivate Boolean enableFileOperator;\n\n\n\t@Schema(title = \"是否允许匿名进行文件操作\", example = \"true\", description =\"是否允许匿名进行文件上传，编辑，删除等操作.\")\n\tprivate Boolean enableFileAnnoOperator;\n\n\t@Schema(title = \"是否开启缓存\", example = \"true\")\n\tprivate Boolean enableCache;\n\n\n\t@Schema(title = \"存储源名称\", example = \"阿里云 OSS 存储\")\n\tprivate String name;\n\n\n\t@Schema(title = \"存储源别名\", example = \"存储源别名，用于 URL 中展示, 如 http://ip:port/{存储源别名}\")\n\tprivate String key;\n\n\n\t@Schema(title = \"存储源备注\", example = \"这是一个备注信息, 用于管理员区分不同的存储源, 此字段仅管理员可见\")\n\tprivate String remark;\n\n\n\t@Schema(title = \"是否开启缓存自动刷新\", example = \"true\")\n\tprivate Boolean autoRefreshCache;\n\n\n\t@Schema(title = \"存储源类型\")\n\tprivate StorageTypeEnum type;\n\n\n\t@Schema(title = \"是否开启搜索\", example = \"true\")\n\tprivate Boolean searchEnable;\n\n\n\t@Schema(title = \"搜索是否忽略大小写\", example = \"true\")\n\tprivate Boolean searchIgnoreCase;\n\n\n\t@Schema(title = \"搜索模式\", example = \"SEARCH_CACHE\", description =\"仅从缓存中搜索或直接全量搜索\")\n\tprivate SearchModeEnum searchMode;\n\n\n\t@Schema(title = \"排序值\", example = \"1\")\n\tprivate Integer orderNum;\n\n\n\t@Schema(title = \"是否默认开启图片模式\", example = \"true\")\n\tprivate Boolean defaultSwitchToImgMode;\n\n\n\t@Schema(title = \"存储源刷新信息\")\n\tprivate RefreshTokenCacheBO.RefreshTokenInfo refreshTokenInfo;\n\t\n\t\n\t@Schema(title = \"兼容 readme 模式\", example = \"true\", description =\"兼容模式, 目录文档读取 readme.md 文件\")\n\tprivate Boolean compatibilityReadme;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/result/StorageSourceConfigResult.java",
    "content": "package im.zhaojun.zfile.module.storage.model.result;\n\nimport im.zhaojun.zfile.module.readme.model.enums.ReadmeDisplayModeEnum;\nimport im.zhaojun.zfile.module.storage.model.bo.StorageSourceMetadata;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport java.util.Map;\n\n/**\n * 存储源设置响应类\n *\n * @author zhaojun\n */\n@Schema(title=\"存储源设置响应类\")\n@Data\npublic class StorageSourceConfigResult {\n\n\t@Schema(title=\"readme 文本内容, 支持 md 语法.\")\n\tprivate String readmeText;\n\n\t@Schema(title = \"显示模式\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"readme 显示模式，支持顶部显示: top, 底部显示:bottom, 弹窗显示: dialog\")\n\tprivate ReadmeDisplayModeEnum readmeDisplayMode;\n\n\t@Schema(title = \"是否默认开启图片模式\", example = \"true\")\n\tprivate Boolean defaultSwitchToImgMode;\n\n\tprivate Map<String, Boolean> permission;\n\n\t@Schema(title = \"存储源元数据\")\n\tprivate StorageSourceMetadata metadata;\n\n\tprivate String rootPath;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/result/StorageSourceResult.java",
    "content": "package im.zhaojun.zfile.module.storage.model.result;\n\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport java.io.Serializable;\n\n/**\n * 存储源基本信息结果类\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"存储源基本信息响应类\")\npublic class StorageSourceResult implements Serializable {\n\n    private static final long serialVersionUID = 1L;\n\n    @Schema(title = \"存储源名称\", example = \"阿里云 OSS 存储\")\n    private String name;\n\n    @Schema(title = \"存储源别名\", example = \"存储源别名，用于 URL 中展示, 如 http://ip:port/{存储源别名}\")\n    private String key;\n\n    @Schema(title = \"存储源类型\")\n    private StorageTypeEnum type;\n\n    @Schema(title = \"是否开启搜索\", example = \"true\")\n    private Boolean searchEnable;\n\n    @Schema(title = \"是否默认开启图片模式\", example = \"true\")\n    private Boolean defaultSwitchToImgMode;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/model/result/operator/BatchOperatorResult.java",
    "content": "package im.zhaojun.zfile.module.storage.model.result.operator;\n\nimport lombok.Data;\n\n/**\n * 批量操作结果\n *\n * @author zhaojun\n */\n@Data\npublic class BatchOperatorResult {\n\n    private String name;\n\n    private String path;\n\n    private boolean success;\n\n    private String message;\n\n    public static BatchOperatorResult success(String name, String path) {\n        BatchOperatorResult batchOperatorResult = new BatchOperatorResult();\n        batchOperatorResult.setSuccess(true);\n        batchOperatorResult.setName(name);\n        batchOperatorResult.setPath(path);\n        return batchOperatorResult;\n    }\n\n    public static BatchOperatorResult fail(String name, String path, String message) {\n        BatchOperatorResult batchOperatorResult = new BatchOperatorResult();\n        batchOperatorResult.setSuccess(false);\n        batchOperatorResult.setName(name);\n        batchOperatorResult.setPath(path);\n        batchOperatorResult.setMessage(message);\n        return batchOperatorResult;\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/oauth2/service/AbstractMicrosoftOAuth2Service.java",
    "content": "package im.zhaojun.zfile.module.storage.oauth2.service;\n\nimport cn.hutool.core.codec.Base64;\nimport cn.hutool.http.HttpResponse;\nimport cn.hutool.http.HttpUtil;\nimport com.alibaba.fastjson2.JSONObject;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.storage.model.dto.OAuth2TokenDTO;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.http.HttpStatus;\n\n@Slf4j\npublic abstract class AbstractMicrosoftOAuth2Service implements IOAuth2Service {\n\n    /**\n     * 根据 RefreshToken 获取 AccessToken API URI\n     */\n    private static final String AUTHENTICATE_URL = \"https://{authenticateEndPoint}/common/oauth2/v2.0/token\";\n\n    @Override\n    public String generateAuthorizationUrl(String clientId, String clientSecret, String redirectUri) {\n        if (StringUtils.isAllEmpty(clientId, clientSecret, redirectUri)) {\n            clientId = getClientId();\n            clientSecret = getClientSecret();\n            redirectUri = getRedirectUri();\n        }\n\n        String stateStr = \"&state=\" + Base64.encodeUrlSafe(StringUtils.join(\"::\", clientId, clientSecret, redirectUri));\n\n        return \"https://\" + getEndPoint() + \"/common/oauth2/v2.0/authorize?client_id=\" + clientId\n                + \"&response_type=code&redirect_uri=\" + redirectUri\n                + \"&scope=\" + getScope()\n                + stateStr;\n    }\n\n    @Override\n    public OAuth2TokenDTO getTokenByCode(String code, String clientId, String clientSecret, String redirectUri) {\n        if (StringUtils.isAllEmpty(clientId, clientSecret, redirectUri)) {\n            clientId = getClientId();\n            clientSecret = getClientSecret();\n            redirectUri = getRedirectUri();\n        }\n        String param = \"client_id=\" + clientId +\n                \"&redirect_uri=\" + redirectUri +\n                \"&client_secret=\" + clientSecret +\n                \"&code=\" + code +\n                \"&scope=\" + getScope() +\n                \"&grant_type=authorization_code\";\n\n\n        if (log.isDebugEnabled()) {\n            log.debug(\"根据授权回调 code 获取令牌, 请求参数: [{}]\", param);\n        }\n\n        String authenticateUrl = AUTHENTICATE_URL.replace(\"{authenticateEndPoint}\", getEndPoint());\n        HttpResponse response = HttpUtil.createPost(authenticateUrl)\n                .body(param, \"application/x-www-form-urlencoded\")\n                .execute();\n\n        String responseBody = response.body();\n        int responseStatus = response.getStatus();\n        if (responseStatus != HttpStatus.OK.value()) {\n            return OAuth2TokenDTO.fail(clientId, clientSecret, redirectUri, responseBody);\n        }\n\n        JSONObject jsonBody = JSONObject.parseObject(responseBody);\n        String accessToken = jsonBody.getString(ACCESS_TOKEN_FIELD_NAME);\n        String refreshToken = jsonBody.getString(REFRESH_TOKEN_FIELD_NAME);\n        Integer expiresIn = jsonBody.getInteger(EXPIRES_IN_FIELD_NAME);\n        return OAuth2TokenDTO.success(clientId, clientSecret, redirectUri, accessToken, refreshToken, responseBody, expiresIn);\n    }\n\n    public abstract String getEndPoint();\n\n    public abstract String getClientId();\n    \n    public abstract String getClientSecret();\n    \n    public abstract String getRedirectUri();\n    \n    public abstract String getScope();\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/oauth2/service/GoogleDriveOAuth2ServiceImpl.java",
    "content": "package im.zhaojun.zfile.module.storage.oauth2.service;\n\nimport cn.hutool.core.codec.Base64;\nimport com.alibaba.fastjson2.JSONObject;\nimport im.zhaojun.zfile.core.config.ZFileProperties;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.storage.model.dto.OAuth2TokenDTO;\nimport jakarta.annotation.Resource;\nimport jakarta.validation.constraints.NotNull;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.http.*;\nimport org.springframework.http.client.SimpleClientHttpRequestFactory;\nimport org.springframework.stereotype.Component;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\n\nimport java.io.IOException;\nimport java.net.HttpURLConnection;\n\n@Slf4j\n@Component\npublic class GoogleDriveOAuth2ServiceImpl implements IOAuth2Service {\n\n    @Resource\n    private ZFileProperties zFileProperties;\n\n    public static final String GOOGLE_OAUTH2_URL = \"https://accounts.google.com/o/oauth2/token\";\n\n    @Override\n    public String generateAuthorizationUrl(String clientId, String clientSecret, String redirectUri) {\n        if (StringUtils.isAllEmpty(clientId, clientSecret, redirectUri)) {\n            clientId = zFileProperties.getGd().getClientId();\n            clientSecret = zFileProperties.getGd().getClientSecret();\n            redirectUri = zFileProperties.getGd().getRedirectUri();\n        }\n\n        String stateStr = \"&state=\" + Base64.encodeUrlSafe(StringUtils.join(\"::\", clientId, clientSecret, redirectUri));\n\n        return \"https://accounts.google.com/o/oauth2/v2/auth?client_id=\" + clientId\n                + \"&response_type=code&redirect_uri=\" + redirectUri\n                + \"&scope=\" + zFileProperties.getGd().getScope()\n                + \"&access_type=offline\"\n                + \"&prompt=consent\"\n                + stateStr;\n    }\n\n    @Override\n    public OAuth2TokenDTO getTokenByCode(String code, String clientId, String clientSecret, String redirectUri) {\n        if (StringUtils.isAllEmpty(clientId, clientSecret, redirectUri)) {\n            clientId = zFileProperties.getGd().getClientId();\n            clientSecret = zFileProperties.getGd().getClientSecret();\n            redirectUri = zFileProperties.getGd().getRedirectUri();\n        }\n\n        HttpHeaders headers = new HttpHeaders();\n        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);\n        String clientCredentials = Base64.encodeUrlSafe(clientId + \":\" + clientSecret);\n        headers.add(\"Authorization\", \"Basic \" + clientCredentials);\n        MultiValueMap<String, String> requestBody = new LinkedMultiValueMap<>();\n        requestBody.add(\"code\", code);\n        requestBody.add(\"grant_type\", \"authorization_code\");\n        requestBody.add(\"redirect_uri\", redirectUri);\n        requestBody.add(\"scope\", zFileProperties.getGd().getScope());\n\n        HttpEntity<MultiValueMap<String, String>> formEntity = new HttpEntity<>(requestBody, headers);\n\n        ResponseEntity<String> response = new RestTemplate(new NoRedirectClientHttpRequestFactory())\n                                                .exchange(GOOGLE_OAUTH2_URL, HttpMethod.POST, formEntity, String.class);\n\n        String responseBody = response.getBody();\n        if (response.getStatusCode() != HttpStatus.OK) {\n            return OAuth2TokenDTO.fail(clientId, clientSecret, redirectUri, responseBody);\n        }\n\n        JSONObject jsonBody = JSONObject.parseObject(responseBody);\n\n        String accessToken = jsonBody.getString(ACCESS_TOKEN_FIELD_NAME);\n        String refreshToken = jsonBody.getString(REFRESH_TOKEN_FIELD_NAME);\n        Integer expiresIn = jsonBody.getInteger(EXPIRES_IN_FIELD_NAME);\n        return OAuth2TokenDTO.success(clientId, clientSecret, redirectUri, accessToken, refreshToken, responseBody, expiresIn);\n    }\n\n\n    private static class NoRedirectClientHttpRequestFactory extends\n            SimpleClientHttpRequestFactory {\n\n        @Override\n        protected void prepareConnection(@NotNull HttpURLConnection connection,\n                                         @NotNull String httpMethod) throws IOException {\n            super.prepareConnection(connection, httpMethod);\n            connection.setInstanceFollowRedirects(true);\n        }\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/oauth2/service/IOAuth2Service.java",
    "content": "package im.zhaojun.zfile.module.storage.oauth2.service;\n\nimport im.zhaojun.zfile.module.storage.model.dto.OAuth2TokenDTO;\n\npublic interface IOAuth2Service {\n\n    /**\n     * 访问令牌字段名称\n     */\n    String ACCESS_TOKEN_FIELD_NAME = \"access_token\";\n\n    /**\n     * 刷新令牌字段名称\n     */\n    String REFRESH_TOKEN_FIELD_NAME = \"refresh_token\";\n\n    /**\n     * 过期时间字段名称\n     */\n    String EXPIRES_IN_FIELD_NAME = \"expires_in\";\n\n\n    String generateAuthorizationUrl(String clientId, String clientSecret, String redirectUri);\n\n    OAuth2TokenDTO getTokenByCode(String code, String clientId, String clientSecret, String redirectUri);\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/oauth2/service/OneDriveChinaOAuth2ServiceImpl.java",
    "content": "package im.zhaojun.zfile.module.storage.oauth2.service;\n\nimport im.zhaojun.zfile.core.config.ZFileProperties;\nimport org.springframework.stereotype.Component;\n\nimport jakarta.annotation.Resource;\n\n@Component\npublic class OneDriveChinaOAuth2ServiceImpl extends AbstractMicrosoftOAuth2Service {\n\n    @Resource\n    private ZFileProperties zFileProperties;\n\n    @Override\n    public String getEndPoint() {\n        return \"login.partner.microsoftonline.cn\";\n    }\n\n    @Override\n    public String getClientId() {\n        return zFileProperties.getOnedriveChina().getClientId();\n    }\n\n    @Override\n    public String getClientSecret() {\n        return zFileProperties.getOnedriveChina().getClientSecret();\n    }\n\n    @Override\n    public String getRedirectUri() {\n        return zFileProperties.getOnedriveChina().getRedirectUri();\n    }\n\n    @Override\n    public String getScope() {\n        return zFileProperties.getOnedriveChina().getScope();\n    }\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/oauth2/service/OneDriveOAuth2ServiceImpl.java",
    "content": "package im.zhaojun.zfile.module.storage.oauth2.service;\n\nimport im.zhaojun.zfile.core.config.ZFileProperties;\nimport org.springframework.stereotype.Component;\n\nimport jakarta.annotation.Resource;\n\n@Component\npublic class OneDriveOAuth2ServiceImpl extends AbstractMicrosoftOAuth2Service {\n\n    @Resource\n    private ZFileProperties zFileProperties;\n\n    @Override\n    public String getEndPoint() {\n        return \"login.microsoftonline.com\";\n    }\n\n    @Override\n    public String getClientId() {\n        return zFileProperties.getOnedrive().getClientId();\n    }\n\n    @Override\n    public String getClientSecret() {\n        return zFileProperties.getOnedrive().getClientSecret();\n    }\n\n    @Override\n    public String getRedirectUri() {\n        return zFileProperties.getOnedrive().getRedirectUri();\n    }\n\n    @Override\n    public String getScope() {\n        return zFileProperties.getOnedrive().getScope();\n    }\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/StorageSourceConfigService.java",
    "content": "package im.zhaojun.zfile.module.storage.service;\n\nimport cn.hutool.core.convert.Convert;\nimport cn.hutool.core.util.ReflectUtil;\nimport im.zhaojun.zfile.core.exception.biz.InitializeStorageSourceBizException;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.storage.context.StorageSourceContext;\nimport im.zhaojun.zfile.module.storage.event.StorageSourceCopyEvent;\nimport im.zhaojun.zfile.module.storage.event.StorageSourceDeleteEvent;\nimport im.zhaojun.zfile.module.storage.mapper.StorageSourceConfigMapper;\nimport im.zhaojun.zfile.module.storage.model.bo.StorageSourceParamDef;\nimport im.zhaojun.zfile.module.storage.model.dto.StorageSourceAllParamDTO;\nimport im.zhaojun.zfile.module.storage.model.entity.StorageSourceConfig;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.aop.framework.AopContext;\nimport org.springframework.beans.BeanUtils;\nimport org.springframework.cache.annotation.CacheConfig;\nimport org.springframework.cache.annotation.CacheEvict;\nimport org.springframework.cache.annotation.Cacheable;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.ArrayList;\nimport java.util.Collection;\nimport java.util.List;\n\n/**\n * 存储源拓展配置 Service\n *\n * @author zhaojun\n */\n@Service\n@Slf4j\n@CacheConfig(cacheNames = \"storageSourceConfig\")\npublic class StorageSourceConfigService {\n\n    @Resource\n    private StorageSourceConfigMapper storageSourceConfigMapper;\n\n    /**\n     * 根据存储源 ID 查询存储源拓展配置, 并按照存储源 id 排序\n     *\n     * @param   storageId\n     *          存储源 ID\n     *\n     * @return  存储源拓展配置列表\n     */\n    @Cacheable(key = \"#storageId\", unless = \"#result == null or #result.size() == 0\", condition = \"#storageId != null \")\n    public List<StorageSourceConfig> selectStorageConfigByStorageId(Integer storageId) {\n        return storageSourceConfigMapper.findByStorageIdOrderById(storageId);\n    }\n\n\n    /**\n     * 获取指定存储源的指定参数名称\n     *\n     * @param   storageId\n     *          存储源 id\n     *\n     * @param   name\n     *          参数名\n     *\n     * @return  参数信息\n     */\n    public StorageSourceConfig findByStorageIdAndName(Integer storageId, String name) {\n        return ((StorageSourceConfigService) AopContext.currentProxy())\n                .selectStorageConfigByStorageId(storageId)\n                .stream()\n                .filter(storageSourceConfig -> StringUtils.equals(name, storageSourceConfig.getName()))\n                .findFirst()\n                .orElse(null);\n    }\n\n\n    /**\n     * 根据存储源 id 删除所有设置\n     *\n     * @param   storageId\n     *          存储源 ID\n     */\n    @CacheEvict(key = \"#storageId\", beforeInvocation = true)\n    public int deleteByStorageId(Integer storageId) {\n        int deleteSize = storageSourceConfigMapper.deleteByStorageId(storageId);\n        log.info(\"删除存储源 ID 为 {} 的参数配置 {} 条\", storageId, deleteSize);\n        return deleteSize;\n    }\n\n\n    /**\n     * 监听存储源删除事件，根据存储源 id 删除相关的存储源参数\n     *\n     * @param   storageSourceDeleteEvent\n     *          存储源删除事件\n     */\n    @EventListener\n    public void onStorageSourceDelete(StorageSourceDeleteEvent storageSourceDeleteEvent) {\n        Integer storageId = storageSourceDeleteEvent.getId();\n        int updateRows = ((StorageSourceConfigService) AopContext.currentProxy()).deleteByStorageId(storageId);\n        if (log.isDebugEnabled()) {\n            log.debug(\"删除存储源 [id {}, name: {}, type: {}] 时，关联删除存储源参数 {} 条\",\n                    storageId,\n                    storageSourceDeleteEvent.getName(),\n                    storageSourceDeleteEvent.getType().getDescription(),\n                    updateRows);\n        }\n    }\n\n\n    /**\n     * 批量保存\n     *\n     * @param   storageId\n     *          存储源 ID\n     *\n     * @param   configList\n     *          实体对象集合\n     */\n    @Transactional(rollbackFor = Exception.class)\n    public void saveBatch(Integer storageId, Collection<StorageSourceConfig> configList) {\n        ((StorageSourceConfigService) AopContext.currentProxy()).deleteByStorageId(storageId);\n\n        log.info(\"更新存储源 ID 为 {} 的参数配置 {} 条\", storageId, configList.size());\n\n        configList.forEach(storageSourceConfig -> {\n            storageSourceConfig.setStorageId(storageId);\n            storageSourceConfigMapper.insert(storageSourceConfig);\n\n            if (log.isDebugEnabled()) {\n                log.debug(\"新增存储源参数配置, 存储源 ID: {}, 存储源类型: {}, 参数名: {}\",\n                        storageSourceConfig.getStorageId(), storageSourceConfig.getType().getDescription(),\n                        storageSourceConfig.getName());\n            }\n        });\n    }\n\n    /**\n     * 批量更新存储源设置\n     *\n     * @param   storageSourceConfigList\n     *          存储源设置列表\n     */\n    @Transactional(rollbackFor = Exception.class)\n    @CacheEvict(key = \"#storageId\")\n    public void updateBatch(Integer storageId, List<StorageSourceConfig> storageSourceConfigList) {\n        storageSourceConfigList.forEach(storageSourceConfig -> {\n            storageSourceConfig.setStorageId(storageId);\n            storageSourceConfigMapper.updateById(storageSourceConfig);\n\n            if (log.isDebugEnabled()) {\n                log.debug(\"更新存储源参数配置, 存储源 ID: {}, 存储源类型: {}, 参数名: {}\",\n                        storageSourceConfig.getStorageId(), storageSourceConfig.getType().getDescription(),\n                        storageSourceConfig.getName());\n            }\n        });\n    }\n\n\n    /**\n     * 将存储源所有参数转换成指定存储类型的参数对象列表\n     *\n     * @param   storageId\n     *          存储源 ID\n     *\n     * @param   storageType\n     *          存储源类型\n     *\n     * @param   storageSourceAllParam\n     *          存储源所有参数\n     */\n    public List<StorageSourceConfig> storageSourceAllParamToConfigList(Integer storageId, StorageTypeEnum storageType, StorageSourceAllParamDTO storageSourceAllParam) {\n        // 返回结果\n        List<StorageSourceConfig> result = new ArrayList<>();\n\n        // 获取该存储源类型需要的参数列表\n        List<StorageSourceParamDef> storageSourceParamList = StorageSourceContext.getStorageSourceParamListByType(storageType);\n\n        // 遍历参数列表, 将参数转换成存储源参数对象\n        for (StorageSourceParamDef storageSourceParam : storageSourceParamList) {\n            // 根据字段名称获取字段值\n            Object fieldValue = ReflectUtil.getFieldValue(storageSourceAllParam, storageSourceParam.getKey());\n            String fieldStrValue = Convert.toStr(fieldValue);\n\n            // 校验是否必填, 如果不符合则抛出异常\n            boolean paramRequired = storageSourceParam.isRequired();\n            if (paramRequired && StringUtils.isEmpty(fieldStrValue)) {\n                String errMsg = String.format(\"参数「%s」不能为空\", storageSourceParam.getName());\n                throw new InitializeStorageSourceBizException(errMsg, storageId);\n            }\n\n            // 校验如果有默认值，则填充默认值\n            String paramDefaultValue = storageSourceParam.getDefaultValue();\n            if (StringUtils.isNotEmpty(paramDefaultValue) && StringUtils.isEmpty(fieldStrValue)) {\n                fieldStrValue = paramDefaultValue;\n            }\n\n            // 添加到结果列表\n            StorageSourceConfig storageSourceConfig = new StorageSourceConfig();\n            storageSourceConfig.setTitle(storageSourceParam.getName());\n            storageSourceConfig.setName(storageSourceParam.getKey());\n            storageSourceConfig.setValue(fieldStrValue);\n            storageSourceConfig.setType(storageType);\n            storageSourceConfig.setStorageId(storageId);\n            result.add(storageSourceConfig);\n        }\n\n        return result;\n    }\n\n    /**\n     * 监听存储源复制事件, 复制存储源时, 复制存储源参数配置\n     *\n     * @param   storageSourceCopyEvent\n     *          存储源复制事件\n     */\n    @EventListener\n    public void onStorageSourceCopy(StorageSourceCopyEvent storageSourceCopyEvent) {\n        Integer fromId = storageSourceCopyEvent.getFromId();\n        Integer newId = storageSourceCopyEvent.getNewId();\n\n        List<StorageSourceConfig> storageSourceConfigList = ((StorageSourceConfigService) AopContext.currentProxy())\n                .selectStorageConfigByStorageId(fromId);\n\n        storageSourceConfigList.forEach(storageSourceConfig -> {\n            StorageSourceConfig newStorageSourceConfig = new StorageSourceConfig();\n            BeanUtils.copyProperties(storageSourceConfig, newStorageSourceConfig);\n            newStorageSourceConfig.setId(null);\n            newStorageSourceConfig.setStorageId(newId);\n            storageSourceConfigMapper.insert(newStorageSourceConfig);\n        });\n\n        log.info(\"复制存储源 ID 为 {} 的存储源参数配置到存储源 ID 为 {} 成功, 共 {} 条\", fromId, newId, storageSourceConfigList.size());\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/StorageSourceService.java",
    "content": "package im.zhaojun.zfile.module.storage.service;\n\nimport cn.hutool.core.convert.Convert;\nimport cn.hutool.core.util.ObjUtil;\nimport cn.hutool.core.util.ReflectUtil;\nimport im.zhaojun.zfile.core.cache.ZFileCacheManager;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.biz.InvalidStorageSourceBizException;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.util.StrPool;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.core.util.ZFileAuthUtil;\nimport im.zhaojun.zfile.module.password.model.dto.VerifyResultDTO;\nimport im.zhaojun.zfile.module.password.service.PasswordConfigService;\nimport im.zhaojun.zfile.module.readme.model.entity.ReadmeConfig;\nimport im.zhaojun.zfile.module.readme.service.ReadmeConfigService;\nimport im.zhaojun.zfile.module.storage.context.StorageSourceContext;\nimport im.zhaojun.zfile.module.storage.convert.StorageSourceConvert;\nimport im.zhaojun.zfile.module.storage.event.StorageSourceCopyEvent;\nimport im.zhaojun.zfile.module.storage.event.StorageSourceDeleteEvent;\nimport im.zhaojun.zfile.module.storage.mapper.StorageSourceMapper;\nimport im.zhaojun.zfile.module.storage.model.dto.StorageSourceAllParamDTO;\nimport im.zhaojun.zfile.module.storage.model.dto.StorageSourceDTO;\nimport im.zhaojun.zfile.module.storage.model.dto.StorageSourceInitDTO;\nimport im.zhaojun.zfile.module.storage.model.entity.StorageSource;\nimport im.zhaojun.zfile.module.storage.model.entity.StorageSourceConfig;\nimport im.zhaojun.zfile.module.storage.model.enums.SearchModeEnum;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.param.IStorageParam;\nimport im.zhaojun.zfile.module.storage.model.request.admin.CopyStorageSourceRequest;\nimport im.zhaojun.zfile.module.storage.model.request.admin.UpdateStorageSortRequest;\nimport im.zhaojun.zfile.module.storage.model.request.base.FileListConfigRequest;\nimport im.zhaojun.zfile.module.storage.model.request.base.SaveStorageSourceRequest;\nimport im.zhaojun.zfile.module.storage.model.result.StorageSourceConfigResult;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;\nimport im.zhaojun.zfile.module.user.model.entity.UserStorageSource;\nimport im.zhaojun.zfile.module.user.service.UserStorageSourceService;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.aop.framework.AopContext;\nimport org.springframework.beans.BeanUtils;\nimport org.springframework.cache.annotation.CacheConfig;\nimport org.springframework.cache.annotation.CacheEvict;\nimport org.springframework.cache.annotation.Cacheable;\nimport org.springframework.cache.annotation.Caching;\nimport org.springframework.context.ApplicationEventPublisher;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.*;\n\n/**\n * 存储源基本信息 Service\n *\n * @author zhaojun\n */\n@Slf4j\n@Service\n@CacheConfig(cacheNames = \"storageSource\")\npublic class StorageSourceService {\n\n    @Resource\n    private StorageSourceMapper storageSourceMapper;\n\n    @Resource\n    private StorageSourceConvert storageSourceConvert;\n\n    @Resource\n    private StorageSourceConfigService storageSourceConfigService;\n\n    @Resource\n    private PasswordConfigService passwordConfigService;\n\n    @Resource\n    private UserStorageSourceService userStorageSourceService;\n\n    @Resource\n    private ReadmeConfigService readmeConfigService;\n\n    @Resource\n    private ApplicationEventPublisher applicationEventPublisher;\n\n    @Resource\n    private ZFileCacheManager zfileCacheManager;\n\n\n    /**\n     * 获取所有存储源列表\n     *\n     * @return 存储源列表\n     */\n    public List<StorageSource> findAllOrderByOrderNum() {\n        return storageSourceMapper.findAllOrderByOrderNum();\n    }\n\n\n    /**\n     * 获取当前登录用户可访问的存储源列表\n     *\n     * @return 存储源列表\n     */\n    public List<StorageSource> findAllEnableOrderByOrderNum(Integer userId) {\n        if (userId == null) {\n            return Collections.emptyList();\n        }\n        return zfileCacheManager.findAllEnableOrderByOrderNum(userId,\n                userEnableStorageSourceId -> storageSourceMapper.findUserEnableList(userEnableStorageSourceId)\n        );\n    }\n\n    /**\n     * 获取指定存储源设置\n     *\n     * @param   id\n     *          存储源 ID\n     *\n     * @return  存储源设置\n     */\n    @Cacheable(key = \"#id\", unless = \"#result == null\", condition = \"#id != null\")\n    public StorageSource findById(Integer id) {\n        return storageSourceMapper.selectById(id);\n    }\n\n\n    /**\n     * 根据存储源 key 获取存储源\n     *\n     * @param   storageKey\n     *          存储源 key\n     *\n     * @throws InvalidStorageSourceBizException   存储源不存在时, 抛出异常.\n     *\n     * @return  存储源信息\n     */\n    @Cacheable(key = \"#storageKey\", unless = \"#result == null\", condition = \"#storageKey != null\")\n    public StorageSource findByStorageKey(String storageKey) {\n        return storageSourceMapper.findByStorageKey(storageKey);\n    }\n\n\n    /**\n     * 根据存储源 key 清除 key 的缓存\n     *\n     * @param   storageKey\n     *          存储源 key\n     */\n    @CacheEvict(key = \"#storageKey\")\n    public void clearCacheByStorageKey(String storageKey) {}\n\n\n    /**\n     * 根据存储源 key 获取存储源 id\n     *\n     * @param   storageKey\n     *          存储源 key\n     *\n     * @return  存储源信息\n     */\n    public Integer findIdByKey(String storageKey) {\n        return Optional.ofNullable(((StorageSourceService) AopContext.currentProxy()).findByStorageKey(storageKey)).map(StorageSource::getId).orElse(null);\n    }\n\n\n    /**\n     * 根据存储源 id 获取存储源 key\n     *\n     * @param   id\n     *          存储源 id\n     *\n     * @return  存储源 key\n     */\n    public String findStorageKeyById(Integer id){\n        return Optional.ofNullable(((StorageSourceService)AopContext.currentProxy()).findById(id)).map(StorageSource::getKey).orElse(null);\n    }\n\n\n    /**\n     * 根据 id 获取指定存储源的类型.\n     *\n     * @param   id\n     *          存储源 ID\n     *\n     * @return  存储源对应的类型.\n     */\n    public StorageTypeEnum findStorageTypeById(Integer id) {\n        return Optional.ofNullable(((StorageSourceService)AopContext.currentProxy()).findById(id)).map(StorageSource::getType).orElse(null);\n    }\n\n\n    /**\n     * 获取指定存储源 DTO 对象, 此对象包含详细的参数设置.\n     *\n     * @param   id\n     *          存储源 ID\n     *\n     * @return  存储源 DTO\n     */\n    @Cacheable(key = \"'dto-' + #id\", unless = \"#result == null\", condition = \"#id != null\")\n    public StorageSourceDTO findDTOById(Integer id) {\n        // 将参数列表通过反射写入到 StorageSourceAllParam 中.\n        StorageSourceAllParamDTO storageSourceAllParam = new StorageSourceAllParamDTO();\n        for (StorageSourceConfig storageSourceConfig : storageSourceConfigService.selectStorageConfigByStorageId(id)) {\n            if (ReflectUtil.hasField(StorageSourceAllParamDTO.class, storageSourceConfig.getName())) {\n                ReflectUtil.setFieldValue(storageSourceAllParam, storageSourceConfig.getName(), storageSourceConfig.getValue());\n            } else {\n                log.warn(\"数据库中存储源 {} 参数 {} 不存在于存储源参数 DTO 中, 请检查参数名是否正确.\", id, storageSourceConfig.getName());\n            }\n        }\n        // 获取数据库对象，转为 dto 对象返回\n        StorageSource storageSource = findById(id);\n        return storageSourceConvert.entityToDTO(storageSource, storageSourceAllParam);\n    }\n\n\n    /**\n     * 判断存储源 key 是否已存在 (不读取缓存)\n     *\n     * @param   storageKey\n     *          存储源 key\n     *\n     * @return  是否已存在\n     */\n    public boolean existByStorageKey(String storageKey) {\n        return ((StorageSourceService)AopContext.currentProxy()).findByStorageKey(storageKey) != null;\n    }\n\n\n    /**\n     * 删除指定存储源设置, 会级联删除其参数设置\n     *\n     * @param   id\n     *          存储源 ID\n     */\n    @Transactional(rollbackFor = Exception.class)\n    @Caching(evict = {\n            @CacheEvict(key = \"#id\"),\n            @CacheEvict(key = \"'dto-' + #id\"),\n            @CacheEvict(key = \"#result.key\", condition = \"#result != null\")\n    })\n    public StorageSource deleteById(Integer id) {\n        log.info(\"删除 id 为 {} 的存储源\", id);\n        StorageSource storageSource = ((StorageSourceService)AopContext.currentProxy()).findById(id);\n\n        if (storageSource == null) {\n            throw new BizException(ErrorCode.BIZ_STORAGE_NOT_FOUND);\n        }\n\n        StorageSourceDeleteEvent storageSourceDeleteEvent = new StorageSourceDeleteEvent(storageSource);\n        applicationEventPublisher.publishEvent(storageSourceDeleteEvent);\n\n        int deleteEntitySize = storageSourceMapper.deleteById(id);\n\n        StorageSourceContext.destroy(storageSource);\n        log.info(\"删除存储源 {} 成功, 影响行数: {}\", id, deleteEntitySize);\n        zfileCacheManager.clearUserEnableStorageSourceCache();\n        return storageSource;\n    }\n\n\n    /**\n     * 交换存储源排序\n     *\n     * @param   updateStorageSortRequestList\n     *          更新排序的存储源 id 及排序值列表\n     */\n    @Transactional(rollbackFor = Exception.class)\n    @CacheEvict(allEntries = true)\n    public void updateStorageSort(List<UpdateStorageSortRequest> updateStorageSortRequestList) {\n        for (int i = 0; i < updateStorageSortRequestList.size(); i++) {\n            UpdateStorageSortRequest item = updateStorageSortRequestList.get(i);\n            if (!Objects.equals(i, item.getOrderNum())) {\n                log.info(\"变更存储源 {} 顺序号为 {}\", item.getId(), i);\n                storageSourceMapper.updateSetOrderNumById(i, item.getId());\n            }\n        }\n    }\n\n\n    @Caching(evict = {\n            @CacheEvict(key = \"#entity.id\"),\n            @CacheEvict(key = \"#entity.key\"),\n            @CacheEvict(key = \"'dto-' + #entity.id\")\n    })\n    public void updateById(StorageSource entity) {\n        storageSourceMapper.updateById(entity);\n        zfileCacheManager.clearUserEnableStorageSourceCache();\n    }\n\n\n    /**\n     * 保存存储源基本信息及其对应的参数设置\n     *\n     * @param   saveStorageSourceRequest\n     *          存储源 DTO 对象\n     */\n    @Transactional(rollbackFor = Exception.class)\n    public Integer saveStorageSource(SaveStorageSourceRequest saveStorageSourceRequest) {\n        boolean isSave = ObjUtil.isEmpty(saveStorageSourceRequest.getId());\n\n        log.info(\"尝试保存存储源, id: {}, name: {}, key: {}, type: {}\",\n                saveStorageSourceRequest.getId(), saveStorageSourceRequest.getName(),\n                saveStorageSourceRequest.getKey(), saveStorageSourceRequest.getType().getDescription());\n\n        // 转换为存储源 entity 对象\n        StorageSource storageSource = storageSourceConvert.saveRequestToEntity(saveStorageSourceRequest);\n        storageSource.setSearchMode(SearchModeEnum.SEARCH_ALL_MODE);\n\n        // 如果是更新，则销毁之前的存储源上下文\n        if (!isSave) {\n            StorageSource dbStorageSource = ((StorageSourceService) AopContext.currentProxy()).findById(saveStorageSourceRequest.getId());\n            if (dbStorageSource != null) {\n                StorageSourceContext.destroy(dbStorageSource);\n            }\n        }\n\n        // 保存或更新存储源\n        StorageSource dbSaveResult = ((StorageSourceService)AopContext.currentProxy()).saveOrUpdate(storageSource);\n\n        log.info(\"保存存储源成功, id: {}, name: {}, key: {}, type: {}\",\n                dbSaveResult.getId(), dbSaveResult.getName(),\n                dbSaveResult.getKey(), dbSaveResult.getType().getDescription());\n\n        // 存储源 ID\n        Integer storageId = dbSaveResult.getId();\n\n        // 保存存储源参数\n        List<StorageSourceConfig> storageSourceConfigList =\n                storageSourceConfigService.storageSourceAllParamToConfigList(storageId,\n                                                                    dbSaveResult.getType(),\n                                                                    saveStorageSourceRequest.getStorageSourceAllParam());\n        storageSourceConfigService.saveBatch(storageId, storageSourceConfigList);\n        log.info(\"保存存储源参数成功，尝试根据参数初始化存储源, id: {}, name: {}, config size: {}\",\n                dbSaveResult.getId(), dbSaveResult.getName(), storageSourceConfigList.size());\n\n        // 初始化并检查是否可用\n        StorageSourceInitDTO storageSourceInitDTO = StorageSourceInitDTO.convert(dbSaveResult, storageSourceConfigList);\n        StorageSourceContext.init(storageSourceInitDTO);\n        log.info(\"根据参数初始化存储源成功, id: {}, name: {}, config size: {}\",\n                dbSaveResult.getId(), dbSaveResult.getName(), storageSourceConfigList.size());\n\n\n        // 如果是新增存储源，根据用户设置为用户添加默认权限\n        if (isSave) {\n            userStorageSourceService.addDefaultPermissionsForAllUsersInStorageSource(storageId);\n        }\n\n        return storageId;\n    }\n\n\n    /**\n     * 保存或修改存储源设置，如果没有 id 则新增，有则更新，且会检测是否填写 key，如果没写，则自动将 id 设置为 key 并保存。\n     *\n     * @param   storageSource\n     *          存储源对象\n     *\n     * @return  保存后对象\n     */\n    @Caching(evict = {\n            @CacheEvict(key = \"#result.id\"),\n            @CacheEvict(key = \"'dto-' + #result.id\"),\n            @CacheEvict(key = \"#result.key\")\n    })\n    public StorageSource saveOrUpdate(StorageSource storageSource) {\n        // 保存存储源基本信息\n        if (storageSource.getId() == null) {\n            storageSourceMapper.insert(storageSource);\n        } else {\n            // 判断是否修改了存储源别名，如果修改了则清除之前存储源别名的缓存。\n            StorageSource originStorageSource = storageSourceMapper.selectById(storageSource.getId());\n            if (!StringUtils.equals(originStorageSource.getKey(), storageSource.getKey())) {\n                ((StorageSourceService)AopContext.currentProxy()).clearCacheByStorageKey(originStorageSource.getKey());\n            }\n            storageSourceMapper.updateById(storageSource);\n        }\n\n        // 如果没输入存储源 key, 则自动将 id 设置为 key\n        if (StringUtils.isEmpty(storageSource.getKey()) && !StringUtils.equals(storageSource.getId().toString(), storageSource.getKey())) {\n            storageSource.setKey(Convert.toStr(storageSource.getId()));\n            storageSourceMapper.updateById(storageSource);\n        }\n        zfileCacheManager.clearUserEnableStorageSourceCache();\n        return storageSource;\n    }\n\n\n    public StorageSourceConfigResult getStorageConfigSource(FileListConfigRequest fileListConfigRequest) {\n        String storageKey = fileListConfigRequest.getStorageKey();\n\n        // 判断存储源是否存在.\n        StorageSource storageSource = ((StorageSourceService)AopContext.currentProxy()).findByStorageKey(storageKey);\n        if (storageSource == null) {\n            throw new InvalidStorageSourceBizException(storageKey);\n        }\n\n        // 根据存储源 key 获取存储源 id\n        Integer storageId = storageSource.getId();\n\n        // 拼接用户目录\n        AbstractBaseFileService<IStorageParam> baseFileService = StorageSourceContext.getByStorageId(storageId);\n        String fullPath = StringUtils.concat(baseFileService.getCurrentUserBasePath(), fileListConfigRequest.getPath());\n\n        VerifyResultDTO verifyPassword = passwordConfigService.verifyPassword(storageId, fullPath, fileListConfigRequest.getPassword());\n\n        ReadmeConfig readmeByPath = null;\n        if (verifyPassword.isPassed()) {\n            // 获取指定存储源路径下的 readme 信息\n            readmeByPath = readmeConfigService.getByStorageAndPath(storageId, fileListConfigRequest.getPath(), storageSource.getCompatibilityReadme());\n        } else {\n            log.info(\"文件夹密码验证失败，不获取 readme 信息, storageId: {}, path: {}, password: {}\", storageId, fullPath, fileListConfigRequest.getPassword());\n        }\n\n        StorageSourceConfigResult storageSourceConfigResult = storageSourceConvert.entityToConfigResult(storageSource, readmeByPath);\n\n        // 获取当前用户对该存储源的权限\n        HashMap<String, Boolean> permissionMap = userStorageSourceService.getCurrentUserPermissionMapByStorageId(storageId);\n        storageSourceConfigResult.setPermission(permissionMap);\n\n        // 获取存储源元数据\n        storageSourceConfigResult.setMetadata(baseFileService.getStorageSourceMetadata());\n\n        UserStorageSource userStorageSource = userStorageSourceService.getByUserIdAndStorageId(ZFileAuthUtil.getCurrentUserId(), storageId);\n        if (userStorageSource == null) {\n            storageSourceConfigResult.setRootPath(StrPool.SLASH);\n        } else {\n            storageSourceConfigResult.setRootPath(userStorageSource.getRootPath());\n        }\n        return storageSourceConfigResult;\n    }\n\n    @Transactional(rollbackFor = Exception.class)\n    public Integer copy(CopyStorageSourceRequest copyStorageSourceRequest) {\n        // 检查目标存储源别名是否已存在\n        String toKey = copyStorageSourceRequest.getToKey();\n        boolean existByStorageKey = ((StorageSourceService)AopContext.currentProxy()).existByStorageKey(toKey);\n        if (existByStorageKey) {\n            throw new BizException(ErrorCode.BIZ_STORAGE_KEY_EXIST);\n        }\n\n        // 检查复制源是否存在\n        Integer fromStorageId = copyStorageSourceRequest.getFromId();\n        StorageSource storageSource = ((StorageSourceService)AopContext.currentProxy()).findById(fromStorageId);\n        if (storageSource == null) {\n            throw new InvalidStorageSourceBizException(fromStorageId);\n        }\n\n        StorageSource newStorageSource = new StorageSource();\n        BeanUtils.copyProperties(storageSource, newStorageSource);\n        newStorageSource.setId(null);\n        newStorageSource.setKey(copyStorageSourceRequest.getToKey());\n        newStorageSource.setName(copyStorageSourceRequest.getToName());\n        StorageSource dbSaveResult = ((StorageSourceService)AopContext.currentProxy()).saveOrUpdate(newStorageSource);\n        Integer newStorageId = dbSaveResult.getId();\n        log.info(\"复制存储源成功，源 [id: {}, name: {}, key: {}], 复制后 [id: {}, name: {}, key: {}, type: {}]\",\n                fromStorageId, storageSource.getName(), storageSource.getKey(),\n                newStorageId, dbSaveResult.getName(),\n                dbSaveResult.getKey(), dbSaveResult.getType().getDescription());\n\n        StorageSourceCopyEvent storageSourceCopyEvent = new StorageSourceCopyEvent(fromStorageId, newStorageId);\n        applicationEventPublisher.publishEvent(storageSourceCopyEvent);\n\n        // 初始化存储源\n        List<StorageSourceConfig> storageSourceConfigList = storageSourceConfigService.selectStorageConfigByStorageId(newStorageId);\n        StorageSourceInitDTO storageSourceInitDTO = StorageSourceInitDTO.convert(dbSaveResult, storageSourceConfigList);\n        StorageSourceContext.init(storageSourceInitDTO);\n        log.info(\"初始化存储源成功, id: {}, name: {}, config size: {}\",\n                newStorageId, dbSaveResult.getName(), storageSourceConfigList.size());\n\n        return newStorageId;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/base/AbstractBaseFileService.java",
    "content": "package im.zhaojun.zfile.module.storage.service.base;\n\nimport cn.hutool.core.util.ObjUtil;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.biz.InitializeStorageSourceBizException;\nimport im.zhaojun.zfile.core.util.StrPool;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.core.util.ZFileAuthUtil;\nimport im.zhaojun.zfile.module.share.context.ShareAccessContext;\nimport im.zhaojun.zfile.module.storage.model.bo.StorageSourceMetadata;\nimport im.zhaojun.zfile.module.storage.model.param.IStorageParam;\nimport im.zhaojun.zfile.module.user.model.constant.UserConstant;\nimport im.zhaojun.zfile.module.user.model.entity.UserStorageSource;\nimport im.zhaojun.zfile.module.user.service.UserStorageSourceService;\nimport jakarta.annotation.Resource;\nimport lombok.Getter;\nimport lombok.extern.slf4j.Slf4j;\n\n/**\n * @author zhaojun\n */\n@Slf4j\npublic abstract class AbstractBaseFileService<P extends IStorageParam> implements BaseFileService {\n\n    @Resource\n    private UserStorageSourceService userStorageSourceService;\n\n    /**\n     * 存储源初始化配置\n     */\n    @Getter\n    public P param;\n\n    /**\n     * 是否初始化成功\n     */\n    @Getter\n    protected boolean isInitialized = false;\n\n    /**\n     * 存储源 ID\n     */\n    @Getter\n    public Integer storageId;\n\n    /**\n     * 存储源名称\n     */\n    @Getter\n    private String name;\n\n    public void init(String name, Integer storageId, P param) {\n        if (!ObjUtil.hasNull(this.name, this.storageId, this.param)) {\n            throw new IllegalStateException(\"请勿重复初始化\");\n        }\n        if (ObjUtil.hasEmpty(name, storageId, param)) {\n            throw new IllegalStateException(\"初始化参数不能为空\");\n        }\n        this.name = name;\n        this.storageId = storageId;\n        this.param = param;\n        init();\n    }\n\n    /**\n     * 初始化存储源, 在调用前要设置存储的 {@link #storageId} 属性. 和 {@link #param} 属性.\n     */\n    public abstract void init();\n\n    /**\n     * 测试是否连接成功, 会尝试取调用获取根路径的文件, 如果没有抛出异常, 则认为连接成功.\n     */\n    public void testConnection() {\n        try {\n            fileList(StringUtils.SLASH);\n            isInitialized = true;\n        } catch (Exception e) {\n            throw new InitializeStorageSourceBizException(ErrorCode.BIZ_STORAGE_INIT_ERROR.getCode(), \"初始化异常, 错误信息为: \" + e.getMessage(), storageId, e);\n        }\n    }\n\n    protected String getStorageSimpleInfo() {\n        return String.format(\"存储源 [id=%s, name=%s, type: %s]\", storageId, name, getStorageTypeEnum().getDescription());\n    }\n\n\n    public abstract StorageSourceMetadata getStorageSourceMetadata();\n\n    public String getCurrentUserBasePath() {\n        // 检查是否为分享访问，如果是则返回分享的基础路径\n        if (ShareAccessContext.isShareAccess()) {\n            return ShareAccessContext.getShareBasePath();\n        }\n        \n        // 原有逻辑保持不变\n        Integer userId = ZFileAuthUtil.getCurrentUserId();\n        if (!this.isInitialized) {\n            userId = UserConstant.ADMIN_ID;\n        }\n        UserStorageSource userStorageSource = userStorageSourceService.getByUserIdAndStorageId(userId, storageId);\n        if (userStorageSource == null || StringUtils.isEmpty(userStorageSource.getRootPath())) {\n            return StrPool.SLASH;\n        } else {\n            return userStorageSource.getRootPath();\n        }\n    }\n\n\n    @Override\n    public void destroy() {\n\n    }\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/base/AbstractMicrosoftDriveService.java",
    "content": "package im.zhaojun.zfile.module.storage.service.base;\n\nimport cn.hutool.core.convert.Convert;\nimport cn.hutool.core.util.URLUtil;\nimport cn.hutool.http.ContentType;\nimport cn.hutool.http.HttpResponse;\nimport cn.hutool.http.HttpUtil;\nimport cn.hutool.jwt.JWT;\nimport cn.hutool.jwt.JWTPayload;\nimport cn.hutool.jwt.JWTUtil;\nimport com.alibaba.fastjson2.JSONArray;\nimport com.alibaba.fastjson2.JSONObject;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.exception.core.SystemException;\nimport im.zhaojun.zfile.core.exception.status.NotFoundAccessException;\nimport im.zhaojun.zfile.core.exception.system.UploadFileFailSystemException;\nimport im.zhaojun.zfile.core.util.FileUtils;\nimport im.zhaojun.zfile.core.util.RequestHolder;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.storage.constant.StorageConfigConstant;\nimport im.zhaojun.zfile.module.storage.model.bo.RefreshTokenCacheBO;\nimport im.zhaojun.zfile.module.storage.model.bo.StorageSourceMetadata;\nimport im.zhaojun.zfile.module.storage.model.dto.RefreshTokenInfoDTO;\nimport im.zhaojun.zfile.module.storage.model.entity.StorageSourceConfig;\nimport im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.param.MicrosoftDriveParam;\nimport im.zhaojun.zfile.module.storage.model.result.FileItemResult;\nimport im.zhaojun.zfile.module.storage.oauth2.service.IOAuth2Service;\nimport im.zhaojun.zfile.module.storage.service.StorageSourceConfigService;\nimport jakarta.annotation.Nullable;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.apache.hc.client5.http.classic.HttpClient;\nimport org.apache.hc.client5.http.config.RequestConfig;\nimport org.apache.hc.client5.http.impl.classic.CloseableHttpClient;\nimport org.apache.hc.client5.http.impl.classic.HttpClientBuilder;\nimport org.apache.hc.core5.util.Timeout;\nimport org.springframework.http.*;\nimport org.springframework.http.client.HttpComponentsClientHttpRequestFactory;\nimport org.springframework.util.StreamUtils;\nimport org.springframework.web.client.ResourceAccessException;\nimport org.springframework.web.client.RestTemplate;\n\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.util.*;\n\n/**\n * @author zhaojun\n */\n@Slf4j\npublic abstract class AbstractMicrosoftDriveService<P extends MicrosoftDriveParam> extends AbstractProxyTransferService<P> implements RefreshTokenService {\n\n    @Resource\n    private StorageSourceConfigService storageSourceConfigService;\n\n    /**\n     * 获取根文件 API URI\n     */\n    protected static final String DRIVER_ROOT_URL = \"https://{graphEndPoint}/v1.0/{type}/drive/root/children?select=name,size,lastModifiedDateTime,file,@microsoft.graph.downloadUrl,@odata.nextLink,value\";\n\n    /**\n     * 获取非根文件 API URI\n     */\n    protected static final String DRIVER_ITEMS_URL = \"https://{graphEndPoint}/v1.0/{type}/drive/root:{path}:/children?select=name,size,lastModifiedDateTime,file,@microsoft.graph.downloadUrl,@odata.nextLink,value\";\n\n    /**\n     * 获取单文件 API URI\n     */\n    protected static final String DRIVER_ITEM_URL = \"https://{graphEndPoint}/v1.0/{type}/drive/root:{path}?select=name,size,lastModifiedDateTime,file,@microsoft.graph.downloadUrl,id\";\n\n    /**\n     * 操作单文件 API URI\n     */\n    protected static final String DRIVER_ITEM_OPERATOR_URL = \"https://{graphEndPoint}/v1.0/{type}/drive/root:{path}\";\n\n    /**\n     * 根据 RefreshToken 获取 AccessToken API URI\n     */\n    protected static final String AUTHENTICATE_URL = \"https://{authenticateEndPoint}/common/oauth2/v2.0/token\";\n\n    /**\n     * 创建上传文件回话 API\n     */\n    protected static final String CREATE_UPLOAD_SESSION_URL = \"https://{graphEndPoint}/v1.0/{type}/drive/root:{path}:/createUploadSession\";\n\n    /**\n     * 复制文件 API\n     */\n    private static final String DRIVER_COPY_URL = \"https://{graphEndPoint}/v1.0/{type}/drive/root:{path}:/copy\";\n\n    /**\n     * OneDrive 文件类型\n     */\n    private static final String ONE_DRIVE_FILE_FLAG = \"file\";\n\n    /*\n     * 设置 RestTemplate 使用 Netty 底层实现，默认的实现不支持 PATCH 请求\n     */\n    private volatile RestTemplate restTemplate;\n\n    @Override\n    public void init() {\n        Integer refreshTokenExpiredAt = param.getRefreshTokenExpiredAt();\n        if (refreshTokenExpiredAt == null) {\n            try {\n                JWT jwt = JWTUtil.parseToken(param.getAccessToken());\n                JWTPayload payload = jwt.getPayload();\n                refreshTokenExpiredAt = Convert.toInt(payload.getClaim(\"exp\"));\n                if (log.isDebugEnabled()) {\n                    log.debug(\"初始化时尝试根据 AccessToken 自动解析到期时间: {}\", refreshTokenExpiredAt);\n                }\n            } catch (Exception e) {\n                log.warn(\"初始化时尝试根据 AccessToken 自动解析到期时间异常\", e);\n            }\n        }\n\n        if (refreshTokenExpiredAt == null) {\n            refreshAccessToken();\n        } else {\n            RefreshTokenInfoDTO tokenInfoDTO = RefreshTokenInfoDTO.success(param.getAccessToken(), param.getRefreshToken(), refreshTokenExpiredAt);\n            RefreshTokenCacheBO.putRefreshTokenInfo(storageId, RefreshTokenCacheBO.RefreshTokenInfo.success(tokenInfoDTO));\n        }\n    }\n\n    public RestTemplate getRestTemplate() {\n        // 双重检查锁，避免重复创建 RestTemplate 实例的同时减少锁的开销\n        if (restTemplate == null) {\n            synchronized (this) {\n                if (restTemplate == null) {\n                    restTemplate = new RestTemplate();\n                    int timeoutSecond = param.getProxyUploadTimeoutSecond() == null ? 0 : param.getProxyUploadTimeoutSecond();\n                    RequestConfig requestConfig = RequestConfig.custom().setResponseTimeout(Timeout.ofSeconds(timeoutSecond)).build();\n                    HttpClient httpClient = HttpClientBuilder.create().setDefaultRequestConfig((requestConfig)).build();\n                    HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient);\n                    restTemplate.setRequestFactory(requestFactory);\n                }\n            }\n        }\n        return restTemplate;\n    }\n\n\n    @Override\n    public List<FileItemResult> fileList(String folderPath) {\n        String fullPath = StringUtils.concatTrimEndSlashes(param.getBasePath(), getCurrentUserBasePath(), folderPath);\n\n        List<FileItemResult> result = new ArrayList<>();\n        String nextPageLink = null;\n\n        do {\n            String requestUrl;\n\n            // 如果有下一页链接，则优先取下一页\n            // 如果没有则判断是根目录还是子目录\n            if (nextPageLink != null) {\n                nextPageLink = nextPageLink.replace(\"+\", \"%2B\");\n                requestUrl = URLUtil.decode(nextPageLink);\n            } else if (StringUtils.SLASH.equalsIgnoreCase(fullPath) || \"\".equalsIgnoreCase(fullPath)) {\n                requestUrl = DRIVER_ROOT_URL;\n            } else {\n                requestUrl = DRIVER_ITEMS_URL;\n            }\n\n            HttpEntity<Object> entity = getAuthorizationHttpEntity();\n            JSONObject root = getRestTemplate().exchange(requestUrl, HttpMethod.GET, entity, JSONObject.class, getGraphEndPoint(), getType(), fullPath).getBody();\n            if (root == null) {\n                return Collections.emptyList();\n            }\n\n            JSONArray fileList = root.getJSONArray(\"value\");\n            for (int i = 0; i < fileList.size(); i++) {\n                JSONObject fileItem = fileList.getJSONObject(i);\n                FileItemResult fileItemResult = jsonToFileItem(fileItem, folderPath);\n                if (param.isEnableProxyDownload() && StringUtils.isEmpty(param.getProxyDomain())) {\n                    fileItemResult.setUrl(getProxyDownloadUrl(StringUtils.concat(getCurrentUserBasePath(), folderPath, fileItemResult.getName())));\n                }\n                result.add(fileItemResult);\n            }\n    \n            nextPageLink = root.getString(\"@odata.nextLink\");\n        } while (nextPageLink != null);\n    \n        return result;\n    }\n    \n    @Override\n    public FileItemResult getFileItem(String pathAndName) {\n        String fullPath = StringUtils.concat(getCurrentUserBasePath(), pathAndName);\n        return getOriginFileItem(fullPath);\n    }\n\n\n    /**\n     * 获取原始的 FileItem 信息，尚未按照存储源参数设置代理下载地址\n     */\n    public FileItemResult getOriginFileItem(String pathAndName) {\n        JSONObject fileItem = getFileOriginInfo(pathAndName);\n        if (fileItem == null) return null;\n\n        String folderPath = FileUtils.getParentPath(pathAndName);\n        return jsonToFileItem(fileItem, folderPath);\n    }\n\n    @Nullable\n    private JSONObject getFileOriginInfo(String pathAndName) {\n        String fullPath = StringUtils.concat(param.getBasePath(), pathAndName);\n        HttpEntity<Object> entity = getAuthorizationHttpEntity();\n        return getRestTemplate().exchange(DRIVER_ITEM_URL, HttpMethod.GET, entity, JSONObject.class, getGraphEndPoint(), getType(), fullPath).getBody();\n    }\n\n\n    @Override\n    public boolean newFolder(String path, String name) {\n        path = StringUtils.trimStartSlashes(path);\n        String fullPath = StringUtils.concatTrimEndSlashes(param.getBasePath(), getCurrentUserBasePath(), path);\n\n        String requestUrl;\n\n        if (StringUtils.SLASH.equalsIgnoreCase(fullPath) || \"\".equalsIgnoreCase(fullPath)) {\n            requestUrl = DRIVER_ROOT_URL;\n        } else {\n            requestUrl = DRIVER_ITEMS_URL;\n        }\n\n        HashMap<Object, Object> data = new HashMap<>();\n        data.put(\"name\", name);\n        data.put(\"folder\", new HashMap<>());\n        data.put(\"@microsoft.graph.conflictBehavior\", \"replace\");\n\n        HttpEntity<HashMap<Object, Object>> entity = getAuthorizationHttpEntity(data);\n        ResponseEntity<JSONObject> responseEntity = getRestTemplate().exchange(requestUrl, HttpMethod.POST, entity, JSONObject.class, getGraphEndPoint(), getType(), fullPath);\n        return responseEntity.getStatusCode().is2xxSuccessful();\n    }\n\n    @Override\n    public boolean deleteFolder(String path, String name) {\n        return deleteFile(path, name);\n    }\n\n    @Override\n    public boolean deleteFile(String path, String name) {\n        String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name);\n\n        HttpEntity<Object> entity = getAuthorizationHttpEntity();\n        ResponseEntity<JSONObject> responseEntity = getRestTemplate().exchange(DRIVER_ITEM_OPERATOR_URL, HttpMethod.DELETE, entity, JSONObject.class, getGraphEndPoint(), getType(), fullPath);\n        return responseEntity.getStatusCode().is2xxSuccessful();\n    }\n\n    @Override\n    public boolean renameFile(String path, String name, String newName) {\n        String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name);\n\n        JSONObject jsonObject = new JSONObject().fluentPut(\"name\", newName);\n\n        HttpEntity<Object> entity = getAuthorizationHttpEntity(jsonObject);\n        ResponseEntity<JSONObject> responseEntity = getRestTemplate().exchange(DRIVER_ITEM_OPERATOR_URL, HttpMethod.PATCH, entity, JSONObject.class, getGraphEndPoint(), getType(), fullPath);\n        return responseEntity.getStatusCode().is2xxSuccessful();\n    }\n\n    @Override\n    public boolean renameFolder(String path, String name, String newName) {\n        return renameFile(path, name, newName);\n    }\n\n    @Override\n    public String getUploadUrl(String path, String name, Long size) {\n        if (param.isEnableProxyUpload()) {\n            return super.getProxyUploadUrl(path, name);\n        }\n        return getOneDriveUploadUrl(StringUtils.concat(getCurrentUserBasePath(), path), name);\n    }\n\n    private String getOneDriveUploadUrl(String path, String name) {\n        String fullPath = StringUtils.concat(param.getBasePath(), path, name);\n\n        HttpEntity<Object> entity = getAuthorizationHttpEntity();\n        ResponseEntity<JSONObject> responseEntity = getRestTemplate().exchange(CREATE_UPLOAD_SESSION_URL,\n                HttpMethod.POST, entity, JSONObject.class,\n                getGraphEndPoint(), getType(), fullPath);\n\n        JSONObject responseEntityBody = responseEntity.getBody();\n        if (responseEntityBody == null) {\n            throw new SystemException(\"获取上传地址失败, 返回值为空.\");\n        }\n        return responseEntityBody.getString(\"uploadUrl\");\n    }\n\n    @Override\n    public void uploadFile(String pathAndName, InputStream inputStream, Long size) throws IOException {\n        String fullPath = StringUtils.concat(getCurrentUserBasePath(), pathAndName);\n        String folderPath = FileUtils.getParentPath(fullPath);\n        String fileName = FileUtils.getName(fullPath);\n        String uploadUrl = getOneDriveUploadUrl(folderPath, fileName);\n\n        try {\n            getRestTemplate().execute(uploadUrl, HttpMethod.PUT, request -> {\n                HttpHeaders headers = request.getHeaders();\n                headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);\n                headers.setContentLength(size);\n                headers.set(HttpHeaders.CONTENT_RANGE, \"bytes 0-\" + (size - 1) + StringUtils.SLASH + size);\n                StreamUtils.copy(inputStream, request.getBody());\n            }, clientHttpResponse -> {\n                if (!clientHttpResponse.getStatusCode().is2xxSuccessful()) {\n                    throw new UploadFileFailSystemException(this.getStorageTypeEnum(), pathAndName, size,\n                            clientHttpResponse.getStatusCode().value(), clientHttpResponse.getStatusText());\n                }\n                return null;\n            });\n        } catch (Exception e) {\n            if (e instanceof ResourceAccessException && e.getMessage() != null && e.getMessage().contains(\"Timeout on\")) {\n                throw new BizException(ErrorCode.BIZ_UPLOAD_FILE_TIMEOUT_ERROR);\n            }\n            throw new UploadFileFailSystemException(this.getStorageTypeEnum(), pathAndName, size, 500, e.getMessage(), e);\n        }\n    }\n    @Override\n    public String getDownloadUrl(String pathAndName) {\n        if (param.isEnableProxyDownload() && StringUtils.isEmpty(param.getProxyDomain())) {\n            return getProxyDownloadUrl(pathAndName);\n        }\n        FileItemResult fileItem = getFileItem(pathAndName);\n        if (fileItem == null) {\n            throw new NotFoundAccessException(ErrorCode.BIZ_FILE_NOT_EXIST);\n        }\n        return fileItem.getUrl();\n    }\n\n    @Override\n    public ResponseEntity<org.springframework.core.io.Resource> downloadToStream(String pathAndName) throws IOException {\n        FileItemResult fileItem = getOriginFileItem(pathAndName);\n        if (fileItem == null) {\n            throw new NotFoundAccessException(ErrorCode.BIZ_FILE_NOT_EXIST);\n        }\n        Long fileSize = fileItem.getSize();\n        String fileName = fileItem.getName();\n        String url = fileItem.getUrl();\n\n        // url 转换为 inputStream\n        getRestTemplate().execute(url, HttpMethod.GET, null, clientHttpResponse -> {\n            InputStream inputStream = clientHttpResponse.getBody();\n            RequestHolder.writeFile(inputStream, fileName, fileSize, false, param.isProxyLinkForceDownload());\n            return null;\n        });\n        return null;\n    }\n\n    @Override\n    public boolean copyFile(String path, String name, String targetPath, String targetName) {\n        String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name);\n        String targetFullPath = StringUtils.concat(getCurrentUserBasePath(), targetPath);\n\n        JSONObject fileOriginInfo = getFileOriginInfo(targetFullPath);\n        if (fileOriginInfo == null) {\n            throw new BizException(ErrorCode.BIZ_FOLDER_NOT_EXIST);\n        }\n\n        String targetPathId = fileOriginInfo.getString(\"id\");\n        JSONObject jsonObject = new JSONObject()\n                .fluentPut(\"name\", targetName)\n                .fluentPut(\"parentReference\", new JSONObject().fluentPut(\"id\", targetPathId))\n                .fluentPut(\"@microsoft.graph.conflictBehavior\", \"replace\");\n\n        HttpEntity<Object> entity = getAuthorizationHttpEntity(jsonObject);\n        ResponseEntity<JSONObject> responseEntity = getRestTemplate().exchange(DRIVER_COPY_URL,\n                                                                                    HttpMethod.POST,\n                                                                                    entity,\n                                                                                    JSONObject.class,\n                                                                                    getGraphEndPoint(),\n                                                                                    getType(),\n                                                                                    fullPath);\n        return responseEntity.getStatusCode().is2xxSuccessful();\n    }\n\n    @Override\n    public boolean copyFolder(String path, String name, String targetPath, String targetName) {\n        return copyFile(path, name, targetPath, targetName);\n    }\n\n    @Override\n    public boolean moveFile(String path, String name, String targetPath, String targetName) {\n        String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name);\n        String targetFullPath = StringUtils.concat(getCurrentUserBasePath(), targetPath);\n\n        JSONObject fileOriginInfo = getFileOriginInfo(targetFullPath);\n        if (fileOriginInfo == null) {\n            throw new BizException(ErrorCode.BIZ_FOLDER_NOT_EXIST);\n        }\n\n        String targetPathId = fileOriginInfo.getString(\"id\");\n\n        JSONObject jsonObject = new JSONObject()\n                .fluentPut(\"name\", targetName)\n                .fluentPut(\"parentReference\", new JSONObject().fluentPut(\"id\", targetPathId))\n                .fluentPut(\"@microsoft.graph.conflictBehavior\", \"replace\");\n\n        HttpEntity<Object> entity = getAuthorizationHttpEntity(jsonObject);\n        ResponseEntity<JSONObject> responseEntity = getRestTemplate().exchange(DRIVER_ITEM_OPERATOR_URL,\n                                                                                    HttpMethod.PATCH,\n                                                                                    entity,\n                                                                                    JSONObject.class,\n                                                                                    getGraphEndPoint(),\n                                                                                    getType(),\n                                                                                    fullPath);\n        return responseEntity.getStatusCode().is2xxSuccessful();\n    }\n\n    @Override\n    public boolean moveFolder(String path, String name, String targetPath, String targetName) {\n        return moveFile(path, name, targetPath, targetName);\n    }\n\n    /**\n     * 获取存储类型, 对于 OneDrive 或 SharePoint, 此地址会不同.\n     * @return          Graph 连接点\n     */\n    public abstract String getType();\n\n    /**\n     * 获取 GraphEndPoint, 对于不同版本的 OneDrive, 此地址会不同.\n     * @return          Graph 连接点\n     */\n    public abstract String getGraphEndPoint();\n\n\n    /**\n     * 获取 AuthenticateEndPoint, 对于不同版本的 OneDrive, 此地址会不同.\n     * @return          Authenticate 连接点\n     */\n    public abstract String getAuthenticateEndPoint();\n\n    /**\n     * 获取 Client ID.\n     * @return  Client Id\n     */\n    public abstract String getClientId();\n\n    /**\n     * 获取重定向地址.\n     * @return  重定向地址\n     */\n    public abstract String getRedirectUri();\n\n    /**\n     * 获取 Client Secret 密钥.\n     * @return  Client Secret 密钥.\n     */\n    public abstract String getClientSecret();\n\n    /**\n     * 获取 API Scope.\n     * @return  Scope\n     */\n    public abstract String getScope();\n\n\n    /**\n     * 刷新当前存储源 AccessToken\n     */\n    @Override\n    public void refreshAccessToken() {\n        try {\n            RefreshTokenInfoDTO tokenInfoDTO = getAndRefreshToken();\n\n            if (tokenInfoDTO.getAccessToken() == null || tokenInfoDTO.getRefreshToken() == null) {\n                throw new SystemException(\"存储源 \" + storageId + \" 刷新令牌失败, 获取到令牌为空.\");\n            }\n\n            StorageSourceConfig accessTokenConfig = storageSourceConfigService.findByStorageIdAndName(storageId, StorageConfigConstant.ACCESS_TOKEN_KEY);\n            StorageSourceConfig refreshTokenConfig = storageSourceConfigService.findByStorageIdAndName(storageId, StorageConfigConstant.REFRESH_TOKEN_KEY);\n            StorageSourceConfig refreshTokenExpiredAtConfig = storageSourceConfigService.findByStorageIdAndName(storageId, StorageConfigConstant.REFRESH_TOKEN_EXPIRED_AT_KEY);\n            accessTokenConfig.setValue(tokenInfoDTO.getAccessToken());\n            refreshTokenConfig.setValue(tokenInfoDTO.getRefreshToken());\n            refreshTokenExpiredAtConfig.setValue(String.valueOf(tokenInfoDTO.getExpiredAt()));\n\n            storageSourceConfigService.updateBatch(storageId, Arrays.asList(accessTokenConfig, refreshTokenConfig, refreshTokenExpiredAtConfig));\n            RefreshTokenCacheBO.putRefreshTokenInfo(storageId, RefreshTokenCacheBO.RefreshTokenInfo.success(tokenInfoDTO));\n        } catch (Exception e) {\n            RefreshTokenCacheBO.putRefreshTokenInfo(storageId, RefreshTokenCacheBO.RefreshTokenInfo.fail(\"AccessToken 刷新失败: \" + e.getMessage()));\n            throw new SystemException(\"存储源 \" + storageId + \" 刷新令牌失败, 获取时发生异常.\", e);\n\n        }\n    }\n\n\n    /**\n     * 将微软接口返回的 JSON 对象转为 FileItemResult 对象\n     *\n     * @param jsonObject    JSON 对象\n     * @param folderPath    文件夹路径\n     * @return              FileItemResult 对象\n     */\n    private FileItemResult jsonToFileItem(JSONObject jsonObject, String folderPath) {\n        FileItemResult fileItemResult = new FileItemResult();\n        fileItemResult.setName(jsonObject.getString(\"name\"));\n        fileItemResult.setSize(jsonObject.getLong(\"size\"));\n        fileItemResult.setTime(jsonObject.getDate(\"lastModifiedDateTime\"));\n\n        if (jsonObject.containsKey(ONE_DRIVE_FILE_FLAG)) {\n            String originUrl = jsonObject.getString(\"@microsoft.graph.downloadUrl\");\n            if (StringUtils.isNotEmpty(param.getProxyDomain())) {\n                originUrl = StringUtils.replaceHost(originUrl, param.getProxyDomain());\n            }\n\n            fileItemResult.setUrl(originUrl);\n            fileItemResult.setType(FileTypeEnum.FILE);\n        } else {\n            fileItemResult.setType(FileTypeEnum.FOLDER);\n        }\n        fileItemResult.setPath(folderPath);\n        return fileItemResult;\n    }\n    \n    \n    /**\n     * 获取存储源默认的 HttpEntity 对象.\n     * <br>\n     * 该对象默认包含了当前存储源的 AccessToken.\n     *\n     * @return  HttpEntity 对象\n     */\n    private HttpEntity<Object> getAuthorizationHttpEntity() {\n       return getAuthorizationHttpEntity(null);\n    }\n    \n    \n    /**\n     * 获取存储源默认的 HttpEntity 对象.\n     * <br>\n     * 该对象默认包含了当前存储源的 AccessToken.\n     *\n     * @param   body\n     *          请求体\n     *\n     * @return  HttpEntity 对象\n     */\n    private <T> HttpEntity<T> getAuthorizationHttpEntity(T body) {\n        HttpHeaders headers = new HttpHeaders();\n        String accessToken = checkExpiredAndGetAccessToken();\n        headers.setBearerAuth(accessToken);\n        return new HttpEntity<>(body, headers);\n    }\n\n    @Override\n    public StorageSourceMetadata getStorageSourceMetadata() {\n        StorageSourceMetadata storageSourceMetadata = new StorageSourceMetadata();\n        if (param.isEnableProxyUpload()) {\n            storageSourceMetadata.setUploadType(StorageSourceMetadata.UploadType.PROXY);\n        } else {\n            storageSourceMetadata.setUploadType(StorageSourceMetadata.UploadType.MICROSOFT);\n        }\n        storageSourceMetadata.setNeedCreateFolderBeforeUpload(false);\n        return storageSourceMetadata;\n    }\n\n    /**\n     * 根据 RefreshToken 刷新 AccessToken, 返回刷新后的 Token.\n     *\n     * @return  刷新后的 Token\n     */\n    private RefreshTokenInfoDTO getAndRefreshToken() {\n        StorageSourceConfig refreshStorageSourceConfig =\n                storageSourceConfigService.findByStorageIdAndName(storageId, StorageConfigConstant.REFRESH_TOKEN_KEY);\n\n        String param = \"client_id=\" + getClientId() +\n                \"&redirect_uri=\" + getRedirectUri() +\n                \"&client_secret=\" + getClientSecret() +\n                \"&refresh_token=\" + refreshStorageSourceConfig.getValue() +\n                \"&grant_type=refresh_token\";\n\n        if (log.isDebugEnabled()) {\n            log.debug(\"{} 尝试刷新令牌, 请求参数: {}\", getStorageSimpleInfo(), param);\n        }\n\n        String authenticateUrl = AUTHENTICATE_URL.replace(\"{authenticateEndPoint}\", getAuthenticateEndPoint());\n        HttpResponse response = HttpUtil.createPost(authenticateUrl)\n                .body(param, ContentType.FORM_URLENCODED.getValue())\n                .execute();\n\n        String responseBody = response.body();\n        int responseStatus = response.getStatus();\n\n        if (log.isDebugEnabled()) {\n            log.debug(\"{} 刷新令牌完成. 响应状态码: {}, 响应体: {}\", getStorageSimpleInfo(), responseStatus, responseBody);\n        }\n\n        if (responseStatus != HttpStatus.OK.value()) {\n            throw new SystemException(responseBody);\n        }\n\n        JSONObject jsonBody = JSONObject.parseObject(responseBody);\n        String accessToken = jsonBody.getString(IOAuth2Service.ACCESS_TOKEN_FIELD_NAME);\n        String refreshToken = jsonBody.getString(IOAuth2Service.REFRESH_TOKEN_FIELD_NAME);\n        Integer expiresIn = jsonBody.getInteger(IOAuth2Service.EXPIRES_IN_FIELD_NAME);\n        return RefreshTokenInfoDTO.success(accessToken, refreshToken, expiresIn);\n    }\n\n    @Override\n    public void destroy() {\n        if (restTemplate != null && restTemplate.getRequestFactory() instanceof CloseableHttpClient) {\n            try {\n                ((CloseableHttpClient) restTemplate.getRequestFactory()).close();\n            } catch (IOException e) {\n                log.error(\"关闭 {} 的 HTTP 客户端失败.\", getStorageSimpleInfo(), e);\n            }\n        }\n    }\n\n    /**\n     * 检查 AccessToken 是否过期，如果过期则刷新 AccessToken 并返回新的 AccessToken。\n     */\n    private String checkExpiredAndGetAccessToken() {\n        RefreshTokenCacheBO.RefreshTokenInfo refreshTokenInfo = RefreshTokenCacheBO.getRefreshTokenInfo(storageId);\n\n        if (refreshTokenInfo == null || refreshTokenInfo.isExpired()) {\n            // 使用双重检查锁定机制，确保同一个 storageId 只会有一个线程在刷新 AccessToken\n            synchronized ((\"storage-refresh-\" + storageId).intern()) {\n                // 双重检查，再次从缓存中获取，确认是否其他线程已经刷新过\n                refreshTokenInfo = RefreshTokenCacheBO.getRefreshTokenInfo(storageId);\n                if (refreshTokenInfo == null || refreshTokenInfo.isExpired()) {\n                    if (refreshTokenInfo == null || refreshTokenInfo.isExpired()) {\n                        log.info(\"{} AccessToken 未获取或已过期, 尝试刷新: {}\", getStorageSimpleInfo(), refreshTokenInfo);\n                        refreshAccessToken();\n                        refreshTokenInfo = RefreshTokenCacheBO.getRefreshTokenInfo(storageId);\n                    }\n                }\n            }\n        }\n\n        if (refreshTokenInfo == null) {\n            throw new SystemException(\"存储源 \" + storageId + \" AccessToken 刷新失败: 未找到刷新令牌信息.\");\n        }\n\n        return refreshTokenInfo.getData().getAccessToken();\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/base/AbstractOneDriveServiceBase.java",
    "content": "package im.zhaojun.zfile.module.storage.service.base;\n\nimport im.zhaojun.zfile.module.storage.model.param.OneDriveParam;\nimport lombok.extern.slf4j.Slf4j;\n\n/**\n * -50\n * +70\n * -100\n * @author zhaojun\n */\n@Slf4j\npublic abstract class AbstractOneDriveServiceBase<P extends OneDriveParam> extends AbstractMicrosoftDriveService<P> {\n\n    @Override\n    public String getType() {\n        return \"me\";\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/base/AbstractProxyTransferService.java",
    "content": "package im.zhaojun.zfile.module.storage.service.base;\n\nimport cn.hutool.core.net.url.UrlBuilder;\nimport im.zhaojun.zfile.core.util.FileUtils;\nimport im.zhaojun.zfile.core.util.ProxyDownloadUrlUtils;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.config.service.SystemConfigService;\nimport im.zhaojun.zfile.module.storage.model.param.ProxyTransferParam;\nimport im.zhaojun.zfile.module.storage.service.StorageSourceService;\nimport jakarta.annotation.Resource;\nimport org.springframework.http.ResponseEntity;\n\nimport java.io.InputStream;\n\n/**\n * 代理传输数据(上传/下载) Service\n *\n * @author zhaojun\n */\npublic abstract class AbstractProxyTransferService<P extends ProxyTransferParam> extends AbstractBaseFileService<P>{\n\n\n\t/**\n\t * 服务器代理下载 URL 前缀.\n\t */\n\tpublic static final String PROXY_DOWNLOAD_LINK_PREFIX = \"/pd\";\n\n\n\t/**\n\t * 服务器代理下载 URL 前缀.\n\t */\n\tpublic static final String PROXY_UPLOAD_LINK_PREFIX = \"/file/upload\";\n\n\t@Resource\n\tprivate SystemConfigService systemConfigService;\n\n\n\t@Resource\n\tprivate StorageSourceService storageSourceService;\n\n\n\t/**\n\t * 获取默认代理下载 URL.\n\t *\n\t * @param   pathAndName\n\t *          文件路径及文件名称\n\t *\n\t * @return  默认的代理下载 URL\n\t */\n\tpublic String getProxyDownloadUrl(String pathAndName) {\n\t\treturn getProxyDownloadUrl(pathAndName, false);\n\t}\n\n\n\t/**\n\t * 获取默认代理下载 URL.\n\t *\n\t * @param   pathAndName\n\t *          文件路径及文件名称\n\t *\n\t * @param \tuseParamDomain\n\t * \t\t\t是否使用存储源参数中的域名替换系统配置中的域名作为下载地址\n\t *\n\t * @return  默认的代理下载 URL\n\t */\n\tpublic String getProxyDownloadUrl(String pathAndName, boolean useParamDomain) {\n\t\tString path = pathAndName;\n\n\t\tUrlBuilder urlBuilder = UrlBuilder.of();\n\t\tString filename = FileUtils.getName(pathAndName);\n\t\tif (filename.startsWith(\".\")) {\n\t\t\turlBuilder.addQuery(\"filename\", filename);\n\t\t\tpath = FileUtils.getParentPath(pathAndName);\n\t\t}\n\n\t\tif (param.isProxyPrivate()) {\n\t\t\turlBuilder.addQuery(\"signature\", ProxyDownloadUrlUtils.generatorSignature(storageId, pathAndName, param.getProxyTokenTime()));\n\t\t}\n\n\t\tString url;\n\n\t\t// 如果未填写下载域名，则默认使用带来下载地址.\n\t\tif (!useParamDomain || StringUtils.isEmpty(param.getDomain())) {\n\t\t\tString domain = systemConfigService.getAxiosFromDomainOrSetting();\n\t\t\tString storageKey = storageSourceService.findStorageKeyById(storageId);\n\t\t\turl = StringUtils.concat(domain, PROXY_DOWNLOAD_LINK_PREFIX, storageKey, StringUtils.encodeAllIgnoreSlashes(path));\n\t\t} else {\n\t\t\turl = StringUtils.concat(param.getDomain(), StringUtils.encodeAllIgnoreSlashes(path));\n\t\t}\n\n\t\tif (StringUtils.isNotEmpty(urlBuilder.getQueryStr())) {\n\t\t\turl = url + \"?\" + urlBuilder.getQueryStr();\n\t\t}\n\t\treturn url;\n\t}\n\n\n\t/**\n\t * 获取默认代理上传 URL.\n\t *\n\t * @param   path\n\t *          文件路径\n\t *\n\t * @param   name\n\t *          文件名称\n\t *\n\t * @return  默认的代理下上传 URL\n\t */\n\tpublic String getProxyUploadUrl(String path, String name) {\n\t\tString domain = systemConfigService.getAxiosFromDomainOrSetting();\n\t\tString storageKey = storageSourceService.findStorageKeyById(storageId);\n\n\t\tUrlBuilder urlBuilder = UrlBuilder.of();\n\n\t\tString fullPath = StringUtils.concat(path, name);\n\n\t\t// 以 . 开头的文件名, 代表是隐藏文件, 需要特殊处理为参数形式，不然会被安全拦截.\n\t\tif (name.startsWith(\".\")) {\n\t\t\turlBuilder.addQuery(\"filename\", name);\n\t\t\tfullPath = path;\n\t\t}\n\n\t\tString uploadUrl = StringUtils.concat(domain, PROXY_UPLOAD_LINK_PREFIX, storageKey, StringUtils.encodeAllIgnoreSlashes(fullPath));\n\n\t\tif (StringUtils.isNotEmpty(urlBuilder.getQueryStr())) {\n\t\t\tuploadUrl = uploadUrl + \"?\" + urlBuilder.getQueryStr();\n\t\t}\n\n\t\treturn uploadUrl;\n\t}\n\n\t/**\n\t * 上传文件\n\t *\n\t * @param   pathAndName\n\t *          文件上传路径\n\t *\n\t * @param   inputStream\n\t *          文件流\n\t *\n\t * @param \tsize\n\t * \t\t\t文件大小\n\t */\n\tpublic abstract void uploadFile(String pathAndName, InputStream inputStream, Long size) throws Exception;\n\n\n\t/**\n\t * 代理下载指定文件\n\t *\n\t * @param   pathAndName\n\t *          文件路径及文件名称\n\t *\n\t * @return  文件响应.\n\t */\n\tpublic abstract ResponseEntity<org.springframework.core.io.Resource> downloadToStream(String pathAndName) throws Exception;\n\n    protected SystemConfigService getSystemConfigService() {\n        return systemConfigService;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/base/AbstractS3BaseFileService.java",
    "content": "package im.zhaojun.zfile.module.storage.service.base;\n\nimport cn.hutool.core.convert.Convert;\nimport com.alibaba.fastjson2.JSON;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.biz.CorsBizException;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.util.FileUtils;\nimport im.zhaojun.zfile.core.util.RequestHolder;\nimport im.zhaojun.zfile.core.util.RequestUtils;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.storage.constant.StorageSourceConnectionProperties;\nimport im.zhaojun.zfile.module.storage.model.bo.StorageSourceMetadata;\nimport im.zhaojun.zfile.module.storage.model.dto.ZFileCORSRule;\nimport im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.param.S3BaseParam;\nimport im.zhaojun.zfile.module.storage.model.result.FileItemResult;\nimport im.zhaojun.zfile.module.storage.service.impl.S3ServiceImpl;\nimport lombok.extern.slf4j.Slf4j;\nimport org.apache.commons.lang3.BooleanUtils;\nimport org.springframework.http.HttpRange;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.ResponseEntity;\nimport software.amazon.awssdk.awscore.AwsRequestOverrideConfiguration;\nimport software.amazon.awssdk.core.ResponseInputStream;\nimport software.amazon.awssdk.core.client.config.ClientOverrideConfiguration;\nimport software.amazon.awssdk.core.sync.RequestBody;\nimport software.amazon.awssdk.services.s3.S3Client;\nimport software.amazon.awssdk.services.s3.model.*;\nimport software.amazon.awssdk.services.s3.paginators.ListObjectsV2Iterable;\nimport software.amazon.awssdk.services.s3.presigner.S3Presigner;\nimport software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;\nimport software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest;\nimport software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;\nimport software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;\n\nimport java.io.ByteArrayInputStream;\nimport java.io.InputStream;\nimport java.net.URL;\nimport java.net.URLConnection;\nimport java.time.Duration;\nimport java.util.ArrayList;\nimport java.util.Date;\nimport java.util.List;\nimport java.util.Set;\nimport java.util.function.Consumer;\n\n/**\n * @author zhaojun\n */\n@Slf4j\npublic abstract class AbstractS3BaseFileService<P extends S3BaseParam> extends AbstractProxyTransferService<P> {\n\n    protected S3Client s3ClientNew;\n\n    protected S3Presigner s3Presigner;\n\n    protected S3Presigner s3PresignerDownload;\n\n    public static final InputStream EMPTY_INPUT_STREAM = new ByteArrayInputStream(new byte[0]);\n\n    Consumer<AwsRequestOverrideConfiguration.Builder> unlimitTimeoutBuilderConsumer = builder -> builder.apiCallTimeout(Duration.ofDays(30)).build();\n\n    @Override\n    public List<FileItemResult> fileList(String folderPath) {\n        return s3FileList(folderPath);\n    }\n\n\n    /**\n     * 默认 S3 获取对象下载链接的方法, 如果指定了域名, 则替换为自定义域名.\n     * @return  S3 对象访问地址\n     */\n    @Override\n    public String getDownloadUrl(String pathAndName) {\n        if (param.isEnableProxyDownload() && StringUtils.isEmpty(param.getDomain())) {\n            return getProxyDownloadUrl(pathAndName, false);\n        }\n        String bucketName = param.getBucketName();\n        String domain = param.getDomain();\n\n        String fullPath = StringUtils.concatTrimStartSlashes(param.getBasePath(), pathAndName);\n\n        // 如果不是私有空间, 且指定了加速域名, 则直接返回下载地址.\n        if (BooleanUtils.isNotTrue(param.isPrivate()) && StringUtils.isNotEmpty(domain)) {\n            return StringUtils.concat(domain, StringUtils.encodeAllIgnoreSlashes(fullPath));\n        }\n\n        Integer tokenTime = param.getTokenTime();\n        if (param.getTokenTime() == null || param.getTokenTime() < 1) {\n            tokenTime = 1800;\n        }\n\n        GetObjectRequest getObjectRequest = GetObjectRequest.builder()\n                .applyMutation(processGeneratePresignedUrlRequest())\n                .bucket(bucketName)\n                .key(fullPath)\n                .build();\n\n        S3Presigner presigner = s3PresignerDownload != null ? s3PresignerDownload : s3Presigner;\n        PresignedGetObjectRequest presignedGetObjectRequest = presigner.presignGetObject(GetObjectPresignRequest.builder()\n                .getObjectRequest(getObjectRequest)\n                .signatureDuration(Duration.ofSeconds(tokenTime))\n                .build());\n        URL url = presignedGetObjectRequest.url();\n        String defaultUrl = url.toExternalForm();\n        if (StringUtils.isNotEmpty(domain)) {\n            String path = url.getFile();\n            if (this instanceof S3ServiceImpl) {\n                path = path.replaceFirst(bucketName + \"/\", \"\");\n            }\n            defaultUrl = StringUtils.concat(domain, path);\n        }\n        return defaultUrl;\n    }\n\n    public Consumer<GetObjectRequest.Builder> processGeneratePresignedUrlRequest() {\n        return builder -> {\n        };\n    }\n\n    /**\n     * 获取 S3 指定目录下的对象列表\n     * @param path      路径\n     * @return  指定目录下的对象列表\n     */\n    public List<FileItemResult> s3FileList(String path) {\n        String bucketName = param.getBucketName();\n        String fullPath = StringUtils.concatTrimStartSlashes(param.getBasePath(), getCurrentUserBasePath(), path, StringUtils.SLASH);\n\n        List<FileItemResult> fileItemList = new ArrayList<>();\n\n        ListObjectsV2Request listObjectsV2Request = ListObjectsV2Request.builder()\n                .bucket(bucketName)\n                .prefix(fullPath)\n                .maxKeys(1000)\n                .delimiter(StringUtils.SLASH)\n                .build();\n        ListObjectsV2Iterable listObjectsV2Iterable = s3ClientNew.listObjectsV2Paginator(listObjectsV2Request);\n        for (S3Object s : listObjectsV2Iterable.contents()) {\n            FileItemResult fileItemResult = new FileItemResult();\n            if (s.key().equals(fullPath)) {\n                continue;\n            }\n            fileItemResult.setName(s.key().substring(fullPath.length()));\n            fileItemResult.setSize(s.size());\n            fileItemResult.setTime(Date.from(s.lastModified()));\n            fileItemResult.setType(FileTypeEnum.FILE);\n            fileItemResult.setPath(path);\n\n            String fullPathAndName = StringUtils.concat(getCurrentUserBasePath(), path, fileItemResult.getName());\n            fileItemResult.setUrl(getDownloadUrl(fullPathAndName));\n\n            fileItemList.add(fileItemResult);\n        }\n\n        for (CommonPrefix commonPrefix : listObjectsV2Iterable.commonPrefixes()) {\n            String commonPrefixStr = commonPrefix.prefix();\n            FileItemResult fileItemResult = new FileItemResult();\n            fileItemResult.setName(commonPrefixStr.substring(fullPath.length(), commonPrefixStr.length() - 1));\n            String name = fileItemResult.getName();\n            if (StringUtils.isEmpty(name) || StringUtils.equals(name, StringUtils.SLASH)) {\n                continue;\n            }\n\n            fileItemResult.setType(FileTypeEnum.FOLDER);\n            fileItemResult.setPath(path);\n            fileItemList.add(fileItemResult);\n        }\n\n        return fileItemList;\n    }\n\n    @Override\n    public FileItemResult getFileItem(String pathAndName) {\n        String fileName = FileUtils.getName(pathAndName);\n        String parentPath = FileUtils.getParentPath(pathAndName);\n\n        String trimStartPath = StringUtils.concatTrimStartSlashes(param.getBasePath(), getCurrentUserBasePath(), pathAndName);\n        HeadObjectRequest headObjectRequest = HeadObjectRequest.builder().bucket(param.getBucketName()).key(trimStartPath).build();\n        HeadObjectResponse headObjectResponse;\n        try {\n            headObjectResponse = s3ClientNew.headObject(headObjectRequest);\n        } catch (NoSuchKeyException e) {\n            return null;\n        }\n\n        FileItemResult fileItemResult = new FileItemResult();\n        fileItemResult.setName(fileName);\n        fileItemResult.setSize(headObjectResponse.contentLength());\n        fileItemResult.setTime(Date.from(headObjectResponse.lastModified()));\n        fileItemResult.setType(FileTypeEnum.FILE);\n        fileItemResult.setPath(parentPath);\n        fileItemResult.setUrl(getDownloadUrl(StringUtils.concat(getCurrentUserBasePath(), pathAndName)));\n        return fileItemResult;\n    }\n\n    @Override\n    public boolean newFolder(String path, String name) {\n        name = StringUtils.trimSlashes(name);\n        String fullPath = StringUtils.concatTrimStartSlashes(param.getBasePath(), getCurrentUserBasePath(), path, name, StringUtils.SLASH);\n        PutObjectResponse putObjectResponse = s3ClientNew.putObject(PutObjectRequest.builder()\n                        .bucket(param.getBucketName())\n                        .key(fullPath)\n                        .build(),\n                RequestBody.empty());\n\n        return putObjectResponse != null && putObjectResponse.sdkHttpResponse().isSuccessful();\n    }\n\n    @Override\n    public boolean deleteFile(String path, String name) {\n        String fullPath = StringUtils.concatTrimStartSlashes(param.getBasePath(), getCurrentUserBasePath(), path, name);\n        DeleteObjectResponse deleteObjectResponse = s3ClientNew.deleteObject(DeleteObjectRequest.builder()\n                .bucket(param.getBucketName())\n                .key(fullPath)\n                .build());\n        return deleteObjectResponse != null && deleteObjectResponse.sdkHttpResponse().isSuccessful();\n    }\n\n    @Override\n    public boolean deleteFolder(String path, String name) {\n        return deleteFile(path, name + StringUtils.SLASH);\n    }\n\n    @Override\n    public boolean renameFile(String path, String name, String newName) {\n        this.copyFile(path, name, path, newName);\n        this.deleteFile(path, name);\n        return true;\n    }\n\n    @Override\n    public boolean renameFolder(String path, String name, String newName) {\n        throw new BizException(ErrorCode.BIZ_STORAGE_NOT_SUPPORT_OPERATION);\n    }\n\n    @Override\n    public String getUploadUrl(String path, String name, Long size) {\n        if (param.isEnableProxyUpload()) {\n            return super.getProxyUploadUrl(path, name);\n        }\n        String bucketName = param.getBucketName();\n        String uploadToPath = StringUtils.concatTrimStartSlashes(param.getBasePath(), getCurrentUserBasePath(), path, name);\n\n        PutObjectRequest objectRequest = PutObjectRequest.builder()\n                .bucket(bucketName)\n                .key(uploadToPath)\n                .build();\n\n        PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()\n                .signatureDuration(Duration.ofMinutes(30))  // The URL expires in 10 minutes.\n                .putObjectRequest(objectRequest)\n                .build();\n\n\n        PresignedPutObjectRequest presignedPutObjectRequest = s3Presigner.presignPutObject(presignRequest);\n        URL url = presignedPutObjectRequest.url();\n        String urlString = url.toExternalForm();\n\n        String contentType = parseContentTypeByName(name, MediaType.APPLICATION_OCTET_STREAM_VALUE);\n        urlString = urlString + (urlString.contains(\"?\") ? \"&\" : \"?\") + \"Content-Type=\" + contentType;\n        return urlString;\n    }\n\n\n\n    @Override\n    public boolean copyFile(String path, String name, String targetPath, String targetName) {\n        String bucketName = param.getBucketName();\n\n        String srcFilePath = StringUtils.concatTrimStartSlashes(param.getBasePath(), getCurrentUserBasePath(), path, name);\n        String distFilePath = StringUtils.concatTrimStartSlashes(param.getBasePath(), getCurrentUserBasePath(), targetPath, targetName);\n\n        CopyObjectResponse copyObjectResponse = s3ClientNew.copyObject(CopyObjectRequest.builder()\n                .sourceBucket(bucketName)\n                .sourceKey(srcFilePath)\n                .destinationBucket(bucketName)\n                .destinationKey(distFilePath)\n                .build());\n        return copyObjectResponse != null && copyObjectResponse.sdkHttpResponse().isSuccessful();\n    }\n\n    @Override\n    public boolean copyFolder(String path, String name, String targetPath, String targetName) {\n        throw new BizException(ErrorCode.BIZ_STORAGE_NOT_SUPPORT_OPERATION);\n    }\n\n    @Override\n    public boolean moveFile(String path, String name, String targetPath, String targetName) {\n        this.copyFile(path, name, targetPath, targetName);\n        this.deleteFile(path, name);\n        return true;\n    }\n\n    @Override\n    public boolean moveFolder(String path, String name, String targetPath, String targetName) {\n        throw new BizException(ErrorCode.BIZ_STORAGE_NOT_SUPPORT_OPERATION);\n    }\n\n    protected void setUploadCors() {\n        try {\n            List<ZFileCORSRule> zFileCORSRuleList = JSON.parseArray(param.getCorsConfigList(), ZFileCORSRule.class);\n            if (zFileCORSRuleList == null || zFileCORSRuleList.isEmpty()) {\n                return;\n            }\n            Set<CORSRule> s3CORSRuleList = ZFileCORSRule.toCORSRule(zFileCORSRuleList);\n            CORSConfiguration corsConfiguration = CORSConfiguration.builder().corsRules(s3CORSRuleList).build();\n\n            s3ClientNew.putBucketCors(PutBucketCorsRequest.builder()\n                    .bucket(param.getBucketName())\n                    .corsConfiguration(corsConfiguration)\n                    .build());\n        } catch (Exception e) {\n            throw new CorsBizException(\"设置跨域失败, 请检查配置是否正确. 错误信息: \" + e.getMessage(), e);\n        }\n    }\n\n\n    @Override\n    public void uploadFile(String pathAndName, InputStream inputStream, Long size) throws Exception {\n        String contentType = parseContentTypeByName(pathAndName, MediaType.APPLICATION_OCTET_STREAM_VALUE);\n\n        String trimStartPath = StringUtils.concatTrimStartSlashes(param.getBasePath(), getCurrentUserBasePath(), pathAndName);\n\n        s3ClientNew.putObject(PutObjectRequest.builder()\n                        .overrideConfiguration(unlimitTimeoutBuilderConsumer)\n                        .bucket(param.getBucketName())\n                        .key(trimStartPath)\n                        .contentType(contentType)\n                        .build(),\n                RequestBody.fromInputStream(inputStream, size));\n    }\n\n    @Override\n    public ResponseEntity<org.springframework.core.io.Resource> downloadToStream(String pathAndName) throws Exception {\n        String bucketName = param.getBucketName();\n        String trimStartPath = StringUtils.concatTrimStartSlashes(param.getBasePath(), pathAndName);\n\n        HttpRange requestRange = RequestUtils.getRequestRange(RequestHolder.getRequest());\n\n        ResponseInputStream<GetObjectResponse> responseResponseInputStream = s3ClientNew.getObject(GetObjectRequest.builder()\n                .overrideConfiguration(unlimitTimeoutBuilderConsumer)\n                .bucket(bucketName)\n                .key(trimStartPath)\n                .range(requestRange != null ? \"bytes=\" + requestRange.getRangeStart(Integer.MAX_VALUE) + \"-\" + requestRange.getRangeEnd(Integer.MAX_VALUE) : null)\n                .build());\n\n        long fileSize = Convert.toLong(responseResponseInputStream.response().contentLength());\n        String fileName = FileUtils.getName(pathAndName);\n        RequestHolder.writeFile(responseResponseInputStream, fileName, fileSize, true, param.isProxyLinkForceDownload());\n        return null;\n    }\n\n    public ClientOverrideConfiguration getClientConfiguration() {\n        return ClientOverrideConfiguration.builder()\n                .apiCallTimeout(Duration.ofSeconds(StorageSourceConnectionProperties.DEFAULT_CONNECTION_TIMEOUT_SECONDS)) // 设置 API 调用超时时间\n                .build();\n    }\n\n    @Override\n    public StorageSourceMetadata getStorageSourceMetadata() {\n        StorageSourceMetadata storageSourceMetadata = new StorageSourceMetadata();\n        if (param.isEnableProxyUpload()) {\n            storageSourceMetadata.setUploadType(StorageSourceMetadata.UploadType.PROXY);\n        } else {\n            storageSourceMetadata.setUploadType(StorageSourceMetadata.UploadType.S3);\n        }\n        storageSourceMetadata.setSupportRenameFolder(false);\n        storageSourceMetadata.setSupportMoveFolder(false);\n        storageSourceMetadata.setSupportCopyFolder(false);\n        storageSourceMetadata.setSupportDeleteNotEmptyFolder(false);\n        storageSourceMetadata.setNeedCreateFolderBeforeUpload(false);\n        return storageSourceMetadata;\n    }\n\n    private static String parseContentTypeByName(String pathAndName, String defaultContentType) {\n        String contentType = URLConnection.guessContentTypeFromName(pathAndName);\n        if (StringUtils.isBlank(contentType)) {\n            contentType = defaultContentType;\n        }\n        return contentType;\n    }\n\n    @Override\n    public void destroy() {\n        if (this.s3ClientNew != null) {\n            this.s3ClientNew.close();\n        }\n        if (this.s3Presigner != null) {\n            this.s3Presigner.close();\n        }\n        if (this.s3PresignerDownload != null) {\n            this.s3PresignerDownload.close();\n        }\n    }\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/base/AbstractSharePointServiceBase.java",
    "content": "package im.zhaojun.zfile.module.storage.service.base;\n\nimport im.zhaojun.zfile.module.storage.model.param.SharePointParam;\n\n/**\n * @author zhaojun\n */\npublic abstract class AbstractSharePointServiceBase<P extends SharePointParam> extends AbstractMicrosoftDriveService<SharePointParam> {\n\n    @Override\n    public String getType() {\n        return \"sites/\" + param.getSiteId();\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/base/BaseFileService.java",
    "content": "package im.zhaojun.zfile.module.storage.service.base;\n\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.result.FileItemResult;\nimport org.springframework.lang.Nullable;\n\nimport java.util.List;\n\n/**\n * 基础文件服务接口，定义了了一些通用方法定义\n *\n * @author zhaojun\n */\npublic interface BaseFileService {\n\n    /***\n     * 获取指定路径下的文件及文件夹\n     *\n     * @param   folderPath\n     *          文件夹路径\n     *\n     * @return  文件及文件夹列表\n     *\n     * @throws  Exception  获取文件列表中出现的异常\n     */\n    List<FileItemResult> fileList(String folderPath) throws Exception;\n\n    /**\n     * 获取单个文件信息\n     *\n     * @param   pathAndName\n     *          文件路径及文件名称\n     *\n     * @return  单个文件的内容.\n     */\n    @Nullable\n    FileItemResult getFileItem(String pathAndName);\n\n    /**\n     * 创建新文件夹\n     *\n     * @param   path\n     *          文件夹路径\n     *\n     * @param   name\n     *          文件夹名称\n     *\n     * @return  是否创建成功\n     */\n    boolean newFolder(String path, String name);\n\n    /**\n     * 删除文件\n     *\n     * @param   path\n     *          文件路径\n     *\n     * @param   name\n     *          文件名称\n     *\n     * @return  是否删除成功\n     */\n    boolean deleteFile(String path, String name);\n\n    /**\n     * 删除文件夹\n     *\n     * @param   path\n     *          文件夹路径\n     *\n     * @param   name\n     *          文件夹名称\n     *\n     * @return  是否删除成功\n     */\n    boolean deleteFolder(String path, String name);\n\n    /**\n     * 复制文件\n     *\n     * @param   path\n     *          文件路径\n     *\n     * @param   name\n     *          文件名称\n     *\n     * @param   targetPath\n     *          目标文件路径\n     *\n     * @param   targetName\n     *          目标文件名称\n     *\n     * @return  是否复制成功\n     */\n    boolean copyFile(String path, String name, String targetPath, String targetName);\n\n    /**\n     * 复制文件夹\n     *\n     * @param   path\n     *          文件夹路径\n     *\n     * @param   name\n     *          文件夹名称\n     *\n     * @param   targetPath\n     *          目标文件夹路径\n     *\n     * @param   targetName\n     *          目标文件夹名称\n     *\n     * @return  是否复制成功\n     */\n    boolean copyFolder(String path, String name, String targetPath, String targetName);\n\n    /**\n     * 移动文件\n     *\n     * @param   path\n     *          文件路径\n     *\n     * @param   name\n     *          文件名称\n     *\n     * @param   targetPath\n     *          目标文件路径\n     *\n     * @param   targetName\n     *          目标文件名称\n     *\n     * @return  是否移动成功\n     */\n    boolean moveFile(String path, String name, String targetPath, String targetName);\n\n    /**\n     * 移动文件夹\n     *\n     * @param   path\n     *          文件夹路径\n     *\n     * @param   name\n     *          文件夹名称\n     *\n     * @param   targetPath\n     *          目标文件夹路径\n     *\n     * @param   targetName\n     *          目标文件夹名称\n     *\n     * @return  是否移动成功\n     */\n    boolean moveFolder(String path, String name, String targetPath, String targetName);\n\n    /**\n     * 重命名文件\n     *\n     * @param   path\n     *          文件路径\n     *\n     * @param   name\n     *          文件名称\n     *\n     * @param   newName\n     *          新文件名称\n     *\n     * @return  是否重命名成功\n     */\n    boolean renameFile(String path, String name, String newName);\n\n    /**\n     * 重命名文件夹\n     *\n     * @param   path\n     *          文件夹路径\n     *\n     * @param   name\n     *          文件夹名称\n     *\n     * @param   newName\n     *          新文件夹名称\n     *\n     * @return  是否重命名成功\n     */\n    boolean renameFolder(String path, String name, String newName);\n\n    /**\n     * 获取文件上传地址\n     *\n     * @param   path\n     *          文件路径\n     *\n     * @param   name\n     *          文件名称\n     *\n     * @param   size\n     *          文件大小\n     *\n     * @return  文件上传地址\n     */\n    String getUploadUrl(String path, String name, Long size);\n\n    /**\n     * 获取文件下载地址\n     *\n     * @param   pathAndName\n     *          文件路径及文件名称\n     *\n     * @return  文件下载地址\n     */\n    String getDownloadUrl(String pathAndName);\n\n    /**\n     * 获取存储源类型\n     *\n     * @return  存储源类型\n     */\n    StorageTypeEnum getStorageTypeEnum();\n\n    /**\n     * 销毁资源\n     */\n    void destroy();\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/base/RefreshTokenService.java",
    "content": "package im.zhaojun.zfile.module.storage.service.base;\n\n/**\n * 需要刷新 Token 服务的存储源\n *\n * @author zhaojun\n */\npublic interface RefreshTokenService extends BaseFileService {\n\t\n\t/**\n\t * 刷新存储源 AccessToken 或者其他需要定时刷新的 Token\n\t *\n\t * @throws Exception\t刷新 Token 时出现的异常\n\t */\n\tvoid refreshAccessToken() throws Exception;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/impl/AliyunServiceImpl.java",
    "content": "package im.zhaojun.zfile.module.storage.service.impl;\n\nimport cn.hutool.core.net.url.UrlBuilder;\nimport cn.hutool.core.net.url.UrlPath;\nimport cn.hutool.core.util.URLUtil;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.core.util.UrlUtils;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.param.AliyunParam;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractS3BaseFileService;\nimport lombok.extern.slf4j.Slf4j;\nimport org.apache.commons.lang3.BooleanUtils;\nimport org.apache.commons.net.util.Base64;\nimport org.springframework.beans.factory.config.ConfigurableBeanFactory;\nimport org.springframework.context.annotation.Scope;\nimport org.springframework.http.HttpMethod;\nimport org.springframework.stereotype.Service;\nimport software.amazon.awssdk.auth.credentials.AwsBasicCredentials;\nimport software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;\nimport software.amazon.awssdk.regions.Region;\nimport software.amazon.awssdk.services.s3.S3Client;\nimport software.amazon.awssdk.services.s3.presigner.S3Presigner;\n\nimport javax.crypto.Mac;\nimport javax.crypto.spec.SecretKeySpec;\nimport java.net.URI;\nimport java.nio.charset.StandardCharsets;\nimport java.security.InvalidKeyException;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.Date;\nimport java.util.List;\n\n/**\n * @author zhaojun\n */\n@Service\n@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)\n@Slf4j\npublic class AliyunServiceImpl extends AbstractS3BaseFileService<AliyunParam> {\n\n    private Signer signer;\n\n    @Override\n    public void init() {\n        String endPoint = param.getEndPoint();\n        String endPointScheme = param.getEndPointScheme();\n        // 如果 endPoint 不包含协议部分, 且配置了 endPointScheme, 则手动拼接协议部分.\n        if (!UrlUtils.hasScheme(endPoint) && StringUtils.isNotBlank(endPointScheme)) {\n            endPoint = endPointScheme + \"://\" + endPoint;\n        }\n        signer = new Signer(param.getAccessKey(), param.getSecretKey(), endPoint);\n\n        Region oss = Region.of(\"oss\");\n        URI endpointOverride = URI.create(endPoint);\n        StaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(AwsBasicCredentials.create(param.getAccessKey(), param.getSecretKey()));\n\n        super.s3ClientNew = S3Client.builder()\n                .overrideConfiguration(getClientConfiguration())\n                .region(oss)\n                .endpointOverride(endpointOverride)\n                .credentialsProvider(credentialsProvider)\n                .build();\n\n        super.s3Presigner = S3Presigner.builder()\n                .region(oss)\n                .endpointOverride(endpointOverride)\n                .credentialsProvider(credentialsProvider)\n                .build();\n\n        setUploadCors();\n    }\n\n    @Override\n    public StorageTypeEnum getStorageTypeEnum() {\n        return StorageTypeEnum.ALIYUN;\n    }\n\n    @Override\n    public String getDownloadUrl(String pathAndName) {\n        if (param.isEnableProxyDownload() && StringUtils.isEmpty(param.getDomain())) {\n            return getProxyDownloadUrl(pathAndName, false);\n        }\n        String bucketName = param.getBucketName();\n        String domain = param.getDomain();\n        String basePath = param.getBasePath();\n\n        String fullPath = StringUtils.concatTrimStartSlashes(basePath, pathAndName);\n\n        // 如果不是私有空间, 且指定了加速域名, 则直接返回下载地址.\n        if (BooleanUtils.isNotTrue(param.isPrivate()) && StringUtils.isNotEmpty(domain)) {\n            return StringUtils.concat(domain, StringUtils.encodeAllIgnoreSlashes(fullPath));\n        }\n\n        Integer tokenTime = param.getTokenTime();\n        if (param.getTokenTime() == null || param.getTokenTime() < 1) {\n            tokenTime = 1800;\n        }\n\n        Date expiration = new Date(System.currentTimeMillis() + tokenTime * 1000);\n        String defaultUrl = signer.generatePresignedUrl(bucketName, fullPath, HttpMethod.GET, expiration);\n\n        if (StringUtils.isNotEmpty(domain)) {\n            defaultUrl = StringUtils.replaceHost(defaultUrl, domain);\n        }\n        return defaultUrl;\n    }\n\n    static class Signer {\n\n        private final String endPoint;\n\n        private final String accessKey;\n\n        private final String secretKey;\n\n        public Signer(String accessKey, String secretKey, String endPoint) {\n            this.accessKey = accessKey;\n            this.secretKey = secretKey;\n            this.endPoint = endPoint;\n        }\n\n        private String sign(String data) {\n            try {\n                SecretKeySpec signingKey = new SecretKeySpec(secretKey.getBytes(), \"HmacSHA1\");\n                Mac mac = Mac.getInstance(\"HmacSHA1\");\n                mac.init(signingKey);\n                return Base64.encodeBase64StringUnChunked(mac.doFinal(data.getBytes()));\n            } catch (NoSuchAlgorithmException e) {\n                e.printStackTrace();\n            } catch (InvalidKeyException e) {\n                e.printStackTrace();\n            }\n            return null;\n        }\n\n\n        /**\n         * 生成预签名 URL\n         * @param   bucketName\n         *          Bucket 名称\n         *\n         * @param   key\n         *          文件路径\n         *\n         * @param   method\n         *          请求方法\n         *\n         * @param   expiration\n         *          过期时间\n         *\n         * @return  预签名 URL\n         */\n        private String generatePresignedUrl(String bucketName, String key, HttpMethod method, Date expiration) {\n            long expirationLong = expiration.getTime() / 1000;\n            UrlBuilder urlBuilder = UrlBuilder.of()\n                    .setScheme(UrlUtils.getSchema(endPoint))\n                    .setHost(bucketName + \".\" + UrlUtils.removeScheme(endPoint))\n                    .setPath(UrlPath.of(key, StandardCharsets.UTF_8))\n                    .addQuery(\"Expires\", expirationLong)\n                    .addQuery(\"OSSAccessKeyId\", accessKey);\n\n            String url = StringUtils.concat(bucketName, key);\n\n            String data = method.toString() + \"\\n\\n\\n\" + expirationLong + \"\\n\" + url;\n            String signature = sign(data);\n            urlBuilder.addQuery(\"Signature\", URLUtil.encodeAll(signature));\n\n            // 手动编码路径, 防止签名中的特殊字符被 URL 编码\n            StringBuilder encodePath = new StringBuilder();\n            List<String> split = StringUtils.split(urlBuilder.getPath().toString(), \"/\", false, true);\n            for (String s : split) {\n                encodePath.append(\"/\").append(URLUtil.encodeAll(s));\n            }\n\n            return urlBuilder.getScheme() + \"://\" + urlBuilder.getHost() + encodePath + \"?\" + urlBuilder.getQuery();\n        }\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/impl/DogeCloudServiceImpl.java",
    "content": "package im.zhaojun.zfile.module.storage.service.impl;\n\nimport cn.hutool.crypto.SecureUtil;\nimport cn.hutool.http.Header;\nimport cn.hutool.http.HttpResponse;\nimport cn.hutool.http.HttpUtil;\nimport com.alibaba.fastjson2.JSONArray;\nimport com.alibaba.fastjson2.JSONObject;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.exception.core.SystemException;\nimport im.zhaojun.zfile.module.storage.model.bo.RefreshTokenCacheBO;\nimport im.zhaojun.zfile.module.storage.model.dto.RefreshTokenInfoDTO;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.param.DogeCloudParam;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractS3BaseFileService;\nimport im.zhaojun.zfile.module.storage.service.base.RefreshTokenService;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.config.ConfigurableBeanFactory;\nimport org.springframework.context.annotation.Scope;\nimport org.springframework.stereotype.Service;\nimport software.amazon.awssdk.auth.credentials.AwsCredentials;\nimport software.amazon.awssdk.auth.credentials.AwsSessionCredentials;\nimport software.amazon.awssdk.regions.Region;\nimport software.amazon.awssdk.services.s3.S3Client;\nimport software.amazon.awssdk.services.s3.presigner.S3Presigner;\n\nimport java.net.URI;\n\n/**\n * @author zhaojun\n */\n@Service\n@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)\n@Slf4j\npublic class DogeCloudServiceImpl extends AbstractS3BaseFileService<DogeCloudParam> implements RefreshTokenService {\n\n    private AwsCredentials awsCredentials;\n\n    @Override\n    public void init() {\n        refreshAccessToken();\n        Region oss = Region.of(\"automatic\");\n        URI endpointOverride = URI.create(param.getEndPoint());\n\n        super.s3ClientNew = S3Client.builder()\n                .overrideConfiguration(getClientConfiguration())\n                .region(oss)\n                .endpointOverride(endpointOverride)\n                .credentialsProvider(this::checkExpiredAndGetAwsCredentials)\n                .build();\n\n        super.s3Presigner = S3Presigner.builder()\n                .region(oss)\n                .endpointOverride(endpointOverride)\n                .credentialsProvider(this::checkExpiredAndGetAwsCredentials)\n                .build();\n    }\n\n    @Override\n    public StorageTypeEnum getStorageTypeEnum() {\n        return StorageTypeEnum.DOGE_CLOUD;\n    }\n\n    @Override\n    public void refreshAccessToken() {\n        try {\n            JSONObject jsonObject = new JSONObject();\n            jsonObject.put(\"channel\", \"OSS_FULL\");\n            jsonObject.put(\"scopes\", param.getOriginBucketName());\n\n            String apiDomain = \"https://api.dogecloud.com\";\n            String apiPath = \"/auth/tmp_token.json\";\n\n            String jsonString = jsonObject.toJSONString();\n            String token = getToken(apiPath, jsonString);\n\n            if (log.isDebugEnabled()) {\n                log.debug(\"{} 尝试获取 S3 临时密钥, 请求参数: {}\", getStorageSimpleInfo(), param);\n            }\n\n            HttpResponse httpResponse = HttpUtil.createPost(apiDomain + apiPath)\n                    .body(jsonString)\n                    .header(Header.AUTHORIZATION, \"TOKEN \" + param.getAccessKey() + \":\" + token)\n                    .execute();\n\n            String body = httpResponse.body();\n            int responseStatus = httpResponse.getStatus();\n\n            JSONObject resultJsonObject = JSONObject.parseObject(body);\n            if (resultJsonObject.getInteger(\"code\") != 200){\n                log.error(\"{} 获取 S3 临时密钥失败, 响应头: {}\", getStorageSimpleInfo(), body);\n                throw new BizException(resultJsonObject.getString(\"msg\"));\n            }\n\n            if (log.isDebugEnabled()) {\n                log.debug(\"{} 获取 S3 临时密钥完成. 响应状态码: {}, 响应体: {}\", getStorageSimpleInfo(), responseStatus, body);\n            }\n\n            JSONObject data = resultJsonObject.getJSONObject(\"data\");\n            JSONObject credentials = data.getJSONObject(\"Credentials\");\n            String s3AccessKey = credentials.getString(\"accessKeyId\");\n            String s3SecretKey = credentials.getString(\"secretAccessKey\");\n            String s3SessionToken = credentials.getString(\"sessionToken\");\n            Integer expiredAt = data.getInteger(\"ExpiredAt\");\n\n            JSONArray bucketsArray = data.getJSONArray(\"Buckets\");\n            if (bucketsArray == null || bucketsArray.isEmpty()) {\n                throw new SystemException(\"存储空间名称不存在\");\n            }\n            JSONObject buckets = bucketsArray.getJSONObject(0);\n            param.setBucketName(buckets.getString(\"s3Bucket\"));\n            param.setEndPoint(buckets.getString(\"s3Endpoint\"));\n\n            RefreshTokenInfoDTO tokenInfoDTO = RefreshTokenInfoDTO.success(s3AccessKey, s3SecretKey, s3SessionToken, expiredAt);\n            RefreshTokenCacheBO.putRefreshTokenInfo(storageId, RefreshTokenCacheBO.RefreshTokenInfo.success(tokenInfoDTO));\n\n            awsCredentials = AwsSessionCredentials.create(s3AccessKey, s3SecretKey, s3SessionToken);\n        } catch (Exception e) {\n            RefreshTokenCacheBO.putRefreshTokenInfo(storageId, RefreshTokenCacheBO.RefreshTokenInfo.fail(\"AccessToken 刷新失败: \" + e.getMessage()));\n            throw new SystemException(\"存储源 \" + storageId + \" 刷新令牌失败, 获取时发生异常.\", e);\n        }\n    }\n\n    private String getToken(String apiPath, String paramsText) {\n        String signStr = apiPath + \"\\n\" + paramsText;\n        return SecureUtil.hmacSha1(param.getSecretKey()).digestHex(signStr);\n    }\n\n    /**\n     * 检查 AccessToken 是否过期，并获取最新的 AwsCredentials。\n     */\n    private AwsCredentials checkExpiredAndGetAwsCredentials() {\n        RefreshTokenCacheBO.RefreshTokenInfo refreshTokenInfo = RefreshTokenCacheBO.getRefreshTokenInfo(storageId);\n\n        if (refreshTokenInfo == null || refreshTokenInfo.isExpired()) {\n            // 使用双重检查锁定机制，确保同一个 storageId 只会有一个线程在刷新 AccessToken\n            synchronized ((\"storage-refresh-\" + storageId).intern()) {\n                // 双重检查，再次从缓存中获取，确认是否其他线程已经刷新过\n                refreshTokenInfo = RefreshTokenCacheBO.getRefreshTokenInfo(storageId);\n                if (refreshTokenInfo == null || refreshTokenInfo.isExpired()) {\n                    log.info(\"{} S3 临时密钥未获取或已过期, 尝试刷新.\", getStorageSimpleInfo());\n                    refreshAccessToken();\n                    refreshTokenInfo = RefreshTokenCacheBO.getRefreshTokenInfo(storageId);\n                }\n            }\n        }\n\n        if (refreshTokenInfo == null) {\n            throw new SystemException(\"存储源 \" + storageId + \" AccessToken 刷新失败: 未找到刷新令牌信息.\");\n        }\n\n        return awsCredentials;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/impl/FtpServiceImpl.java",
    "content": "package im.zhaojun.zfile.module.storage.service.impl;\n\nimport cn.hutool.core.convert.Convert;\nimport cn.hutool.extra.ftp.Ftp;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.exception.core.SystemException;\nimport im.zhaojun.zfile.core.util.ArrayUtils;\nimport im.zhaojun.zfile.core.util.FileUtils;\nimport im.zhaojun.zfile.core.util.RequestHolder;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.storage.model.bo.StorageSourceMetadata;\nimport im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.param.FtpParam;\nimport im.zhaojun.zfile.module.storage.model.result.FileItemResult;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractProxyTransferService;\nimport im.zhaojun.zfile.module.storage.support.ftp.FtpClientFactory;\nimport im.zhaojun.zfile.module.storage.support.ftp.FtpClientPool;\nimport lombok.extern.slf4j.Slf4j;\nimport org.apache.commons.net.ftp.FTPFile;\nimport org.apache.commons.pool2.impl.GenericObjectPoolConfig;\nimport org.springframework.beans.factory.config.ConfigurableBeanFactory;\nimport org.springframework.context.annotation.Scope;\nimport org.springframework.core.io.Resource;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.stereotype.Service;\n\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.nio.charset.Charset;\nimport java.time.Duration;\nimport java.util.*;\n\n/**\n * @author zhaojun\n */\n@Service\n@Slf4j\n@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)\npublic class FtpServiceImpl extends AbstractProxyTransferService<FtpParam> {\n\n    private FtpClientPool ftpClientPool;\n\n    public static final String FTP_MODE_ACTIVE = \"active\";\n\n    public static final String FTP_MODE_PASSIVE = \"passive\";\n\n    @Override\n    public void init() {\n        Charset charset = Charset.forName(param.getEncoding());\n        FtpClientFactory factory = new FtpClientFactory(param.getHost(), param.getPort(), param.getUsername(), param.getPassword(), charset, param.getFtpMode());\n        GenericObjectPoolConfig<FtpClientFactory> config = new GenericObjectPoolConfig<>();\n        config.setTestOnBorrow(true);\n        config.setMaxWait(Duration.ofSeconds(15));\n        ftpClientPool = new FtpClientPool(factory, config);\n    }\n\n    public Ftp getClientFromPool() {\n        try {\n            return ftpClientPool.borrowObject();\n        } catch (NoSuchElementException e) {\n            throw new BizException(ErrorCode.BIZ_FTP_CLIENT_POOL_FULL);\n        } catch (Exception e) {\n            throw new SystemException(e);\n        }\n    }\n\n    @Override\n    public List<FileItemResult> fileList(String folderPath) throws IOException {\n        Ftp ftp = null;\n        try {\n            ftp = getClientFromPool();\n            String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), folderPath);\n            FTPFile[] ftpFiles = ftp.lsFiles(fullPath);\n            List<FileItemResult> fileItemList = new ArrayList<>();\n\n            for (FTPFile ftpFile : ftpFiles) {\n                // 跳过 ftp 的本目录和上级目录\n                if (Arrays.asList(\".\", \"..\").contains(ftpFile.getName())) {\n                    continue;\n                }\n                FileItemResult fileItemResult = ftpFileToFileItem(ftpFile, folderPath);\n                fileItemList.add(fileItemResult);\n            }\n            return fileItemList;\n        } finally {\n            if (ftp != null) {\n                ftpClientPool.returnObject(ftp);\n            }\n        }\n    }\n\n\n    @Override\n    public FileItemResult getFileItem(String pathAndName) {\n        Ftp ftp = null;\n        try {\n            ftp = getClientFromPool();\n            String folderPath = FileUtils.getParentPath(pathAndName);\n            String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), folderPath);\n            FTPFile[] ftpFiles = ftp.lsFiles(fullPath);\n\n            if (ArrayUtils.isEmpty(ftpFiles)) {\n                return null;\n            }\n\n            String fileName = FileUtils.getName(pathAndName);\n\n            for (FTPFile ftpFile : ftpFiles) {\n                if (Objects.equals(ftpFile.getName(), fileName)) {\n                    return ftpFileToFileItem(ftpFile, folderPath);\n                }\n            }\n            return null;\n        } finally {\n            if (ftp != null) {\n                ftpClientPool.returnObject(ftp);\n            }\n        }\n    }\n\n\n    @Override\n    public boolean newFolder(String path, String name) {\n        String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name);\n        Ftp ftp = null;\n        try {\n            ftp = getClientFromPool();\n            return ftp.mkdir(fullPath);\n        } finally {\n            if (ftp != null) {\n                ftpClientPool.returnObject(ftp);\n            }\n        }\n    }\n\n\n    @Override\n    public boolean deleteFile(String path, String name) {\n        String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name);\n        Ftp ftp = null;\n        try {\n            ftp = getClientFromPool();\n            return ftp.delFile(fullPath);\n        } finally {\n            if (ftp != null) {\n                ftpClientPool.returnObject(ftp);\n            }\n        }\n    }\n\n\n    @Override\n    public boolean deleteFolder(String path, String name) {\n        String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name);\n        Ftp ftp = null;\n        try {\n            ftp = getClientFromPool();\n            return ftp.delDir(fullPath);\n        } finally {\n            if (ftp != null) {\n                ftpClientPool.returnObject(ftp);\n            }\n        }\n    }\n\n\n    @Override\n    public boolean renameFile(String path, String name, String newName) {\n        return moveFile(path, name, path, newName);\n    }\n\n\n    @Override\n    public boolean renameFolder(String path, String name, String newName) {\n        return renameFile(path, name, newName);\n    }\n\n\n    @Override\n    public String getDownloadUrl(String pathAndName) {\n        if (StringUtils.isNotBlank(param.getDomain())) {\n            return StringUtils.concat(param.getDomain(), StringUtils.encodeAllIgnoreSlashes(pathAndName));\n        }\n        return super.getProxyDownloadUrl(pathAndName);\n    }\n\n\n\n    @Override\n    public ResponseEntity<Resource> downloadToStream(String pathAndName) throws IOException {\n        Ftp ftp = null;\n        try {\n            ftp = getClientFromPool();\n            // 如果配置了域名，还访问代理下载 URL, 则抛出异常进行提示.\n            if (StringUtils.isNotEmpty(param.getDomain())) {\n                throw new BizException(ErrorCode.BIZ_UNSUPPORTED_PROXY_DOWNLOAD);\n            }\n\n            pathAndName = StringUtils.concat(param.getBasePath(), pathAndName);\n            String fileName = FileUtils.getName(pathAndName);\n            Long fileSize = param.isEnableRange() ? Convert.toLong(ftp.getClient().getSize(pathAndName),0L) : null;\n\n            InputStream inputStream = ftp.getClient().retrieveFileStream(pathAndName);\n            RequestHolder.writeFile(inputStream, fileName, fileSize, false, param.isProxyLinkForceDownload());\n        } finally {\n            if (ftp != null) {\n                ftpClientPool.returnObject(ftp);\n            }\n        }\n        return null;\n    }\n\n\n    @Override\n    public String getUploadUrl(String path, String name, Long size) {\n        return super.getProxyUploadUrl(path, name);\n    }\n\n\n    @Override\n    public void uploadFile(String pathAndName, InputStream inputStream, Long size) {\n        String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), pathAndName);\n        String fileName = FileUtils.getName(pathAndName);\n        String folderName = FileUtils.getParentPath(fullPath);\n\n        Ftp ftp = null;\n        try {\n            ftp = getClientFromPool();\n            ftp.upload(folderName, fileName, inputStream);\n        } finally {\n            if (ftp != null) {\n                ftpClientPool.returnObject(ftp);\n            }\n        }\n    }\n\n    @Override\n    public boolean copyFile(String path, String name, String targetPath, String targetName) {\n        throw new BizException(ErrorCode.BIZ_UNSUPPORTED_OPERATION);\n    }\n\n    @Override\n    public boolean copyFolder(String path, String name, String targetPath, String targetName) {\n        throw new BizException(ErrorCode.BIZ_UNSUPPORTED_OPERATION);\n    }\n\n    @Override\n    public boolean moveFile(String path, String name, String targetPath, String targetName) {\n        String srcPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name);\n        String distPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), targetPath, targetName);\n\n        Ftp ftp = null;\n        try {\n            ftp = getClientFromPool();\n            return ftp.getClient().rename(srcPath, distPath);\n        } catch (IOException e) {\n            throw new SystemException(e);\n        } finally {\n            if (ftp != null) {\n                ftpClientPool.returnObject(ftp);\n            }\n        }\n    }\n\n    @Override\n    public boolean moveFolder(String path, String name, String targetPath, String targetName) {\n        return moveFile(path, name, targetPath, targetName);\n    }\n\n    private FileItemResult ftpFileToFileItem(FTPFile ftpFile, String folderPath) {\n        FileItemResult fileItemResult = new FileItemResult();\n        fileItemResult.setName(ftpFile.getName());\n        fileItemResult.setSize(ftpFile.getSize());\n        fileItemResult.setTime(ftpFile.getTimestamp().getTime());\n        fileItemResult.setType(ftpFile.isDirectory() ? FileTypeEnum.FOLDER : FileTypeEnum.FILE);\n        fileItemResult.setPath(folderPath);\n\n        if (fileItemResult.getType() == FileTypeEnum.FILE) {\n            fileItemResult.setUrl(getDownloadUrl(StringUtils.concat(getCurrentUserBasePath(), folderPath, fileItemResult.getName())));\n        }\n        return fileItemResult;\n    }\n\n    @Override\n    public StorageSourceMetadata getStorageSourceMetadata() {\n        StorageSourceMetadata storageSourceMetadata = new StorageSourceMetadata();\n        storageSourceMetadata.setUploadType(StorageSourceMetadata.UploadType.PROXY);\n        return storageSourceMetadata;\n    }\n\n    public StorageTypeEnum getStorageTypeEnum() {\n        return StorageTypeEnum.FTP;\n    }\n\n    @Override\n    public void destroy() {\n        if (ftpClientPool != null) {\n            ftpClientPool.close();\n        }\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/impl/GoogleDriveServiceImpl.java",
    "content": "package im.zhaojun.zfile.module.storage.service.impl;\n\nimport cn.hutool.core.exceptions.ExceptionUtil;\nimport cn.hutool.core.net.url.UrlBuilder;\nimport cn.hutool.core.net.url.UrlQuery;\nimport cn.hutool.core.util.IdUtil;\nimport cn.hutool.core.util.ObjectUtil;\nimport cn.hutool.core.util.ReflectUtil;\nimport cn.hutool.http.HttpRequest;\nimport cn.hutool.http.HttpResponse;\nimport cn.hutool.http.HttpUtil;\nimport cn.hutool.http.Method;\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONArray;\nimport com.alibaba.fastjson2.JSONObject;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.exception.core.SystemException;\nimport im.zhaojun.zfile.core.util.CollectionUtils;\nimport im.zhaojun.zfile.core.util.FileUtils;\nimport im.zhaojun.zfile.core.util.RequestHolder;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.storage.constant.StorageConfigConstant;\nimport im.zhaojun.zfile.module.storage.constant.StorageSourceConnectionProperties;\nimport im.zhaojun.zfile.module.storage.model.bo.RefreshTokenCacheBO;\nimport im.zhaojun.zfile.module.storage.model.bo.StorageSourceMetadata;\nimport im.zhaojun.zfile.module.storage.model.dto.RefreshTokenInfoDTO;\nimport im.zhaojun.zfile.module.storage.model.entity.StorageSourceConfig;\nimport im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.param.GoogleDriveParam;\nimport im.zhaojun.zfile.module.storage.model.result.FileItemResult;\nimport im.zhaojun.zfile.module.storage.oauth2.service.IOAuth2Service;\nimport im.zhaojun.zfile.module.storage.service.StorageSourceConfigService;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractProxyTransferService;\nimport im.zhaojun.zfile.module.storage.service.base.RefreshTokenService;\nimport jakarta.servlet.http.HttpServletRequest;\nimport jakarta.servlet.http.HttpServletResponse;\nimport lombok.Data;\nimport lombok.extern.slf4j.Slf4j;\nimport org.apache.http.HttpEntity;\nimport org.apache.http.HttpHeaders;\nimport org.apache.http.client.methods.CloseableHttpResponse;\nimport org.apache.http.client.methods.HttpUriRequest;\nimport org.apache.http.client.methods.RequestBuilder;\nimport org.apache.http.entity.ContentType;\nimport org.apache.http.entity.mime.MultipartEntityBuilder;\nimport org.apache.http.impl.client.CloseableHttpClient;\nimport org.apache.http.impl.client.HttpClients;\nimport org.apache.http.util.EntityUtils;\nimport org.springframework.beans.factory.config.ConfigurableBeanFactory;\nimport org.springframework.context.annotation.Scope;\nimport org.springframework.core.io.Resource;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.stereotype.Service;\n\nimport java.io.FileNotFoundException;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.io.OutputStream;\nimport java.lang.reflect.Field;\nimport java.net.URLEncoder;\nimport java.nio.charset.StandardCharsets;\nimport java.util.*;\n\n/**\n * @author zhaojun\n */\n@Service\n@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)\n@Slf4j\npublic class GoogleDriveServiceImpl extends AbstractProxyTransferService<GoogleDriveParam> implements RefreshTokenService {\n\n\t/**\n\t * 文件类型：文件夹\n\t */\n\tprivate static final String FOLDER_MIME_TYPE = \"application/vnd.google-apps.folder\";\n\n\t/**\n\t * 文件类型：快捷方式\n\t */\n\tprivate static final String SHORTCUT_MIME_TYPE = \"application/vnd.google-apps.shortcut\";\n\n\t/**\n\t * 文件基础操作 API\n\t */\n\tprivate static final String DRIVE_FILE_URL = \"https://www.googleapis.com/drive/v3/files\";\n\n\n\t/**\n\t * 文件上传操作 API\n\t */\n\tprivate static final String DRIVE_FILE_UPLOAD_URL = \"https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart\";\n\n\t/**\n\t * 刷新 AccessToken URL\n\t */\n\tprivate static final String REFRESH_TOKEN_URL = \"https://oauth2.googleapis.com/token\";\n\n\t@jakarta.annotation.Resource\n\tprivate StorageSourceConfigService storageSourceConfigService;\n\n\t@Override\n\tpublic void init() {\n\t\tInteger refreshTokenExpiredAt = param.getRefreshTokenExpiredAt();\n\t\tif (refreshTokenExpiredAt == null) {\n\t\t\trefreshAccessToken();\n\t\t} else {\n\t\t\tRefreshTokenInfoDTO tokenInfoDTO = RefreshTokenInfoDTO.success(param.getAccessToken(), param.getRefreshToken(), refreshTokenExpiredAt);\n\t\t\tRefreshTokenCacheBO.putRefreshTokenInfo(storageId, RefreshTokenCacheBO.RefreshTokenInfo.success(tokenInfoDTO));\n\t\t}\n\t}\n\n\tprivate String getIdByPath(String path) {\n\t\treturn getIdByPath(path, true);\n\t}\n\n\t/**\n\t * 根据路径获取文件/文件夹 id\n\t *\n\t * @param \tpath\n\t * \t\t\t路径\n\t *\n\t * @return\t文件/文件夹 id\n\t */\n\tprivate String getIdByPath(String path, boolean concatCurrentUserBasePath) {\n\t\tString fullPath = StringUtils.concat(param.getBasePath(), concatCurrentUserBasePath ? getCurrentUserBasePath() : \"\", path);\n\t\tif (StringUtils.isEmpty(fullPath) || StringUtils.equals(fullPath, StringUtils.SLASH)) {\n\t\t\treturn StringUtils.isEmpty(param.getDriveId()) ? \"root\" : param.getDriveId();\n\t\t}\n\n\t\tList<String> pathList = StringUtils.split(fullPath, StringUtils.SLASH, false, true);\n\n\t\tString driveId = \"\";\n\t\tfor (String subPath : pathList) {\n\t\t\tString folderIdParam = new GoogleDriveAPIParam().getDriveIdByPathParam(subPath, driveId);\n\t\t\tHttpRequest httpRequest = commonHttpRequest(HttpUtil.createGet(DRIVE_FILE_URL + \"?\" + folderIdParam));\n\n\t\t\tHttpResponse httpResponse = httpRequest.execute();\n\n\t\t\tcheckHttpResponseIsError(httpResponse);\n\n\t\t\tString body = httpResponse.body();\n\n\t\t\tJSONObject jsonRoot = JSON.parseObject(body);\n\t\t\tJSONArray files = jsonRoot.getJSONArray(\"files\");\n\n\t\t\tif (files.isEmpty()) {\n\t\t\t\tthrow ExceptionUtil.wrapRuntime(new FileNotFoundException());\n\t\t\t}\n\n\t\t\tJSONObject jsonLastItem = files.getJSONObject(files.size() - 1);\n\t\t\tif (jsonLastItem.containsKey(\"shortcutDetails\")) {\n\t\t\t\tdriveId = jsonLastItem.getJSONObject(\"shortcutDetails\").getString(\"targetId\");\n\t\t\t} else {\n\t\t\t\tdriveId = jsonLastItem.getString(\"id\");\n\t\t\t}\n\t\t}\n\n\t\treturn driveId;\n\t}\n\n\t@Override\n\tpublic List<FileItemResult> fileList(String folderPath) throws Exception {\n\t\tList<FileItemResult> result = new ArrayList<>();\n\n\t\tString folderId = getIdByPath(folderPath);\n\t\tString pageToken = \"\";\n\t\tdo {\n\t\t\tString folderIdParam = new GoogleDriveAPIParam().getFileListParam(folderId, pageToken);\n\t\t\tHttpRequest httpRequest = commonHttpRequest(HttpUtil.createGet(DRIVE_FILE_URL + \"?\" + folderIdParam));\n\t\t\thttpRequest.setConnectionTimeout(StorageSourceConnectionProperties.DEFAULT_CONNECTION_TIMEOUT_MILLIS);\n\n\t\t\tHttpResponse httpResponse = httpRequest.execute();\n\n\t\t\tcheckHttpResponseIsError(httpResponse);\n\n\t\t\tString body = httpResponse.body();\n\n\t\t\tJSONObject jsonObject = JSON.parseObject(body);\n\t\t\tpageToken = jsonObject.getString(\"nextPageToken\");\n\t\t\tJSONArray files = jsonObject.getJSONArray(\"files\");\n\t\t\tresult.addAll(jsonArrayToFileList(files, folderPath));\n\t\t} while (StringUtils.isNotEmpty(pageToken));\n\n\t\treturn result;\n\t}\n\n\t@Override\n\tpublic FileItemResult getFileItem(String pathAndName) {\n\t\tString fileId = getIdByPath(pathAndName);\n\n\t\tHttpRequest httpRequest = commonHttpRequest(HttpUtil.createGet(DRIVE_FILE_URL + StringUtils.SLASH + fileId));\n\t\thttpRequest.body(\"fields=id,name,mimeType,shortcutDetails,size,modifiedTime\");\n\t\tHttpResponse httpResponse = httpRequest.execute();\n\n\t\tif (httpResponse.getStatus() == HttpStatus.NOT_FOUND.value()) {\n\t\t\treturn null;\n\t\t}\n\n\t\tcheckHttpResponseIsError(httpResponse);\n\n\t\tString body = httpResponse.body();\n\t\tJSONObject jsonObject = JSON.parseObject(body);\n\t\tString folderPath = FileUtils.getParentPath(pathAndName);\n\t\treturn jsonObjectToFileItem(jsonObject, folderPath);\n\t}\n\n\n\t@Override\n\tpublic boolean newFolder(String path, String name) {\n\t\tHttpResponse httpResponse = commonHttpRequest(HttpUtil.createPost(DRIVE_FILE_URL))\n\t\t\t\t\t\t.body(new JSONObject()\n\t\t\t\t\t\t.fluentPut(\"name\", name)\n\t\t\t\t\t\t.fluentPut(\"mimeType\", FOLDER_MIME_TYPE)\n\t\t\t\t\t\t.fluentPut(\"parents\", Collections.singletonList(getIdByPath(path)))\n\t\t\t\t\t\t.toJSONString())\n\t\t\t\t.execute();\n\n\t\tcheckHttpResponseIsError(httpResponse);\n\n\t\treturn true;\n\t}\n\n\t@Override\n\tpublic boolean deleteFile(String path, String name) {\n\t\tString pathAndName = StringUtils.concat(path, name);\n\t\tHttpResponse httpResponse = commonHttpRequest(HttpUtil.createRequest(Method.DELETE, DRIVE_FILE_URL + StringUtils.SLASH + getIdByPath(pathAndName)))\n\t\t\t\t.execute();\n\n\t\tcheckHttpResponseIsError(httpResponse);\n\n\t\treturn true;\n\t}\n\n\t@Override\n\tpublic boolean deleteFolder(String path, String name) {\n\t\treturn deleteFile(path, name);\n\t}\n\n\t@Override\n\tpublic boolean renameFile(String path, String name, String newName) {\n\t\tString pathAndName = StringUtils.concat(path, name);\n\t\tString fileId = getIdByPath(pathAndName);\n\n\t\tHttpResponse httpResponse = commonHttpRequest(HttpUtil.createRequest(Method.PATCH, DRIVE_FILE_URL + StringUtils.SLASH + fileId))\n\t\t\t\t.body(new JSONObject()\n\t\t\t\t\t\t.fluentPut(\"name\", newName)\n\t\t\t\t\t\t.toJSONString())\n\t\t\t\t.execute();\n\n\t\tcheckHttpResponseIsError(httpResponse);\n\n\t\treturn true;\n\t}\n\n\t@Override\n\tpublic boolean renameFolder(String path, String name, String newName) {\n\t\treturn renameFile(path, name, newName);\n\t}\n\n\n\t@Override\n\tpublic String getUploadUrl(String path, String name, Long size) {\n\t\treturn super.getProxyUploadUrl(path, name);\n\t}\n\n\n\t@Override\n\tpublic void uploadFile(String pathAndName, InputStream inputStream, Long size) {\n\t\tString boundary = IdUtil.fastSimpleUUID();\n\t\tString fileName = FileUtils.getName(pathAndName);\n\t\tString folderName = FileUtils.getParentPath(pathAndName);\n\n\t\tString jsonString = new JSONObject()\n\t\t\t\t.fluentPut(\"name\", fileName)\n\t\t\t\t.fluentPut(\"parents\", Collections.singletonList(getIdByPath(folderName)))\n\t\t\t\t.toJSONString();\n\t\tHttpEntity entity = MultipartEntityBuilder.create()\n\t\t\t\t.setMimeSubtype(\"related\")\n\t\t\t\t.setBoundary(boundary)\n\t\t\t\t.addTextBody(boundary, jsonString, ContentType.APPLICATION_JSON)\n\t\t\t\t.addBinaryBody(boundary, inputStream)\n\t\t\t\t.build();\n\n\t\ttry (CloseableHttpClient httpClient = HttpClients.createDefault()) {\n\t\t\tHttpUriRequest httpUriRequest = RequestBuilder.post(DRIVE_FILE_UPLOAD_URL)\n\t\t\t\t\t.addHeader(HttpHeaders.AUTHORIZATION, \"Bearer \" + checkExpiredAndGetAccessToken())\n\t\t\t\t\t.setEntity(entity)\n\t\t\t\t\t.build();\n\n\t\t\tCloseableHttpResponse response = httpClient.execute(httpUriRequest);\n\t\t\tcheckHttpResponseIsError(response);\n\t\t} catch (IOException e) {\n\t\t\tthrow ExceptionUtil.wrapRuntime(e);\n\t\t}\n\t}\n\n\t@Override\n\tpublic String getDownloadUrl(String pathAndName) {\n\t\treturn super.getProxyDownloadUrl(pathAndName);\n\t}\n\n\t@Override\n\tpublic ResponseEntity<Resource> downloadToStream(String pathAndName) {\n\t\tString fileId = getIdByPath(pathAndName, false);\n\n\t\tHttpServletRequest request = RequestHolder.getRequest();\n\n\t\tHttpRequest httpRequest = commonHttpRequest(HttpUtil.createGet(DRIVE_FILE_URL + StringUtils.SLASH + fileId));\n\t\thttpRequest.body(\"alt=media\");\n\t\thttpRequest.header(HttpHeaders.RANGE, request.getHeader(HttpHeaders.RANGE));\n\t\tHttpResponse httpResponse = httpRequest.executeAsync();\n\t\tcheckHttpResponseIsError(httpResponse);\n\n\n\t\ttry {\n\t\t\tHttpServletResponse response = RequestHolder.getResponse();\n\t\t\tresponse.setStatus(httpResponse.getStatus());\n\t\t\tfor (Map.Entry<String, List<String>> stringListEntry : httpResponse.headers().entrySet()) {\n\t\t\t\tString key = stringListEntry.getKey();\n\t\t\t\tList<String> values = stringListEntry.getValue();\n\t\t\t\tif (key != null && CollectionUtils.isNotEmpty(values)) {\n\t\t\t\t\tresponse.setHeader(key, stringListEntry.getValue().get(0));\n\t\t\t\t}\n\t\t\t}\n\t\t\tOutputStream outputStream = response.getOutputStream();\n\t\t\thttpResponse.writeBody(outputStream, true, null);\n\t\t} catch (IOException e) {\n\t\t\tthrow ExceptionUtil.wrapRuntime(e);\n\t\t}\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic boolean copyFile(String path, String name, String targetPath, String targetName) {\n\t\tString srcPathAndName = StringUtils.concat(path, name);\n\t\tString srcFileId = getIdByPath(srcPathAndName);\n\n\t\tHttpResponse httpResponse = commonHttpRequest(HttpUtil.createPost(DRIVE_FILE_URL + StringUtils.SLASH + srcFileId + \"/copy\"))\n\t\t\t\t.body(new JSONObject()\n\t\t\t\t\t\t.fluentPut(\"name\", targetName)\n\t\t\t\t\t\t.fluentPut(\"parents\", Collections.singletonList(getIdByPath(targetPath)))\n\t\t\t\t\t\t.fluentPut(\"supportsAllDrives\", true)\n\t\t\t\t\t\t.toJSONString())\n\t\t\t\t.execute();\n\n\t\tcheckHttpResponseIsError(httpResponse);\n\n\t\treturn true;\n\t}\n\n\t@Override\n\tpublic boolean copyFolder(String path, String name, String targetPath, String targetName) {\n\t\tthrow new BizException(ErrorCode.BIZ_STORAGE_NOT_SUPPORT_OPERATION);\n\t}\n\n\t@Override\n\tpublic boolean moveFile(String path, String name, String targetPath, String targetName) {\n\t\tString pathAndName = StringUtils.concat(path, name);\n\t\tString fileId = getIdByPath(pathAndName);\n\n\t\tHttpResponse httpResponse = commonHttpRequest(\n\t\t\t\tHttpUtil.createRequest(Method.PATCH,\n\t\t\t\t\t\tUrlBuilder.of(DRIVE_FILE_URL + StringUtils.SLASH + fileId)\n\t\t\t\t\t\t\t\t.setQuery(UrlQuery.of(new JSONObject()\n\t\t\t\t\t\t\t\t\t\t\t\t.fluentPut(\"addParents\", getIdByPath(targetPath))\n\t\t\t\t\t\t\t\t\t\t\t\t.fluentPut(\"removeParents\", getIdByPath(path))\n\t\t\t\t\t\t\t\t\t\t\t\t.fluentPut(\"supportsAllDrives\", true)\n\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t).build()\n\t\t\t\t)\n\t\t).execute();\n\n\t\tcheckHttpResponseIsError(httpResponse);\n\n\t\treturn true;\n\t}\n\n\t@Override\n\tpublic boolean moveFolder(String path, String name, String targetPath, String targetName) {\n\t\treturn moveFile(path, name, targetPath, targetName);\n\t}\n\n\t@Override\n\tpublic StorageTypeEnum getStorageTypeEnum() {\n\t\treturn StorageTypeEnum.GOOGLE_DRIVE;\n\t}\n\n\t/**\n\t * 根据 RefreshToken 刷新 AccessToken, 返回刷新后的 Token.\n\t *\n\t * @return  刷新后的 Token\n\t */\n\tpublic RefreshTokenInfoDTO getRefreshToken() {\n\t\tStorageSourceConfig refreshStorageSourceConfig =\n\t\t\t\tstorageSourceConfigService.findByStorageIdAndName(storageId, StorageConfigConstant.REFRESH_TOKEN_KEY);\n\n\t\tString paramStr = \"client_id=\" + param.getClientId() +\n\t\t\t\t\"&client_secret=\" + param.getClientSecret() +\n\t\t\t\t\"&refresh_token=\" + refreshStorageSourceConfig.getValue() +\n\t\t\t\t\"&grant_type=refresh_token\" +\n\t\t\t\t\"&access_type=offline\";\n\n\t\tif (log.isDebugEnabled()) {\n\t\t\tlog.debug(\"{} 尝试刷新令牌, 请求参数: {}\", getStorageSimpleInfo(), param);\n\t\t}\n\n\t\tHttpRequest post = HttpUtil.createPost(REFRESH_TOKEN_URL + \"?\" + paramStr);\n\t\tpost.timeout(5 * 1000);\n\t\tHttpResponse response = post.execute();\n\n\t\tString responseBody = response.body();\n\t\tint responseStatus = response.getStatus();\n\n\t\tif (log.isDebugEnabled()) {\n\t\t\tlog.debug(\"{} 刷新令牌完成. 响应状态码: {}, 响应体: {}\", getStorageSimpleInfo(), responseStatus, responseBody);\n\t\t}\n\n\t\tif (response.getStatus() != HttpStatus.OK.value()) {\n\t\t\tthrow new SystemException(responseBody);\n\t\t}\n\n\t\tJSONObject jsonBody = JSONObject.parseObject(responseBody);\n\t\tString accessToken = jsonBody.getString(IOAuth2Service.ACCESS_TOKEN_FIELD_NAME);\n\t\tString refreshToken = jsonBody.getString(IOAuth2Service.REFRESH_TOKEN_FIELD_NAME);\n\t\tInteger expiresIn = jsonBody.getInteger(IOAuth2Service.EXPIRES_IN_FIELD_NAME);\n\t\treturn RefreshTokenInfoDTO.success(accessToken, refreshToken, expiresIn);\n\t}\n\n\t/**\n\t * 刷新当前存储源 AccessToken\n\t */\n\t@Override\n\tpublic void refreshAccessToken() {\n\t\ttry {\n\t\t\tRefreshTokenInfoDTO tokenInfoDTO = getRefreshToken();\n\n\t\t\tif (tokenInfoDTO.getAccessToken() == null) {\n\t\t\t\tthrow new SystemException(\"存储源 \" + storageId + \" 刷新令牌失败, 获取到令牌为空.\");\n\t\t\t}\n\n\t\t\tStorageSourceConfig accessTokenConfig = storageSourceConfigService.findByStorageIdAndName(storageId, StorageConfigConstant.ACCESS_TOKEN_KEY);\n\t\t\tStorageSourceConfig refreshTokenConfig = storageSourceConfigService.findByStorageIdAndName(storageId, StorageConfigConstant.REFRESH_TOKEN_KEY);\n\t\t\tStorageSourceConfig refreshTokenExpiredAtConfig = storageSourceConfigService.findByStorageIdAndName(storageId, StorageConfigConstant.REFRESH_TOKEN_EXPIRED_AT_KEY);\n\t\t\taccessTokenConfig.setValue(tokenInfoDTO.getAccessToken());\n\t\t\trefreshTokenConfig.setValue(tokenInfoDTO.getRefreshToken());\n\t\t\trefreshTokenExpiredAtConfig.setValue(String.valueOf(tokenInfoDTO.getExpiredAt()));\n\n\t\t\tstorageSourceConfigService.updateBatch(storageId, Arrays.asList(accessTokenConfig, refreshTokenConfig, refreshTokenExpiredAtConfig));\n\t\t\tRefreshTokenCacheBO.putRefreshTokenInfo(storageId, RefreshTokenCacheBO.RefreshTokenInfo.success(tokenInfoDTO));\n\t\t} catch (Exception e) {\n\t\t\tRefreshTokenCacheBO.putRefreshTokenInfo(storageId, RefreshTokenCacheBO.RefreshTokenInfo.fail(\"AccessToken 刷新失败: \" + e.getMessage()));\n\t\t\tthrow new SystemException(\"存储源 \" + storageId + \" 刷新令牌失败, 获取时发生异常.\", e);\n\t\t}\n\t}\n\n\t/**\n\t * 转换 api 返回的 json array 为 zfile 文件对象列表\n\t *\n\t * @param \tjsonArray\n\t * \t\t\tapi 返回文件 json array\n\t *\n\t * @param \tfolderPath\n\t * \t\t\t所属文件夹路径\n\t *\n\t * @return\tzfile 文件对象列表\n\t */\n\tpublic List<FileItemResult> jsonArrayToFileList(JSONArray jsonArray, String folderPath) {\n\t\tArrayList<FileItemResult> fileList = new ArrayList<>();\n\n\t\tfor (int i = 0; i < jsonArray.size(); i++) {\n\t\t\tfileList.add(jsonObjectToFileItem(jsonArray.getJSONObject(i), folderPath));\n\t\t}\n\n\t\treturn fileList;\n\t}\n\n\n\t/**\n\t * 转换 api 返回的 json object 为 zfile 文件对象\n\t *\n\t * @param \tjsonObject\n\t * \t\t\tapi 返回文件 json object\n\t *\n\t * @param \tfolderPath\n\t * \t\t\t所属文件夹路径\n\t *\n\t * @return\tzfile 文件对象\n\t */\n\tpublic FileItemResult jsonObjectToFileItem(JSONObject jsonObject, String folderPath) {\n\t\tFileItemResult fileItemResult = new FileItemResult();\n\t\tfileItemResult.setName(jsonObject.getString(\"name\"));\n\t\tfileItemResult.setPath(folderPath);\n\t\tfileItemResult.setSize(jsonObject.getLong(\"size\"));\n\n\t\tString mimeType = jsonObject.getString(\"mimeType\");\n\t\tif (ObjectUtil.equals(SHORTCUT_MIME_TYPE, mimeType)) {\n\t\t\tJSONObject shortcutDetails = jsonObject.getJSONObject(\"shortcutDetails\");\n\t\t\tmimeType = shortcutDetails.getString(\"targetMimeType\");\n\t\t}\n\n\t\tif (StringUtils.equals(mimeType, FOLDER_MIME_TYPE)) {\n\t\t\tfileItemResult.setType(FileTypeEnum.FOLDER);\n\t\t} else {\n\t\t\tfileItemResult.setType(FileTypeEnum.FILE);\n\t\t\tfileItemResult.setUrl(getDownloadUrl(StringUtils.concat(getCurrentUserBasePath(), folderPath, fileItemResult.getName())));\n\t\t}\n\n\t\tfileItemResult.setTime(jsonObject.getDate(\"modifiedTime\"));\n\n\t\tif (fileItemResult.getSize() == null) {\n\t\t\tfileItemResult.setSize(-1L);\n\t\t}\n\n\t\treturn fileItemResult;\n\t}\n\n\n\t/**\n\t * 请求参数类\n\t */\n\t@Data\n\tclass GoogleDriveAPIParam {\n\n\t\tprivate final Integer DEFAULT_PAGE_SIZE = 1000;\n\n\t\t// 存储源 id\n\t\tprivate String driveId;\n\n\t\t// 是否返回共享驱动器或团队盘的内容\n\t\tprivate boolean includeItemsFromAllDrives;\n\n\t\t// 查询适用的文件分组, 支持 'user', 'drive', 'allDrives'\n\t\tprivate String corpora;\n\n\t\t// 请求的应用程序是否同时支持“我的云端硬盘”和共享云端硬盘\n\t\tprivate boolean supportsAllDrives;\n\n\t\t// 请求的字段\n\t\tprivate String fields;\n\n\t\t// 查询参数\n\t\tprivate String q;\n\n\t\t// 每页多少条\n\t\tprivate Integer pageSize;\n\n\t\t// 下页的页码\n\t\tprivate String pageToken;\n\n\t\t/**\n\t\t * 根据路径获取 id 的 api 请求参数\n\t\t *\n\t\t * @param \tfolderPath\n\t\t * \t\t\t文件夹路径\n\t\t */\n\t\tpublic String getDriveIdByPathParam(String folderPath, String parentId) {\n\t\t\tGoogleDriveAPIParam googleDriveApiParam = getBasicParam();\n\n\t\t\tString parentIdParam = \"\";\n\n\t\t\tif (StringUtils.isNotEmpty(parentId)) {\n\t\t\t\tparentIdParam = \"'\" + parentId + \"' in parents and \";\n\t\t\t}\n\n\t\t\tfolderPath = folderPath.replace(\"'\", \"\\\\'\");\n\n\t\t\tgoogleDriveApiParam.setFields(\"files(id,shortcutDetails)\");\n\t\t\tgoogleDriveApiParam.setQ(parentIdParam + \"name = '\" + folderPath + \"' and trashed = false\");\n\n\t\t\treturn googleDriveApiParam.toString();\n\t\t}\n\n\n\t\t/**\n\t\t * 根据路径获取 id 的 api 请求参数\n\t\t *\n\t\t * @param \tfolderId\n\t\t * \t\t\tgoogle drive 文件夹 id\n\t\t *\n\t\t * @param   pageToken\n\t\t * \t\t\t分页 token\n\t\t */\n\t\tpublic String getFileListParam(String folderId, String pageToken) {\n\t\t\tGoogleDriveAPIParam googleDriveAPIParam = getBasicParam();\n\n\t\t\tgoogleDriveAPIParam.setFields(\"files(id,name,mimeType,shortcutDetails,size,modifiedTime),nextPageToken\");\n\t\t\tgoogleDriveAPIParam.setQ(\"'\" + folderId + \"' in parents and trashed = false\");\n\t\t\tgoogleDriveAPIParam.setPageToken(pageToken);\n\t\t\tgoogleDriveAPIParam.setPageSize(DEFAULT_PAGE_SIZE);\n\t\t\treturn googleDriveAPIParam.toString();\n\t\t}\n\n\n\t\t/**\n\t\t * 根据关键字和路径搜索文件 api 请求参数\n\t\t *\n\t\t * @param \tfolderId\n\t\t * \t\t\t搜索的父文件夹 id\n\t\t *\n\t\t * @param   pageToken\n\t\t * \t\t\t分页 token\n\t\t *\n\t\t * @param \tkeyword\n\t\t * \t\t\t搜索关键字\n\t\t */\n\t\tpublic String getSearchParam(String folderId, String pageToken, String keyword) {\n\t\t\tGoogleDriveAPIParam googleDriveAPIParam = getBasicParam();\n\n\t\t\tString parentIdParam = \"\";\n\t\t\tif (StringUtils.isNotEmpty(folderId)) {\n\t\t\t\tparentIdParam = \"'\" + folderId + \"' in parents and \";\n\t\t\t}\n\n\t\t\tgoogleDriveAPIParam.setFields(\"files(id,name,mimeType,shortcutDetails,size,modifiedTime),nextPageToken\");\n\t\t\tgoogleDriveAPIParam.setQ(parentIdParam + \" name contains '\" + keyword + \"' and trashed = false\");\n\t\t\tgoogleDriveAPIParam.setPageToken(pageToken);\n\t\t\tgoogleDriveAPIParam.setPageSize(DEFAULT_PAGE_SIZE);\n\t\t\treturn googleDriveAPIParam.toString();\n\t\t}\n\n\n\t\t/**\n\t\t * 判断是否是团队盘，填充基础参数\n\t\t */\n\t\tpublic GoogleDriveAPIParam getBasicParam() {\n\t\t\tGoogleDriveAPIParam googleDriveAPIParam = new GoogleDriveAPIParam();\n\t\t\tString driveId = param.getDriveId();\n\n\t\t\t// 判断是否是团队盘，如果是，则需要添加团队盘的参数\n\t\t\tboolean isTeamDrive = StringUtils.isNotEmpty(driveId);\n\n\t\t\tgoogleDriveAPIParam.setCorpora(\"user\");\n\t\t\tgoogleDriveAPIParam.setIncludeItemsFromAllDrives(true);\n\t\t\tgoogleDriveAPIParam.setSupportsAllDrives(true);\n\t\t\tif (isTeamDrive) {\n\t\t\t\tgoogleDriveAPIParam.setDriveId(driveId);\n\t\t\t\tgoogleDriveAPIParam.setCorpora(\"drive\");\n\t\t\t}\n\n\t\t\treturn googleDriveAPIParam;\n\t\t}\n\n\t\t/**\n\t\t * 请求对象转 url param string\n\t\t *\n\t\t * @return\turl param string\n\t\t */\n\t\t@Override\n\t\tpublic String toString() {\n\t\t\treturn toString(true);\n\t\t}\n\n\t\t/**\n\t\t * 请求对象转 url param string\n\t\t *\n\t\t * @return\turl param string\n\t\t */\n\t\tpublic String toString(boolean encodeValue) {\n\t\t\tField[] fields = ReflectUtil.getFields(this.getClass());\n\n\t\t\tUrlQuery urlQuery = new UrlQuery();\n\n\t\t\tfor (Field field : fields) {\n\t\t\t\tif (StringUtils.startWith(field.getName(), \"this\")) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tObject fieldValue = ReflectUtil.getFieldValue(this, field);\n\n\t\t\t\tif (ObjectUtil.isNotEmpty(fieldValue) && ObjectUtil.notEqual(fieldValue, false)) {\n\t\t\t\t\turlQuery.add(field.getName(),\n\t\t\t\t\t\t\tencodeValue ?\n\t\t\t\t\t\t\t\t\tURLEncoder.encode(fieldValue.toString(), StandardCharsets.UTF_8)\n\t\t\t\t\t\t\t\t\t:\n\t\t\t\t\t\t\t\t\tfieldValue.toString()\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn urlQuery.toString();\n\t\t}\n\t}\n\n\n\t/**\n\t * 检查 http 响应是否为 5xx, 如果是，则抛出异常\n\t *\n\t * @param \thttpResponse\n\t * \t\t\thttp 响应\n\t */\n\tprivate void checkHttpResponseIsError(HttpResponse httpResponse) {\n\t\tif (HttpStatus.valueOf(httpResponse.getStatus()).isError()) {\n\t\t\tint statusCode = httpResponse.getStatus();\n\t\t\tString responseBody = httpResponse.body();\n\t\t\tString msg = String.format(\"statusCode: %s, responseBody: %s\", statusCode, responseBody);\n\t\t\tthrow new SystemException(msg);\n\t\t}\n\t}\n\n\t/**\n\t * 检查 http 响应是否为 5xx, 如果是，则抛出异常  (http client)\n\t *\n\t * @param \tcloseableHttpResponse\n\t * \t\t\thttp 响应\n\t */\n\tprivate void checkHttpResponseIsError(CloseableHttpResponse closeableHttpResponse) throws IOException {\n\t\tint statusCode = closeableHttpResponse.getStatusLine().getStatusCode();\n\t\tif (HttpStatus.valueOf(statusCode).isError()) {\n\t\t\tHttpEntity responseEntity = closeableHttpResponse.getEntity();\n\t\t\tString responseBody = EntityUtils.toString(responseEntity, StandardCharsets.UTF_8);\n\t\t\tString msg = String.format(\"statusCode: %s, responseBody: %s\", statusCode, responseBody);\n\t\t\tthrow new SystemException(msg);\n\t\t}\n\t}\n\n\t@Override\n\tpublic StorageSourceMetadata getStorageSourceMetadata() {\n\t\tStorageSourceMetadata storageSourceMetadata = new StorageSourceMetadata();\n\t\tstorageSourceMetadata.setUploadType(StorageSourceMetadata.UploadType.PROXY);\n\t\treturn storageSourceMetadata;\n\t}\n\n\tprivate HttpRequest commonHttpRequest(HttpRequest httpRequest) {\n\t\tString accessToken = checkExpiredAndGetAccessToken();\n\t\thttpRequest.bearerAuth(accessToken);\n\t\treturn httpRequest;\n\t}\n\n\t/**\n\t * 检查 AccessToken 是否过期，如果过期则刷新 AccessToken 并返回新的 AccessToken。\n\t */\n\tprivate String checkExpiredAndGetAccessToken() {\n\t\tRefreshTokenCacheBO.RefreshTokenInfo refreshTokenInfo = RefreshTokenCacheBO.getRefreshTokenInfo(storageId);\n\n\t\tif (refreshTokenInfo == null || refreshTokenInfo.isExpired()) {\n\t\t\t// 使用双重检查锁定机制，确保同一个 storageId 只会有一个线程在刷新 AccessToken\n\t\t\tsynchronized ((\"storage-refresh-\" + storageId).intern()) {\n\t\t\t\t// 双重检查，再次从缓存中获取，确认是否其他线程已经刷新过\n\t\t\t\trefreshTokenInfo = RefreshTokenCacheBO.getRefreshTokenInfo(storageId);\n\t\t\t\tif (refreshTokenInfo == null || refreshTokenInfo.isExpired()) {\n\t\t\t\t\tlog.info(\"{} AccessToken 未获取或已过期, 尝试刷新.\", getStorageSimpleInfo());\n\t\t\t\t\trefreshAccessToken();\n\t\t\t\t\trefreshTokenInfo = RefreshTokenCacheBO.getRefreshTokenInfo(storageId);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif (refreshTokenInfo == null) {\n\t\t\tthrow new SystemException(\"存储源 \" + storageId + \" AccessToken 刷新失败: 未找到刷新令牌信息.\");\n\t\t}\n\n\t\treturn refreshTokenInfo.getData().getAccessToken();\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/impl/HuaweiServiceImpl.java",
    "content": "package im.zhaojun.zfile.module.storage.service.impl;\n\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.core.util.UrlUtils;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.param.HuaweiParam;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractS3BaseFileService;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.config.ConfigurableBeanFactory;\nimport org.springframework.context.annotation.Scope;\nimport org.springframework.stereotype.Service;\nimport software.amazon.awssdk.auth.credentials.AwsBasicCredentials;\nimport software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;\nimport software.amazon.awssdk.regions.Region;\nimport software.amazon.awssdk.services.s3.S3Client;\nimport software.amazon.awssdk.services.s3.presigner.S3Presigner;\n\nimport java.net.URI;\n\n/**\n * @author zhaojun\n */\n@Service\n@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)\n@Slf4j\npublic class HuaweiServiceImpl extends AbstractS3BaseFileService<HuaweiParam> {\n\n    @Override\n    public void init() {\n        String endPoint = param.getEndPoint();\n        String endPointScheme = param.getEndPointScheme();\n        // 如果 endPoint 不包含协议部分, 且配置了 endPointScheme, 则手动拼接协议部分.\n        if (!UrlUtils.hasScheme(endPoint) && StringUtils.isNotBlank(endPointScheme)) {\n            endPoint = endPointScheme + \"://\" + endPoint;\n        }\n\n        Region oss = Region.of(\"obs\");\n        URI endpointOverride = URI.create(endPoint);\n        StaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(AwsBasicCredentials.create(param.getAccessKey(), param.getSecretKey()));\n\n        super.s3ClientNew = S3Client.builder()\n                .overrideConfiguration(getClientConfiguration())\n                .region(oss)\n                .endpointOverride(endpointOverride)\n                .credentialsProvider(credentialsProvider)\n                .build();\n\n        super.s3Presigner = S3Presigner.builder()\n                .region(oss)\n                .endpointOverride(endpointOverride)\n                .credentialsProvider(credentialsProvider)\n                .build();\n\n        setUploadCors();\n    }\n\n    @Override\n    public StorageTypeEnum getStorageTypeEnum() {\n        return StorageTypeEnum.HUAWEI;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/impl/LocalServiceImpl.java",
    "content": "package im.zhaojun.zfile.module.storage.service.impl;\n\nimport cn.hutool.core.exceptions.ExceptionUtil;\nimport cn.hutool.core.io.FileUtil;\nimport cn.hutool.core.io.IoUtil;\nimport cn.hutool.core.util.ObjectUtil;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.biz.FilePathSecurityBizException;\nimport im.zhaojun.zfile.core.exception.biz.InitializeStorageSourceBizException;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.exception.status.NotFoundAccessException;\nimport im.zhaojun.zfile.core.util.FileUtils;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.storage.model.bo.StorageSourceMetadata;\nimport im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.param.LocalParam;\nimport im.zhaojun.zfile.module.storage.model.result.FileItemResult;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractProxyTransferService;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.config.ConfigurableBeanFactory;\nimport org.springframework.context.annotation.Scope;\nimport org.springframework.core.io.FileSystemResource;\nimport org.springframework.core.io.Resource;\nimport org.springframework.http.ContentDisposition;\nimport org.springframework.http.HttpHeaders;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.MediaTypeFactory;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.stereotype.Service;\n\nimport java.io.BufferedOutputStream;\nimport java.io.File;\nimport java.io.FileNotFoundException;\nimport java.io.InputStream;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Date;\nimport java.util.List;\n\n/**\n * @author zhaojun\n */\n@Service\n@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)\n@Slf4j\npublic class LocalServiceImpl extends AbstractProxyTransferService<LocalParam> {\n\n    @Override\n    public void init() {\n        // 初始化存储源\n        File file = new File(param.getFilePath());\n        // 校验文件夹是否存在\n        if (!file.exists()) {\n            String errMsg = String.format(\"文件路径:「%s」不存在, 请检查是否填写正确.\", file.getAbsolutePath());\n            throw new InitializeStorageSourceBizException(errMsg, storageId);\n        }\n    }\n\n\n    @Override\n    public List<FileItemResult> fileList(String folderPath) throws FileNotFoundException {\n        checkPathSecurity(folderPath);\n\n        List<FileItemResult> fileItemList = new ArrayList<>();\n\n        String fullPath = StringUtils.concat(param.getFilePath(), getCurrentUserBasePath(), folderPath);\n\n        File file = new File(fullPath);\n\n        if (!(file.isDirectory() && file.exists())) {\n            throw new BizException(ErrorCode.BIZ_FOLDER_NOT_EXIST);\n        }\n\n        File[] files = file.listFiles();\n\n        if (files == null) {\n            return fileItemList;\n        }\n        for (File f : files) {\n            fileItemList.add(fileToFileItem(f, folderPath));\n        }\n\n        return fileItemList;\n    }\n\n\n    @Override\n    public FileItemResult getFileItem(String pathAndName) {\n        checkPathSecurity(pathAndName);\n\n        String fullPath = StringUtils.concat(param.getFilePath(), getCurrentUserBasePath(), pathAndName);\n\n        File file = new File(fullPath);\n\n        if (!file.exists()) {\n            return null;\n        }\n\n        String folderPath = FileUtils.getParentPath(pathAndName);\n        return fileToFileItem(file, folderPath);\n    }\n\n\n    @Override\n    public boolean newFolder(String path, String name) {\n        checkPathSecurity(path);\n        checkNameSecurity(name);\n\n        String fullPath = StringUtils.concat(param.getFilePath(), getCurrentUserBasePath(), path, name);\n        return FileUtil.mkdir(fullPath) != null;\n    }\n\n\n    @Override\n    public boolean deleteFile(String path, String name) {\n        checkPathSecurity(path);\n        checkNameSecurity(name);\n\n        String fullPath = StringUtils.concat(param.getFilePath(), getCurrentUserBasePath(), path, name);\n        return FileUtil.del(fullPath);\n    }\n\n\n    @Override\n    public boolean deleteFolder(String path, String name) {\n        return deleteFile(path, name);\n    }\n\n\n    @Override\n    public boolean renameFile(String path, String name, String newName) {\n        return operateFile(path, name, path, newName, FileOperatorTypeEnum.RENAME);\n    }\n\n\n    @Override\n    public boolean renameFolder(String path, String name, String newName) {\n        return operateFile(path, name, path, newName, FileOperatorTypeEnum.RENAME);\n    }\n\n\n    @Override\n    public StorageTypeEnum getStorageTypeEnum() {\n        return StorageTypeEnum.LOCAL;\n    }\n\n\n    @Override\n    public void uploadFile(String pathAndName, InputStream inputStream, Long size) {\n        checkPathSecurity(pathAndName);\n\n        String uploadPath = StringUtils.concat(param.getFilePath(), getCurrentUserBasePath(), pathAndName);\n\n        // 如果目录不存在则创建\n        String parentPath = FileUtils.getParentPath(uploadPath);\n        if (!FileUtil.exist(parentPath)) {\n            FileUtil.mkdir(parentPath);\n        }\n\n        File uploadToFileObj = new File(uploadPath);\n        BufferedOutputStream outputStream = FileUtil.getOutputStream(uploadToFileObj);\n        IoUtil.copy(inputStream, outputStream);\n        IoUtil.close(outputStream);\n        IoUtil.close(inputStream);\n    }\n\n    @Override\n    public boolean copyFile(String path, String name, String targetPath, String targetName) {\n        return operateFile(path, name, targetPath, targetName, FileOperatorTypeEnum.COPY);\n    }\n\n    @Override\n    public boolean copyFolder(String path, String name, String targetPath, String targetName) {\n        return operateFile(path, name, targetPath, targetName, FileOperatorTypeEnum.COPY);\n    }\n\n    @Override\n    public boolean moveFile(String path, String name, String targetPath, String targetName) {\n        return operateFile(path, name, targetPath, targetName, FileOperatorTypeEnum.MOVE);\n    }\n\n    @Override\n    public boolean moveFolder(String path, String name, String targetPath, String targetName) {\n        return operateFile(path, name, targetPath, targetName, FileOperatorTypeEnum.MOVE);\n    }\n\n    private boolean operateFile(String path, String name, String newPath, String newName, FileOperatorTypeEnum operatorTypeEnum) {\n        checkPathSecurity(path, newPath);\n        checkNameSecurity(name, newName);\n\n        // 如果原文件路径和目的文件路径没变，不做任何操作.\n        if (StringUtils.equals(path, newPath) && StringUtils.equals(name, newName)) {\n            return true;\n        }\n\n        String srcPath = StringUtils.concat(param.getFilePath(), getCurrentUserBasePath(), path, name);\n\n        // 如果是复制，不需要拼接新的文件名\n        String appendName = operatorTypeEnum == FileOperatorTypeEnum.COPY ? \"\" : newName;\n        String distPath = StringUtils.concat(param.getFilePath(), getCurrentUserBasePath(), newPath, appendName);\n        File srcFile = new File(srcPath);\n        File distFile = new File(distPath);\n\n        if (operatorTypeEnum == FileOperatorTypeEnum.MOVE) {\n            FileUtil.move(srcFile, distFile, true);\n        } else if (operatorTypeEnum == FileOperatorTypeEnum.COPY) {\n            FileUtil.copy(srcFile, distFile, true);\n        } else if (operatorTypeEnum == FileOperatorTypeEnum.RENAME) {\n            FileUtil.rename(srcFile, newName, true);\n        } else {\n            throw ExceptionUtil.wrapRuntime(new RuntimeException(\"不支持的操作类型.\"));\n        }\n        return true;\n    }\n\n\n    @Override\n    public String getUploadUrl(String path, String name, Long size) {\n        return super.getProxyUploadUrl(path, name);\n    }\n\n\n    @Override\n    public String getDownloadUrl(String pathAndName) {\n        if (StringUtils.isNotBlank(param.getDomain())) {\n            return StringUtils.concat(param.getDomain(), StringUtils.encodeAllIgnoreSlashes(pathAndName));\n        }\n        return super.getProxyDownloadUrl(pathAndName);\n    }\n\n\n    @Override\n    public ResponseEntity<Resource> downloadToStream(String pathAndName) {\n        checkPathSecurity(pathAndName);\n\n        File file = new File(StringUtils.concat(param.getFilePath(), pathAndName));\n        if (!file.exists()) {\n            throw new NotFoundAccessException(ErrorCode.BIZ_FILE_NOT_EXIST);\n        }\n\n        Resource body = new FileSystemResource(file);\n\n        MediaType mimeType;\n        if (param.isProxyLinkForceDownload()) {\n            mimeType = MediaType.APPLICATION_OCTET_STREAM;\n        } else {\n            mimeType = MediaTypeFactory.getMediaType(file.getName()).orElse(MediaType.APPLICATION_OCTET_STREAM);\n        }\n\n        HttpHeaders headers = new HttpHeaders();\n        String fileName = file.getName();\n\n        ContentDisposition contentDisposition = ContentDisposition\n                .builder(ObjectUtil.equals(mimeType, MediaType.APPLICATION_OCTET_STREAM) ? \"attachment\" : \"inline\")\n                .filename(fileName, StandardCharsets.UTF_8)\n                .build();\n        headers.setContentDisposition(contentDisposition);\n\n        return ResponseEntity\n                .ok()\n                .headers(headers)\n                .contentLength(file.length())\n                .contentType(mimeType)\n                .body(body);\n    }\n\n\n    private FileItemResult fileToFileItem(File file, String folderPath) {\n        FileItemResult fileItemResult = new FileItemResult();\n        fileItemResult.setType(file.isDirectory() ? FileTypeEnum.FOLDER : FileTypeEnum.FILE);\n        fileItemResult.setTime(new Date(file.lastModified()));\n        fileItemResult.setSize(file.length());\n        fileItemResult.setName(file.getName());\n        fileItemResult.setPath(folderPath);\n\n        if (fileItemResult.getType() == FileTypeEnum.FILE) {\n            fileItemResult.setUrl(getDownloadUrl(StringUtils.concat(getCurrentUserBasePath(), folderPath, file.getName())));\n        } else {\n            fileItemResult.setSize(null);\n        }\n        return fileItemResult;\n    }\n\n\n    /**\n     * 检查路径合法性：\n     *  - 只有以 . 开头的允许通过，其他的如 ./ ../ 的都是非法获取上层文件夹内容的路径.\n     *\n     * @param   paths\n     *          文件路径\n     *\n     * @throws IllegalArgumentException    文件路径包含非法字符时会抛出此异常\n     */\n    private static void checkPathSecurity(String... paths) {\n        for (String path : paths) {\n            // 路径中不能包含 .. 不然可能会获取到上层文件夹的内容\n            if (StringUtils.startWith(path, \"/..\") || StringUtils.containsAny(path, \"../\", \"..\\\\\")) {\n                throw new FilePathSecurityBizException(path);\n            }\n        }\n    }\n\n\n    /**\n     * 检查路径合法性：\n     *  - 不为空，且不包含 \\ / 字符\n     *\n     * @param   names\n     *          文件路径\n     *\n     * @throws IllegalArgumentException    文件名包含非法字符时会抛出此异常\n     */\n    private static void checkNameSecurity(String... names) {\n        for (String name : names) {\n            // 路径中不能包含 .. 不然可能会获取到上层文件夹的内容\n            if (StringUtils.containsAny(name, \"\\\\\", StringUtils.SLASH)) {\n                throw new FilePathSecurityBizException(name);\n            }\n        }\n    }\n\n    @Override\n    public StorageSourceMetadata getStorageSourceMetadata() {\n        StorageSourceMetadata storageSourceMetadata = new StorageSourceMetadata();\n        storageSourceMetadata.setUploadType(StorageSourceMetadata.UploadType.PROXY);\n        storageSourceMetadata.setNeedCreateFolderBeforeUpload(false);\n        return storageSourceMetadata;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/impl/MinIOServiceImpl.java",
    "content": "package im.zhaojun.zfile.module.storage.service.impl;\n\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.core.util.UrlUtils;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.param.MinIOParam;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractS3BaseFileService;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.config.ConfigurableBeanFactory;\nimport org.springframework.context.annotation.Scope;\nimport org.springframework.stereotype.Service;\nimport software.amazon.awssdk.auth.credentials.AwsBasicCredentials;\nimport software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;\nimport software.amazon.awssdk.regions.Region;\nimport software.amazon.awssdk.services.s3.S3Client;\nimport software.amazon.awssdk.services.s3.S3Configuration;\nimport software.amazon.awssdk.services.s3.presigner.S3Presigner;\n\nimport java.net.URI;\n\n/**\n * @author zhaojun\n */\n@Service\n@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)\n@Slf4j\npublic class MinIOServiceImpl extends AbstractS3BaseFileService<MinIOParam> {\n\n    @Override\n    public void init() {\n        String endPoint = param.getEndPoint();\n        String endPointScheme = param.getEndPointScheme();\n        // 如果 endPoint 不包含协议部分, 且配置了 endPointScheme, 则手动拼接协议部分.\n        if (!UrlUtils.hasScheme(endPoint) && StringUtils.isNotBlank(endPointScheme)) {\n            endPoint = endPointScheme + \"://\" + endPoint;\n        }\n\n        Region oss = Region.of(param.getRegion());\n        URI endpointOverride = URI.create(endPoint);\n        StaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(AwsBasicCredentials.create(param.getAccessKey(), param.getSecretKey()));\n\n        super.s3ClientNew = S3Client.builder()\n                .forcePathStyle(true)\n                .overrideConfiguration(getClientConfiguration())\n                .region(oss)\n                .endpointOverride(endpointOverride)\n                .credentialsProvider(credentialsProvider)\n                .build();\n\n        // In MinIOServiceImpl.java's init() method, update your s3Presigner initialization:\n        super.s3Presigner = S3Presigner.builder()\n                .region(oss)\n                .endpointOverride(endpointOverride)\n                .credentialsProvider(credentialsProvider)\n                .serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build()) // Add this line\n                .build();\n\n\n\n\n    }\n\n    @Override\n    public StorageTypeEnum getStorageTypeEnum() {\n        return StorageTypeEnum.MINIO;\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/impl/OneDriveChinaServiceImpl.java",
    "content": "package im.zhaojun.zfile.module.storage.service.impl;\n\nimport im.zhaojun.zfile.core.config.ZFileProperties;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.param.OneDriveChinaParam;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractOneDriveServiceBase;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.config.ConfigurableBeanFactory;\nimport org.springframework.context.annotation.Scope;\nimport org.springframework.stereotype.Service;\n\nimport jakarta.annotation.Resource;\n\n/**\n * @author zhaojun\n */\n@Service\n@Slf4j\n@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)\npublic class OneDriveChinaServiceImpl extends AbstractOneDriveServiceBase<OneDriveChinaParam> {\n\n    @Resource\n    private ZFileProperties zFileProperties;\n\n    @Override\n    public StorageTypeEnum getStorageTypeEnum() {\n        return StorageTypeEnum.ONE_DRIVE_CHINA;\n    }\n\n    @Override\n    public String getGraphEndPoint() {\n        return \"microsoftgraph.chinacloudapi.cn\";\n    }\n\n    @Override\n    public String getAuthenticateEndPoint() {\n        return \"login.partner.microsoftonline.cn\";\n    }\n    \n    @Override\n    public String getClientId() {\n        if (param == null || param.getClientId() == null) {\n            return zFileProperties.getOnedriveChina().getClientId();\n        }\n        return param.getClientId();\n    }\n    \n    @Override\n    public String getRedirectUri() {\n        if (param == null || param.getRedirectUri() == null) {\n            return zFileProperties.getOnedriveChina().getRedirectUri();\n        }\n        return param.getRedirectUri();\n    }\n    \n    @Override\n    public String getClientSecret() {\n        if (param == null || param.getClientSecret() == null) {\n            return zFileProperties.getOnedriveChina().getClientSecret();\n        }\n        return param.getClientSecret();\n    }\n    \n    @Override\n    public String getScope() {\n        return zFileProperties.getOnedriveChina().getScope();\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/impl/OneDriveServiceImpl.java",
    "content": "package im.zhaojun.zfile.module.storage.service.impl;\n\nimport im.zhaojun.zfile.core.config.ZFileProperties;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.param.OneDriveParam;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractOneDriveServiceBase;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.config.ConfigurableBeanFactory;\nimport org.springframework.context.annotation.Scope;\nimport org.springframework.stereotype.Service;\n\nimport jakarta.annotation.Resource;\n\n/**\n * @author zhaojun\n */\n@Service\n@Slf4j\n@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)\npublic class OneDriveServiceImpl extends AbstractOneDriveServiceBase<OneDriveParam> {\n\n    @Resource\n    private ZFileProperties zFileProperties;\n    \n    @Override\n    public StorageTypeEnum getStorageTypeEnum() {\n        return StorageTypeEnum.ONE_DRIVE;\n    }\n\n    @Override\n    public String getGraphEndPoint() {\n        return \"graph.microsoft.com\";\n    }\n\n    @Override\n    public String getAuthenticateEndPoint() {\n        return \"login.microsoftonline.com\";\n    }\n    \n    @Override\n    public String getClientId() {\n        if (param == null || param.getClientId() == null) {\n            return zFileProperties.getOnedrive().getClientId();\n        }\n        return param.getClientId();\n    }\n    \n    @Override\n    public String getRedirectUri() {\n        if (param == null || param.getRedirectUri() == null) {\n            return zFileProperties.getOnedrive().getRedirectUri();\n        }\n        return param.getRedirectUri();\n    }\n    \n    @Override\n    public String getClientSecret() {\n        if (param == null || param.getClientSecret() == null) {\n            return zFileProperties.getOnedrive().getClientSecret();\n        }\n        return param.getClientSecret();\n    }\n\n    @Override\n    public String getScope() {\n        return zFileProperties.getOnedrive().getScope();\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/impl/Open115ServiceImpl.java",
    "content": "package im.zhaojun.zfile.module.storage.service.impl;\n\nimport cn.hutool.cache.Cache;\nimport cn.hutool.cache.CacheUtil;\nimport cn.hutool.http.HttpRequest;\nimport cn.hutool.http.HttpResponse;\nimport cn.hutool.http.HttpUtil;\nimport cn.hutool.http.Method;\nimport com.alibaba.fastjson2.JSONArray;\nimport com.alibaba.fastjson2.JSONObject;\nimport com.google.common.util.concurrent.RateLimiter;\nimport im.zhaojun.zfile.module.storage.controller.helper.Open115UploadUtils;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.exception.core.SystemException;\nimport im.zhaojun.zfile.core.exception.status.NotFoundAccessException;\nimport im.zhaojun.zfile.core.util.FileSizeConverter;\nimport im.zhaojun.zfile.core.util.FileUtils;\nimport im.zhaojun.zfile.core.util.RequestHolder;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.storage.constant.StorageConfigConstant;\nimport im.zhaojun.zfile.module.storage.controller.proxy.Open115UrlController;\nimport im.zhaojun.zfile.module.storage.model.bo.RefreshTokenCacheBO;\nimport im.zhaojun.zfile.module.storage.model.bo.StorageSourceMetadata;\nimport im.zhaojun.zfile.module.storage.model.dto.RefreshTokenInfoDTO;\nimport im.zhaojun.zfile.module.storage.model.entity.StorageSourceConfig;\nimport im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.param.Open115Param;\nimport im.zhaojun.zfile.module.storage.model.result.FileItemResult;\nimport im.zhaojun.zfile.module.storage.service.StorageSourceConfigService;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractProxyTransferService;\nimport im.zhaojun.zfile.module.storage.service.base.RefreshTokenService;\nimport im.zhaojun.zfile.module.storage.support.Open115IdCacheService;\nimport jakarta.annotation.Resource;\nimport jakarta.servlet.http.HttpServletRequest;\nimport jakarta.servlet.http.HttpServletResponse;\nimport lombok.extern.slf4j.Slf4j;\nimport org.apache.commons.io.IOUtils;\nimport org.springframework.beans.factory.config.ConfigurableBeanFactory;\nimport org.springframework.context.annotation.Scope;\nimport org.springframework.http.HttpHeaders;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.stereotype.Service;\n\nimport java.io.File;\nimport java.io.FileOutputStream;\nimport java.io.InputStream;\nimport java.io.OutputStream;\nimport java.util.*;\n\n@Service\n@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)\n@Slf4j\npublic class Open115ServiceImpl extends AbstractProxyTransferService<Open115Param>  implements RefreshTokenService {\n\n    @Resource\n    private StorageSourceConfigService storageSourceConfigService;\n\n    /**\n     * 默认 User-Agent, 用于获取下载地址时使用.\n     */\n    private static final String DEFAULT_USER_AGENT = \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36\";\n\n    /**\n     * 访问令牌字段名称\n     */\n    public static final String ACCESS_TOKEN_FIELD_NAME = \"access_token\";\n\n    /**\n     * 刷新令牌字段名称\n     */\n    public static final String REFRESH_TOKEN_FIELD_NAME = \"refresh_token\";\n\n    /**\n     * 过期时间字段名称\n     */\n    public static final String EXPIRES_IN_FIELD_NAME = \"expires_in\";\n\n    /**\n     * 分页最大每页条数限制\n     */\n    public static final Integer FILE_LIST_LIMIT = 1150;\n\n    /**\n     * 文件类型: 文件\n     */\n    private static final String FC_FILE = \"1\";\n\n    /**\n     * 下载地址缓存: 默认过期时间 50 分钟 (115 下载地址到期时间为 1 小时)， key 为 pick_code + userAgent\n     */\n    private final Cache<String, String> DOWNLOAD_URL_CACHE = CacheUtil.newTimedCache(50 * 60 * 1000,10 * 60 * 1000);\n\n    /**\n     * 访问频率控制器, 用于限制 QPS, 避免请求过快被 115 限制.\n     */\n    private RateLimiter rateLimiter;\n\n    /**\n     * ID 缓存服务\n     */\n    private Open115IdCacheService idCacheService;\n\n    @Override\n    public void init() {\n        this.rateLimiter = RateLimiter.create(param.getQps());\n        this.idCacheService = new Open115IdCacheService(this::sendGetRequestWithAuth);\n\n        Integer refreshTokenExpiredAt = param.getRefreshTokenExpiredAt();\n        if (refreshTokenExpiredAt == null) {\n            refreshAccessToken();\n        } else {\n            RefreshTokenInfoDTO tokenInfoDTO = RefreshTokenInfoDTO.success(param.getAccessToken(), param.getRefreshToken(), refreshTokenExpiredAt);\n            RefreshTokenCacheBO.putRefreshTokenInfo(storageId, RefreshTokenCacheBO.RefreshTokenInfo.success(tokenInfoDTO));\n        }\n    }\n\n    @Override\n    public List<FileItemResult> fileList(String folderPath) throws Exception {\n        String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), folderPath);\n        String pathId = idCacheService.getPathId(fullPath, true);\n        List<FileItemResult> result = new ArrayList<>();\n\n        int offset = 0;\n        int count;\n        do {\n            // https://www.yuque.com/115yun/open/kz9ft9a7s57ep868\n            JSONObject jsonObject = sendGetRequestWithAuth(\"https://proapi.115.com/open/ufile/files\", new JSONObject()\n                    .fluentPut(\"cid\", pathId)\n                    .fluentPut(\"offset\", offset)\n                    .fluentPut(\"limit\", FILE_LIST_LIMIT)\n                    .fluentPut(\"show_dir\", 1));\n\n            // 如果请求 id 与返回 id 不符，是 115 做了兼容处理，直接返回了根目录\n            String cid = jsonObject.getString(\"cid\");\n            if (StringUtils.isNotBlank(pathId) && !Objects.equals(cid, pathId)) {\n                log.warn(\"请求的路径 ID '{}' 与返回的路径 ID '{}' 不符, 可能是 115 做了兼容处理, 返回了根目录.\", pathId, cid);\n                throw new NotFoundAccessException(ErrorCode.BIZ_FILE_NOT_EXIST);\n            }\n\n            JSONArray fileList = jsonObject.getJSONArray(\"data\");\n            for (int i = 0; i < fileList.size(); i++) {\n                JSONObject fileItem = fileList.getJSONObject(i);\n                FileItemResult fileItemResult = listJsonToFileItem(fileItem, folderPath);\n                result.add(fileItemResult);\n            }\n\n            count = jsonObject.getInteger(\"count\");\n            offset += FILE_LIST_LIMIT;\n        } while (result.size() < count);\n\n        return result;\n    }\n\n    @Override\n    public FileItemResult getFileItem(String pathAndName) {\n        String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), pathAndName);\n        String fileId = idCacheService.getFileId(fullPath, false);\n        if (fileId == null) {\n            fileId = idCacheService.getPathId(fullPath, true);\n        }\n\n        // https://www.yuque.com/115yun/open/rl8zrhe2nag21dfw\n        JSONObject jsonObject = sendGetRequestWithAuth(\"https://proapi.115.com/open/folder/get_info\", new JSONObject()\n                .fluentPut(\"file_id\", fileId));\n\n        JSONObject fileItem = jsonObject.getJSONObject(\"data\");\n        return itemJsonToFileItem(fileItem, FileUtils.getParentPath(pathAndName));\n    }\n\n    @Override\n    public boolean newFolder(String path, String name) {\n        if (StringUtils.length(name) > 255) {\n            throw new BizException(\"文件夹名称过长, 不能超过 255 个字符.\");\n        }\n        String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path);\n        String pathId = idCacheService.getPathId(fullPath, true);\n\n        // https://www.yuque.com/115yun/open/qur839kyx9cgxpxi\n        JSONObject jsonObject = sendPostRequestWithAuth(\"https://proapi.115.com/open/folder/add\", new JSONObject()\n                .fluentPut(\"pid\", pathId)\n                .fluentPut(\"file_name\", name));\n\n        JSONObject data = jsonObject.getJSONObject(\"data\");\n        String fileId = data.getString(\"file_id\");\n        idCacheService.putPathId(StringUtils.concat(fullPath, name), fileId);\n        return true;\n    }\n\n    @Override\n    public boolean deleteFile(String path, String name) {\n        String deleteFullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name);\n        String deleteParentPath = FileUtils.getParentPath(deleteFullPath);\n\n        String fileId = idCacheService.getFileId(deleteFullPath, true);\n        String parentPathId = idCacheService.getPathId(deleteParentPath, true);\n\n        // https://www.yuque.com/115yun/open/kt04fu8vcchd2fnb\n        sendPostRequestWithAuth(\"https://proapi.115.com/open/ufile/delete\", new JSONObject()\n                .fluentPut(\"parent_id\", parentPathId)\n                .fluentPut(\"file_ids\", fileId));\n\n        idCacheService.deleteFileId(deleteFullPath);\n        return true;\n    }\n\n    @Override\n    public boolean deleteFolder(String path, String name) {\n        String deleteFullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name);\n        String deleteParentPath = FileUtils.getParentPath(deleteFullPath);\n\n        String pathId = idCacheService.getPathId(deleteFullPath, true);\n        String parentPathId = idCacheService.getPathId(deleteParentPath, true);\n\n        // https://www.yuque.com/115yun/open/kt04fu8vcchd2fnb\n        sendPostRequestWithAuth(\"https://proapi.115.com/open/ufile/delete\", new JSONObject()\n                .fluentPut(\"parent_id\", parentPathId)\n                .fluentPut(\"file_ids\", pathId));\n\n        idCacheService.deletePathId(deleteFullPath);\n        return true;\n    }\n\n    @Override\n    public boolean copyFile(String path, String name, String targetPath, String targetName) {\n        String srcFullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name);\n        String targetFullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), targetPath);\n\n        String srcFileId = idCacheService.getFileId(srcFullPath, true);\n        String targetPathId = idCacheService.getPathId(targetFullPath, true);\n\n        // https://www.yuque.com/115yun/open/lvas49ar94n47bbk\n        sendPostRequestWithAuth(\"https://proapi.115.com/open/ufile/copy\", new JSONObject()\n                .fluentPut(\"pid\", targetPathId)\n                .fluentPut(\"file_id\", srcFileId)\n                .fluentPut(\"nodupli\", 1));\n        return true;\n    }\n\n    @Override\n    public boolean copyFolder(String path, String name, String targetPath, String targetName) {\n        String srcFullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name);\n        String targetFullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), targetPath);\n\n        String srcPathId = idCacheService.getPathId(srcFullPath, true);\n        String targetPathId = idCacheService.getPathId(targetFullPath, true);\n\n        // https://www.yuque.com/115yun/open/lvas49ar94n47bbk\n        sendPostRequestWithAuth(\"https://proapi.115.com/open/ufile/copy\", new JSONObject()\n                .fluentPut(\"pid\", targetPathId)\n                .fluentPut(\"file_id\", srcPathId)\n                .fluentPut(\"nodupli\", 1));\n\n        return true;\n    }\n\n    @Override\n    public boolean moveFile(String path, String name, String targetPath, String targetName) {\n        String srcFullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name);\n        String targetFullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), targetPath, targetName);\n\n        String srcFileId = idCacheService.getFileId(srcFullPath, true);\n        String targetPathId = idCacheService.getPathId(StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), targetPath), true);\n\n        // https://www.yuque.com/115yun/open/vc6fhi2mrkenmav2\n        sendPostRequestWithAuth(\"https://proapi.115.com/open/ufile/move\", new JSONObject()\n                .fluentPut(\"to_cid\", targetPathId)\n                .fluentPut(\"file_ids\", srcFileId));\n\n        String id = idCacheService.removeFileIdByPath(srcFullPath);\n        idCacheService.putFileId(targetFullPath, id);\n        return true;\n    }\n\n    @Override\n    public boolean moveFolder(String path, String name, String targetPath, String targetName) {\n        String srcFullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name);\n\n        String srcPathId = idCacheService.getPathId(srcFullPath, true);\n        String targetPathId = idCacheService.getPathId(StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), targetPath), true);\n\n        // https://www.yuque.com/115yun/open/vc6fhi2mrkenmav2\n        sendPostRequestWithAuth(\"https://proapi.115.com/open/ufile/move\", new JSONObject()\n                .fluentPut(\"to_cid\", targetPathId)\n                .fluentPut(\"file_ids\", srcPathId));\n\n        idCacheService.deletePathId(srcFullPath);\n        return true;\n    }\n\n    @Override\n    public boolean renameFile(String path, String name, String newName) {\n        if (StringUtils.length(newName) > 255) {\n            throw new BizException(\"文件夹名称过长, 不能超过 255 个字符.\");\n        }\n        String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name);\n        String fileId = idCacheService.getFileId(fullPath, true);\n\n        // https://www.yuque.com/115yun/open/gyrpw5a0zc4sengm\n        sendPostRequestWithAuth(\"https://proapi.115.com/open/ufile/update\", new JSONObject()\n                .fluentPut(\"file_id\", fileId)\n                .fluentPut(\"file_name\", newName));\n\n        idCacheService.deletePathId(fullPath);\n        idCacheService.putFileId(StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, newName), fileId);\n        return true;\n    }\n\n    @Override\n    public boolean renameFolder(String path, String name, String newName) {\n        if (StringUtils.length(newName) > 255) {\n            throw new BizException(\"文件夹名称过长, 不能超过 255 个字符.\");\n        }\n        String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name);\n        String pathId = idCacheService.getPathId(fullPath, true);\n\n        // https://www.yuque.com/115yun/open/gyrpw5a0zc4sengm\n        sendPostRequestWithAuth(\"https://proapi.115.com/open/ufile/update\", new JSONObject()\n                .fluentPut(\"file_id\", pathId)\n                .fluentPut(\"file_name\", newName));\n\n        idCacheService.deletePathId(fullPath);\n        idCacheService.putPathId(StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, newName), pathId);\n        return true;\n    }\n\n    @Override\n    public String getDownloadUrl(String pathAndName) {\n        if (param.isEnableProxyDownload() && StringUtils.isEmpty(param.getDomain())) {\n            return getProxyDownloadUrl(pathAndName);\n        } else {\n            FileItemResult fileItem = getFileItem(pathAndName);\n            if (fileItem == null || fileItem.getType() != FileTypeEnum.FILE) {\n                throw new NotFoundAccessException(ErrorCode.BIZ_FILE_NOT_EXIST);\n            }\n            return fileItem.getUrl();\n        }\n    }\n\n    private String getProxyDownloadUrlByPickCode(String pathAndName, String pickCode) {\n        if (param.isEnableProxyDownload() && StringUtils.isEmpty(param.getDomain())) {\n            return getProxyDownloadUrl(pathAndName);\n        } else {\n            return StringUtils.concat(getSystemConfigService().getAxiosFromDomainOrSetting(), Open115UrlController.PROXY_DOWNLOAD_LINK_PREFIX, storageId + \"\", pickCode);\n        }\n    }\n\n    public String getOpen115DownloadUrlByPickCode(String pickCode) {\n        HttpServletRequest request = RequestHolder.getRequest();\n        String userAgent;\n        if (StringUtils.isBlank(request.getHeader(HttpHeaders.USER_AGENT))) {\n            userAgent = DEFAULT_USER_AGENT;\n        } else {\n            userAgent = request.getHeader(HttpHeaders.USER_AGENT);\n        }\n\n        return DOWNLOAD_URL_CACHE.get(pickCode + \"_\" + userAgent, () -> {\n            Map<String, List<String>> headers = new HashMap<>();\n            headers.put(HttpHeaders.USER_AGENT, Collections.singletonList(userAgent));\n\n            // https://www.yuque.com/115yun/open/um8whr91bxb5997o\n            JSONObject jsonObject = sendRequest(\"https://proapi.115.com/open/ufile/downurl\", Method.POST, true,\n                    new JSONObject().fluentPut(\"pick_code\", pickCode),\n                    headers\n            );\n\n            String finalUrl = null;\n            JSONObject dataObject = jsonObject.getJSONObject(\"data\");\n            if (dataObject != null && !dataObject.isEmpty()) {\n                Set<String> keys = dataObject.keySet();\n                String dynamicKey = keys.iterator().next();\n                JSONObject fileObject = dataObject.getJSONObject(dynamicKey);\n                if (fileObject != null) {\n                    JSONObject urlObject = fileObject.getJSONObject(\"url\");\n                    if (urlObject != null) {\n                        finalUrl = urlObject.getString(\"url\");\n                    }\n                }\n            }\n\n            if (finalUrl == null) {\n                throw new BizException(ErrorCode.BIZ_FILE_NOT_EXIST);\n            }\n\n            return finalUrl;\n        });\n    }\n\n    @Override\n    public String getUploadUrl(String path, String name, Long size) {\n        return super.getProxyUploadUrl(path, name);\n    }\n\n    @Override\n    public void uploadFile(String pathAndName, InputStream inputStream, Long size) throws Exception {\n        File tempFile = File.createTempFile(\"open115-upload-\", \".tmp\");\n        try {\n            // 将 inputStream 写入到 tempFile\n            IOUtils.copy(inputStream, new FileOutputStream(tempFile));\n\n            String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), pathAndName);\n            String folderPath = FileUtils.getParentPath(fullPath);\n            String fileName = FileUtils.getName(fullPath);\n            String pathId = idCacheService.getPathId(folderPath, true);\n\n            Open115UploadUtils.uploadFile(tempFile, fileName, pathId, this::checkExpiredAndGetAccessToken);\n        } finally {\n            boolean delete = tempFile.delete();\n            if (!delete) {\n                log.warn(\"上传 115 时无法删除临时文件: {}\", tempFile.getAbsolutePath());\n            }\n        }\n    }\n\n    @Override\n    public ResponseEntity<org.springframework.core.io.Resource> downloadToStream(String pathAndName) throws Exception {\n        String fullPath = StringUtils.concat(param.getBasePath(), pathAndName);\n        String fileId = idCacheService.getFileId(fullPath, true);\n\n        // https://www.yuque.com/115yun/open/rl8zrhe2nag21dfw\n        JSONObject fileInfoJSONObj = sendGetRequestWithAuth(\"https://proapi.115.com/open/folder/get_info\", new JSONObject().fluentPut(\"file_id\", fileId));\n        String pickCode = fileInfoJSONObj.getJSONObject(\"data\").getString(\"pick_code\");\n        String originUrl = getOpen115DownloadUrlByPickCode(pickCode);\n\n        HttpServletRequest request = RequestHolder.getRequest();\n        HttpRequest httpRequest = HttpUtil.createGet(originUrl);\n        httpRequest.header(HttpHeaders.RANGE, request.getHeader(HttpHeaders.RANGE));\n        httpRequest.header(HttpHeaders.USER_AGENT, RequestHolder.getRequest().getHeader(HttpHeaders.USER_AGENT), true);\n        HttpResponse httpResponse = httpRequest.executeAsync();\n\n        try {\n            HttpServletResponse response = RequestHolder.getResponse();\n            response.setStatus(httpResponse.getStatus());\n            OutputStream outputStream = response.getOutputStream();\n\n            Map<String, List<String>> headers = httpResponse.headers();\n            for (Map.Entry<String, List<String>> entry : headers.entrySet()) {\n                String key = entry.getKey();\n                List<String> value = entry.getValue();\n                response.setHeader(key, String.join(\",\", value));\n            }\n            httpResponse.writeBody(outputStream, true, null);\n        } catch (Exception e) {\n            if (StringUtils.contains(e.getMessage(), \"ClientAbortException\")) {\n                // ignore 客户端中止异常\n            } else {\n                throw e;\n            }\n        }\n\n        return null;\n    }\n\n    @Override\n    public void refreshAccessToken() {\n        try {\n            RefreshTokenInfoDTO tokenInfoDTO = getAndRefreshToken();\n\n            StorageSourceConfig accessTokenConfig = storageSourceConfigService.findByStorageIdAndName(storageId, StorageConfigConstant.ACCESS_TOKEN_KEY);\n            StorageSourceConfig refreshTokenConfig = storageSourceConfigService.findByStorageIdAndName(storageId, StorageConfigConstant.REFRESH_TOKEN_KEY);\n            StorageSourceConfig refreshTokenExpiredAtConfig = storageSourceConfigService.findByStorageIdAndName(storageId, StorageConfigConstant.REFRESH_TOKEN_EXPIRED_AT_KEY);\n            accessTokenConfig.setValue(tokenInfoDTO.getAccessToken());\n            refreshTokenConfig.setValue(tokenInfoDTO.getRefreshToken());\n            refreshTokenExpiredAtConfig.setValue(String.valueOf(tokenInfoDTO.getExpiredAt()));\n\n            storageSourceConfigService.updateBatch(storageId, Arrays.asList(accessTokenConfig, refreshTokenConfig, refreshTokenExpiredAtConfig));\n            RefreshTokenCacheBO.putRefreshTokenInfo(storageId, RefreshTokenCacheBO.RefreshTokenInfo.success(tokenInfoDTO));\n\n        } catch (Exception e) {\n            RefreshTokenCacheBO.putRefreshTokenInfo(storageId, RefreshTokenCacheBO.RefreshTokenInfo.fail(getStorageTypeEnum().getDescription() + \" AccessToken 刷新失败: \" + e.getMessage()));\n            throw new SystemException(\"存储源 \" + storageId + \" 刷新令牌失败, 获取时发生异常.\", e);\n\n        }\n    }\n\n    @Override\n    public StorageSourceMetadata getStorageSourceMetadata() {\n        StorageSourceMetadata storageSourceMetadata = new StorageSourceMetadata();\n        storageSourceMetadata.setUploadType(StorageSourceMetadata.UploadType.PROXY);\n        return storageSourceMetadata;\n    }\n\n    @Override\n    public StorageTypeEnum getStorageTypeEnum() {\n        return StorageTypeEnum.OPEN115;\n    }\n\n    /**\n     * 根据 RefreshToken 刷新 AccessToken, 返回刷新后的 Token.\n     *\n     * @return  刷新后的 Token\n     */\n    private RefreshTokenInfoDTO getAndRefreshToken() {\n        StorageSourceConfig refreshStorageSourceConfig =\n                storageSourceConfigService.findByStorageIdAndName(storageId, StorageConfigConstant.REFRESH_TOKEN_KEY);\n\n        String value = refreshStorageSourceConfig.getValue();\n\n        // https://www.yuque.com/115yun/open/opnx8yezo4at2be6\n        JSONObject jsonObject = sendRequest(\"https://passportapi.115.com/open/refreshToken\", Method.POST, false,\n                Map.of(\"refresh_token\", refreshStorageSourceConfig.getValue()),\n                null);\n\n        JSONObject jsonBody = jsonObject.getJSONObject(\"data\");\n        String accessToken = jsonBody.getString(ACCESS_TOKEN_FIELD_NAME);\n        String refreshToken = jsonBody.getString(REFRESH_TOKEN_FIELD_NAME);\n        Integer expiresIn = jsonBody.getInteger(EXPIRES_IN_FIELD_NAME);\n        return RefreshTokenInfoDTO.success(accessToken, refreshToken, expiresIn);\n    }\n\n    /**\n     * 检查 AccessToken 是否过期，如果过期则刷新 AccessToken 并返回新的 AccessToken。\n     */\n    private String checkExpiredAndGetAccessToken() {\n        RefreshTokenCacheBO.RefreshTokenInfo refreshTokenInfo = RefreshTokenCacheBO.getRefreshTokenInfo(storageId);\n\n        if (refreshTokenInfo == null || refreshTokenInfo.isExpired()) {\n            // 使用双重检查锁定机制，确保同一个 storageId 只会有一个线程在刷新 AccessToken\n            synchronized ((\"storage-refresh-\" + storageId).intern()) {\n                // 双重检查，再次从缓存中获取，确认是否其他线程已经刷新过\n                refreshTokenInfo = RefreshTokenCacheBO.getRefreshTokenInfo(storageId);\n                if (refreshTokenInfo == null || refreshTokenInfo.isExpired()) {\n                    log.info(\"{} AccessToken 未获取或已过期, 尝试刷新.\", getStorageSimpleInfo());\n                    refreshAccessToken();\n                    refreshTokenInfo = RefreshTokenCacheBO.getRefreshTokenInfo(storageId);\n                }\n            }\n        }\n\n        if (refreshTokenInfo == null) {\n            throw new SystemException(\"存储源 \" + storageId + \" AccessToken 刷新失败: 未找到刷新令牌信息.\");\n        }\n\n        return refreshTokenInfo.getData().getAccessToken();\n    }\n\n    private JSONObject sendRequest(String url, Method method, boolean withAuth, Map<String, Object> form, Map<String, List<String>> headers) {\n        rateLimiter.acquire();\n\n        HttpRequest httpRequest = HttpUtil.createRequest(method, url)\n                .header(headers, true)\n                .form(form);\n\n        if (withAuth) {\n            httpRequest.bearerAuth(checkExpiredAndGetAccessToken());\n        }\n\n        HttpResponse httpResponse = httpRequest.execute();\n        return handleEntity(httpResponse);\n    }\n\n    private JSONObject sendPostRequestWithAuth(String url, Map<String, Object> form) {\n        return sendRequest(url, Method.POST, true, form, null);\n    }\n\n    private JSONObject sendGetRequestWithAuth(String url, Map<String, Object> form) {\n        return sendRequest(url, Method.GET, true, form, null);\n    }\n\n    private static JSONObject handleEntity(HttpResponse httpResponse) {\n        if (!httpResponse.isOk()) {\n            throw new SystemException(\"请求失败, 状态码: \" + httpResponse.getStatus() + \", 响应体: \" + httpResponse.body());\n        }\n\n        String responseBody = httpResponse.body();\n        JSONObject jsonObject = JSONObject.parseObject(responseBody);\n        if (jsonObject == null) {\n            throw new SystemException(\"请求失败, 响应体解析失败: \" + responseBody);\n        }\n        Boolean state = jsonObject.getBoolean(\"state\");\n        if (state == null || !state) {\n            String message = jsonObject.getString(\"message\");\n            String code = jsonObject.getString(\"code\");\n            throw new SystemException(\"请求失败, 响应消息: \" + message + \", 响应码: \" + code + \", 详见: https://www.yuque.com/115yun/open/rnq0cbz8tt7cu43i\");\n        }\n\n        return jsonObject;\n    }\n\n    /**\n     * 将微软接口返回的 JSON 对象转为 FileItemResult 对象\n     *\n     * @param jsonObject    JSON 对象\n     * @return              FileItemResult 对象\n     */\n    private FileItemResult listJsonToFileItem(JSONObject jsonObject, String folderPath) {\n        FileItemResult fileItemResult = new FileItemResult();\n        fileItemResult.setName(jsonObject.getString(\"fn\"));\n        fileItemResult.setSize(jsonObject.getLong(\"fs\"));\n        fileItemResult.setTime(new Date(jsonObject.getLong(\"upt\") * 1000));\n\n        if (Objects.equals(jsonObject.getString(\"fc\"), FC_FILE)) {\n            fileItemResult.setType(FileTypeEnum.FILE);\n            String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), folderPath, fileItemResult.getName());\n            idCacheService.putFileId(fullPath, jsonObject.getString(\"fid\"));\n\n            String pickCode = jsonObject.getString(\"pc\");\n            fileItemResult.setUrl(getProxyDownloadUrlByPickCode(StringUtils.concat(getCurrentUserBasePath(), folderPath, fileItemResult.getName()), pickCode));\n        } else {\n            fileItemResult.setType(FileTypeEnum.FOLDER);\n            String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), folderPath, fileItemResult.getName());\n            idCacheService.putPathId(fullPath, jsonObject.getString(\"fid\"));\n        }\n        fileItemResult.setPath(folderPath);\n        return fileItemResult;\n    }\n\n    /**\n     * 将微软接口返回的 JSON 对象转为 FileItemResult 对象\n     *\n     * @param jsonObject    JSON 对象\n     * @return              FileItemResult 对象\n     */\n    private FileItemResult itemJsonToFileItem(JSONObject jsonObject, String folderPath) {\n        FileItemResult fileItemResult = new FileItemResult();\n        fileItemResult.setName(jsonObject.getString(\"file_name\"));\n        // 字符型转为 Long 字节型\n        String size = jsonObject.getString(\"size\");\n        fileItemResult.setSize(FileSizeConverter.convertFileSizeToBytes(size));\n        fileItemResult.setTime(new Date(jsonObject.getLong(\"utime\") * 1000));\n\n        String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), folderPath, fileItemResult.getName());\n\n        if (Objects.equals(jsonObject.getString(\"file_category\"), FC_FILE)) {\n            fileItemResult.setType(FileTypeEnum.FILE);\n            idCacheService.putFileId(fullPath, jsonObject.getString(\"file_id\"));\n\n            String pickCode = jsonObject.getString(\"pick_code\");\n            fileItemResult.setUrl(getProxyDownloadUrlByPickCode(fullPath, pickCode));\n        } else {\n            fileItemResult.setType(FileTypeEnum.FOLDER);\n            idCacheService.putPathId(fullPath, jsonObject.getString(\"file_id\"));\n        }\n        fileItemResult.setPath(folderPath);\n        return fileItemResult;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/impl/QiniuServiceImpl.java",
    "content": "package im.zhaojun.zfile.module.storage.service.impl;\n\nimport cn.hutool.core.exceptions.ExceptionUtil;\nimport com.qiniu.common.QiniuException;\nimport com.qiniu.storage.BucketManager;\nimport com.qiniu.storage.Configuration;\nimport com.qiniu.storage.Region;\nimport com.qiniu.util.Auth;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.core.util.UrlUtils;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.param.QiniuParam;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractS3BaseFileService;\nimport lombok.extern.slf4j.Slf4j;\nimport org.apache.commons.lang3.BooleanUtils;\nimport org.springframework.beans.factory.config.ConfigurableBeanFactory;\nimport org.springframework.context.annotation.Scope;\nimport org.springframework.stereotype.Service;\nimport software.amazon.awssdk.auth.credentials.AwsBasicCredentials;\nimport software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;\nimport software.amazon.awssdk.services.s3.S3Client;\nimport software.amazon.awssdk.services.s3.presigner.S3Presigner;\n\nimport java.net.URI;\n\n/**\n * @author zhaojun\n */\n@Service\n@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)\n@Slf4j\npublic class QiniuServiceImpl extends AbstractS3BaseFileService<QiniuParam> {\n\n    private BucketManager bucketManager;\n\n    private Auth auth;\n\n    @Override\n    public void init() {\n        String endPoint = param.getEndPoint();\n        String endPointScheme = param.getEndPointScheme();\n        // 如果 endPoint 不包含协议部分, 且配置了 endPointScheme, 则手动拼接协议部分.\n        if (!UrlUtils.hasScheme(endPoint) && StringUtils.isNotBlank(endPointScheme)) {\n            endPoint = endPointScheme + \"://\" + endPoint;\n        }\n\n        software.amazon.awssdk.regions.Region oss = software.amazon.awssdk.regions.Region.of(\"kodo\");\n        URI endpointOverride = URI.create(endPoint);\n        StaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(AwsBasicCredentials.create(param.getAccessKey(), param.getSecretKey()));\n\n        super.s3ClientNew = S3Client.builder()\n                .overrideConfiguration(getClientConfiguration())\n                .region(oss)\n                .endpointOverride(endpointOverride)\n                .credentialsProvider(credentialsProvider)\n                .build();\n\n        super.s3Presigner = S3Presigner.builder()\n                .region(oss)\n                .endpointOverride(endpointOverride)\n                .credentialsProvider(credentialsProvider)\n                .build();\n\n\n        Configuration cfg = new Configuration(Region.autoRegion());\n        auth = Auth.create(param.getAccessKey(), param.getSecretKey());\n        bucketManager = new BucketManager(auth, cfg);\n\n        setUploadCors();\n    }\n\n    @Override\n    public StorageTypeEnum getStorageTypeEnum() {\n        return StorageTypeEnum.QINIU;\n    }\n\n    @Override\n    public boolean renameFile(String path, String name, String newName) {\n        String bucketName = param.getBucketName();\n        String basePath = param.getBasePath();\n\n        String srcPath = StringUtils.concat(basePath, getCurrentUserBasePath(), path, name);\n        srcPath = StringUtils.trimStartSlashes(srcPath);\n\n        String distPath = StringUtils.concat(basePath, getCurrentUserBasePath(), path, newName);\n        distPath = StringUtils.trimStartSlashes(distPath);\n\n        try {\n            bucketManager.move(bucketName, srcPath, bucketName, distPath);\n            return true;\n        } catch (QiniuException e) {\n            throw ExceptionUtil.wrapRuntime(e);\n        }\n\n    }\n\n    @Override\n    public String getDownloadUrl(String pathAndName) {\n        if (param.isEnableProxyDownload() && StringUtils.isEmpty(param.getDomain())) {\n            return getProxyDownloadUrl(pathAndName, false);\n        }\n        String domain = param.getDomain();\n\n        Integer tokenTime = param.getTokenTime();\n        if (param.getTokenTime() == null || param.getTokenTime() < 1) {\n            tokenTime = 1800;\n        }\n\n        String fullPath = StringUtils.concatTrimStartSlashes(param.getBasePath(), pathAndName);\n        // 如果不是私有空间, 且指定了加速域名, 则使用 qiniu 的 sdk 获取下载链接\n        // (使用 s3 sdk 获取到的下载链接替换自动加速域名后无法访问, 故这里使用 qiniu sdk).\n        if (BooleanUtils.isTrue(param.isPrivate()) && StringUtils.isNotEmpty(domain)) {\n            String customDomainFullPath = StringUtils.removeDuplicateSlashes(domain + StringUtils.SLASH + StringUtils.encodeAllIgnoreSlashes(fullPath));\n            return auth.privateDownloadUrl(customDomainFullPath, tokenTime);\n        }\n\n        return super.getDownloadUrl(pathAndName);\n    }\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/impl/S3ServiceImpl.java",
    "content": "package im.zhaojun.zfile.module.storage.service.impl;\n\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.core.util.UrlUtils;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.param.S3Param;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractS3BaseFileService;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.config.ConfigurableBeanFactory;\nimport org.springframework.context.annotation.Scope;\nimport org.springframework.stereotype.Service;\nimport software.amazon.awssdk.auth.credentials.AwsBasicCredentials;\nimport software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;\nimport software.amazon.awssdk.regions.Region;\nimport software.amazon.awssdk.services.s3.S3Client;\nimport software.amazon.awssdk.services.s3.S3Configuration;\nimport software.amazon.awssdk.services.s3.presigner.S3Presigner;\n\nimport java.net.URI;\n\n/**\n * @author zhaojun\n */\n@Service\n@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)\n@Slf4j\npublic class S3ServiceImpl extends AbstractS3BaseFileService<S3Param> {\n\n    @Override\n    public void init() {\n        String endPoint = param.getEndPoint();\n        String endPointScheme = param.getEndPointScheme();\n        // 如果 endPoint 不包含协议部分, 且配置了 endPointScheme, 则手动拼接协议部分.\n        if (!UrlUtils.hasScheme(endPoint) && StringUtils.isNotBlank(endPointScheme)) {\n            endPoint = endPointScheme + \"://\" + endPoint;\n        }\n\n        boolean isPathStyle = \"path-style\".equals(param.getPathStyle());\n        String domain = param.getDomain();\n        if (StringUtils.isNotBlank(domain) && !isPathStyle) {\n            throw new BizException(\"当使用域名访问时, 域名风格只能使用路径模式, 请修改存储配置中的域名风格选项.\");\n        }\n\n        String region = param.getRegion();\n        if (StringUtils.isEmpty(param.getRegion()) && StringUtils.isNotEmpty(endPoint)) {\n            region = endPoint.split(\"\\\\.\")[1];\n        }\n\n        Region oss = Region.of(region);\n        URI endpointOverride = URI.create(endPoint);\n        StaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(AwsBasicCredentials.create(param.getAccessKey(), param.getSecretKey()));\n\n        super.s3ClientNew = S3Client.builder()\n                .overrideConfiguration(getClientConfiguration())\n                .forcePathStyle(isPathStyle)\n                .region(oss)\n                .endpointOverride(endpointOverride)\n                .credentialsProvider(credentialsProvider)\n                .build();\n\n        super.s3PresignerDownload = S3Presigner.builder()\n                .serviceConfiguration(S3Configuration.builder()\n                        .pathStyleAccessEnabled(isPathStyle)\n                        .build())\n                .region(oss)\n                .endpointOverride(StringUtils.isBlank(domain) ? endpointOverride : URI.create(domain))\n                .credentialsProvider(credentialsProvider)\n                .build();\n\n        super.s3Presigner = S3Presigner.builder()\n                .serviceConfiguration(S3Configuration.builder()\n                        .pathStyleAccessEnabled(isPathStyle)\n                        .build())\n                .region(oss)\n                .endpointOverride(endpointOverride)\n                .credentialsProvider(credentialsProvider)\n                .build();\n\n        setUploadCors();\n    }\n\n    @Override\n    public StorageTypeEnum getStorageTypeEnum() {\n        return StorageTypeEnum.S3;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/impl/SftpServiceImpl.java",
    "content": "package im.zhaojun.zfile.module.storage.service.impl;\n\nimport cn.hutool.core.date.DateUtil;\nimport cn.hutool.extra.ssh.Sftp;\nimport com.jcraft.jsch.ChannelSftp;\nimport com.jcraft.jsch.SftpException;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.exception.core.SystemException;\nimport im.zhaojun.zfile.core.exception.status.NotFoundAccessException;\nimport im.zhaojun.zfile.core.util.*;\nimport im.zhaojun.zfile.module.storage.model.bo.StorageSourceMetadata;\nimport im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.param.SftpParam;\nimport im.zhaojun.zfile.module.storage.model.result.FileItemResult;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractProxyTransferService;\nimport im.zhaojun.zfile.module.storage.support.ftp.FtpClientFactory;\nimport im.zhaojun.zfile.module.storage.support.sftp.SFtpClientFactory;\nimport im.zhaojun.zfile.module.storage.support.sftp.SFtpClientPool;\nimport lombok.extern.slf4j.Slf4j;\nimport org.apache.commons.pool2.impl.GenericObjectPoolConfig;\nimport org.springframework.beans.factory.config.ConfigurableBeanFactory;\nimport org.springframework.context.annotation.Scope;\nimport org.springframework.core.io.Resource;\nimport org.springframework.http.HttpRange;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.stereotype.Service;\n\nimport java.io.InputStream;\nimport java.nio.charset.Charset;\nimport java.time.Duration;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.NoSuchElementException;\n\n/**\n * @author zhaojun\n */\n@Service\n@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)\n@Slf4j\npublic class SftpServiceImpl extends AbstractProxyTransferService<SftpParam> {\n\n\tprivate SFtpClientPool sftpClientPool;\n\n\t@Override\n\tpublic void init() {\n\t\tCharset charset = Charset.forName(param.getEncoding());\n\t\tSFtpClientFactory factory = new SFtpClientFactory(param.getHost(), param.getPort(), param.getUsername(), param.getPassword(), param.getPrivateKey(), param.getPassphrase(), charset);\n\t\tGenericObjectPoolConfig<FtpClientFactory> config = new GenericObjectPoolConfig<>();\n\t\tconfig.setTestOnBorrow(true);\n\t\tconfig.setMaxTotal(param.getMaxConnections());\n\t\t// 2 分钟没有使用则进行回收\n\t\tconfig.setMinEvictableIdleDuration(Duration.ofMinutes(2));\n\t\tconfig.setMaxWait(Duration.ofSeconds(15));\n\t\tsftpClientPool = new SFtpClientPool(factory, config);\n\t}\n\n\tpublic Sftp getClientFromPool() {\n\t\ttry {\n\t\t\treturn sftpClientPool.borrowObject();\n\t\t} catch (NoSuchElementException e) {\n\t\t\tthrow new BizException(ErrorCode.BIZ_SFTP_CLIENT_POOL_FULL);\n\t\t} catch (Exception e) {\n\t\t\tthrow new SystemException(e);\n\t\t}\n\t}\n\n\t@Override\n\tpublic List<FileItemResult> fileList(String folderPath) throws Exception {\n\t\tList<FileItemResult> result = new ArrayList<>();\n\n\t\tSftp sftp = null;\n\t\ttry {\n\t\t\tsftp = getClientFromPool();\n\t\t\tString fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), folderPath);\n\t\t\tList<ChannelSftp.LsEntry> entryList = sftp.lsEntries(fullPath);\n\t\t\tfor (ChannelSftp.LsEntry sftpEntry : entryList) {\n\t\t\t\tFileItemResult fileItemResult = sftpEntryToFileItem(sftpEntry, folderPath);\n\t\t\t\tresult.add(fileItemResult);\n\t\t\t}\n\t\t\treturn result;\n\t\t} finally {\n\t\t\tif (sftp != null) {\n\t\t\t\tsftpClientPool.returnObject(sftp);\n\t\t\t}\n\t\t}\n\t}\n\n\n\t@Override\n\tpublic StorageTypeEnum getStorageTypeEnum() {\n\t\treturn StorageTypeEnum.SFTP;\n\t}\n\n\n\tpublic FileItemResult getFileItem(String pathAndName, boolean containUserBasePath) {\n\t\tString fullPath = StringUtils.concat(param.getBasePath(), containUserBasePath ? getCurrentUserBasePath() : \"\", pathAndName);\n\n\t\tSftp sftp = null;\n\t\ttry {\n\t\t\tsftp = getClientFromPool();\n\t\t\tList<ChannelSftp.LsEntry> entryList = sftp.lsEntries(fullPath);\n\n\t\t\tif (CollectionUtils.isEmpty(entryList)) {\n\t\t\t\treturn null;\n\t\t\t}\n\n\t\t\tChannelSftp.LsEntry sftpEntry = CollectionUtils.getFirst(entryList);\n\t\t\tif (sftpEntry == null) {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\tString folderName = FileUtils.getParentPath(pathAndName);\n\t\t\treturn sftpEntryToFileItem(sftpEntry, folderName);\n\t\t} finally {\n\t\t\tif (sftp != null) {\n\t\t\t\tsftpClientPool.returnObject(sftp);\n\t\t\t}\n\t\t}\n\t}\n\n\t@Override\n\tpublic FileItemResult getFileItem(String pathAndName) {\n\t\treturn getFileItem(pathAndName, true);\n\t}\n\n\t@Override\n\tpublic boolean newFolder(String path, String name) {\n\t\tSftp sftp = null;\n\t\ttry {\n\t\t\tsftp = getClientFromPool();\n\t\t\tsftp.mkdir(StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name));\n\t\t\treturn true;\n\t\t} finally {\n\t\t\tif (sftp != null) {\n\t\t\t\tsftpClientPool.returnObject(sftp);\n\t\t\t}\n\t\t}\n\t}\n\n\n\t@Override\n\tpublic boolean deleteFile(String path, String name) {\n\t\tSftp sftp = null;\n\t\ttry {\n\t\t\tsftp = getClientFromPool();\n\t\t\treturn sftp.delFile(StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name));\n\t\t} finally {\n\t\t\tif (sftp != null) {\n\t\t\t\tsftpClientPool.returnObject(sftp);\n\t\t\t}\n\t\t}\n\t}\n\n\n\t@Override\n\tpublic boolean deleteFolder(String path, String name) {\n\t\tSftp sftp = null;\n\t\ttry {\n\t\t\tsftp = getClientFromPool();\n\t\t\treturn sftp.delDir(StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name));\n\t\t} finally {\n\t\t\tif (sftp != null) {\n\t\t\t\tsftpClientPool.returnObject(sftp);\n\t\t\t}\n\t\t}\n\t}\n\n\t@Override\n\tpublic boolean copyFile(String path, String name, String targetPath, String targetName) {\n\t\tthrow new BizException(ErrorCode.BIZ_UNSUPPORTED_OPERATION);\n\t}\n\n\t@Override\n\tpublic boolean copyFolder(String path, String name, String targetPath, String targetName) {\n\t\tthrow new BizException(ErrorCode.BIZ_UNSUPPORTED_OPERATION);\n\t}\n\n\t@Override\n\tpublic boolean moveFile(String path, String name, String targetPath, String targetName) {\n\t\tSftp sftp = null;\n\t\ttry {\n\t\t\tsftp = getClientFromPool();\n\t\t\tString srcPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name);\n\t\t\tString distPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), targetPath, targetName);\n\t\t\tsftp.getClient().rename(srcPath, distPath);\n\t\t\treturn true;\n\t\t} catch (SftpException e) {\n\t\t\tthrow new SystemException(e);\n\t\t} finally {\n\t\t\tif (sftp != null) {\n\t\t\t\tsftpClientPool.returnObject(sftp);\n\t\t\t}\n\t\t}\n\t}\n\n\t@Override\n\tpublic boolean moveFolder(String path, String name, String targetPath, String targetName) {\n\t\treturn moveFile(path, name, targetPath, targetName);\n\t}\n\n\n\t@Override\n\tpublic boolean renameFile(String path, String name, String newName) {\n\t\tSftp sftp = null;\n\t\ttry {\n\t\t\tsftp = getClientFromPool();\n\t\t\tString srcPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name);\n\t\t\tString distPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, newName);\n\t\t\tsftp.getClient().rename(srcPath, distPath);\n\t\t\treturn true;\n\t\t} catch (SftpException e) {\n\t\t\tthrow new SystemException(e);\n\t\t} finally {\n\t\t\tif (sftp != null) {\n\t\t\t\tsftpClientPool.returnObject(sftp);\n\t\t\t}\n\t\t}\n\t}\n\n\n\t@Override\n\tpublic boolean renameFolder(String path, String name, String newName) {\n\t\treturn renameFile(path, name, newName);\n\t}\n\n\n\t@Override\n\tpublic ResponseEntity<Resource> downloadToStream(String pathAndName) throws Exception {\n\t\t// 如果配置了域名，还访问代理下载 URL, 则抛出异常进行提示.\n\t\tif (StringUtils.isNotEmpty(param.getDomain())) {\n\t\t\tthrow new BizException(ErrorCode.BIZ_UNSUPPORTED_PROXY_DOWNLOAD);\n\t\t}\n\n\t\tFileItemResult fileItem = getFileItem(pathAndName, false);\n\t\tif (fileItem == null) {\n\t\t\tthrow new NotFoundAccessException(ErrorCode.BIZ_FILE_NOT_EXIST);\n\t\t}\n\n\t\tlong fileSize = fileItem.getSize();\n\t\tpathAndName = StringUtils.concat(param.getBasePath(), pathAndName);\n\t\tString fileName = FileUtils.getName(pathAndName);\n\n\t\t// 根据请求头中的 Range 参数, 获取要跳过的字节数.\n\t\tlong skip = 0;\n\t\tHttpRange requestRange = RequestUtils.getRequestRange(RequestHolder.getRequest());\n\t\tif (requestRange != null) {\n\t\t\tskip = (int) requestRange.getRangeStart(fileSize);\n\t\t}\n\n\t\tSftp sftp = null;\n\t\ttry {\n\t\t\tsftp = getClientFromPool();\n\t\t\tInputStream inputStream = sftp.getClient().get(pathAndName, null, skip);\n\t\t\tRequestHolder.writeFile(inputStream, fileName, fileSize, true, param.isProxyLinkForceDownload());\n\t\t\treturn null;\n\t\t} finally {\n\t\t\tif (sftp != null) {\n\t\t\t\tsftpClientPool.returnObject(sftp);\n\t\t\t}\n\t\t}\n\t}\n\n\n\t@Override\n\tpublic String getUploadUrl(String path, String name, Long size) {\n\t\treturn super.getProxyUploadUrl(path, name);\n\t}\n\n\n\t@Override\n\tpublic String getDownloadUrl(String pathAndName) {\n\t\tif (StringUtils.isNotBlank(param.getDomain())) {\n\t\t\treturn StringUtils.concat(param.getDomain(), StringUtils.encodeAllIgnoreSlashes(pathAndName));\n\t\t}\n\t\treturn super.getProxyDownloadUrl(pathAndName);\n\t}\n\n\t@Override\n\tpublic void uploadFile(String pathAndName, InputStream inputStream, Long size) {\n\t\tString fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), pathAndName);\n\t\tString fileName = FileUtils.getName(pathAndName);\n\t\tString folderName = FileUtils.getParentPath(fullPath);\n\t\tSftp sftp = null;\n\t\ttry {\n\t\t\tsftp = getClientFromPool();\n\t\t\tsftp.upload(folderName, fileName, inputStream);\n\t\t} finally {\n\t\t\tif (sftp != null) {\n\t\t\t\tsftpClientPool.returnObject(sftp);\n\t\t\t}\n\t\t}\n\t}\n\n\n\tpublic FileItemResult sftpEntryToFileItem(ChannelSftp.LsEntry sftpEntry, String folderPath) {\n\t\tFileItemResult fileItemResult = new FileItemResult();\n\t\tfileItemResult.setName(sftpEntry.getFilename());\n\t\tfileItemResult.setTime(DateUtil.date(sftpEntry.getAttrs().getMTime() * 1000L));\n\t\tfileItemResult.setSize(sftpEntry.getAttrs().isDir() ? null : sftpEntry.getAttrs().getSize());\n\t\tfileItemResult.setType(sftpEntry.getAttrs().isDir() ? FileTypeEnum.FOLDER : FileTypeEnum.FILE);\n\t\tfileItemResult.setPath(folderPath);\n\t\tif (fileItemResult.getType() == FileTypeEnum.FILE) {\n\t\t\tfileItemResult.setUrl(getDownloadUrl(StringUtils.concat(getCurrentUserBasePath(), folderPath, fileItemResult.getName())));\n\t\t}\n\t\treturn fileItemResult;\n\t}\n\n\t@Override\n\tpublic StorageSourceMetadata getStorageSourceMetadata() {\n\t\tStorageSourceMetadata storageSourceMetadata = new StorageSourceMetadata();\n\t\tstorageSourceMetadata.setUploadType(StorageSourceMetadata.UploadType.PROXY);\n\t\treturn storageSourceMetadata;\n\t}\n\n\t@Override\n\tpublic void destroy() {\n\t\tif (sftpClientPool != null) {\n\t\t\tsftpClientPool.close();\n\t\t}\n\t}\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/impl/SharePointChinaServiceImpl.java",
    "content": "package im.zhaojun.zfile.module.storage.service.impl;\n\nimport im.zhaojun.zfile.core.config.ZFileProperties;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.param.SharePointChinaParam;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractSharePointServiceBase;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.config.ConfigurableBeanFactory;\nimport org.springframework.context.annotation.Scope;\nimport org.springframework.stereotype.Service;\n\nimport jakarta.annotation.Resource;\n\n/**\n * @author zhaojun\n */\n@Service\n@Slf4j\n@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)\npublic class SharePointChinaServiceImpl extends AbstractSharePointServiceBase<SharePointChinaParam> {\n\n    @Resource\n    private ZFileProperties zFileProperties;\n\n    @Override\n    public StorageTypeEnum getStorageTypeEnum() {\n        return StorageTypeEnum.SHAREPOINT_DRIVE_CHINA;\n    }\n\n    @Override\n    public String getGraphEndPoint() {\n        return \"microsoftgraph.chinacloudapi.cn\";\n    }\n\n    @Override\n    public String getAuthenticateEndPoint() {\n        return \"login.partner.microsoftonline.cn\";\n    }\n    \n    @Override\n    public String getClientId() {\n        if (param == null || param.getClientId() == null) {\n            return zFileProperties.getOnedriveChina().getClientId();\n        }\n        return param.getClientId();\n    }\n    \n    @Override\n    public String getRedirectUri() {\n        if (param == null || param.getRedirectUri() == null) {\n            return zFileProperties.getOnedriveChina().getRedirectUri();\n        }\n        return param.getRedirectUri();\n    }\n    \n    @Override\n    public String getClientSecret() {\n        if (param == null || param.getClientSecret() == null) {\n            return zFileProperties.getOnedriveChina().getClientSecret();\n        }\n        return param.getClientSecret();\n    }\n    \n    @Override\n    public String getScope() {\n        return zFileProperties.getOnedriveChina().getScope();\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/impl/SharePointServiceImpl.java",
    "content": "package im.zhaojun.zfile.module.storage.service.impl;\n\nimport im.zhaojun.zfile.core.config.ZFileProperties;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.param.SharePointParam;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractSharePointServiceBase;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.config.ConfigurableBeanFactory;\nimport org.springframework.context.annotation.Scope;\nimport org.springframework.stereotype.Service;\n\nimport jakarta.annotation.Resource;\n\n/**\n * @author zhaojun\n */\n@Service\n@Slf4j\n@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)\npublic class SharePointServiceImpl extends AbstractSharePointServiceBase<SharePointParam> {\n\n    @Resource\n    private ZFileProperties zFileProperties;\n\n    @Override\n    public StorageTypeEnum getStorageTypeEnum() {\n        return StorageTypeEnum.SHAREPOINT_DRIVE;\n    }\n\n    @Override\n    public String getGraphEndPoint() {\n        return \"graph.microsoft.com\";\n    }\n\n    @Override\n    public String getAuthenticateEndPoint() {\n        return \"login.microsoftonline.com\";\n    }\n    \n    @Override\n    public String getClientId() {\n        if (param == null || param.getClientId() == null) {\n            return zFileProperties.getOnedrive().getClientId();\n        }\n        return param.getClientId();\n    }\n    \n    @Override\n    public String getRedirectUri() {\n        if (param == null || param.getRedirectUri() == null) {\n            return zFileProperties.getOnedrive().getRedirectUri();\n        }\n        return param.getRedirectUri();\n    }\n    \n    @Override\n    public String getClientSecret() {\n        if (param == null || param.getClientSecret() == null) {\n            return zFileProperties.getOnedrive().getClientSecret();\n        }\n        return param.getClientSecret();\n    }\n    \n    @Override\n    public String getScope() {\n        return zFileProperties.getOnedrive().getScope();\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/impl/TencentServiceImpl.java",
    "content": "package im.zhaojun.zfile.module.storage.service.impl;\n\nimport cn.hutool.core.convert.Convert;\nimport im.zhaojun.zfile.core.util.NumberUtils;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.core.util.UrlUtils;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.param.TencentParam;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractS3BaseFileService;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.config.ConfigurableBeanFactory;\nimport org.springframework.context.annotation.Scope;\nimport org.springframework.stereotype.Service;\nimport software.amazon.awssdk.auth.credentials.AwsBasicCredentials;\nimport software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;\nimport software.amazon.awssdk.awscore.AwsRequestOverrideConfiguration;\nimport software.amazon.awssdk.regions.Region;\nimport software.amazon.awssdk.services.s3.S3Client;\nimport software.amazon.awssdk.services.s3.model.GetObjectRequest;\nimport software.amazon.awssdk.services.s3.presigner.S3Presigner;\n\nimport java.net.URI;\nimport java.util.function.Consumer;\n\n/**\n * @author zhaojun\n */\n@Service\n@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)\n@Slf4j\npublic class TencentServiceImpl extends AbstractS3BaseFileService<TencentParam> {\n\n    @Override\n    public void init() {\n        String endPoint = param.getEndPoint();\n        String endPointScheme = param.getEndPointScheme();\n        // 如果 endPoint 不包含协议部分, 且配置了 endPointScheme, 则手动拼接协议部分.\n        if (!UrlUtils.hasScheme(endPoint) && StringUtils.isNotBlank(endPointScheme)) {\n            endPoint = endPointScheme + \"://\" + endPoint;\n        }\n\n        Region oss = Region.of(\"cos\");\n        URI endpointOverride = URI.create(endPoint);\n        StaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(AwsBasicCredentials.create(param.getAccessKey(), param.getSecretKey()));\n\n        super.s3ClientNew = S3Client.builder()\n                .overrideConfiguration(getClientConfiguration())\n                .region(oss)\n                .endpointOverride(endpointOverride)\n                .credentialsProvider(credentialsProvider)\n                .build();\n\n        super.s3Presigner = S3Presigner.builder()\n                .region(oss)\n                .endpointOverride(endpointOverride)\n                .credentialsProvider(credentialsProvider)\n                .build();\n\n        setUploadCors();\n    }\n\n    @Override\n    public StorageTypeEnum getStorageTypeEnum() {\n        return StorageTypeEnum.TENCENT;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/impl/UpYunServiceImpl.java",
    "content": "package im.zhaojun.zfile.module.storage.service.impl;\n\nimport cn.hutool.core.convert.Convert;\nimport cn.hutool.core.exceptions.ExceptionUtil;\nimport cn.hutool.crypto.SecureUtil;\nimport com.UpYun;\nimport com.alibaba.fastjson2.JSON;\nimport com.upyun.Params;\nimport com.upyun.RestManager;\nimport com.upyun.UpException;\nimport com.upyun.UpYunUtils;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.util.FileUtils;\nimport im.zhaojun.zfile.core.util.RequestHolder;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.storage.constant.StorageSourceConnectionProperties;\nimport im.zhaojun.zfile.module.storage.model.bo.AuthModel;\nimport im.zhaojun.zfile.module.storage.model.bo.StorageSourceMetadata;\nimport im.zhaojun.zfile.module.storage.model.bo.UploadSignParam;\nimport im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.param.UpYunParam;\nimport im.zhaojun.zfile.module.storage.model.result.FileItemResult;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractProxyTransferService;\nimport lombok.extern.slf4j.Slf4j;\nimport okhttp3.Response;\nimport org.springframework.beans.factory.config.ConfigurableBeanFactory;\nimport org.springframework.context.annotation.Scope;\nimport org.springframework.core.io.Resource;\nimport org.springframework.http.HttpHeaders;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.stereotype.Service;\n\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.util.*;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.locks.Lock;\nimport java.util.concurrent.locks.ReentrantLock;\n\n/**\n * @author zhaojun\n */\n@Service\n@Slf4j\n@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)\npublic class UpYunServiceImpl extends AbstractProxyTransferService<UpYunParam> {\n\n    private static final String DELETE_NO_EMPTY_FOLDERS_MESSAGE = \"directory not empty\";\n\n    private static final String END_MARK = \"g2gCZAAEbmV4dGQAA2VvZg\";\n\n    private UpYun upYun;\n\n    private RestManager restManager;\n\n    private static volatile boolean isFirstUpload = true;\n\n    private static final Lock lock = new ReentrantLock();\n\n    @Override\n    public void init() {\n        restManager = new RestManager(param.getBucketName(), param.getUsername(), param.getPassword());\n        restManager.setTimeout(StorageSourceConnectionProperties.DEFAULT_CONNECTION_TIMEOUT_SECONDS);\n        upYun = new UpYun(param.getBucketName(), param.getUsername(), param.getPassword());\n        upYun.setTimeout(StorageSourceConnectionProperties.DEFAULT_CONNECTION_TIMEOUT_SECONDS);\n    }\n\n    @Override\n    public List<FileItemResult> fileList(String folderPath) throws Exception {\n        ArrayList<FileItemResult> fileItemList = new ArrayList<>();\n        String nextMark = null;\n\n        do {\n            HashMap<String, String> hashMap = new HashMap<>(24);\n            hashMap.put(\"x-list-iter\", nextMark);\n            hashMap.put(\"x-list-limit\", \"100\");\n            UpYun.FolderItemIter folderItemIter = upYun.readDirIter(StringUtils.concat(true, param.getBasePath(), getCurrentUserBasePath(), folderPath), hashMap);\n            nextMark = folderItemIter.iter;\n            ArrayList<UpYun.FolderItem> folderItems = folderItemIter.files;\n            if (folderItems != null) {\n                for (UpYun.FolderItem folderItem : folderItems) {\n                    FileItemResult fileItemResult = new FileItemResult();\n                    fileItemResult.setName(folderItem.name);\n                    fileItemResult.setSize(folderItem.size);\n                    fileItemResult.setTime(folderItem.date);\n                    fileItemResult.setPath(folderPath);\n                    fileItemResult.setType(\"folder\".equals(folderItem.type) ? FileTypeEnum.FOLDER : FileTypeEnum.FILE);\n                    if (fileItemResult.getType() == FileTypeEnum.FILE) {\n                        String pathAndName = StringUtils.concat(getCurrentUserBasePath(), folderPath, fileItemResult.getName());\n                        fileItemResult.setUrl(getDownloadUrl(pathAndName));\n                    }\n                    fileItemList.add(fileItemResult);\n                }\n            }\n        } while (!END_MARK.equals(nextMark));\n        return fileItemList;\n    }\n\n    @Override\n    public String getDownloadUrl(String pathAndName) {\n        if (param.isEnableProxyDownload() && StringUtils.isEmpty(param.getDomain())) {\n            return super.getProxyDownloadUrl(pathAndName);\n        } else {\n            return getOriginDownloadUrl(pathAndName);\n        }\n    }\n\n    public String getOriginDownloadUrl(String pathAndName) {\n        String fullPath = StringUtils.concat(param.getBasePath(), pathAndName);\n\n        String domain = StringUtils.isBlank(param.getDomain()) ? \"http://\" + param.getBucketName() + \".test.upcdn.net\" : param.getDomain();\n\n        String baseDownloadUrl = StringUtils.concat(domain, StringUtils.encodeAllIgnoreSlashes(fullPath));\n        // 判断是否配置了 token 防盗链.\n        if (StringUtils.isNotEmpty(param.getToken())) {\n            // 如果前面没有补 /, 则自动补 /, 不然生成的防盗链是无效的.\n            long tokenTime = param.getProxyTokenTime();\n            long etime = System.currentTimeMillis() / 1000 + TimeUnit.MINUTES.toSeconds(tokenTime);\n            String downloadToken = SecureUtil.md5(param.getToken() + \"&\" + etime + \"&\" + fullPath).substring(12, 20);\n            baseDownloadUrl += \"?_upt=\" + downloadToken + etime;\n        }\n\n        return baseDownloadUrl;\n    }\n\n    @Override\n    public StorageTypeEnum getStorageTypeEnum() {\n        return StorageTypeEnum.UPYUN;\n    }\n\n    @Override\n    public FileItemResult getFileItem(String pathAndName) {\n        String encodeFullUrl = StringUtils.concat(true, param.getBasePath(), getCurrentUserBasePath(), pathAndName);\n        Map<String, String> fileInfo;\n        try {\n            fileInfo = upYun.getFileInfo(encodeFullUrl);\n        } catch (IOException | UpException e) {\n            throw ExceptionUtil.wrapRuntime(e);\n        }\n\n        if (fileInfo == null) {\n            return null;\n        }\n\n        String name = FileUtils.getName(pathAndName);\n        String folderPath = FileUtils.getParentPath(pathAndName);\n        FileItemResult fileItemResult = new FileItemResult();\n        fileItemResult.setName(name);\n        fileItemResult.setSize(Long.valueOf(fileInfo.get(\"x-upyun-file-size\")));\n        fileItemResult.setTime(new Date(Long.parseLong(fileInfo.get(\"x-upyun-file-date\")) * 1000));\n        fileItemResult.setPath(folderPath);\n\n        if (\"folder\".equals(fileInfo.get(\"x-upyun-file-type\"))) {\n            fileItemResult.setType(FileTypeEnum.FOLDER);\n        } else {\n            fileItemResult.setType(FileTypeEnum.FILE);\n            fileItemResult.setUrl(getDownloadUrl(StringUtils.concat(getCurrentUserBasePath(), pathAndName)));\n        }\n        return fileItemResult;\n    }\n\n    @Override\n    public boolean newFolder(String path, String name) {\n        String fullPath = StringUtils.concat(true, param.getBasePath(), getCurrentUserBasePath(), path, name);\n        try {\n            return upYun.mkDir(fullPath, true);\n        } catch (IOException | UpException e) {\n            throw ExceptionUtil.wrapRuntime(e);\n        }\n    }\n\n    @Override\n    public boolean deleteFile(String path, String name) {\n        String fullPath = StringUtils.concat(true, param.getBasePath(), getCurrentUserBasePath(), path, name);\n        try {\n            return upYun.deleteFile(fullPath, null);\n        } catch (IOException | UpException e) {\n            if (e instanceof  UpException) {\n                String message = e.getMessage();\n                if (StringUtils.contains(message, DELETE_NO_EMPTY_FOLDERS_MESSAGE)) {\n                    throw new BizException(ErrorCode.BIZ_DELETE_FILE_NOT_EMPTY);\n                }\n            }\n            throw ExceptionUtil.wrapRuntime(e);\n        }\n    }\n\n    @Override\n    public boolean deleteFolder(String path, String name) {\n        return deleteFile(path, name);\n    }\n\n    @Override\n    public boolean renameFile(String path, String name, String newName) {\n        String srcPath = StringUtils.concat(true, param.getBucketName(), param.getBasePath(), getCurrentUserBasePath(), path, name);\n        String distPath = StringUtils.concat(true, param.getBasePath(), getCurrentUserBasePath(), path, newName);\n\n        try {\n            return upYun.moveFile(distPath, srcPath);\n        } catch (IOException | UpException e) {\n            throw ExceptionUtil.wrapRuntime(e);\n        }\n    }\n\n    @Override\n    public boolean renameFolder(String path, String name, String newName) {\n        throw new BizException(ErrorCode.BIZ_STORAGE_NOT_SUPPORT_OPERATION);\n    }\n\n    @Override\n    public String getUploadUrl(String path, String name, Long size) {\n        if (param.isEnableProxyUpload()) {\n            return super.getProxyUploadUrl(path, name);\n        }\n        UploadSignParam uploadSignParam = new UploadSignParam();\n        uploadSignParam.setPath(StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path));\n        uploadSignParam.setSize(size);\n        uploadSignParam.setName(name);\n        AuthModel authModel = generatorAuthModel(uploadSignParam);\n        return JSON.toJSONString(authModel);\n    }\n\n    @Override\n    public boolean copyFile(String path, String name, String targetPath, String targetName) {\n        String srcPath = StringUtils.concat(true, param.getBucketName(), param.getBasePath(), getCurrentUserBasePath(), path, name);\n        String distPath = StringUtils.concat(true, param.getBasePath(), getCurrentUserBasePath(), targetPath, targetName);\n\n        try {\n            return upYun.copyFile(distPath, srcPath);\n        } catch (IOException | UpException e) {\n            throw ExceptionUtil.wrapRuntime(e);\n        }\n    }\n\n    @Override\n    public boolean copyFolder(String path, String name, String targetPath, String targetName) {\n        throw new BizException(ErrorCode.BIZ_STORAGE_NOT_SUPPORT_OPERATION);\n    }\n\n    @Override\n    public boolean moveFile(String path, String name, String targetPath, String targetName) {\n        String srcPath = StringUtils.concat(true, param.getBucketName(), param.getBasePath(), getCurrentUserBasePath(), path, name);\n        String distPath = StringUtils.concat(true, param.getBasePath(), getCurrentUserBasePath(), targetPath, targetName);\n\n        try {\n            return upYun.moveFile(distPath, srcPath);\n        } catch (IOException | UpException e) {\n            throw ExceptionUtil.wrapRuntime(e);\n        }\n    }\n\n    @Override\n    public boolean moveFolder(String path, String name, String targetPath, String targetName) {\n        throw new BizException(ErrorCode.BIZ_STORAGE_NOT_SUPPORT_OPERATION);\n    }\n\n    private static final int UPLOAD_SESSION_EXPIRATION = 1800;\n\n    // 计算签名\n    private String sign(String key, String secret, String method, String uri, String policy) {\n        String value = method + \"&\" + uri;\n        if (StringUtils.isNotEmpty(policy)) {\n            value = value + \"&\" + policy;\n        }\n        byte[] hmac = SecureUtil.hmacSha1(secret).digest(value);\n        String sign = Base64.getEncoder().encodeToString(hmac);\n        return \"UPYUN \" + key + \":\" + sign;\n    }\n\n    // 计算上传签名\n    public AuthModel generatorAuthModel(UploadSignParam uploadSignParam) {\n        String policy = getPolicy(uploadSignParam);\n\n        String method = \"POST\";\n        String uri = StringUtils.SLASH + param.getBucketName();\n\n        // 上传，处理，内容识别有存储\n        String signature = sign(param.getUsername(), SecureUtil.md5(param.getPassword()), method, uri, policy);\n\n        return new AuthModel(\"https://v0.api.upyun.com/\" + param.getBucketName(), signature, policy);\n    }\n\n    /**\n     * 获取上传 policy\n     *\n     * @param   uploadSignParam\n     *          上传签名参数\n     *\n     * @return  上传 policy\n     */\n    private String getPolicy(UploadSignParam uploadSignParam) {\n        String bucketName = param.getBucketName();\n        HashMap<String, Object> params = new HashMap<>();\n        params.put(Params.BUCKET, bucketName);\n        params.put(Params.SAVE_KEY, StringUtils.concat(uploadSignParam.getPath(), uploadSignParam.getName()));\n        params.put(Params.EXPIRATION, System.currentTimeMillis() / 1000 + UPLOAD_SESSION_EXPIRATION);\n        params.put(\"content-length\", uploadSignParam.getSize());\n        params.put(Params.CONTENT_LENGTH_RANGE, \"0,\" + uploadSignParam.getSize());\n        return UpYunUtils.getPolicy(params);\n    }\n\n    /**\n     * 第一次上传时需加锁，不然又拍云这个上传 API 可能会遇到并发异常\n     */\n    @Override\n    public void uploadFile(String pathAndName, InputStream inputStream, Long size) throws IOException, UpException {\n        boolean doLock = isFirstUpload;\n\n        if (doLock) {\n            lock.lock(); // 在第一次上传时加锁\n            try {\n                // 再次检查以确保 isFirstUpload 没有变更\n                if (isFirstUpload) {\n                    tryUpload(pathAndName, inputStream);\n                    isFirstUpload = false; // 第一次上传后修改标志\n                }\n            } finally {\n                lock.unlock(); // 释放锁\n            }\n        } else {\n            // 对于后续的上传，直接处理，无需锁\n            tryUpload(pathAndName, inputStream);\n        }\n    }\n\n    private void tryUpload(String pathAndName, InputStream inputStream) throws IOException, UpException {\n        String encodeFullUrl = StringUtils.concat(true, param.getBasePath(), getCurrentUserBasePath(), pathAndName);\n        boolean isSuccess = upYun.writeFile(encodeFullUrl, inputStream, true, null);\n        if (!isSuccess) {\n            log.error(\"又拍云上传失败，pathAndName：{}\", pathAndName);\n            throw new UpException(\"上传失败\"); // 抛出异常，便于上层处理错误\n        }\n    }\n\n    @Override\n    public ResponseEntity<Resource> downloadToStream(String pathAndName) throws Exception {\n        String fullUrl = StringUtils.concat(param.getBasePath(), pathAndName);\n        Response response = restManager.readFile(fullUrl);\n        InputStream inputStream = response.body().byteStream();\n        String fileName = FileUtils.getName(pathAndName);\n        long fileSize = Convert.toLong(response.header(HttpHeaders.CONTENT_LENGTH, \"0\"));\n        RequestHolder.writeFile(inputStream, fileName, fileSize, false, param.isProxyLinkForceDownload());\n        return null;\n    }\n\n    @Override\n    public StorageSourceMetadata getStorageSourceMetadata() {\n        StorageSourceMetadata storageSourceMetadata = new StorageSourceMetadata();\n        if (param.isEnableProxyUpload()) {\n            storageSourceMetadata.setUploadType(StorageSourceMetadata.UploadType.PROXY);\n        } else {\n            storageSourceMetadata.setUploadType(StorageSourceMetadata.UploadType.UPYUN);\n        }\n        storageSourceMetadata.setSupportRenameFolder(false);\n        storageSourceMetadata.setSupportMoveFolder(false);\n        storageSourceMetadata.setSupportCopyFolder(false);\n        storageSourceMetadata.setSupportDeleteNotEmptyFolder(false);\n        storageSourceMetadata.setNeedCreateFolderBeforeUpload(false);\n        return storageSourceMetadata;\n    }\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/service/impl/WebdavServiceImpl.java",
    "content": "package im.zhaojun.zfile.module.storage.service.impl;\n\nimport cn.hutool.core.exceptions.ExceptionUtil;\nimport cn.hutool.core.util.URLUtil;\nimport com.github.sardine.DavResource;\nimport com.github.sardine.Sardine;\nimport com.github.sardine.impl.SardineException;\nimport com.github.sardine.impl.io.ContentLengthInputStream;\nimport im.zhaojun.zfile.core.util.FileUtils;\nimport im.zhaojun.zfile.core.util.RequestHolder;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.storage.model.bo.StorageSourceMetadata;\nimport im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.param.WebdavParam;\nimport im.zhaojun.zfile.module.storage.model.result.FileItemResult;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractProxyTransferService;\nimport im.zhaojun.zfile.module.storage.support.webdav.CustomSardine;\nimport lombok.SneakyThrows;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.config.ConfigurableBeanFactory;\nimport org.springframework.context.annotation.Scope;\nimport org.springframework.core.io.Resource;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.stereotype.Service;\n\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.net.URI;\nimport java.time.Duration;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Objects;\n\n/**\n * @author zhaojun\n */\n@Service\n@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)\n@Slf4j\npublic class WebdavServiceImpl extends AbstractProxyTransferService<WebdavParam> {\n\n\tpublic static final Duration connectTimeoutSecond = Duration.ofSeconds(10);\n\n\tprivate Sardine sardine;\n\n\tprivate String getRequestPath(String... strs) {\n\t\treturn getRequestPath(true, strs);\n\t}\n\n\tprivate String getRequestPath(boolean containUserBasePath, String... strs) {\n\t\treturn StringUtils.concat(param.getUrl(),\n\t\t\t\tStringUtils.encodeAllIgnoreSlashes(param.getBasePath()),\n\t\t\t\tcontainUserBasePath ? StringUtils.encodeAllIgnoreSlashes(getCurrentUserBasePath()) : \"\",\n\t\t\t\tStringUtils.encodeAllIgnoreSlashes(StringUtils.concat(strs)));\n\t}\n\n\t@SneakyThrows\n\t@Override\n\tpublic void init() {\n\t\tsardine = new CustomSardine(param.getUsername(), param.getPassword(), connectTimeoutSecond, null);\n\t\tString host = URI.create(param.getUrl()).getHost();\n\t\tsardine.enablePreemptiveAuthentication(host);\n\t}\n\n\t@Override\n\tpublic List<FileItemResult> fileList(String folderPath) throws Exception {\n\t\tList<FileItemResult> resultList = new ArrayList<>();\n\n\t\tString requestUrl = getRequestPath(folderPath);\n\t\tString requestPath = URLUtil.getPath(requestUrl);\n\n\t\tList<DavResource> resources = sardine.list(requestUrl);\n\t\tfor (DavResource davResource : resources) {\n\t\t\tif (Objects.equals(StringUtils.concat(requestPath, StringUtils.SLASH),\n\t\t\t\t\t\t\t\tStringUtils.concat(davResource.getPath(), StringUtils.SLASH))) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tFileItemResult fileItemResult = davResourceToFileItem(davResource, folderPath);\n\t\t\tresultList.add(fileItemResult);\n\t\t}\n\t\treturn resultList;\n\t}\n\n\n\t@Override\n\tpublic StorageTypeEnum getStorageTypeEnum() {\n\t\treturn StorageTypeEnum.WEBDAV;\n\t}\n\n\n\t@Override\n\tpublic FileItemResult getFileItem(String pathAndName) {\n\t\treturn getFileItem(pathAndName, true);\n\t}\n\n    public FileItemResult getFileItem(String pathAndName, boolean containUserBasePath) {\n        try {\n            String requestUrl = getRequestPath(containUserBasePath, pathAndName);\n\n            List<DavResource> resources = sardine.list(requestUrl, 0);\n\n            DavResource davResource = resources.isEmpty() ? null : resources.get(0);\n\n            if (davResource == null) {\n                return null;\n            }\n\n            String folderPath = FileUtils.getParentPath(pathAndName);\n            return davResourceToFileItem(davResource, folderPath);\n        } catch (Exception e) {\n            if (e instanceof SardineException && ((SardineException) e).getStatusCode() == 404) {\n                return null;\n            }\n            throw ExceptionUtil.wrapRuntime(e);\n        }\n    }\n\n\t@Override\n\tpublic boolean newFolder(String path, String name) {\n\t\ttry {\n\t\t\tString requestPath = getRequestPath(path, name);\n\t\t\tsardine.createDirectory(requestPath + \"/\");\n\t\t\treturn true;\n\t\t} catch (Exception e) {\n\t\t\tthrow ExceptionUtil.wrapRuntime(e);\n\t\t}\n\t}\n\n\t@Override\n\tpublic boolean deleteFile(String path, String name) {\n\t\ttry {\n\t\t\tsardine.delete(getRequestPath(path, name));\n\t\t\treturn true;\n\t\t} catch (IOException e) {\n\t\t\tthrow ExceptionUtil.wrapRuntime(e);\n\t\t}\n\t}\n\n\t@Override\n\tpublic boolean deleteFolder(String path, String name) {\n\t\treturn deleteFile(path, name);\n\t}\n\n\t@Override\n\tpublic boolean renameFolder(String path, String name, String newName) {\n\t\treturn moveFolder(path, name, path, newName);\n\t}\n\n\t@Override\n\tpublic boolean renameFile(String path, String name, String newName) {\n\t\treturn moveFolder(path, name, path, newName);\n\t}\n\n\n\t@Override\n\tpublic String getDownloadUrl(String pathAndName) {\n\t\tif (param.isRedirectMode()) {\n\t\t\treturn getRequestPath(false, pathAndName);\n\t\t}\n\t\tif (StringUtils.isNotBlank(param.getDomain())) {\n\t\t\treturn StringUtils.concat(param.getDomain(), StringUtils.encodeAllIgnoreSlashes(StringUtils.concat(param.getBasePath(), pathAndName)));\n\t\t}\n\t\treturn super.getProxyDownloadUrl(pathAndName);\n\t}\n\n\n\t@Override\n\tpublic ResponseEntity<Resource> downloadToStream(String pathAndName) throws IOException {\n\t\tContentLengthInputStream inputStream = (ContentLengthInputStream) sardine.get(getRequestPath(false, pathAndName));\n\t\tString fileName = FileUtils.getName(pathAndName);\n\t\tRequestHolder.writeFile(inputStream, fileName, inputStream.getLength(), false, param.isProxyLinkForceDownload());\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic String getUploadUrl(String path, String name, Long size) {\n\t\treturn super.getProxyUploadUrl(path, name);\n\t}\n\n\t@Override\n\tpublic void uploadFile(String pathAndName, InputStream inputStream, Long size) {\n\t\ttry {\n\t\t\tpathAndName = getRequestPath(pathAndName);\n\t\t\tsardine.put(pathAndName, inputStream, null, true, size);\n\t\t} catch (IOException e) {\n\t\t\tthrow ExceptionUtil.wrapRuntime(e);\n\t\t}\n\t}\n\n\tprivate FileItemResult davResourceToFileItem(DavResource davResource, String folderPath) {\n\t\tFileItemResult fileItemResult = new FileItemResult();\n\t\tfileItemResult.setName(davResource.getName());\n\t\tfileItemResult.setTime(davResource.getModified());\n\t\tfileItemResult.setSize(davResource.getContentLength());\n\t\tfileItemResult.setType(davResource.isDirectory() ? FileTypeEnum.FOLDER : FileTypeEnum.FILE);\n\t\tfileItemResult.setPath(folderPath);\n\t\tif (fileItemResult.getType() == FileTypeEnum.FILE) {\n\t\t\tfileItemResult.setUrl(getDownloadUrl(StringUtils.concat(getCurrentUserBasePath(), folderPath, fileItemResult.getName())));\n\t\t}\n\t\treturn fileItemResult;\n\t}\n\n\t@Override\n\tpublic boolean copyFile(String path, String name, String targetPath, String targetName) {\n\t\treturn copyFolder(path, name, targetPath, targetName);\n\t}\n\n\t@Override\n\tpublic boolean copyFolder(String path, String name, String targetPath, String targetName) {\n\t\ttry {\n\t\t\tsardine.copy(getRequestPath(path, name), getRequestPath(targetPath, targetName));\n\t\t\treturn true;\n\t\t} catch (IOException e) {\n\t\t\tthrow ExceptionUtil.wrapRuntime(e);\n\t\t}\n\t}\n\n\t@Override\n\tpublic boolean moveFile(String path, String name, String targetPath, String targetName) {\n\t\treturn moveFolder(path, name, targetPath, targetName);\n\t}\n\n\t@Override\n\tpublic boolean moveFolder(String path, String name, String targetPath, String targetName) {\n\t\ttry {\n\t\t\tsardine.move(getRequestPath(path, name) + \"/\", getRequestPath(targetPath, targetName) + \"/\");\n\t\t\treturn true;\n\t\t} catch (IOException e) {\n\t\t\tthrow ExceptionUtil.wrapRuntime(e);\n\t\t}\n\t}\n\n\t@Override\n\tpublic StorageSourceMetadata getStorageSourceMetadata() {\n\t\tStorageSourceMetadata storageSourceMetadata = new StorageSourceMetadata();\n\t\tstorageSourceMetadata.setUploadType(StorageSourceMetadata.UploadType.PROXY);\n\t\treturn storageSourceMetadata;\n\t}\n\n\n\t@Override\n\tpublic void destroy() {\n\t\tif (sardine != null) {\n\t\t\ttry {\n\t\t\t\tsardine.shutdown();\n\t\t\t} catch (IOException e) {\n\t\t\t\tlog.error(\"WebDAV 服务关闭失败\", e);\n\t\t\t}\n\t\t}\n\t}\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/support/Open115IdCacheService.java",
    "content": "package im.zhaojun.zfile.module.storage.support;\n\nimport com.alibaba.fastjson2.JSONArray;\nimport com.alibaba.fastjson2.JSONObject;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.SystemException;\nimport im.zhaojun.zfile.core.exception.status.NotFoundAccessException;\nimport im.zhaojun.zfile.core.util.FileUtils;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport lombok.extern.slf4j.Slf4j;\n\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.function.BiFunction;\n\n/**\n * 115 文件和路径 ID 缓存服务.\n * <p>\n * 每个存储源实例持有一个此类的实例，用于隔离不同存储源的缓存。\n *\n * @author zhaojun\n */\n@Slf4j\npublic class Open115IdCacheService {\n\n    /**\n     * 分页最大每页条数限制\n     */\n    private static final Integer FILE_LIST_LIMIT = 1150;\n\n    /**\n     * 文件类型: 文件\n     */\n    private static final String FC_FILE = \"1\";\n\n    /**\n     * 路径 ID 缓存\n     */\n    private final Map<String, String> pathIdMap = new ConcurrentHashMap<>() {{\n        put(\"/\", \"0\");\n        put(\"\", \"0\");\n    }};\n\n    /**\n     * 文件 ID 缓存\n     */\n    private final Map<String, String> fileIdMap = new ConcurrentHashMap<>();\n\n    /**\n     * 发送带认证的 GET 请求的函数.\n     * <p>\n     * 参数1: 请求 URL\n     * 参数2: 请求参数\n     * 返回值: 响应的 JSON 对象\n     */\n    private final BiFunction<String, Map<String, Object>, JSONObject> sendGetRequestWithAuth;\n\n    public Open115IdCacheService(BiFunction<String, Map<String, Object>, JSONObject> sendGetRequestWithAuth) {\n        this.sendGetRequestWithAuth = sendGetRequestWithAuth;\n    }\n\n    /**\n     * 获取文件 ID，如果缓存中不存在，则会尝试通过 API 获取父目录内容来缓存.\n     *\n     * @param fullPath          文件完整路径\n     * @param throwIfNotFound   如果未找到是否抛出异常\n     * @return                  文件 ID\n     */\n    public String getFileId(String fullPath, boolean throwIfNotFound) {\n        String id = fileIdMap.get(fullPath);\n        if (id != null) {\n            return id;\n        }\n\n        String parentPath = FileUtils.getParentPath(fullPath);\n        if (parentPath == null) {\n            throw new SystemException(\"无法解析路径 '\" + fullPath + \"' 的父路径。\");\n        }\n\n        cachePathAndFileId(parentPath);\n\n        id = fileIdMap.get(fullPath);\n        if (id == null && throwIfNotFound) {\n            throw new NotFoundAccessException(ErrorCode.BIZ_FILE_NOT_EXIST);\n        }\n        return id;\n    }\n\n    /**\n     * 获取路径 ID，如果缓存中不存在，则会尝试通过 API 获取父目录内容来缓存.\n     *\n     * @param fullPath          文件夹完整路径\n     * @param throwIfNotFound   如果未找到是否抛出异常\n     * @return                  路径 ID\n     */\n    public String getPathId(String fullPath, boolean throwIfNotFound) {\n        String trimEndSlashes = StringUtils.trimEndSlashes(fullPath);\n        String id = pathIdMap.get(trimEndSlashes);\n        if (id != null) {\n            return id;\n        }\n\n        String parentPath = FileUtils.getParentPath(trimEndSlashes);\n        if (parentPath == null) {\n            throw new SystemException(\"无法解析路径 '\" + trimEndSlashes + \"' 的父路径。\");\n        }\n\n        cachePathAndFileId(parentPath);\n\n        id = pathIdMap.get(trimEndSlashes);\n        if (id == null && throwIfNotFound) {\n            throw new NotFoundAccessException(ErrorCode.BIZ_FILE_NOT_EXIST);\n        }\n        return id;\n    }\n\n    /**\n     * 缓存指定文件夹下的所有文件和子文件夹的 ID.\n     *\n     * @param folderPath 文件夹路径\n     */\n    private void cachePathAndFileId(String folderPath) {\n        String pathId = getPathId(folderPath, true);\n        List<String> idList = new ArrayList<>();\n\n        int offset = 0;\n        int count;\n        do {\n            JSONObject jsonObject = sendGetRequestWithAuth.apply(\"https://proapi.115.com/open/ufile/files\", new JSONObject()\n                    .fluentPut(\"cid\", pathId)\n                    .fluentPut(\"offset\", offset)\n                    .fluentPut(\"limit\", FILE_LIST_LIMIT)\n                    .fluentPut(\"show_dir\", 1));\n\n            String cid = jsonObject.getString(\"cid\");\n            if (!Objects.equals(pathId, cid)) {\n                log.warn(\"请求的路径 ID '{}' 与返回的路径 ID '{}' 不符, 可能是 115 做了兼容处理, 返回了根目录.\", pathId, cid);\n                throw new NotFoundAccessException(ErrorCode.BIZ_FILE_NOT_EXIST);\n            }\n\n            JSONArray fileList = jsonObject.getJSONArray(\"data\");\n            for (int i = 0; i < fileList.size(); i++) {\n                JSONObject fileItem = fileList.getJSONObject(i);\n\n                String fid = fileItem.getString(\"fid\");\n                String fn = fileItem.getString(\"fn\");\n\n                String fullPath = StringUtils.concat(folderPath, fn);\n                if (Objects.equals(fileItem.getString(\"fc\"), FC_FILE)) {\n                    fileIdMap.put(fullPath, fid);\n                } else {\n                    pathIdMap.put(fullPath, fid);\n                }\n                idList.add(fid);\n            }\n\n            count = jsonObject.getInteger(\"count\");\n            offset += FILE_LIST_LIMIT;\n        } while (idList.size() < count);\n    }\n\n    public void putFileId(String fullPath, String id) {\n        fileIdMap.put(fullPath, id);\n    }\n\n    public void putPathId(String fullPath, String id) {\n        pathIdMap.put(StringUtils.trimEndSlashes(fullPath), id);\n    }\n\n    public void deleteFileId(String fullPath) {\n        fileIdMap.remove(fullPath);\n    }\n\n    public void deletePathId(String fullPath) {\n        String trimmedPath = StringUtils.trimEndSlashes(fullPath);\n        pathIdMap.remove(trimmedPath);\n\n        pathIdMap.entrySet().removeIf(entry -> entry.getKey().startsWith(trimmedPath));\n        fileIdMap.entrySet().removeIf(entry -> entry.getKey().startsWith(trimmedPath));\n    }\n\n    public String removeFileIdByPath(String fullPath) {\n        return fileIdMap.remove(fullPath);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/support/StorageSourceSupport.java",
    "content": "package im.zhaojun.zfile.module.storage.support;\n\nimport cn.hutool.core.util.ReflectUtil;\nimport cn.hutool.extra.spring.SpringUtil;\nimport im.zhaojun.zfile.core.util.*;\nimport im.zhaojun.zfile.module.config.service.SystemConfigService;\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamItem;\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamSelect;\nimport im.zhaojun.zfile.module.storage.annotation.StorageParamSelectOption;\nimport im.zhaojun.zfile.module.storage.enums.StorageParamItemAnnoEnum;\nimport im.zhaojun.zfile.module.storage.model.bo.StorageSourceParamDef;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageParamTypeEnum;\nimport im.zhaojun.zfile.module.storage.model.param.IStorageParam;\nimport im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;\nimport org.apache.commons.lang3.BooleanUtils;\nimport org.springframework.aop.support.AopUtils;\n\nimport java.lang.reflect.Field;\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\n\n/**\n * 存储源支持类\n *\n * @author zhaojun\n */\npublic class StorageSourceSupport {\n\n    /**\n     * 存储源类型与存储源参数列表的缓存\n     */\n    private static final Map<Class<? extends AbstractBaseFileService>, List<StorageSourceParamDef>> STORAGE_SOURCE_PARAM_CACHE = new ConcurrentHashMap<>();\n\n    /**\n     * 获取指定存储源所有的参数列表定义\n     *\n     * @return 存储源参数列表定义\n     */\n    public static List<StorageSourceParamDef> getStorageSourceParamList(AbstractBaseFileService abstractBaseFileService) {\n        Class<? extends AbstractBaseFileService> clazz;\n        if (AopUtils.isAopProxy(abstractBaseFileService)) {\n            clazz = (Class<? extends AbstractBaseFileService>) AopUtils.getTargetClass(abstractBaseFileService);\n        } else {\n            clazz = abstractBaseFileService.getClass();\n        }\n        IStorageParam storageParam = abstractBaseFileService.getParam();\n        // 如果缓存中有, 则直接返回\n        if (STORAGE_SOURCE_PARAM_CACHE.containsKey(clazz)) {\n            return STORAGE_SOURCE_PARAM_CACHE.get(clazz);\n        }\n\n        Map<String, StorageSourceParamDef> storageSourceParamDefMap = new HashMap<>();\n\n        // 获取存储源实现类的泛型参数类型\n        Class<?> paramClass = ClassUtils.getClassFirstGenericsParam(clazz);\n        Field[] fields = ReflectUtil.getFields(paramClass);\n\n        // 已添加的字段列表.\n        List<String> useFieldNames = new ArrayList<>();\n\n        Map<String, Set<StorageParamItemAnnoEnum>> fieldOverrideMap = new HashMap<>();\n\n        for (Field field : fields) {\n            // 获取字段上的注解\n            StorageParamItem storageParamItemAnnotation = field.getAnnotation(StorageParamItem.class);\n            if (storageParamItemAnnotation == null) {\n                continue;\n            }\n\n            // 如果字段被忽略, 则添加到忽略列表中\n            String fieldName = field.getName();\n            if (storageParamItemAnnotation.ignoreInput()) {\n                useFieldNames.add(fieldName);\n                continue;\n            }\n\n            String key = storageParamItemAnnotation.key();\n            String name = storageParamItemAnnotation.name();\n            String description = storageParamItemAnnotation.description();\n            boolean required = storageParamItemAnnotation.required();\n            String defaultValue = PlaceholderUtils.resolvePlaceholdersBySpringProperties(storageParamItemAnnotation.defaultValue());\n            String link = parseAnnotationLinkField(storageParamItemAnnotation);\n            String linkName = storageParamItemAnnotation.linkName();\n            StorageParamTypeEnum type = storageParamItemAnnotation.type();\n            List<StorageSourceParamDef.Options> optionsList = getOptionsList(storageParamItemAnnotation, storageParam);\n            boolean optionAllowCreate = storageParamItemAnnotation.optionAllowCreate();\n            int order = storageParamItemAnnotation.order();\n            boolean pro = storageParamItemAnnotation.pro();\n            String condition = storageParamItemAnnotation.condition();\n            boolean hidden = storageParamItemAnnotation.hidden();\n\n            // 默认 key 为字段名，默认 name 为 key\n            if (StringUtils.isEmpty(key)) key = fieldName;\n            if (StringUtils.isEmpty(name)) name = key;\n\n            // 如果字段已存在且不是覆盖属性, 则跳过\n            if (useFieldNames.contains(fieldName) && !fieldOverrideMap.containsKey(fieldName)) {\n                continue;\n            }\n\n            Set<StorageParamItemAnnoEnum> fieldOverrideSet = fieldOverrideMap.get(fieldName);\n\n            // 如果默认值不为空, 则该字段则不是必填的\n            if (StringUtils.isNotEmpty(defaultValue)) {\n                required = false;\n            }\n\n            StorageSourceParamDef storageSourceParamDef = storageSourceParamDefMap.getOrDefault(fieldName, new StorageSourceParamDef());\n            boolean fieldOverrideSetIsEmpty = CollectionUtils.isEmpty(fieldOverrideSet);\n            if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.KEY)) {\n                storageSourceParamDef.setKey(key);\n            }\n            if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.NAME)) {\n                storageSourceParamDef.setName(name);\n            }\n            if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.DESCRIPTION)) {\n                storageSourceParamDef.setDescription(description);\n            }\n            if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.REQUIRED)) {\n                storageSourceParamDef.setRequired(required);\n            }\n            if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.DEFAULT_VALUE)) {\n                storageSourceParamDef.setDefaultValue(defaultValue);\n            }\n            if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.LINK)) {\n                storageSourceParamDef.setLink(link);\n            }\n            if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.LINK_NAME)) {\n                storageSourceParamDef.setLinkName(linkName);\n            }\n            if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.TYPE)) {\n                storageSourceParamDef.setType(type);\n            }\n            if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.OPTIONS)) {\n                storageSourceParamDef.setOptions(optionsList);\n            }\n            if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.OPTION_ALLOW_CREATE)) {\n                storageSourceParamDef.setOptionAllowCreate(optionAllowCreate);\n            }\n            if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.ORDER)) {\n                storageSourceParamDef.setOrder(order);\n            }\n            if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.PRO)) {\n                storageSourceParamDef.setPro(pro);\n            }\n            if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.CONDITION)) {\n                storageSourceParamDef.setCondition(condition);\n            }\n            if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.HIDDEN)) {\n                storageSourceParamDef.setHidden(hidden);\n            }\n            storageSourceParamDefMap.put(fieldName, storageSourceParamDef);\n            useFieldNames.add(fieldName);\n\n            StorageParamItemAnnoEnum[] storageParamItemAnnoEnumArray = storageParamItemAnnotation.onlyOverwrite();\n            if (ArrayUtils.isNotEmpty(storageParamItemAnnoEnumArray)) {\n                Set<StorageParamItemAnnoEnum> set = fieldOverrideMap.getOrDefault(fieldName, new HashSet<>());\n                set.addAll(Arrays.asList(storageParamItemAnnoEnumArray));\n                fieldOverrideMap.put(fieldName, set);\n            }\n        }\n\n        // 按照顺序排序\n        ArrayList<StorageSourceParamDef> result = new ArrayList<>(storageSourceParamDefMap.values());\n        result.sort(Comparator.comparingInt(StorageSourceParamDef::getOrder));\n\n        // 写入到缓存中\n        STORAGE_SOURCE_PARAM_CACHE.put(clazz, result);\n        return result;\n    }\n\n    /**\n     * 从注解中获取 options 列表\n     *\n     * @param   storageParamItemAnnotation\n     *          存储源参数注解\n     *\n     * @return  options 列表，如果没有则返回空列表，不会返回 null\n     */\n    private static List<StorageSourceParamDef.Options> getOptionsList(StorageParamItem storageParamItemAnnotation, IStorageParam storageParam) {\n        // 如果不是默认的空接口实现，优先从实现类中通过反射获取 options 列表\n        Class<? extends StorageParamSelect> storageParamSelectClass = storageParamItemAnnotation.optionsClass();\n        if (BooleanUtils.isNotTrue(storageParamSelectClass.isInterface())) {\n            StorageParamSelect storageParamSelect = ReflectUtil.newInstance(storageParamSelectClass);\n            List<StorageSourceParamDef.Options> options = storageParamSelect.getOptions(storageParamItemAnnotation, storageParam);\n            if (CollectionUtils.isEmpty(options)) {\n                return Collections.emptyList();\n            }\n            return options;\n        }\n\n        // 从注解中获取 options\n        List<StorageSourceParamDef.Options> optionsList = new ArrayList<>();\n        StorageParamSelectOption[] options = storageParamItemAnnotation.options();\n        if (ArrayUtils.isNotEmpty(options)) {\n            for (StorageParamSelectOption storageParamSelectOption : options) {\n                StorageSourceParamDef.Options option = new StorageSourceParamDef.Options(storageParamSelectOption);\n                optionsList.add(option);\n            }\n        }\n        return optionsList;\n    }\n\n    /**\n     * 解析注解中的 link 字段, 如果不为空, 且不是 http 或 https 开头, 则认为是相对地址，添加站点域名为开头\n     *\n     * @param   storageParamItemAnnotation\n     *          存储源参数注解\n     *\n     * @return  解析后的 link 字段\n     */\n    private static String parseAnnotationLinkField(StorageParamItem storageParamItemAnnotation) {\n        String link = storageParamItemAnnotation.link();\n        // 如果不为空，且不是 http 或 https 开头，则添加站点域名开头\n        if (StringUtils.isNotEmpty(link) && !link.toLowerCase().startsWith(StringUtils.HTTP)) {\n            SystemConfigService systemConfigService = SpringUtil.getBean(SystemConfigService.class);\n            String domain = systemConfigService.getAxiosFromDomainOrSetting();\n            link = StringUtils.concat(domain, link);\n        }\n        return link;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/support/ftp/FtpClientFactory.java",
    "content": "package im.zhaojun.zfile.module.storage.support.ftp;\n\nimport cn.hutool.extra.ftp.Ftp;\nimport cn.hutool.extra.ftp.FtpConfig;\nimport cn.hutool.extra.ftp.FtpMode;\nimport im.zhaojun.zfile.module.storage.constant.StorageSourceConnectionProperties;\nimport im.zhaojun.zfile.module.storage.service.impl.FtpServiceImpl;\nimport lombok.extern.slf4j.Slf4j;\nimport org.apache.commons.net.ftp.FTP;\nimport org.apache.commons.pool2.BasePooledObjectFactory;\nimport org.apache.commons.pool2.PooledObject;\nimport org.apache.commons.pool2.impl.DefaultPooledObject;\n\nimport java.nio.charset.Charset;\n\n@Slf4j\npublic class FtpClientFactory extends BasePooledObjectFactory<Ftp> {\n\n    private final String host;\n    private final int port;\n    private final String username;\n    private final String password;\n    private final Charset charset;\n    private final String ftpMode;\n\n\n    public FtpClientFactory(String host, int port, String username, String password, Charset charset, String ftpMode) {\n        this.host = host;\n        this.port = port;\n        this.username = username;\n        this.password = password;\n        this.charset = charset;\n        this.ftpMode = ftpMode;\n    }\n\n    @Override\n    public Ftp create() throws Exception {\n        FtpConfig ftpConfig = new FtpConfig(host, port, username, password, charset);\n        ftpConfig.setConnectionTimeout(StorageSourceConnectionProperties.DEFAULT_CONNECTION_TIMEOUT_MILLIS);\n        Ftp ftp = new Ftp(ftpConfig, FtpServiceImpl.FTP_MODE_ACTIVE.equals(ftpMode) ? FtpMode.Active : FtpMode.Passive);\n        ftp.getClient().setFileType(FTP.BINARY_FILE_TYPE);\n        ftp.getClient().setListHiddenFiles(true);\n        log.debug(\"Creating object: {}\", ftp);\n        return ftp;\n    }\n\n    @Override\n    public PooledObject<Ftp> wrap(Ftp ftpClient) {\n        return new DefaultPooledObject(ftpClient);\n    }\n\n    @Override\n    public boolean validateObject(PooledObject<Ftp> p) {\n        String pwd = null;\n        try {\n            pwd = p.getObject().pwd();\n        } catch (Exception fex) {\n            // ignore\n        }\n        boolean isValid = pwd != null;\n        log.debug(\"Validating object: {} isValid: {}\", p.getObject(), isValid);\n        return isValid;\n    }\n\n    @Override\n    public void destroyObject(PooledObject<Ftp> p) throws Exception {\n        p.getObject().close();\n        log.debug(\"Destroying object: {}\", p.getObject());\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/support/ftp/FtpClientPool.java",
    "content": "package im.zhaojun.zfile.module.storage.support.ftp;\n\nimport cn.hutool.extra.ftp.Ftp;\nimport org.apache.commons.pool2.PooledObjectFactory;\nimport org.apache.commons.pool2.impl.GenericObjectPool;\nimport org.apache.commons.pool2.impl.GenericObjectPoolConfig;\n\npublic class FtpClientPool extends GenericObjectPool<Ftp> {\n\n    public FtpClientPool(PooledObjectFactory<Ftp> factory) {\n        super(factory);\n    }\n\n    public FtpClientPool(PooledObjectFactory<Ftp> factory, GenericObjectPoolConfig config) {\n        super(factory, config);\n    }\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/support/sftp/SFtpClientFactory.java",
    "content": "package im.zhaojun.zfile.module.storage.support.sftp;\n\nimport cn.hutool.extra.ssh.Sftp;\nimport com.jcraft.jsch.JSch;\nimport com.jcraft.jsch.Session;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.storage.constant.StorageSourceConnectionProperties;\nimport lombok.extern.slf4j.Slf4j;\nimport org.apache.commons.pool2.BasePooledObjectFactory;\nimport org.apache.commons.pool2.PooledObject;\nimport org.apache.commons.pool2.impl.DefaultPooledObject;\n\nimport java.nio.charset.Charset;\nimport java.nio.charset.StandardCharsets;\n\n@Slf4j\npublic class SFtpClientFactory extends BasePooledObjectFactory<Sftp> {\n\n    private final String host;\n    private final int port;\n    private final String username;\n    private final String password;\n    private final String privateKey;\n    private final String passphrase;\n\n    private final Charset charset;\n\n    static {\n        JSch.setConfig(\"StrictHostKeyChecking\", \"no\");\n    }\n\n    public SFtpClientFactory(String host, int port, String username, String password, String privateKey, String passphrase, Charset charset) {\n        this.host = host;\n        this.port = port;\n        this.username = username;\n        this.password = password;\n        this.privateKey = privateKey;\n        this.passphrase = passphrase;\n        this.charset = charset;\n    }\n\n    @Override\n    public Sftp create() throws Exception {\n        // 密码登录\n        JSch jsch = new JSch();\n        Session session = jsch.getSession(username, host, port);\n        session.setTimeout(StorageSourceConnectionProperties.DEFAULT_CONNECTION_TIMEOUT_MILLIS);\n        if (StringUtils.isBlank(privateKey)) {\n            session.setPassword(password);\n        } else {\n            byte[] passphraseBytes = null;\n            if (passphrase != null && !passphrase.isEmpty()) {\n                passphraseBytes = passphrase.getBytes(StandardCharsets.UTF_8);\n            }\n            jsch.addIdentity(username, privateKey.getBytes(StandardCharsets.UTF_8), null, passphraseBytes);\n        }\n        Sftp sftp = new Sftp(session, charset, StorageSourceConnectionProperties.DEFAULT_CONNECTION_TIMEOUT_MILLIS);\n        log.debug(\"Creating object: {}\", sftp);\n        return sftp;\n    }\n\n    @Override\n    public PooledObject<Sftp> wrap(Sftp sftpClient) {\n        return new DefaultPooledObject(sftpClient);\n    }\n\n    @Override\n    public boolean validateObject(PooledObject<Sftp> p) {\n        String pwd = null;\n        try {\n            pwd = p.getObject().pwd();\n        } catch (Exception fex) {\n            // ignore\n        }\n        boolean isValid = pwd != null;\n        log.debug(\"Validating object: {} isValid: {}\", p.getObject(), isValid);\n        return isValid;\n    }\n\n    @Override\n    public void destroyObject(PooledObject<Sftp> p) throws Exception {\n        p.getObject().close();\n        log.debug(\"Destroying object: {}\", p.getObject());\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/support/sftp/SFtpClientPool.java",
    "content": "package im.zhaojun.zfile.module.storage.support.sftp;\n\nimport cn.hutool.extra.ssh.Sftp;\nimport org.apache.commons.pool2.PooledObjectFactory;\nimport org.apache.commons.pool2.impl.GenericObjectPool;\nimport org.apache.commons.pool2.impl.GenericObjectPoolConfig;\n\npublic class SFtpClientPool extends GenericObjectPool<Sftp> {\n\n    public SFtpClientPool(PooledObjectFactory<Sftp> factory) {\n        super(factory);\n    }\n\n    public SFtpClientPool(PooledObjectFactory<Sftp> factory, GenericObjectPoolConfig config) {\n        super(factory, config);\n    }\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/storage/support/webdav/CustomSardine.java",
    "content": "package im.zhaojun.zfile.module.storage.support.webdav;\n\nimport com.github.sardine.impl.SardineImpl;\nimport org.apache.http.client.ResponseHandler;\nimport org.apache.http.client.config.RequestConfig;\nimport org.apache.http.client.methods.HttpRequestBase;\nimport org.apache.http.client.protocol.HttpClientContext;\n\nimport java.io.IOException;\nimport java.time.Duration;\n\n/**\n * 自定义 Sardine 实现，支持设置连接超时和读取超时时间\n */\npublic class CustomSardine extends SardineImpl {\n\n    private final Duration connectTimeout;\n\n    private final Duration readTimeout;\n\n    public CustomSardine(String username, String password, Duration connectTimeout, Duration readTimeout) {\n        super();\n        setCredentials(username, password);\n        this.connectTimeout = connectTimeout;\n        this.readTimeout = readTimeout;\n    }\n\n    @Override\n    protected <T> T execute(HttpClientContext context, HttpRequestBase request, ResponseHandler<T> responseHandler) throws IOException {\n        RequestConfig.Builder configBuilder = request.getConfig() != null ? RequestConfig.copy(request.getConfig()) : RequestConfig.custom();\n\n        if (connectTimeout != null && connectTimeout.compareTo(Duration.ZERO) > 0) {\n            configBuilder.setConnectTimeout((int) (1000 * connectTimeout.getSeconds() + connectTimeout.getNano() / 1000000));\n            configBuilder.setConnectionRequestTimeout((int) (1000 * connectTimeout.getSeconds() + connectTimeout.getNano() / 1000000));\n        }\n        if (readTimeout != null && readTimeout.compareTo(Duration.ZERO) > 0) {\n            configBuilder.setSocketTimeout((int) (1000 * readTimeout.getSeconds() + readTimeout.getNano() / 1000000));\n        }\n\n        request.setConfig(configBuilder.build());\n        return super.execute(context, request, responseHandler);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/aspect/LoginLogAspect.java",
    "content": "package im.zhaojun.zfile.module.user.aspect;\n\nimport cn.hutool.extra.servlet.JakartaServletUtil;\nimport im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;\nimport im.zhaojun.zfile.module.config.service.SystemConfigService;\nimport im.zhaojun.zfile.module.log.model.entity.LoginLog;\nimport im.zhaojun.zfile.module.log.service.LoginLogService;\nimport im.zhaojun.zfile.module.user.model.enums.LoginLogModeEnum;\nimport im.zhaojun.zfile.module.user.model.request.UserLoginRequest;\nimport jakarta.annotation.Resource;\nimport jakarta.servlet.http.HttpServletRequest;\nimport lombok.extern.slf4j.Slf4j;\nimport org.aspectj.lang.ProceedingJoinPoint;\nimport org.aspectj.lang.annotation.Around;\nimport org.aspectj.lang.annotation.Aspect;\nimport org.springframework.http.HttpHeaders;\nimport org.springframework.stereotype.Component;\n\nimport java.util.Date;\n\n@Aspect\n@Component\n@Slf4j\npublic class LoginLogAspect {\n\n    @Resource\n    private SystemConfigService systemConfigService;\n\n    @Resource\n    private LoginLogService loginLogService;\n\n    @Resource\n    private HttpServletRequest httpServletRequest;\n\n    public static final String DEFAULT_LOGIN_SUCCESS_RESULT = \"登录成功\";\n\n    /**\n     * 登录日志切面，拦截 im.zhaojun.zfile.module.user.controller.UserController.doLogin() 方法\n     */\n    @Around(value = \"execution(* im.zhaojun.zfile.module.user.controller.UserController.doLogin(..))\")\n    public Object around(ProceedingJoinPoint pjp) throws Throwable {\n        SystemConfigDTO systemConfig = systemConfigService.getSystemConfig();\n        LoginLogModeEnum loginLogMode = systemConfig.getLoginLogMode();\n\n        if (loginLogMode == LoginLogModeEnum.OFF) {\n            return pjp.proceed();\n        }\n\n        // 获取方法的第一个参数 UserLoginRequest 对象\n        Object[] args = pjp.getArgs();\n        Object arg = args[0];\n        UserLoginRequest userLoginRequest = (UserLoginRequest) arg;\n\n        LoginLog loginLog = new LoginLog();\n        loginLog.setUsername(userLoginRequest.getUsername());\n        loginLog.setPassword(userLoginRequest.getPassword());\n        loginLog.setCreateTime(new Date());\n        loginLog.setIp(JakartaServletUtil.getClientIP(httpServletRequest));\n        loginLog.setUserAgent(httpServletRequest.getHeader(HttpHeaders.USER_AGENT));\n        loginLog.setReferer(httpServletRequest.getHeader(HttpHeaders.REFERER));\n\n        String msg = DEFAULT_LOGIN_SUCCESS_RESULT;\n        try {\n            return pjp.proceed();\n        } catch (Throwable throwable) {\n            msg = throwable.getMessage();\n            throw throwable;\n        } finally {\n            if (loginLogMode != LoginLogModeEnum.ALL) {\n                if (loginLogMode == LoginLogModeEnum.IGNORE_ALL_PWD) {\n                    loginLog.setPassword(\"******\");\n                }\n                if (loginLogMode == LoginLogModeEnum.IGNORE_SUCCESS_PWD && DEFAULT_LOGIN_SUCCESS_RESULT.equals(msg)) {\n                    loginLog.setPassword(\"******\");\n                }\n            }\n            loginLog.setResult(msg);\n            loginLogService.save(loginLog);\n        }\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/controller/AdminTwoFAController.java",
    "content": "package im.zhaojun.zfile.module.user.controller;\n\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport com.github.xiaoymin.knife4j.annotations.ApiSort;\nimport dev.samstevens.totp.exceptions.QrGenerationException;\nimport im.zhaojun.zfile.core.annotation.DemoDisable;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.module.user.model.request.VerifyLoginTwoFactorAuthenticatorRequest;\nimport im.zhaojun.zfile.module.user.model.result.LoginTwoFactorAuthenticatorResult;\nimport im.zhaojun.zfile.module.user.service.login.TwoFactorAuthenticatorVerifyService;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport io.swagger.v3.oas.annotations.Operation;\nimport org.springframework.web.bind.annotation.*;\n\nimport jakarta.annotation.Resource;\nimport jakarta.validation.Valid;\n\n/**\n * 登陆注销相关接口\n *\n * @author zhaojun\n */\n@Tag(name = \"登录模块\")\n@ApiSort(1)\n@RestController\n@RequestMapping(\"/admin\")\npublic class AdminTwoFAController {\n\n    @Resource\n    private TwoFactorAuthenticatorVerifyService twoFactorAuthenticatorVerifyService;\n\n    @ApiOperationSupport(order = 1)\n    @Operation(summary = \"生成 2FA\")\n    @GetMapping(\"/2fa/setup\")\n    public AjaxJson<LoginTwoFactorAuthenticatorResult> setupDevice() throws QrGenerationException {\n        LoginTwoFactorAuthenticatorResult loginTwoFactorAuthenticatorResult = twoFactorAuthenticatorVerifyService.setupDevice();\n        return AjaxJson.getSuccessData(loginTwoFactorAuthenticatorResult);\n    }\n\n\n    @ApiOperationSupport(order = 2)\n    @Operation(summary = \"2FA 验证并绑定\")\n    @PostMapping(\"/2fa/verify\")\n    @DemoDisable\n    public AjaxJson<Void> deviceVerify(@Valid @RequestBody VerifyLoginTwoFactorAuthenticatorRequest verifyLoginTwoFactorAuthenticatorRequest) {\n        twoFactorAuthenticatorVerifyService.deviceVerify(verifyLoginTwoFactorAuthenticatorRequest);\n        return AjaxJson.getSuccess();\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/controller/UserController.java",
    "content": "package im.zhaojun.zfile.module.user.controller;\n\nimport cn.dev33.satoken.annotation.SaCheckLogin;\nimport cn.dev33.satoken.stp.StpUtil;\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport com.github.xiaoymin.knife4j.annotations.ApiSort;\nimport im.zhaojun.zfile.core.annotation.ApiLimit;\nimport im.zhaojun.zfile.core.annotation.DemoDisable;\nimport im.zhaojun.zfile.core.config.ZFileProperties;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.core.util.ZFileAuthUtil;\nimport im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;\nimport im.zhaojun.zfile.module.config.model.entity.SystemConfig;\nimport im.zhaojun.zfile.module.config.service.SystemConfigService;\nimport im.zhaojun.zfile.module.user.model.entity.User;\nimport im.zhaojun.zfile.module.user.model.enums.LoginVerifyModeEnum;\nimport im.zhaojun.zfile.module.user.model.request.ResetAdminUserNameAndPasswordRequest;\nimport im.zhaojun.zfile.module.user.model.request.UpdateUserPwdRequest;\nimport im.zhaojun.zfile.module.user.model.request.UserLoginRequest;\nimport im.zhaojun.zfile.module.user.model.result.CheckLoginResult;\nimport im.zhaojun.zfile.module.user.model.result.LoginResult;\nimport im.zhaojun.zfile.module.user.model.result.LoginVerifyImgResult;\nimport im.zhaojun.zfile.module.user.service.UserService;\nimport im.zhaojun.zfile.module.user.service.DynamicLoginEntryService;\nimport im.zhaojun.zfile.module.user.service.login.ImgVerifyCodeService;\nimport im.zhaojun.zfile.module.user.service.login.LoginService;\nimport im.zhaojun.zfile.module.user.util.LoginEntryPathUtils;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.annotation.Resource;\nimport jakarta.validation.Valid;\nimport lombok.extern.slf4j.Slf4j;\nimport org.apache.commons.lang3.BooleanUtils;\nimport org.springframework.boot.context.event.ApplicationReadyEvent;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.web.bind.annotation.*;\nimport org.springframework.web.servlet.mvc.method.RequestMappingInfo;\n\nimport java.lang.reflect.Method;\n\n@Slf4j\n@Tag(name = \"用户接口\")\n@ApiSort(6)\n@RestController\n@RequestMapping(\"/user\")\npublic class UserController {\n\n    @Resource\n    private UserService userService;\n\n    @Resource\n    private LoginService loginService;\n\n    @Resource\n    private SystemConfigService systemConfigService;\n\n    @Resource\n    private ImgVerifyCodeService imgVerifyCodeService;\n\n    @Resource\n    private ZFileProperties zFileProperties;\n\n    @EventListener(ApplicationReadyEvent.class)\n    public void initSecureLoginEntry() throws NoSuchMethodException {\n        Method doLoginMethod = UserController.class.getMethod(\"doLogin\", UserLoginRequest.class);\n        SystemConfigDTO systemConfigDTO = systemConfigService.getSystemConfig();\n        String secureLoginEntry = systemConfigDTO.getSecureLoginEntry();\n        RequestMappingInfo requestMappingInfo = dynamicLoginEntryService.buildLoginRequestMappingInfo(secureLoginEntry);\n        dynamicLoginEntryService.registerMappingHandlerMapping(SystemConfig.SECURE_LOGIN_ENTRY_NAME, requestMappingInfo, this, doLoginMethod);\n        log.info(\"注册安全登录入口成功，当前登录路径为: {} \", LoginEntryPathUtils.resolveLoginPath(secureLoginEntry));\n    }\n\n    @Resource\n    private DynamicLoginEntryService dynamicLoginEntryService;\n\n    @ApiOperationSupport(order = 0)\n    @Operation(summary = \"校验安全登录入口\")\n    @GetMapping(\"/login/entry/validate\")\n    public AjaxJson<Void> validateLoginEntry(@RequestParam(value = \"entry\", required = false, defaultValue = \"\") String entry) {\n        SystemConfigDTO systemConfigDTO = systemConfigService.getSystemConfig();\n        boolean matched = systemConfigDTO.getSecureLoginEntry() == null || StringUtils.equals(systemConfigDTO.getSecureLoginEntry(), entry);\n        if (!matched) {\n            return AjaxJson.getError(\"安全登录入口不正确\");\n        }\n        return AjaxJson.getSuccess();\n    }\n\n    @ApiOperationSupport(order = 1, ignoreParameters = {\"zfile-token\"})\n    @Operation(summary = \"登录\")\n    @ApiLimit(timeout = 60, maxCount = 10)\n    public AjaxJson<LoginResult> doLogin(@Valid @RequestBody UserLoginRequest userLoginRequest) {\n        // 进行登录验证，如果验证失败，会抛出异常\n        loginService.verify(userLoginRequest);\n\n        // 获取用户的上下文信息, 并登录\n        User user = userService.getByUsername(userLoginRequest.getUsername());\n        Integer userId = user.getId();\n        StpUtil.login(userId);\n\n        // 返回登录结果\n        boolean isAdmin = userService.isAdmin(userId);\n        LoginResult loginResult = new LoginResult(StpUtil.getTokenInfo().getTokenValue(), isAdmin);\n        return AjaxJson.getSuccess(\"登录成功\", loginResult);\n    }\n\n    @ApiOperationSupport(order = 2)\n    @Operation(summary = \"注销\")\n    @PostMapping(\"/logout\")\n    public AjaxJson<Void> logout() {\n        StpUtil.logout();\n        return AjaxJson.getSuccess(\"注销成功\");\n    }\n\n    @ApiOperationSupport(order = 3)\n    @Operation(summary = \"获取登陆验证方式\")\n    @GetMapping(\"/login/verify-mode\")\n    public AjaxJson<LoginVerifyModeEnum> loginVerifyMode(String username) {\n        LoginVerifyModeEnum loginVerifyModeEnum = LoginVerifyModeEnum.OFF_MODE;\n\n        // 判断是否开启图形验证码\n        SystemConfigDTO systemConfig = systemConfigService.getSystemConfig();\n        Boolean loginImgVerify = systemConfig.getLoginImgVerify();\n        if (BooleanUtils.isTrue(loginImgVerify)) {\n            loginVerifyModeEnum = LoginVerifyModeEnum.IMG_VERIFY_MODE;\n        }\n\n        // 判断是否是管理员, 并且开启了二次验证\n        boolean isAdmin = userService.isAdmin(username);\n        boolean enableTwoFactorAuth = BooleanUtils.isTrue(systemConfig.getAdminTwoFactorVerify());\n        boolean loginVerifySecretNotBlank = StringUtils.isNotBlank(systemConfig.getLoginVerifySecret());\n        if (isAdmin && enableTwoFactorAuth && loginVerifySecretNotBlank) {\n            loginVerifyModeEnum = LoginVerifyModeEnum.TWO_FACTOR_AUTHENTICATION_MODE;\n        }\n        return AjaxJson.getSuccessData(loginVerifyModeEnum);\n    }\n\n\n    @ApiOperationSupport(order = 4)\n    @Operation(summary = \"获取图形验证码\")\n    @GetMapping(\"/login/captcha\")\n    public AjaxJson<LoginVerifyImgResult> captcha() {\n        LoginVerifyImgResult loginVerifyImgResult = imgVerifyCodeService.generatorCaptcha();\n        return AjaxJson.getSuccessData(loginVerifyImgResult);\n    }\n\n\n    @ApiOperationSupport(order = 5)\n    @Operation(summary = \"检测是否已登录\")\n    @GetMapping(\"/login/check\")\n    public AjaxJson<CheckLoginResult> checkLogin() {\n        CheckLoginResult checkLoginResult = new CheckLoginResult();\n        checkLoginResult.setIsLogin(StpUtil.isLogin());\n        if (checkLoginResult.getIsLogin()) {\n            checkLoginResult.setIsAdmin(StpUtil.hasRole(\"admin\"));\n            User currentUser = ZFileAuthUtil.getCurrentUser();\n            if (currentUser != null) {\n                checkLoginResult.setNickname(currentUser.getNickname());\n                checkLoginResult.setUsername(currentUser.getUsername());\n            }\n        }\n\n        return AjaxJson.getSuccessData(checkLoginResult);\n    }\n\n    @SaCheckLogin\n    @ApiOperationSupport(order = 6)\n    @PostMapping(\"/updatePwd\")\n    @Operation(summary = \"修改用户密码\")\n    @ResponseBody\n    @DemoDisable\n    public AjaxJson<Void> updatePwd(@RequestBody @Valid UpdateUserPwdRequest updateUserPwdRequest) {\n        userService.updateUserNameAndPwdById(ZFileAuthUtil.getCurrentUserId(), updateUserPwdRequest);\n        return AjaxJson.getSuccess();\n    }\n\n    @ResponseBody\n    @ApiOperationSupport(order = 7)\n    @Operation(summary = \"重置管理员密码\", description = \"开启 debug 模式时，访问此接口会强制将管理员账户密码修改为用户指定值\")\n    @PutMapping(\"/resetAdminPassword\")\n    @DemoDisable\n    public AjaxJson<Void> resetPwd(@RequestBody @Valid ResetAdminUserNameAndPasswordRequest requestObj) {\n        if (!zFileProperties.isDebug()) {\n            log.warn(\"当前为非调试模式, 无法重置管理员登录信息\");\n            throw new BizException(ErrorCode.BIZ_ERROR);\n        }\n        userService.resetAdminLoginInfo(requestObj);\n        systemConfigService.resetLoginVerifyMode();\n        return AjaxJson.getSuccess();\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/controller/UserManagerController.java",
    "content": "package im.zhaojun.zfile.module.user.controller;\n\nimport com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;\nimport com.github.xiaoymin.knife4j.annotations.ApiSort;\nimport im.zhaojun.zfile.core.annotation.DemoDisable;\nimport im.zhaojun.zfile.core.util.AjaxJson;\nimport im.zhaojun.zfile.module.user.model.entity.User;\nimport im.zhaojun.zfile.module.user.model.request.CheckUserDuplicateRequest;\nimport im.zhaojun.zfile.module.user.model.request.CopyUserRequest;\nimport im.zhaojun.zfile.module.user.model.request.QueryUserRequest;\nimport im.zhaojun.zfile.module.user.model.request.SaveUserRequest;\nimport im.zhaojun.zfile.module.user.model.response.UserDetailResponse;\nimport im.zhaojun.zfile.module.user.service.UserService;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport io.swagger.v3.oas.annotations.Operation;\nimport jakarta.validation.Valid;\nimport org.springframework.web.bind.annotation.*;\n\nimport jakarta.annotation.Resource;\nimport java.util.Collection;\nimport java.util.List;\n\n/**\n * 用户管理接口\n *\n * @author zhaojun\n */\n@Tag(name = \"用户管理\")\n@ApiSort(6)\n@RestController\n@RequestMapping(\"/admin\")\npublic class UserManagerController {\n\n    @Resource\n    private UserService userService;\n\n    @ApiOperationSupport(order = 1)\n    @GetMapping(\"/user/list\")\n    @Operation(summary = \"用户列表\")\n    @ResponseBody\n    public AjaxJson<Collection<UserDetailResponse>> list(QueryUserRequest queryObj) {\n        List<UserDetailResponse> userList = userService.listUserDetail(queryObj);\n        return AjaxJson.getSuccessData(userList);\n    }\n\n    @ApiOperationSupport(order = 2)\n    @PostMapping(\"/user/saveOrUpdate\")\n    @Operation(summary = \"添加用户\")\n    @ResponseBody\n    @DemoDisable\n    public AjaxJson<User> saveOrUpdate(@RequestBody SaveUserRequest saveUserRequest) {\n        return AjaxJson.getSuccessData(userService.saveOrUpdate(saveUserRequest));\n    }\n\n    @ApiOperationSupport(order = 3)\n    @DeleteMapping(\"/user/delete/{id}\")\n    @Operation(summary = \"删除用户\")\n    @ResponseBody\n    @DemoDisable\n    public AjaxJson<Integer> delete(@PathVariable(\"id\") Integer id) {\n        userService.deleteById(id);\n        return AjaxJson.getSuccessData(id);\n    }\n\n    @ApiOperationSupport(order = 5)\n    @PostMapping(\"/user/enable/{id}\")\n    @Operation(summary = \"启用用户\")\n    @ResponseBody\n    @DemoDisable\n    public AjaxJson<Integer> enable(@PathVariable Integer id) {\n        userService.updateUserEnable(id, true);\n        return AjaxJson.getSuccessData(id);\n    }\n\n    @ApiOperationSupport(order = 6)\n    @PostMapping(\"/user/disable/{id}\")\n    @Operation(summary = \"禁用用户\")\n    @ResponseBody\n    @DemoDisable\n    public AjaxJson<Integer> disable(@PathVariable Integer id) {\n        userService.updateUserEnable(id, false);\n        return AjaxJson.getSuccessData(id);\n    }\n\n    @ApiOperationSupport(order = 7)\n    @GetMapping(\"/user/{id}\")\n    @Operation(summary = \"获取用户信息\")\n    @ResponseBody\n    public AjaxJson<UserDetailResponse> getUser(@PathVariable(\"id\") Integer id) {\n        UserDetailResponse user = userService.getUserDetailById(id);\n        return AjaxJson.getSuccessData(user);\n    }\n\n    @ApiOperationSupport(order = 8)\n    @GetMapping(\"/user/checkDuplicate\")\n    @Operation(summary = \"检查用户名是否重复\")\n    @ResponseBody\n    public AjaxJson<Boolean> checkDuplicate(CheckUserDuplicateRequest checkUserDuplicateRequest) {\n        Integer id = checkUserDuplicateRequest.getId();\n        String username = checkUserDuplicateRequest.getUsername();\n        return AjaxJson.getSuccessData(userService.checkDuplicateUsername(id, username));\n    }\n\n    @ApiOperationSupport(order = 9)\n    @Operation(summary = \"复制用户\", description =\"复制用户配置\")\n    @PostMapping(\"/user/copy\")\n    @DemoDisable\n    public AjaxJson<Integer> copyStorage(@RequestBody @Valid CopyUserRequest copyUserRequest) {\n        Integer id = userService.copy(copyUserRequest);\n        return AjaxJson.getSuccessData(id);\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/event/UserCopyEvent.java",
    "content": "package im.zhaojun.zfile.module.user.event;\n\nimport lombok.Data;\n\n/**\n * 复制用户事件\n *\n * @author zhaojun\n */\n@Data\npublic class UserCopyEvent {\n\n    private Integer fromId;\n\n    private Integer newId;\n\n    public UserCopyEvent(Integer fromId, Integer newId) {\n        this.fromId = fromId;\n        this.newId = newId;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/event/UserDeleteEvent.java",
    "content": "package im.zhaojun.zfile.module.user.event;\n\nimport im.zhaojun.zfile.module.user.model.entity.User;\nimport lombok.Data;\n\n/**\n * 复制用户事件\n *\n * @author zhaojun\n */\n@Data\npublic class UserDeleteEvent {\n\n    private Integer id;\n\n    private String username;\n\n    public UserDeleteEvent(User user) {\n        this.id = user.getId();\n        this.username = user.getUsername();\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/manager/UserManager.java",
    "content": "package im.zhaojun.zfile.module.user.manager;\n\nimport im.zhaojun.zfile.core.util.CollectionUtils;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.user.mapper.UserMapper;\nimport im.zhaojun.zfile.module.user.mapper.UserStorageSourceMapper;\nimport im.zhaojun.zfile.module.user.model.dto.UserStorageSourceDetailDTO;\nimport im.zhaojun.zfile.module.user.model.entity.User;\nimport im.zhaojun.zfile.module.user.model.entity.UserStorageSource;\nimport im.zhaojun.zfile.module.user.model.response.UserDetailResponse;\nimport im.zhaojun.zfile.module.user.service.UserStorageSourceService;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.cache.annotation.CacheEvict;\nimport org.springframework.cache.annotation.Caching;\nimport org.springframework.stereotype.Component;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.List;\nimport java.util.Set;\n\n@Slf4j\n@Component\npublic class UserManager {\n\n    @Resource\n    private UserMapper userMapper;\n\n    @Resource\n    private UserStorageSourceMapper userStorageSourceMapper;\n\n\n    /**\n     * 保存用户信息及用户关联的存储策略权限\n     *\n     * @param   user\n     *          用户\n     *\n     * @param   userStorageSourceList\n     *          用户存储策略权限列表\n     */\n    @Caching(evict = {\n            @CacheEvict(cacheNames = UserStorageSourceService.USER_STORAGE_SOURCE_CACHE_KEY, allEntries = true)\n    })\n    @Transactional(rollbackFor = Exception.class)\n    public void saveUserInfo(User user, List<UserStorageSource> userStorageSourceList) {\n        // 保存或新增用户\n        Integer userId = user.getId();\n        if (userId == null) {\n            userMapper.insert(user);\n            userId = user.getId();\n        } else {\n            userMapper.updateById(user);\n        }\n\n        // 更新用户存储策略权限列表\n        userStorageSourceMapper.deleteByUserId(userId);\n        for (UserStorageSource userStorageSource : userStorageSourceList) {\n            userStorageSource.setUserId(userId);\n            userStorageSourceMapper.insert(userStorageSource);\n        }\n    }\n\n\n    /**\n     * 删除用户, 同时删除用户存储策略权限\n     *\n     * @param   userId\n     *          用户 ID\n     */\n    @Transactional(rollbackFor = Exception.class)\n    public void deleteAllByUserId(Integer userId) {\n        int deleteUserCount = userMapper.deleteById(userId);\n        log.info(\"删除用户, userId: {}, deleteCount: {}\", userId, deleteUserCount);\n\n        int deleteUserStorageSourceCount = userStorageSourceMapper.deleteByUserId(userId);\n        log.info(\"删除用户存储策略权限, userId: {}, deleteCount: {}\", userId, deleteUserStorageSourceCount);\n    }\n\n\n    /**\n     * 根据 user 对象获取包含用户的基本信息和用户与存储策略的关联关系的对象\n     *\n     * @param   user\n     *          用户对象\n     *\n     * @return  用户详细信息\n     */\n    public UserDetailResponse assembleUserDetail(User user) {\n        Integer userId = user.getId();\n        // 获取用户与存储策略的关联关系\n        List<UserStorageSourceDetailDTO> userStorageListByUserId = userStorageSourceMapper.getDTOListByUserId(userId);\n\n        return UserDetailResponse.builder()\n                .id(user.getId())\n                .username(user.getUsername())\n                .nickname(user.getNickname())\n                .enable(user.getEnable())\n                .createTime(user.getCreateTime())\n                .userStorageSourceList(userStorageListByUserId)\n                .defaultPermissions(user.getDefaultPermissions())\n                .build();\n    }\n\n\n    /**\n     * 新增存储源时，自动按照各个用户的默认权限配置，为该存储源添加权限\n     *\n     * @param   storageId\n     *          存储源 ID\n     */\n    @Transactional(rollbackFor = Exception.class)\n    public void addDefaultPermissionsForAllUsersInStorageSource(Integer storageId) {\n        log.info(\"为存储源添加默认权限, storageId: {}\", storageId);\n        List<User> users = userMapper.selectList(null);\n        for (User user : users) {\n            Set<String> defaultPermissions = user.getDefaultPermissions();\n            UserStorageSource userStorageSource = new UserStorageSource();\n            userStorageSource.setUserId(user.getId());\n            userStorageSource.setStorageSourceId(storageId);\n            userStorageSource.setRootPath(StringUtils.SLASH);\n            userStorageSource.setPermissions(defaultPermissions);\n            userStorageSource.setEnable(CollectionUtils.isNotEmpty(defaultPermissions));\n            userStorageSourceMapper.insert(userStorageSource);\n            log.info(\"为用户添加存储源的默认权限: username: {}, storageId: {}, defaultPermissions: {}\", user.getUsername(), storageId, defaultPermissions);\n        }\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/mapper/UserMapper.java",
    "content": "package im.zhaojun.zfile.module.user.mapper;\n\nimport com.baomidou.mybatisplus.core.mapper.BaseMapper;\nimport im.zhaojun.zfile.module.user.model.entity.User;\nimport org.apache.ibatis.annotations.Mapper;\nimport org.apache.ibatis.annotations.Param;\n\n@Mapper\npublic interface UserMapper extends BaseMapper<User> {\n\n    Integer findIdByUsername(@Param(\"username\") String username);\n\n    int countByUsername(@Param(\"username\") String username, @Param(\"ignoreId\") Integer ignoreId);\n\n    int updateUserNameAndPwdById(@Param(\"id\") Integer id, @Param(\"username\") String username, @Param(\"password\") String password);\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/mapper/UserStorageSourceMapper.java",
    "content": "package im.zhaojun.zfile.module.user.mapper;\n\nimport com.baomidou.mybatisplus.core.mapper.BaseMapper;\nimport im.zhaojun.zfile.module.user.model.dto.UserStorageSourceDetailDTO;\nimport im.zhaojun.zfile.module.user.model.entity.UserStorageSource;\nimport org.apache.ibatis.annotations.Mapper;\nimport org.apache.ibatis.annotations.Param;\n\nimport java.util.List;\n\n@Mapper\npublic interface UserStorageSourceMapper extends BaseMapper<UserStorageSource> {\n\n    int deleteByUserId(@Param(\"userId\") Integer userId);\n\n    int deleteByStorageId(@Param(\"storageId\") Integer storageId);\n\n    List<UserStorageSourceDetailDTO> getDTOListByUserId(@Param(\"userId\") Integer userId);\n\n    UserStorageSource getByUserIdAndStorageId(@Param(\"userId\") Integer userId, @Param(\"storageId\") Integer storageId);\n\n    List<UserStorageSource> selectByStorageId(@Param(\"storageId\") Integer storageId);\n\n    List<UserStorageSource> selectByUserId(@Param(\"userId\") Integer userId);\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/model/constant/UserConstant.java",
    "content": "package im.zhaojun.zfile.module.user.model.constant;\n\npublic class UserConstant {\n\n    public static final Integer NEW_USER_TEMPLATE_ID = 0;\n\n    public static final Integer ADMIN_ID = 1;\n\n    public static final Integer ANONYMOUS_ID = 2;\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/model/dto/UserStorageSourceDetailDTO.java",
    "content": "package im.zhaojun.zfile.module.user.model.dto;\n\nimport com.baomidou.mybatisplus.annotation.TableField;\nimport com.baomidou.mybatisplus.annotation.TableName;\nimport im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.io.Serial;\nimport java.io.Serializable;\nimport java.util.Set;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\n@TableName(autoResultMap = true)\npublic class UserStorageSourceDetailDTO implements Serializable {\n\n    @Serial\n    private static final long serialVersionUID = 1L;\n\n    @Schema(title=\"id\")\n    private Integer id;\n\n    @Schema(title=\"用户 id\")\n    private Integer userId;\n\n    @Schema(title=\"存储源 ID\")\n    private Integer storageSourceId;\n\n    /**\n     * 相较于 entity 额外查询的字段\n     */\n    @Schema(title=\"存储源名称\")\n    private String storageSourceName;\n\n    /**\n     * 相较于 entity 额外查询的字段\n     */\n    @Schema(title=\"存储策略类型\")\n    private StorageTypeEnum storageSourceType;\n\n    @Schema(title=\"允许访问的基础路径\")\n    private String rootPath;\n\n    @Schema(title=\"是否启用\")\n    private Boolean enable;\n\n    @Schema(title=\"权限列表\")\n    @TableField(typeHandler = im.zhaojun.zfile.core.config.mybatis.CollectionStrTypeHandler.class)\n    private Set<String> permissions;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/model/entity/User.java",
    "content": "package im.zhaojun.zfile.module.user.model.entity;\n\nimport com.baomidou.mybatisplus.annotation.*;\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport im.zhaojun.zfile.core.config.mybatis.CollectionStrTypeHandler;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport java.io.Serializable;\nimport java.util.Date;\nimport java.util.Set;\n\n@Data\n@TableName(value = \"`user`\", autoResultMap = true)\npublic class User implements Serializable {\n\n    private static final long serialVersionUID = 1L;\n\n    @TableId(value = \"id\", type = IdType.AUTO)\n    private Integer id;\n\n    @TableField(value = \"`username`\")\n    private String username;\n\n    @TableField(value = \"`nickname`\")\n    private String nickname;\n\n    @TableField(value = \"`password`\")\n    @JsonIgnore\n    private String password;\n\n    @Schema(title=\"盐\")\n    @JsonIgnore\n    private String salt;\n\n    @TableField(value = \"`enable`\")\n    private Boolean enable;\n\n    @TableField(value = \"create_time\", fill = FieldFill.INSERT)\n    private Date createTime;\n\n    @TableField(value = \"update_time\", fill = FieldFill.UPDATE)\n    private Date updateTime;\n\n    @TableField(value = \"default_permissions\", typeHandler = CollectionStrTypeHandler.class)\n    private Set<String> defaultPermissions;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/model/entity/UserStorageSource.java",
    "content": "package im.zhaojun.zfile.module.user.model.entity;\n\nimport com.baomidou.mybatisplus.annotation.IdType;\nimport com.baomidou.mybatisplus.annotation.TableField;\nimport com.baomidou.mybatisplus.annotation.TableId;\nimport com.baomidou.mybatisplus.annotation.TableName;\nimport lombok.Data;\n\nimport java.io.Serializable;\nimport java.util.Set;\n\n@Data\n@TableName(value = \"user_storage_source\")\npublic class UserStorageSource implements Serializable {\n\n    private static final long serialVersionUID = 1L;\n\n    @TableId(value = \"id\", type = IdType.INPUT)\n    private Integer id;\n\n    @TableField(value = \"user_id\")\n    private Integer userId;\n\n    @TableField(value = \"storage_source_id\")\n    private Integer storageSourceId;\n\n    @TableField(value = \"root_path\")\n    private String rootPath;\n\n    @TableField(value = \"`enable`\")\n    private Boolean enable;\n\n    @TableField(value = \"`permissions`\", typeHandler = im.zhaojun.zfile.core.config.mybatis.CollectionStrTypeHandler.class)\n    private Set<String> permissions;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/model/enums/LoginLogModeEnum.java",
    "content": "package im.zhaojun.zfile.module.user.model.enums;\n\nimport com.baomidou.mybatisplus.annotation.EnumValue;\nimport com.fasterxml.jackson.annotation.JsonValue;\nimport lombok.AllArgsConstructor;\nimport lombok.Getter;\n\n/**\n * 登陆日志模式枚举\n *\n * @author zhaojun\n */\n@Getter\n@AllArgsConstructor\npublic enum LoginLogModeEnum {\n\n\t/**\n\t * 不记录登录日志\n\t */\n\tOFF(\"off\"),\n\n\t/**\n\t * 记录所有登录信息作为日志\n\t */\n\tALL(\"all\"),\n\n\t/**\n\t * 不在日志中记录登录成功的密码\n\t */\n\tIGNORE_SUCCESS_PWD(\"ignoreSuccessPwd\"),\n\n\t/**\n\t * 不在日志中记录密码\n\t */\n\tIGNORE_ALL_PWD(\"ignoreAllPwd\");\n\n\t@EnumValue\n\t@JsonValue\n\tprivate final String value;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/model/enums/LoginVerifyModeEnum.java",
    "content": "package im.zhaojun.zfile.module.user.model.enums;\n\nimport com.baomidou.mybatisplus.annotation.EnumValue;\nimport com.fasterxml.jackson.annotation.JsonValue;\nimport lombok.AllArgsConstructor;\nimport lombok.Getter;\n\n/**\n * 登陆验证方式枚举\n *\n * @author zhaojun\n */\n@Getter\n@AllArgsConstructor\npublic enum LoginVerifyModeEnum {\n\n\t/**\n\t * 不启用登陆模式\n\t */\n\tOFF_MODE(\"off\"),\n\n\t/**\n\t * 图形验证码模式\n\t */\n\tIMG_VERIFY_MODE(\"image\"),\n\n\t/**\n\t * 图形验证码模式\n\t */\n\tTWO_FACTOR_AUTHENTICATION_MODE(\"2fa\");\n\n\t@EnumValue\n\t@JsonValue\n\tprivate final String value;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/model/request/CheckUserDuplicateRequest.java",
    "content": "package im.zhaojun.zfile.module.user.model.request;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\n@Data\npublic class CheckUserDuplicateRequest {\n\n    @Schema(title=\"用户 id\")\n    private Integer id;\n\n    @Schema(title=\"用户名\")\n    private String username;\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/model/request/CopyUserRequest.java",
    "content": "package im.zhaojun.zfile.module.user.model.request;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotNull;\nimport lombok.Data;\n\n/**\n * 复制用户名请求参数\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"复制用户名请求类\")\npublic class CopyUserRequest {\n\n    @Schema(title = \"存储源 ID\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"1\")\n    @NotNull(message = \"存储源 id 不能为空\")\n    private Integer fromId;\n\n    @Schema(title = \"复制后用户名\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"1\")\n    @NotBlank(message = \"复制后用户名不能为空\")\n    private String toUsername;\n\n    @Schema(title = \"复制后用户昵称\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"1\")\n    @NotBlank(message = \"复制后用户昵称不能为空\")\n    private String toNickname;\n\n    @Schema(title = \"复制后用户密码\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"1\")\n    private String toPassword;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/model/request/QueryUserRequest.java",
    "content": "package im.zhaojun.zfile.module.user.model.request;\n\nimport cn.hutool.core.date.DateUtil;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\nimport org.springframework.format.annotation.DateTimeFormat;\n\nimport java.util.Date;\nimport java.util.List;\n\n/**\n * @author zhaojun\n */\n@Data\npublic class QueryUserRequest {\n\n\t@Schema(title=\"用户名\")\n\tprivate String username;\n\n\t@Schema(title=\"昵称\")\n\tprivate String nickname;\n\n\t@Schema(title=\"是否启用\")\n\tprivate Boolean enable;\n\n\t@Schema(title=\"创建时间\")\n\t@DateTimeFormat(pattern = \"yyyy-MM-dd HH:mm:ss\")\n\tprivate List<Date> searchDate;\n\n\t@Schema(title=\"排序字段\")\n\tprivate String sortField = \"id\";\n\n\t@Schema(title=\"排序方式\")\n\tprivate Boolean sortAsc = true;\n\n\t@Schema(title=\"是否隐藏未启用的存储源\")\n\tprivate Boolean hideDisabledStorage;\n\n\tpublic Date getDateFrom() {\n\t\tif (searchDate == null) {\n\t\t\treturn null;\n\t\t}\n\t\treturn DateUtil.beginOfDay(searchDate.getFirst());\n\t}\n\n\tpublic Date getDateTo() {\n\t\tif (searchDate == null) {\n\t\t\treturn null;\n\t\t}\n\t\treturn DateUtil.endOfDay(searchDate.getLast());\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/model/request/ResetAdminUserNameAndPasswordRequest.java",
    "content": "package im.zhaojun.zfile.module.user.model.request;\n\nimport jakarta.validation.constraints.NotBlank;\nimport lombok.Data;\n\n/**\n * 重置用\n *\n * @author zhaojun\n */\n@Data\npublic class ResetAdminUserNameAndPasswordRequest {\n\n    @NotBlank(message = \"用户名不能为空\")\n    private String username;\n\n    @NotBlank(message = \"密码不能为空\")\n    private String password;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/model/request/SaveUserRequest.java",
    "content": "package im.zhaojun.zfile.module.user.model.request;\n\nimport im.zhaojun.zfile.module.user.model.entity.UserStorageSource;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport java.util.List;\nimport java.util.Set;\n\n@Data\npublic class SaveUserRequest {\n\n    @Schema(title=\"用户 id\")\n    private Integer id;\n\n    @Schema(title=\"用户名\")\n    private String username;\n\n    @Schema(title=\"昵称\")\n    private String nickname;\n\n    @Schema(title=\"密码\")\n    private String password;\n\n    @Schema(title=\"盐\")\n    private String salt;\n\n    @Schema(title=\"用户默认权限\", description =\"当新增存储源时, 自动授予该用户新存储源的权限.\")\n    private Set<String> defaultPermissions;\n\n    @Schema(title=\"授予给用户的存储策略列表\")\n    private List<UserStorageSource> userStorageSourceList;\n\n    @Schema(title=\"是否启用\")\n    private Boolean enable;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/model/request/UpdateUserPwdRequest.java",
    "content": "package im.zhaojun.zfile.module.user.model.request;\n\nimport lombok.Data;\n\nimport jakarta.validation.constraints.NotBlank;\n\n@Data\npublic class UpdateUserPwdRequest {\n\n    private String oldPassword;\n\n    @NotBlank(message = \"新密码不能为空\")\n    private String newPassword;\n\n    @NotBlank(message = \"确认密码不能为空\")\n    private String confirmPassword;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/model/request/UserLoginRequest.java",
    "content": "package im.zhaojun.zfile.module.user.model.request;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport jakarta.validation.constraints.NotBlank;\n\n/**\n * 用户登录请求参数参数\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"用户登录请求参数类\")\npublic class UserLoginRequest {\n\n    @Schema(title = \"用户名\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"admin\")\n    @NotBlank(message = \"用户名不能为空\")\n    private String username;\n\n    @Schema(title = \"密码\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"123456\")\n    @NotBlank(message = \"密码不能为空\")\n    private String password;\n\n    @Schema(title = \"验证码\", example = \"123456\")\n    private String verifyCode;\n\n    @Schema(title = \"验证码 UUID\", description =\"用于图形验证码确认每个验证码图片请求的唯一值.\", example = \"c140a792-4ca2-4dac-8d4c-35750b78524f\")\n    private String verifyCodeUUID;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/model/request/VerifyLoginTwoFactorAuthenticatorRequest.java",
    "content": "package im.zhaojun.zfile.module.user.model.request;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\n\nimport jakarta.validation.constraints.NotBlank;\n\n/**\n * 验证 2FA 认证返回结果\n *\n * @author zhaojun\n */\n@Data\n@AllArgsConstructor\n@Schema(description = \"验证二步验证结果\")\npublic class VerifyLoginTwoFactorAuthenticatorRequest {\n\n\t@Schema(title = \"二步验证二维码\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"EwBoxxxxxxxxxxxxxxxbAI=\")\n\t@NotBlank(message = \"二步验证密钥不能为空\")\n\tprivate String secret;\n\n\t@Schema(title = \"APP 生成的二步验证验证码\", requiredMode = Schema.RequiredMode.REQUIRED, example = \"125612\")\n\t@NotBlank(message = \"二步验证验证码不能为空\")\n\tprivate String code;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/model/response/UserDetailResponse.java",
    "content": "package im.zhaojun.zfile.module.user.model.response;\n\nimport im.zhaojun.zfile.module.user.model.dto.UserStorageSourceDetailDTO;\nimport lombok.Builder;\nimport lombok.Data;\n\nimport java.util.Date;\nimport java.util.List;\nimport java.util.Set;\n\n@Data\n@Builder\npublic class UserDetailResponse {\n\n    private Integer id;\n\n    private String username;\n\n    private String nickname;\n\n    private Set<String> defaultPermissions;\n\n    private List<UserStorageSourceDetailDTO> userStorageSourceList;\n\n    private Boolean enable;\n\n    private Date createTime;\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/model/result/CheckLoginResult.java",
    "content": "package im.zhaojun.zfile.module.user.model.result;\n\nimport lombok.Data;\n\nimport java.io.Serializable;\n\n@Data\npublic class CheckLoginResult implements Serializable {\n\n    private static final long serialVersionUID = 1L;\n\n    private Boolean isLogin;\n\n    private Boolean isAdmin;\n\n    private String username;\n\n    private String nickname;\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/model/result/LoginResult.java",
    "content": "package im.zhaojun.zfile.module.user.model.result;\n\nimport lombok.Data;\n\nimport java.io.Serializable;\n\n@Data\npublic class LoginResult implements Serializable {\n\n    private static final long serialVersionUID = 1L;\n\n    private String token;\n\n    private boolean admin;\n\n    public LoginResult(String tokenValue, boolean isAdmin) {\n        this.token = tokenValue;\n        this.admin = isAdmin;\n    }\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/model/result/LoginTwoFactorAuthenticatorResult.java",
    "content": "package im.zhaojun.zfile.module.user.model.result;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\n\nimport java.io.Serializable;\n\n/**\n * 登陆 2FA 认证生成返回结果\n *\n * @author zhaojun\n */\n@Data\n@AllArgsConstructor\n@Schema(description = \"生成二步验证结果\")\npublic class LoginTwoFactorAuthenticatorResult implements Serializable {\n\n\tprivate static final long serialVersionUID = 1L;\n\n\t@Schema(title = \"二步验证二维码\")\n\tprivate String qrcode;\n\n\t@Schema(title = \"二步验证密钥\")\n\tprivate String secret;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/model/result/LoginVerifyImgResult.java",
    "content": "package im.zhaojun.zfile.module.user.model.result;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport lombok.Data;\n\nimport java.io.Serializable;\n\n/**\n * 生成图片验证码结果类\n *\n * @author zhaojun\n */\n@Data\n@Schema(description = \"生成图片验证码结果类\")\npublic class LoginVerifyImgResult implements Serializable {\n\n\tprivate static final long serialVersionUID = 1L;\n\n\t@Schema(title = \"验证码图片\", example = \"data:image/png;base64,iajsiAAA...\")\n\tprivate String imgBase64;\n\n\t@Schema(title = \"验证码 UUID\", example = \"c140a792-4ca2-4dac-8d4c-35750b78524f\")\n\tprivate String uuid;\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/service/DynamicLoginEntryService.java",
    "content": "package im.zhaojun.zfile.module.user.service;\n\nimport im.zhaojun.zfile.module.link.dto.DynamicRegisterMappingHandlerDTO;\nimport im.zhaojun.zfile.module.user.util.LoginEntryPathUtils;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.stereotype.Service;\nimport org.springframework.web.bind.annotation.RequestMethod;\nimport org.springframework.web.servlet.mvc.method.RequestMappingInfo;\nimport org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;\nimport org.springframework.http.MediaType;\n\nimport java.lang.reflect.Method;\nimport java.util.Map;\nimport java.util.concurrent.ConcurrentHashMap;\n\n/**\n * 动态登录入口注册服务，负责动态注册或更新登录接口的请求映射。\n *\n * @author zhaojun\n */\n@Slf4j\n@Service\npublic class DynamicLoginEntryService {\n\n    @Resource\n    private RequestMappingHandlerMapping requestMappingHandlerMapping;\n\n    private static final Map<String, DynamicRegisterMappingHandlerDTO> REGISTER_MAPPING = new ConcurrentHashMap<>();\n\n    public void registerMappingHandlerMapping(String key, RequestMappingInfo requestMappingInfo, Object controllerObj, Method method) {\n        requestMappingHandlerMapping.registerMapping(requestMappingInfo, controllerObj, method);\n        REGISTER_MAPPING.put(key, new DynamicRegisterMappingHandlerDTO(requestMappingInfo, controllerObj, method));\n    }\n\n    public void updateRegisterMappingHandler(String key, RequestMappingInfo requestMappingInfo) {\n        synchronized (key.intern()) {\n            DynamicRegisterMappingHandlerDTO dynamicRegisterMappingHandlerDTO = REGISTER_MAPPING.get(key);\n            if (dynamicRegisterMappingHandlerDTO != null) {\n                requestMappingHandlerMapping.unregisterMapping(dynamicRegisterMappingHandlerDTO.getRequestMappingInfo());\n                requestMappingHandlerMapping.registerMapping(requestMappingInfo, dynamicRegisterMappingHandlerDTO.getObject(), dynamicRegisterMappingHandlerDTO.getMethod());\n                REGISTER_MAPPING.put(key, new DynamicRegisterMappingHandlerDTO(requestMappingInfo, dynamicRegisterMappingHandlerDTO.getObject(), dynamicRegisterMappingHandlerDTO.getMethod()));\n            } else {\n                log.warn(\"尝试更新不存在的动态登录入口映射，key: {}\", key);\n            }\n        }\n    }\n\n    public RequestMappingInfo buildLoginRequestMappingInfo(String secureLoginEntry) {\n        String loginPath = LoginEntryPathUtils.resolveLoginApiPath(secureLoginEntry);\n        return RequestMappingInfo.paths(loginPath)\n                .methods(RequestMethod.POST)\n                .consumes(MediaType.APPLICATION_JSON_VALUE)\n                .produces(MediaType.APPLICATION_JSON_VALUE)\n                .build();\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/service/UserService.java",
    "content": "package im.zhaojun.zfile.module.user.service;\n\nimport cn.hutool.core.util.ObjUtil;\nimport cn.hutool.crypto.SecureUtil;\nimport com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;\nimport com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;\nimport im.zhaojun.zfile.core.cache.ZFileCacheManager;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;\nimport im.zhaojun.zfile.module.user.event.UserCopyEvent;\nimport im.zhaojun.zfile.module.user.event.UserDeleteEvent;\nimport im.zhaojun.zfile.module.user.manager.UserManager;\nimport im.zhaojun.zfile.module.user.mapper.UserMapper;\nimport im.zhaojun.zfile.module.user.model.constant.UserConstant;\nimport im.zhaojun.zfile.module.user.model.entity.User;\nimport im.zhaojun.zfile.module.user.model.request.*;\nimport im.zhaojun.zfile.module.user.model.response.UserDetailResponse;\nimport im.zhaojun.zfile.module.user.utils.PasswordVerifyUtils;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.aop.framework.AopContext;\nimport org.springframework.beans.BeanUtils;\nimport org.springframework.cache.annotation.CacheConfig;\nimport org.springframework.cache.annotation.CacheEvict;\nimport org.springframework.cache.annotation.Cacheable;\nimport org.springframework.context.ApplicationEventPublisher;\nimport org.springframework.data.util.Pair;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.*;\n\nimport static im.zhaojun.zfile.module.user.service.UserService.USER_CACHE_KEY;\n\n@Slf4j\n@Service\n@CacheConfig(cacheNames = USER_CACHE_KEY)\npublic class UserService {\n\n    public static final String USER_CACHE_KEY = \"user\";\n\n    @Resource\n    private UserMapper userMapper;\n\n    @Resource\n    private UserManager userManager;\n\n    @Resource\n    private ApplicationEventPublisher applicationEventPublisher;\n\n    @Resource\n    private ZFileCacheManager zfileCacheManager;\n\n    /**\n     * 根据用户 ID 获取用户\n     *\n     * @param   id\n     *          用户 ID\n     *\n     * @return  用户\n     */\n    @Cacheable(key = \"#id\", unless = \"#result == null\", condition = \"#id != null\")\n    public User getById(Integer id) {\n        return userMapper.selectById(id);\n    }\n\n    @Cacheable(key = \"#username\", unless = \"#result == null\", condition = \"#username != null\")\n    public Integer getIdByUsername(String username) {\n        return userMapper.findIdByUsername(username);\n    }\n\n    /**\n     * 根据用户名获取用户\n     *\n     * @param   username\n     *          用户名\n     *\n     * @return  用户\n     */\n    public User getByUsername(String username) {\n        UserService userService = (UserService) AopContext.currentProxy();\n        Integer userId = userService.getIdByUsername(username);\n        if (userId == null) {\n            return null;\n        }\n        return userService.getById(userId);\n    }\n\n\n    /**\n     * 根据用户 ID 获取用户详细信息，包含用户的基本信息和用户与存储策略的关联关系.\n     *\n     * @param   userId\n     *          用户 ID\n     *\n     * @return  用户详细信息\n     */\n    public UserDetailResponse getUserDetailById(Integer userId) {\n        User user = ((UserService) AopContext.currentProxy()).getById(userId);\n        if (user == null) {\n            throw new BizException(ErrorCode.BIZ_USER_NOT_EXIST);\n        }\n        return userManager.assembleUserDetail(user);\n    }\n\n\n    /**\n     * 根据查询条件查询用户列表\n     *\n     * @param   queryObj\n     *          查询条件对象\n     *\n     * @return  用户列表\n     */\n    public List<UserDetailResponse> listUserDetail(QueryUserRequest queryObj) {\n        List<UserDetailResponse> userDetailResponseList = new ArrayList<>();\n\n        LambdaQueryWrapper<User> queryWrapper = new QueryWrapper<User>()\n                .orderBy(true, queryObj.getSortAsc(), StringUtils.camelToUnderline(queryObj.getSortField()))\n                .lambda()\n                .like(ObjUtil.isNotEmpty(queryObj.getUsername()), User::getUsername, queryObj.getUsername())\n                .like(ObjUtil.isNotEmpty(queryObj.getNickname()), User::getNickname, queryObj.getNickname())\n                .eq(ObjUtil.isNotEmpty(queryObj.getEnable()), User::getEnable, queryObj.getEnable())\n                .ge(ObjUtil.isNotEmpty(queryObj.getDateFrom()), User::getCreateTime, queryObj.getDateFrom())\n                .le(ObjUtil.isNotEmpty(queryObj.getDateTo()), User::getCreateTime, queryObj.getDateTo());\n\n        List<User> users = userMapper.selectList(queryWrapper);\n\n        users.forEach(user -> userDetailResponseList.add(userManager.assembleUserDetail(user)));\n        if (queryObj.getHideDisabledStorage()) {\n            userDetailResponseList.forEach(userDetailResponse -> {\n                userDetailResponse.getUserStorageSourceList().removeIf(userStorageSourceDetailDTO -> !userStorageSourceDetailDTO.getEnable());\n            });\n        }\n        return userDetailResponseList;\n    }\n\n\n    /**\n     * 保存或更新用户\n     *\n     * @param   saveUserRequest\n     *          用户信息\n     *\n     * @return  保存后的用户\n     */\n    @CacheEvict(allEntries = true)\n    public User saveOrUpdate(SaveUserRequest saveUserRequest) {\n        // 校验用户是否存在\n        boolean userNameIsDuplicate = checkDuplicateUsername(saveUserRequest.getId(), saveUserRequest.getUsername());\n        if (userNameIsDuplicate) {\n            throw new BizException(ErrorCode.BIZ_USER_EXIST);\n        }\n\n        User user = new User();\n        user.setId(saveUserRequest.getId());\n        user.setUsername(saveUserRequest.getUsername());\n        user.setNickname(saveUserRequest.getNickname());\n        user.setEnable(saveUserRequest.getEnable());\n        user.setDefaultPermissions(saveUserRequest.getDefaultPermissions());\n        if (StringUtils.isNotBlank(saveUserRequest.getPassword())) {\n            passwordEncryptAndSet(saveUserRequest.getPassword(), user);\n        }\n\n        userManager.saveUserInfo(user, saveUserRequest.getUserStorageSourceList());\n        if (user.getId() != null) {\n            zfileCacheManager.clearUserEnableStorageSourceCache(user.getId());\n        }\n        return user;\n    }\n\n\n    /**\n     * 更新用户启用状态\n     *\n     * @param   id\n     *          用户 ID\n     *\n     * @param   enable\n     *          是否启用\n     */\n    @CacheEvict(key = \"#id\")\n    public void updateUserEnable(Integer id, boolean enable) {\n        User user = new User();\n        user.setId(id);\n        user.setEnable(enable);\n        userMapper.updateById(user);\n        zfileCacheManager.clearUserEnableStorageSourceCache(id);\n    }\n\n\n    /**\n     * 初始化管理员用户名、密码、权限.\n     *\n     * @param   username\n     *          用户名\n     *\n     * @param   password\n     *          密码\n     *\n     * @return  是否更新成功\n     */\n    public boolean initAdminUser(String username, String password) {\n        User user = userMapper.selectById(UserConstant.ADMIN_ID);\n        if (user == null) {\n            throw new BizException(\"系统异常，管理员用户不存在，请检测数据库或重建数据库。\");\n        }\n\n        user.setUsername(username);\n        passwordEncryptAndSet(password, user);\n\n        // 管理员用户默认权限\n        Set<String> defaultPermissions = new HashSet<>();\n        for (FileOperatorTypeEnum value : FileOperatorTypeEnum.values()) {\n            if (!StringUtils.startWith(value.getValue(), \"ignore\")) {\n                defaultPermissions.add(value.getValue());\n            }\n        }\n        user.setDefaultPermissions(defaultPermissions);\n        return userMapper.updateById(user) == 1;\n    }\n\n\n    /**\n     * 修改当前用户的用户名和密码，需要校验旧密码是否正确.\n     *\n     * @param   updateUserPwdRequest\n     *          修改密码请求对象\n     */\n    @CacheEvict(allEntries = true)\n    @Transactional(rollbackFor = Exception.class)\n    public void updateUserNameAndPwdById(Integer id, UpdateUserPwdRequest updateUserPwdRequest) {\n        User user = userMapper.selectById(id);\n        if (user == null || Objects.equals(id, UserConstant.ANONYMOUS_ID)) {\n            throw new BizException(ErrorCode.BIZ_USER_NOT_EXIST);\n        }\n\n        // 验证旧密码是否正确，如果旧密码不为空，则进行验证\n        if (StringUtils.isNotBlank(user.getPassword()) &&\n                !PasswordVerifyUtils.verify(user.getPassword(), user.getSalt(), updateUserPwdRequest.getOldPassword())) {\n            throw new BizException(ErrorCode.BIZ_OLD_PASSWORD_ERROR);\n        }\n\n        // 验证新密码和确认密码是否一致\n        if (!updateUserPwdRequest.getNewPassword().equals(updateUserPwdRequest.getConfirmPassword())) {\n            throw new BizException(ErrorCode.BIZ_PASSWORD_NOT_SAME);\n        }\n\n        passwordEncryptAndSet(updateUserPwdRequest.getNewPassword(), user);\n        userMapper.updateById(user);\n    }\n\n\n    /**\n     * 根据 ID 删除用户，无法删除内置的管理员和匿名用户，删除是会自动删除用户与存储策略的关联关系.\n     *\n     * @param   id\n     *          要删除调用用户 ID\n     */\n    @CacheEvict(allEntries = true)\n    public void deleteById(Integer id) {\n        User user = userMapper.selectById(id);\n        if (user == null) {\n            throw new BizException(ErrorCode.BIZ_USER_NOT_EXIST);\n        }\n        if (user.getId() <= UserConstant.ANONYMOUS_ID) {\n            throw new BizException(ErrorCode.BIZ_DELETE_BUILT_IN_USER);\n        }\n\n        // 删除用户及关联的数据\n        userManager.deleteAllByUserId(id);\n\n        // 发布用户删除事件\n        applicationEventPublisher.publishEvent(new UserDeleteEvent(user));\n        zfileCacheManager.clearUserEnableStorageSourceCache(id);\n    }\n\n\n    /**\n     * 根据用户 ID 判断是否是管理员，(管理员 ID 强制为 1)\n     *\n     * @param   id\n     *          用户 ID\n     *\n     * @return  是否是管理员\n     */\n    public boolean isAdmin(Integer id) {\n        return ObjUtil.equal(UserConstant.ADMIN_ID, id);\n    }\n\n\n    /**\n     * 根据用户名判断是否是管理员，(管理员 ID 强制为 1)\n     *\n     * @param   username\n     *          用户名\n     *\n     * @return  是否是管理员\n     */\n    public boolean isAdmin(String username) {\n        User user = ((UserService) AopContext.currentProxy()).getByUsername(username);\n        return user != null && isAdmin(user.getId());\n    }\n\n\n    /**\n     * 检查用户名是否重复\n     *\n     * @param   ignoreUserId\n     *          忽略的用户 ID, 用于检查重复时, 排除自身.\n     *\n     * @param   username\n     *          要检查的用户名\n     *\n     * @return  是否重复\n     */\n    public boolean checkDuplicateUsername(Integer ignoreUserId, String username) {\n        return userMapper.countByUsername(username, ignoreUserId) > 0;\n    }\n\n    /**\n     * 重置管理员用户的用户名、密码。\n     *\n     * @param   requestObj\n     *          重置用户名和密码请求对象\n     */\n    @CacheEvict(allEntries = true)\n    public void resetAdminLoginInfo(ResetAdminUserNameAndPasswordRequest requestObj) {\n        User user = userMapper.selectById(UserConstant.ADMIN_ID);\n        user.setUsername(requestObj.getUsername());\n        user.setPassword(SecureUtil.md5(requestObj.getPassword()));\n        passwordEncryptAndSet(requestObj.getPassword(), user);\n        userMapper.updateById(user);\n\n    }\n\n    /**\n     * 密码加盐并设置到用户对象中\n     *\n     * @param   originPassword\n     *          原始密码\n     *\n     * @param   user\n     *          用户对象\n     */\n    private static void passwordEncryptAndSet(String originPassword, User user) {\n        Pair<String, String> encryptPair = PasswordVerifyUtils.encrypt(originPassword);\n        user.setPassword(encryptPair.getFirst());\n        user.setSalt(encryptPair.getSecond());\n    }\n\n    @Transactional(rollbackFor = Exception.class)\n    public Integer copy(CopyUserRequest copyUserRequest) {\n        // 检查目标用户名是否已存在\n        String toUsername = copyUserRequest.getToUsername();\n        boolean existUser = ((UserService)AopContext.currentProxy()).getByUsername(toUsername) != null;\n        if (existUser) {\n            throw new BizException(ErrorCode.BIZ_USER_EXIST);\n        }\n\n        // 检查复制源是否存在\n        Integer fromUserId = copyUserRequest.getFromId();\n        User user = ((UserService)AopContext.currentProxy()).getById(fromUserId);\n        if (user == null) {\n            throw new BizException(ErrorCode.BIZ_USER_NOT_EXIST);\n        }\n\n        User newUser = new User();\n        BeanUtils.copyProperties(user, newUser);\n        newUser.setId(null);\n        newUser.setCreateTime(null);\n        newUser.setUsername(null);\n        newUser.setUsername(copyUserRequest.getToUsername());\n        newUser.setNickname(copyUserRequest.getToNickname());\n        if (StringUtils.isNotEmpty(copyUserRequest.getToPassword())) {\n            passwordEncryptAndSet(copyUserRequest.getToPassword(), newUser);\n        }\n        userMapper.insert(newUser);\n\n        Integer newUserId = newUser.getId();\n        log.info(\"复制用户成功，源 [id: {}, username: {}, nickname: {}], 复制后 [id: {}, username: {}, nickname: {}]\",\n                fromUserId, user.getUsername(), user.getNickname(),\n                newUserId, newUser.getUsername(), user.getNickname());\n\n        UserCopyEvent userCopyEvent = new UserCopyEvent(fromUserId, newUserId);\n        applicationEventPublisher.publishEvent(userCopyEvent);\n        return newUserId;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/service/UserStorageSourceService.java",
    "content": "package im.zhaojun.zfile.module.user.service;\n\nimport im.zhaojun.zfile.core.util.ZFileAuthUtil;\nimport im.zhaojun.zfile.module.storage.event.StorageSourceCopyEvent;\nimport im.zhaojun.zfile.module.storage.event.StorageSourceDeleteEvent;\nimport im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;\nimport im.zhaojun.zfile.module.user.event.UserCopyEvent;\nimport im.zhaojun.zfile.module.user.manager.UserManager;\nimport im.zhaojun.zfile.module.user.mapper.UserStorageSourceMapper;\nimport im.zhaojun.zfile.module.user.model.entity.UserStorageSource;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.aop.framework.AopContext;\nimport org.springframework.beans.BeanUtils;\nimport org.springframework.cache.annotation.CacheConfig;\nimport org.springframework.cache.annotation.CacheEvict;\nimport org.springframework.cache.annotation.Cacheable;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Service;\n\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Set;\n\nimport static im.zhaojun.zfile.module.user.service.UserStorageSourceService.USER_STORAGE_SOURCE_CACHE_KEY;\n\n@Slf4j\n@Service\n@CacheConfig(cacheNames = USER_STORAGE_SOURCE_CACHE_KEY)\npublic class UserStorageSourceService {\n\n    public static final String USER_STORAGE_SOURCE_CACHE_KEY = \"userStorageSource\";\n\n    @Resource\n    private UserManager userManager;\n\n    @Resource\n    private UserStorageSourceMapper userStorageSourceMapper;\n\n\n    /**\n     * 根据用户 ID 和存储策略 ID 查询存储策略权限\n     *\n     * @param   userId\n     *          用户 ID\n     *\n     * @param   storageId\n     *          存储策略 ID\n     *\n     * @return  存储策略权限\n     */\n    @Cacheable(key = \"#userId + '-' + #storageId\",\n            unless = \"#result == null\",\n            condition = \"#userId != null && #storageId != null\")\n    public UserStorageSource getByUserIdAndStorageId(Integer userId, Integer storageId) {\n        return userStorageSourceMapper.getByUserIdAndStorageId(userId, storageId);\n    }\n\n    /**\n     * 判断当前登录用户在指定存储策略是否有指定操作的权限\n     *\n     * @param   storageId\n     *          存储策略 ID\n     *\n     * @param   operatorTypeEnum\n     *          操作类型\n     *\n     * @return  当前登录用户在指定存储策略是否有指定操作的权限\n     */\n    public boolean hasCurrentUserStorageOperatorPermission(Integer storageId, FileOperatorTypeEnum operatorTypeEnum) {\n        UserStorageSource userStorageSource = ((UserStorageSourceService) AopContext.currentProxy()).getByUserIdAndStorageId(ZFileAuthUtil.getCurrentUserId(), storageId);\n        return userStorageSource.getPermissions().contains(operatorTypeEnum.getValue());\n    }\n\n    /**\n     * 判断指定用户在指定存储策略是否有指定操作的权限（分享模式下按分享者判断）\n     */\n    public boolean hasUserStorageOperatorPermission(Integer userId, Integer storageId, FileOperatorTypeEnum operatorTypeEnum) {\n        if (userId == null) {\n            return hasCurrentUserStorageOperatorPermission(storageId, operatorTypeEnum);\n        }\n        UserStorageSource userStorageSource = ((UserStorageSourceService) AopContext.currentProxy()).getByUserIdAndStorageId(userId, storageId);\n        if (userStorageSource == null || userStorageSource.getPermissions() == null) {\n            return false;\n        }\n        return userStorageSource.getPermissions().contains(operatorTypeEnum.getValue());\n    }\n\n\n    /**\n     * 获取当前登录用户在指定存储策略的权限支持情况，数据结构为 Map，Key 为权限名称，Value 为布尔值表示是否支持\n     *\n     * @param   storageId\n     *          存储策略 ID\n     *\n     * @return  当前登录用户在指定存储策略的权限支持情况\n     */\n    public HashMap<String, Boolean> getCurrentUserPermissionMapByStorageId(Integer storageId) {\n        Integer currentUserId = ZFileAuthUtil.getCurrentUserId();\n        UserStorageSource userStorageSource = ((UserStorageSourceService) AopContext.currentProxy()).getByUserIdAndStorageId(currentUserId, storageId);\n        return buildPermissionMap(userStorageSource);\n    }\n\n\n    /**\n     * 获取指定用户在指定存储源下的权限映射表\n     */\n    public HashMap<String, Boolean> getPermissionMapByUserIdAndStorageId(Integer userId, Integer storageId) {\n        if (userId == null || storageId == null) {\n            return buildPermissionMap(null);\n        }\n        UserStorageSource userStorageSource = ((UserStorageSourceService) AopContext.currentProxy()).getByUserIdAndStorageId(userId, storageId);\n        return buildPermissionMap(userStorageSource);\n    }\n\n\n    private HashMap<String, Boolean> buildPermissionMap(UserStorageSource userStorageSource) {\n        HashMap<String, Boolean> map = new HashMap<>();\n        Set<String> permissions = userStorageSource != null ? userStorageSource.getPermissions() : null;\n        for (FileOperatorTypeEnum operatorTypeEnum : FileOperatorTypeEnum.values()) {\n            map.put(operatorTypeEnum.getValue(), permissions != null && permissions.contains(operatorTypeEnum.getValue()));\n        }\n        return map;\n    }\n\n\n    /**\n     * 新增存储源时，自动按照各个用户的默认权限配置，为该存储源添加权限\n     *\n     * @param   storageId\n     *          存储源 ID\n     */\n    public void addDefaultPermissionsForAllUsersInStorageSource(Integer storageId) {\n        userManager.addDefaultPermissionsForAllUsersInStorageSource(storageId);\n    }\n\n\n    /**\n     * 删除指定存储策略 ID 的存储策略权限\n     *\n     * @param   storageId\n     *          存储策略 ID\n     *\n     * @return  删除的条数\n     */\n    @CacheEvict(allEntries = true)\n    public int deleteByStorageId(Integer storageId) {\n        int deleteSize = userStorageSourceMapper.deleteByStorageId(storageId);\n        log.info(\"删除存储源 ID 为 {} 的存储源用户权限 {} 条\", storageId, deleteSize);\n        return deleteSize;\n    }\n\n    /**\n     * 监听存储源删除事件，根据存储源 id 删除相关的用户权限\n     *\n     * @param   storageSourceDeleteEvent\n     *          存储源删除事件\n     */\n    @EventListener\n    public void onStorageSourceDelete(StorageSourceDeleteEvent storageSourceDeleteEvent) {\n        Integer storageId = storageSourceDeleteEvent.getId();\n        int updateRows = ((UserStorageSourceService) AopContext.currentProxy()).deleteByStorageId(storageId);\n        log.info(\"删除存储源 [id {}, name: {}, type: {}] 时，关联删除存储源用户权限 {} 条\",\n                storageId,\n                storageSourceDeleteEvent.getName(),\n                storageSourceDeleteEvent.getType().getDescription(),\n                updateRows);\n    }\n\n\n    /**\n     * 监听存储源复制事件, 复制存储源时, 复制用户的存储源权限\n     *\n     * @param   storageSourceCopyEvent\n     *          存储源复制事件\n     */\n    @EventListener\n    public void onStorageSourceCopy(StorageSourceCopyEvent storageSourceCopyEvent) {\n        Integer fromId = storageSourceCopyEvent.getFromId();\n        Integer newId = storageSourceCopyEvent.getNewId();\n\n        List<UserStorageSource> userStorageSourceList = userStorageSourceMapper.selectByStorageId(fromId);\n\n        userStorageSourceList.forEach(userStorageSource -> {\n            UserStorageSource newUserStorageSource = new UserStorageSource();\n            BeanUtils.copyProperties(userStorageSource, newUserStorageSource);\n            newUserStorageSource.setId(null);\n            newUserStorageSource.setStorageSourceId(newId);\n            userStorageSourceMapper.insert(newUserStorageSource);\n        });\n\n        log.info(\"复制存储源 ID 为 {} 的存储源用户权限设置到存储源 ID 为 {} 成功, 共 {} 条\", fromId, newId, userStorageSourceList.size());\n    }\n\n\n    /**\n     * 监听用户复制事件, 复制用户时, 复制原用户的存储源权限到新用户\n     *\n     * @param   userCopyEvent\n     *          用户复制事件\n     */\n    @EventListener\n    public void onUserCopy(UserCopyEvent userCopyEvent) {\n        Integer fromId = userCopyEvent.getFromId();\n        Integer newId = userCopyEvent.getNewId();\n\n        List<UserStorageSource> userStorageSourceList = userStorageSourceMapper.selectByUserId(fromId);\n\n        userStorageSourceList.forEach(userStorageSource -> {\n            UserStorageSource newUserStorageSource = new UserStorageSource();\n            BeanUtils.copyProperties(userStorageSource, newUserStorageSource);\n            newUserStorageSource.setId(null);\n            newUserStorageSource.setUserId(newId);\n            userStorageSourceMapper.insert(newUserStorageSource);\n        });\n\n        log.info(\"复制 ID 为 {} 的存储源用户权限设置到用户 ID 为 {} 成功, 共 {} 条\", fromId, newId, userStorageSourceList.size());\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/service/login/ImgVerifyCodeService.java",
    "content": "package im.zhaojun.zfile.module.user.service.login;\n\nimport cn.hutool.cache.CacheUtil;\nimport cn.hutool.cache.impl.FIFOCache;\nimport cn.hutool.captcha.CaptchaUtil;\nimport cn.hutool.captcha.CircleCaptcha;\nimport cn.hutool.core.lang.UUID;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.core.exception.status.ForbiddenAccessException;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.user.model.result.LoginVerifyImgResult;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.stereotype.Service;\n\n/**\n * 图片验证码 Service\n *\n * @author zhaojun\n */\n@Service\n@Slf4j\npublic class ImgVerifyCodeService {\n\n\t/**\n\t * 最大容量为 100 的验证码缓存，防止恶意请求占满内存. 验证码有效期为 60 秒.\n\t */\n\tprivate final FIFOCache<String, String> verifyCodeCache = CacheUtil.newFIFOCache(100,60 * 1000L);\n\n\n\t/**\n\t * 生成验证码，并写入缓存中.\n\t *\n\t * @return  验证码生成结果\n\t */\n\tpublic LoginVerifyImgResult generatorCaptcha() {\n\t\tCircleCaptcha captcha = CaptchaUtil.createCircleCaptcha(200, 45, 4, 7);\n\t\tString code = null;\n\t\ttry {\n\t\t\tcode = captcha.getCode();\n\t\t} catch (Exception e) {\n\t\t\tif (StringUtils.contains(e.getMessage(), \"Fontconfig\")) {\n\t\t\t\tthrow new BizException(\"验证码生成失败, 请安装字体库后重试，参考文档: https://docs.zfile.vip/question/ubuntu-awt\");\n\t\t\t}\n\t\t}\n\t\tString imageBase64 = captcha.getImageBase64Data();\n\n\t\tString uuid = UUID.fastUUID().toString();\n\t\tverifyCodeCache.put(uuid, code);\n\n\t\tLoginVerifyImgResult loginVerifyImgResult = new LoginVerifyImgResult();\n\t\tloginVerifyImgResult.setImgBase64(imageBase64);\n\t\tloginVerifyImgResult.setUuid(uuid);\n\t\treturn loginVerifyImgResult;\n\t}\n\n\n\t/**\n\t * 对验证码进行验证.\n\t *\n\t * @param   uuid\n\t *          验证码 uuid\n\t *\n\t * @param   code\n\t *          验证码\n\t *\n\t * @return  是否验证成功\n\t */\n\tpublic boolean verifyCaptcha(String uuid, String code) {\n\t\tString expectedCode = verifyCodeCache.get(uuid);\n\t\treturn StringUtils.equalsIgnoreCase(expectedCode, code);\n\t}\n\n\n\t/**\n\t * 对验证码进行验证, 如验证失败则抛出异常\n\t *\n\t * @param   uuid\n\t *          验证码 uuid\n\t *\n\t * @param   code\n\t *          验证码\n\t */\n\tpublic void checkCaptcha(String uuid, String code) {\n\t\tboolean flag = verifyCaptcha(uuid, code);\n\t\tif (!flag) {\n\t\t\tthrow new ForbiddenAccessException(ErrorCode.BIZ_VERIFY_CODE_ERROR);\n\t\t}\n\t}\n\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/service/login/LoginService.java",
    "content": "package im.zhaojun.zfile.module.user.service.login;\n\nimport im.zhaojun.zfile.module.user.model.request.UserLoginRequest;\nimport im.zhaojun.zfile.module.user.service.login.verify.LoginVerifyService;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.stereotype.Service;\n\nimport jakarta.annotation.Resource;\nimport java.util.List;\n\n@Slf4j\n@Service\npublic class LoginService {\n\n    @Resource\n    private List<LoginVerifyService> loginVerifyServiceList;\n\n    public void verify(UserLoginRequest userLoginRequest) {\n        loginVerifyServiceList.forEach(loginVerifyService -> {\n            loginVerifyService.verify(userLoginRequest);\n        });\n    }\n\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/service/login/TwoFactorAuthenticatorVerifyService.java",
    "content": "package im.zhaojun.zfile.module.user.service.login;\n\nimport dev.samstevens.totp.code.CodeVerifier;\nimport dev.samstevens.totp.qr.QrData;\nimport dev.samstevens.totp.qr.QrDataFactory;\nimport dev.samstevens.totp.secret.SecretGenerator;\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.status.ForbiddenAccessException;\nimport im.zhaojun.zfile.core.util.ZFileAuthUtil;\nimport im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;\nimport im.zhaojun.zfile.module.config.service.SystemConfigService;\nimport im.zhaojun.zfile.module.user.model.entity.User;\nimport im.zhaojun.zfile.module.user.model.request.VerifyLoginTwoFactorAuthenticatorRequest;\nimport im.zhaojun.zfile.module.user.model.result.LoginTwoFactorAuthenticatorResult;\nimport jakarta.annotation.Resource;\nimport org.springframework.stereotype.Service;\n\n/**\n * 2FA 双因素认证 Service\n *\n * @author zhaojun\n */\n@Service\npublic class TwoFactorAuthenticatorVerifyService {\n\n\t@Resource\n\tprivate SecretGenerator secretGenerator;\n\n\t@Resource\n\tprivate QrDataFactory qrDataFactory;\n\n\t@Resource\n\tprivate CodeVerifier verifier;\n\n\t@Resource\n\tprivate SystemConfigService systemConfigService;\n\n\n\t/**\n\t * 生成 2FA 双因素认证二维码和密钥\n\t *\n\t * @return  2FA 双因素认证二维码和密钥\n     */\n\tpublic LoginTwoFactorAuthenticatorResult setupDevice() {\n\t\t// 生成 2FA 密钥\n\t\tString secret = secretGenerator.generate();\n\n\t\t// 将生成的 2FA 密钥转换为 Base64 图像字符串\n\t\tUser currentUser = ZFileAuthUtil.getCurrentUser();\n\t\tQrData data = qrDataFactory.newBuilder().label(\"ZFile:\" + currentUser.getUsername()).secret(secret).issuer(\"ZFile\").build();\n\n\t\treturn new LoginTwoFactorAuthenticatorResult(data.getUri(), secret);\n\t}\n\n\n\t/**\n\t * 验证 2FA 双因素认证是否正确，正确则进行绑定.\n\t *\n\t * @param   verifyLoginTwoFactorAuthenticatorRequest\n\t *          2FA 双因素认证请求参数\n\t */\n\tpublic void deviceVerify(VerifyLoginTwoFactorAuthenticatorRequest verifyLoginTwoFactorAuthenticatorRequest) {\n\t\tString secret = verifyLoginTwoFactorAuthenticatorRequest.getSecret();\n\t\tString code = verifyLoginTwoFactorAuthenticatorRequest.getCode();\n\n\t\tcheckCode(secret, code);\n\n\t\tSystemConfigDTO systemConfig = systemConfigService.getSystemConfig();\n//\t\t\tsystemConfig.setLoginVerifyMode(LoginVerifyModeEnum.TWO_FACTOR_AUTHENTICATION_MODE);\n\t\tsystemConfig.setAdminTwoFactorVerify(true);\n\t\tsystemConfig.setLoginVerifySecret(secret);\n\t\tsystemConfigService.updateSystemConfig(systemConfig);\n\t}\n\n\n\t/**\n\t * 验证 2FA 双因素认证.\n\t *\n\t * @param   loginVerifySecret\n\t *          2FA 双因素认证密钥\n\t *\n\t * @param   verifyCode\n\t *          2FA 双因素认证验证码\n\t *\n\t * @throws \tForbiddenAccessException \t2FA 双因素认证失败会抛出此异常\n\t */\n\tpublic void checkCode(String loginVerifySecret, String verifyCode) {\n\t\tif (!verifier.isValidCode(loginVerifySecret, verifyCode)) {\n\t\t\tthrow new ForbiddenAccessException(ErrorCode.BIZ_2FA_CODE_ERROR);\n\t\t}\n\t}\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/service/login/verify/LoginVerifyService.java",
    "content": "package im.zhaojun.zfile.module.user.service.login.verify;\n\nimport im.zhaojun.zfile.module.user.model.request.UserLoginRequest;\n\npublic interface LoginVerifyService {\n\n    void verify(UserLoginRequest userLoginRequest);\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/service/login/verify/impl/ImgCodeLoginVerifyService.java",
    "content": "package im.zhaojun.zfile.module.user.service.login.verify.impl;\n\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;\nimport im.zhaojun.zfile.module.config.service.SystemConfigService;\nimport im.zhaojun.zfile.module.user.model.request.UserLoginRequest;\nimport im.zhaojun.zfile.module.user.service.UserService;\nimport im.zhaojun.zfile.module.user.service.login.ImgVerifyCodeService;\nimport im.zhaojun.zfile.module.user.service.login.verify.LoginVerifyService;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.apache.commons.lang3.BooleanUtils;\nimport org.springframework.core.annotation.Order;\nimport org.springframework.stereotype.Service;\n\n@Slf4j\n@Service\n@Order(1)\npublic class ImgCodeLoginVerifyService implements LoginVerifyService {\n\n    @Resource\n    private ImgVerifyCodeService imgVerifyCodeService;\n\n    @Resource\n    private SystemConfigService systemConfigService;\n\n    @Resource\n    private UserService userService;\n\n    @Override\n    public void verify(UserLoginRequest userLoginRequest) {\n        SystemConfigDTO systemConfig = systemConfigService.getSystemConfig();\n        if (BooleanUtils.isNotTrue(systemConfig.getLoginImgVerify())) {\n            return;\n        }\n\n        // 如果是管理员, 且开启了管理员二次验证, 则不需要进行图片验证码验证\n        boolean isAdmin = userService.isAdmin(userLoginRequest.getUsername());\n        boolean enable2FA = BooleanUtils.isTrue(systemConfig.getAdminTwoFactorVerify()) && StringUtils.isNotBlank(systemConfig.getLoginVerifySecret());\n        if (isAdmin && enable2FA) {\n            return;\n        }\n\n        String verifyCode = userLoginRequest.getVerifyCode();\n        String verifyCodeUuid = userLoginRequest.getVerifyCodeUUID();\n        imgVerifyCodeService.checkCaptcha(verifyCodeUuid, verifyCode);\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/service/login/verify/impl/PasswordVerifyService.java",
    "content": "package im.zhaojun.zfile.module.user.service.login.verify.impl;\n\nimport im.zhaojun.zfile.core.exception.ErrorCode;\nimport im.zhaojun.zfile.core.exception.core.BizException;\nimport im.zhaojun.zfile.module.user.model.entity.User;\nimport im.zhaojun.zfile.module.user.model.request.UserLoginRequest;\nimport im.zhaojun.zfile.module.user.service.UserService;\nimport im.zhaojun.zfile.module.user.service.login.verify.LoginVerifyService;\nimport im.zhaojun.zfile.module.user.utils.PasswordVerifyUtils;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.core.annotation.Order;\nimport org.springframework.stereotype.Service;\n\nimport jakarta.annotation.Resource;\nimport java.util.Objects;\n\n@Slf4j\n@Service\n@Order(3)\npublic class PasswordVerifyService implements LoginVerifyService {\n\n    @Resource\n    private UserService userService;\n\n    @Override\n    public void verify(UserLoginRequest userLoginRequest) {\n        User dbUser = userService.getByUsername(userLoginRequest.getUsername());\n        if (dbUser == null) {\n            throw new BizException(ErrorCode.BIZ_LOGIN_ERROR);\n        }\n\n        String dbPassword = dbUser.getPassword();\n        String dbSalt = dbUser.getSalt();\n        String requestPassword = userLoginRequest.getPassword();\n\n        if (!PasswordVerifyUtils.verify(dbPassword, dbSalt, requestPassword)) {\n            throw new BizException(ErrorCode.BIZ_LOGIN_ERROR);\n        }\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/service/login/verify/impl/TwoFactorAuthLoginVerifyService.java",
    "content": "package im.zhaojun.zfile.module.user.service.login.verify.impl;\n\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;\nimport im.zhaojun.zfile.module.config.service.SystemConfigService;\nimport im.zhaojun.zfile.module.user.model.request.UserLoginRequest;\nimport im.zhaojun.zfile.module.user.service.UserService;\nimport im.zhaojun.zfile.module.user.service.login.TwoFactorAuthenticatorVerifyService;\nimport im.zhaojun.zfile.module.user.service.login.verify.LoginVerifyService;\nimport jakarta.annotation.Resource;\nimport lombok.extern.slf4j.Slf4j;\nimport org.apache.commons.lang3.BooleanUtils;\nimport org.springframework.core.annotation.Order;\nimport org.springframework.stereotype.Service;\n\n@Slf4j\n@Service\n@Order(2)\npublic class TwoFactorAuthLoginVerifyService implements LoginVerifyService {\n\n    @Resource\n    private TwoFactorAuthenticatorVerifyService twoFactorAuthVerifyService;\n\n    @Resource\n    private SystemConfigService systemConfigService;\n\n    @Resource\n    private UserService userService;\n\n    @Override\n    public void verify(UserLoginRequest userLoginRequest) {\n        // 如果不是管理员, 则不需要进行二次验证\n        if (!userService.isAdmin(userLoginRequest.getUsername())) {\n            return;\n        }\n\n        // 判断是否开启管理员二次验证\n        SystemConfigDTO systemConfig = systemConfigService.getSystemConfig();\n        boolean disable2FA = BooleanUtils.isNotTrue(systemConfig.getAdminTwoFactorVerify());\n        boolean empty2FASecret = StringUtils.isBlank(systemConfig.getLoginVerifySecret());\n        if (disable2FA || empty2FASecret) {\n            return;\n        }\n\n        String loginVerifySecret = systemConfig.getLoginVerifySecret();\n        String verifyCode = userLoginRequest.getVerifyCode();\n        twoFactorAuthVerifyService.checkCode(loginVerifySecret, verifyCode);\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/util/LoginEntryPathUtils.java",
    "content": "package im.zhaojun.zfile.module.user.util;\n\nimport im.zhaojun.zfile.core.util.StringUtils;\n\n/**\n * 登录入口路径解析工具类。\n *\n * @author zhaojun\n */\npublic final class LoginEntryPathUtils {\n\n    private LoginEntryPathUtils() {\n    }\n    public static final String DEFAULT_LOGIN_PATH = \"/login\";\n\n    public static final String DEFAULT_LOGIN_API_PATH = \"/user/login\";\n\n    /**\n     * 根据安全入口配置值解析实际登录地址。\n     *\n     * @param secureLoginEntry 配置的安全入口值\n     * @return 对应的登录路径\n     */\n    public static String resolveLoginPath(String secureLoginEntry) {\n        if (StringUtils.isBlank(secureLoginEntry)) {\n            return DEFAULT_LOGIN_PATH;\n        }\n\n        return DEFAULT_LOGIN_PATH + StringUtils.SLASH + secureLoginEntry;\n    }\n\n    /**\n     * 根据安全入口配置值解析实际登录地址。\n     *\n     * @param secureLoginEntry 配置的安全入口值\n     * @return 对应的登录路径\n     */\n    public static String resolveLoginApiPath(String secureLoginEntry) {\n        if (StringUtils.isBlank(secureLoginEntry)) {\n            return DEFAULT_LOGIN_API_PATH;\n        }\n\n        return DEFAULT_LOGIN_API_PATH + StringUtils.SLASH + secureLoginEntry;\n    }\n\n}"
  },
  {
    "path": "src/main/java/im/zhaojun/zfile/module/user/utils/PasswordVerifyUtils.java",
    "content": "package im.zhaojun.zfile.module.user.utils;\n\nimport cn.hutool.crypto.SecureUtil;\nimport im.zhaojun.zfile.core.util.StringUtils;\nimport org.springframework.data.util.Pair;\n\nimport java.util.Objects;\n\n/**\n * @author zhaojun\n */\npublic class PasswordVerifyUtils {\n\n    public static boolean verify(String dbPassword, String dbSalt, String requestPassword) {\n        // 根据是否有盐值, 选择加密方式（兼容旧版本没有盐值的情况）\n        String encryptedPassword;\n        if (StringUtils.isBlank(dbSalt)) {\n            encryptedPassword = SecureUtil.md5(requestPassword);\n        } else {\n            String sha1Pwd = SecureUtil.sha1(requestPassword);\n            encryptedPassword = SecureUtil.md5(sha1Pwd + dbSalt);\n        }\n        return Objects.equals(dbPassword, encryptedPassword);\n    }\n\n    public static Pair<String, String> encrypt(String password) {\n        String sha1Pwd = SecureUtil.sha1(password);\n        String randomSalt = SecureUtil.md5(SecureUtil.sha1(String.valueOf(System.currentTimeMillis())));\n        return Pair.of(SecureUtil.md5(sha1Pwd + randomSalt), randomSalt);\n    }\n\n}"
  },
  {
    "path": "src/main/resources/META-INF/additional-spring-configuration-metadata.json",
    "content": "{\n    \"properties\": [\n        {\n            \"name\": \"zfile.onedrive.clientId\",\n            \"type\": \"java.lang.String\",\n            \"description\": \"OneDrive ClientId.\"\n        },\n        {\n            \"name\": \"zfile.onedrive.clientSecret\",\n            \"type\": \"java.lang.String\",\n            \"description\": \"OneDrive ClientSecret.\"\n        },\n        {\n            \"name\": \"zfile.onedrive.redirectUri\",\n            \"type\": \"java.lang.String\",\n            \"description\": \"OneDrive 认证重定向地址.\"\n        },\n        {\n            \"name\": \"zfile.onedrive.scope\",\n            \"type\": \"java.lang.String\",\n            \"description\": \"OneDrive 认证权限.\"\n        },\n        {\n            \"name\": \"zfile.onedrive-china.clientId\",\n            \"type\": \"java.lang.String\",\n            \"description\": \"OneDrive China ClientId.\"\n        },\n        {\n            \"name\": \"zfile.onedrive-china.clientSecret\",\n            \"type\": \"java.lang.String\",\n            \"description\": \"OneDrive China ClientSecret.\"\n        },\n        {\n            \"name\": \"zfile.onedrive-china.redirectUri\",\n            \"type\": \"java.lang.String\",\n            \"description\": \"OneDrive China 认证重定向地址.\"\n        },\n        {\n            \"name\": \"zfile.onedrive-china.scope\",\n            \"type\": \"java.lang.String\",\n            \"description\": \"OneDrive China 认证权限.\"\n        },\n        {\n            \"name\": \"zfile.preview.text.maxFileSizeKb\",\n            \"type\": \"java.lang.Long\",\n            \"description\": \"允许在线读取文本文件的文件大小, 单位为 KB.\"\n        },\n        {\n            \"name\": \"zfile.log.path\",\n            \"type\": \"java.lang.String\",\n            \"description\": \"日志文件路径.\"\n        },\n        {\n            \"name\": \"zfile.db.path\",\n            \"type\": \"java.lang.String\",\n            \"description\": \"数据库文件路径.\"\n        },\n        {\n            \"name\": \"zfile.debug\",\n            \"type\": \"java.lang.Boolean\",\n            \"description\": \"是否开启 debug 模式.\"\n        },\n        {\n            \"name\": \"zfile.directLinkPrefix\",\n            \"type\": \"java.lang.String\",\n            \"defaultValue\": \"directlink\",\n            \"description\": \"直链前缀名称, 默认为 directlink\"\n        }\n    ]\n}"
  },
  {
    "path": "src/main/resources/application-default.properties",
    "content": "zfile.demo-site=false\n\nzfile.db.version=4.5.0\n\n# onedrive config\nzfile.onedrive.clientId=09939809-c617-43c8-a220-a93c1513c5d4\nzfile.onedrive.clientSecret=_l:zI-_yrW75lV8M61K@z.I2K@B/On6Q\nzfile.onedrive.redirectUri=https://zfile.jun6.net/onedrive/callback\nzfile.onedrive.scope=offline_access User.Read Files.ReadWrite.All Sites.Read.All Sites.ReadWrite.All\n\n# onedrive china config\nzfile.onedrive-china.clientId=4a72d927-1907-488d-9eb2-1b465c53c1c5\nzfile.onedrive-china.clientSecret=Y9CEA=82da5n-y_]KAWAgLH3?R9xf7Uw\nzfile.onedrive-china.redirectUri=https://zfile.jun6.net/onedrive/china-callback\nzfile.onedrive-china.scope=offline_access User.Read Files.ReadWrite.All Sites.Read.All Sites.ReadWrite.All\n\n# gd config\nzfile.gd.clientId=659016983345-vlp413rgrl2spe5d53ml16p2btslfa44.apps.googleusercontent.com\nzfile.gd.clientSecret=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\nzfile.gd.redirectUri=http://localhost:8080/gd/callback\nzfile.gd.scope=https://www.googleapis.com/auth/drive\n\n# 115 config\nzfile.open115.appId=100196273\n\n# result config\nspring.jackson.date-format=yyyy-MM-dd HH:mm:ss\nspring.jackson.time-zone=GMT+8\nspring.web.resources.chain.compressed=true\n\n## mybatis config\nmybatis-plus.configuration.map-underscore-to-camel-case=true\nmybatis-plus.mapper-locations=classpath*:mapper/*.xml,classpath*:com/gitee/sunchenbin/mybatis/actable/mapping/*/*.xml\n\n## flyway config\nspring.flyway.clean-disabled=true\nspring.flyway.enabled=true\nspring.flyway.out-of-order=true\nspring.flyway.ignore-migration-patterns=versioned:missing\n\n# knife4j config\nknife4j.enable=true\nknife4j.setting.enableSwaggerModels=true\n\n# sa-token config\nsa-token.is-print=false\nsa-token.token-name=zfile-token\nspring.main.allow-circular-references=false\n\nspring.servlet.multipart.max-request-size=-1\nspring.servlet.multipart.max-file-size=-1\n\nmybatis-plus.configuration.log-impl=org.apache.ibatis.logging.nologging.NoLoggingImpl\n\nspring.mvc.pathmatch.matching-strategy=ant_path_matcher\n\nserver.compression.enabled=true\n\nzfile.log.encoder=UTF-8\n\nspring.cache.redis.time-to-live=10m"
  },
  {
    "path": "src/main/resources/application-dev.properties",
    "content": ""
  },
  {
    "path": "src/main/resources/application-prod.properties",
    "content": "# product disable swagger and knife4j\nspringdoc.swagger-ui.enabled=false\nspringdoc.api-docs.enabled=false\nknife4j.enable=false"
  },
  {
    "path": "src/main/resources/application.properties",
    "content": "spring.profiles.active=prod\nspring.config.import=classpath:application-default.properties\n\nzfile.debug=false\n\nzfile.log.path=${user.home}/.zfile-v4/logs\nzfile.db.path=${user.home}/.zfile-v4/db/zfile\n\nzfile.preview.text.maxFileSizeKb=512\n\nzfile.dbCache.enable=true\n\n# read external static resources\nspring.web.resources.static-locations=file:static/\nserver.port=8080\n\n# -------------- database config start --------------\n\n## sqlite\nspring.datasource.driver-class-name=org.sqlite.JDBC\nspring.datasource.url=jdbc:sqlite:${zfile.db.path}\n\n## mysql\n#spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver\n#spring.datasource.url=jdbc:mysql://127.0.0.1:3306/zfile?characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true\n#spring.datasource.username=root\n#spring.datasource.password=password\n\nmybatis-plus.type-handlers-package=im.zhaojun.zfile.core.config\n# -------------- database config end --------------\n\n\n\n# -------------- redis config start --------------\n\n#spring.data.redis.host=127.0.0.1\n#spring.data.redis.port=6379\n#spring.data.redis.password=\n#spring.data.redis.database=1\n#spring.data.redis.timeout=5s\n#spring.data.redis.lettuce.pool.max-active=20\n#spring.data.redis.lettuce.pool.max-idle=10\n#spring.data.redis.lettuce.pool.min-idle=5\n\n# -------------- redis config end ----------------\n"
  },
  {
    "path": "src/main/resources/banner.txt",
    "content": " ________  ________ ___  ___       _______\n|\\_____  \\|\\  _____\\\\  \\|\\  \\     |\\  ___ \\\n \\|___/  /\\ \\  \\__/\\ \\  \\ \\  \\    \\ \\  ___\n     /  / /\\ \\   __\\\\ \\  \\ \\  \\    \\ \\  ___\\\n    /  /_/__\\ \\  \\_| \\ \\  \\ \\  \\____\\ \\  _____\n   |\\________\\ \\__\\   \\ \\__\\ \\_______\\ \\_______\\\n    \\|_______|\\|__|    \\|__|\\|_______|\\|_______|"
  },
  {
    "path": "src/main/resources/db/migration-mysql/R__data.sql",
    "content": "INSERT INTO system_config (`name`, `title`) VALUES ('siteName', '站点名称');\rINSERT INTO system_config (`name`, `title`) VALUES ('username', '管理员账号');\rINSERT INTO system_config (`name`, `title`) VALUES ('password', '管理员密码');\rINSERT INTO system_config (`name`, `title`) VALUES ('domain', '站点域名');\rINSERT INTO system_config (`name`, `title`) VALUES ('customCss', '自定义 CSS');\rINSERT INTO system_config (`name`, `title`) VALUES ('customJs', '自定义 JS (可用于统计代码)');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('tableSize', '表格大小', 'small');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('showDocument', '是否显示文档', 'true');\rINSERT INTO system_config (`name`, `title`) VALUES ('announcement', '网站公告');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('showAnnouncement', '是否显示网站公告', 'true');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('layout', '页面布局', 'full');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('showLinkBtn', '是否显示生成直链按钮', 'true');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('showShortLink', '是否显示短链', 'true');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('showPathLink', '是否显示路径直链', 'true');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('installed', '是否已初始化安装', 'false');\rINSERT INTO system_config (`name`, `title`) VALUES ('avatar', '头像地址');\rINSERT INTO system_config (`name`, `title`) VALUES ('icp', 'ICP 备案号');\rINSERT INTO system_config (`name`, `title`) VALUES ('customVideoSuffix', '自定义视频文件后缀格式');\rINSERT INTO system_config (`name`, `title`) VALUES ('customImageSuffix', '自定义图像文件后缀格式');\rINSERT INTO system_config (`name`, `title`) VALUES ('customAudioSuffix', '自定义音频文件后缀格式');\rINSERT INTO system_config (`name`, `title`) VALUES ('customTextSuffix', '自定义文本文件后缀格式');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('directLinkPrefix', '直链前缀地址', 'directlink');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('refererType', '直链 Referer 防盗链类型', 'off');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('refererAllowEmpty', '直链 Referer 是否允许为空', 'true');\rINSERT INTO system_config (`name`, `title`) VALUES ('refererValue', '直链 Referer 值');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('loginVerifyMode', '登陆验证方式，支持验证码和 2FA 认证', 'off');\rINSERT INTO system_config (`name`, `title`) VALUES ('loginVerifySecret', '登陆验证 Secret');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('rootShowStorage', '根目录是否显示所有存储源', 'true');\rINSERT INTO system_config (`name`, `title`) VALUES ('frontDomain', '前端域名，前后端分离情况下需要配置');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('recordDownloadLog', '是否记录下载日志', 'true');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('showLogin', '是否在前台显示登陆按钮', 'true');\rINSERT INTO system_config (`name`, `title`) VALUES ('rsaHexKey', 'RSA 算法 HEX 格式密钥');"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V10__system_config_add_field_webdav.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('webdavEnable', '启用 WebDAV 服务', 'false');\nINSERT INTO system_config (`name`, `title`, `value`) VALUES ('webdavProxy', '是否启用 WebDAV 服务器中转下载', 'true');\nINSERT INTO system_config (`name`, `title`, `value`) VALUES ('webdavUsername', 'WebDAV 账号', '');\nINSERT INTO system_config (`name`, `title`, `value`) VALUES ('webdavPassword', 'WebDAV 密码', '');"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V11__system_config_modify_field_only_office_url_to_https.sql",
    "content": "UPDATE system_config SET value = 'https://office.zfile.vip' WHERE value = 'http://office.zfile.vip';"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V12__system_config_modify_field_value_to_text.sql",
    "content": "alter table system_config modify value text null comment '系统设置属性 value';"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V13__system_config_add_field_allow_path_link_anon_access.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('allowPathLinkAnonAccess', '是否允许路径直链可直接访问', 'false');"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V14__system_config_add_field_load_more_size.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('maxShowSize', '默认最大显示文件数', '1000');\nINSERT INTO system_config (`name`, `title`, `value`) VALUES ('loadMoreSize', '每次加载更多文件数', '50');"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V15__system_config_add_field_site_home_name.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('siteHomeName', '站点 Home 名称', '首页');\nINSERT INTO system_config (`name`, `title`, `value`) VALUES ('siteHomeLogo', '站点 Home Logo', null);\nINSERT INTO system_config (`name`, `title`, `value`) VALUES ('siteHomeLogoLink', '站点 Logo 打开链接', '/');\nINSERT INTO system_config (`name`, `title`, `value`) VALUES ('siteHomeLogoTargetMode', '站点 Logo 链接打开方式', '_blank');"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V16__system_config_add_field_default_sort_field.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('defaultSortField', '默认排序字段', 'name');\nINSERT INTO system_config (`name`, `title`, `value`) VALUES ('defaultSortOrder', '默认排序字段', 'asc');"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V17__system_config_add_field_link_limit_field.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('linkLimitSecond', '限制直链下载秒数', '3600');\nINSERT INTO system_config (`name`, `title`, `value`) VALUES ('linkDownloadLimit', '限制直链下载次数', '10000');"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V18__download_log_add_field_download_type.sql",
    "content": "alter table download_log add download_type varchar(32);"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V19__short_link_add_field_expire_date.sql",
    "content": "alter table short_link add expire_date datetime;"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V1__Base_version.sql",
    "content": "create table if not exists storage_source\r(\r    id int auto_increment\r        primary key,\r    enable bit null comment '使用启用',\r    enable_cache bit null comment '是否开启缓存',\r    name varchar(255) null comment '存储源名称',\r    auto_refresh_cache bit null comment '是否开启缓存自动刷新',\r    type varchar(64) null comment '存储源类型',\r    search_enable bit null comment '是否开启搜索',\r    search_ignore_case bit null comment '搜索是否忽略大小写',\r    order_num int null comment '排序',\r    default_switch_to_img_mode bit null comment '是否默认开启图片模式',\r    remark text null comment '备注',\r    `key` varchar(64) null comment '存储源别名',\r    enable_file_operator bit null comment '是否启用文件操作',\r    search_mode varchar(32) null comment '搜索模式, 仅从缓存中搜索还是直接搜索',\r    enable_file_anno_operator bit null comment '是否允许匿名进行文件操作'\r)\r    comment '存储源设置';\r\rcreate table if not exists filter_config\r(\r    id int auto_increment\r        primary key,\r    storage_id int null comment '存储源 ID',\r    expression varchar(255) null comment '路径表达式',\r    description varchar(255) null comment '表达式描述',\r    mode varchar(255) null\r)\r    comment '过滤设置';\r\rcreate table if not exists short_link\r(\r    id int auto_increment\r        primary key,\r    short_key varchar(255) null comment '短链 key',\r    url text null comment '链接 url',\r    create_date datetime null comment '创建时间',\r    storage_id int null comment '存储源 ID'\r)\r    comment '短链设置';\r\rcreate table if not exists storage_source_config\r(\r    id int auto_increment\r        primary key,\r    name varchar(255) null comment '存储源属性 name',\r    type text null comment '存储源类型',\r    title varchar(255) null comment '存储源属性名称',\r    storage_id int null comment '存储源 ID',\r    value text null comment '存储源属性 value'\r)\r    comment '存储源属性设置';\r\rcreate table if not exists system_config\r(\r    id int auto_increment\r        primary key,\r    name varchar(255) null comment '系统设置属性 name',\r    value varchar(255) null comment '系统设置属性 value',\r    title varchar(255) null comment '系统设置属性标题'\r)\r    comment '系统设置';\r\rcreate table if not exists password_config\r(\r    id int auto_increment\r        primary key,\r    storage_id int null comment '存储源 ID',\r    expression varchar(255) null comment '路径表达式',\r    password varchar(255) null comment '密码',\r    description varchar(255) null comment '表达式描述'\r)\r    comment '密码文件夹设置';\r\rcreate table if not exists readme_config\r(\r    id int auto_increment\r        primary key,\r    storage_id int null comment '存储源 ID',\r    expression varchar(255) null comment '路径表达式',\r    description varchar(255) null comment '表达式描述',\r    readme_text text null comment 'readme 文本内容, 支持 md 语法.',\r    display_mode varchar(32) null comment '显示模式，支持顶部显示: top, 底部显示:bottom, 弹窗显示: dialog'\r)\r    comment 'readme 文档配置';\r\rcreate table if not exists download_log\r(\r    id   int auto_increment primary key,\r    path varchar(2048) null comment '文件路径',\r    storage_key varchar(8) null comment '存储源 key',\r    create_time datetime null comment '访问时间',\r    ip varchar(20) null comment '访问 ip',\r    user_agent varchar(2048) null comment '访问 user_agent',\r    referer varchar(2048) null comment '访问 referer',\r    short_key varchar(255) null comment '短链 key'\r)\r    comment '文件下载日志';"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V20__system_config_add_field_favicon_url_field.sql",
    "content": "INSERT INTO system_config (`name`, `title`) VALUES ('faviconUrl', '网站 favicon 图标地址');"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V21__system_config_add_field_expire_times_field.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('linkExpireTimes', '短链过期时间设置', '[{ \"value\": 1, \"unit\": \"d\", \"seconds\": 86400 }]');"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V22__system_config_add_field_default_save_pwd_field.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('defaultSavePwd', '是否默认记住密码', 'false');"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V23__system_config_add_field_only_office_secret_field.sql",
    "content": "INSERT INTO system_config (`name`, `title`) VALUES ('onlyOfficeSecret', 'OnlyOffice Secret，用于生成 JWT Token');"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V24__system_config_add_field_enable_hover_menu_field.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('enableHoverMenu', '是否启用悬浮菜单', 'true');"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V25__system_config_add_field_site_access_field.sql",
    "content": "INSERT INTO system_config (`name`, `title`) VALUES ('accessIpBlocklist', 'ip 黑名单');\nINSERT INTO system_config (`name`, `title`) VALUES ('accessUaBlocklist', 'ua 黑名单');"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V26__system_config_add_field_login_verify.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`)\nSELECT 'loginImgVerify', '是否启用登录图片验证码',\n       IF(\n                   (SELECT value FROM system_config WHERE name = 'loginVerifyMode') = 'image',\n                   'true',\n                   'false'\n           );\n\nINSERT INTO system_config (`name`, `title`, `value`)\nSELECT 'adminTwoFactorVerify', '是否为管理员启用双因素认证',\n       IF(\n                   (SELECT value FROM system_config WHERE name = 'loginVerifyMode') = '2fa',\n                   'true',\n                   'false'\n           );"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V27__add_table_login_log.sql",
    "content": "create table if not exists login_log\n(\n    id   int auto_increment primary key,\n    username varchar(255) null comment '用户名',\n    nickname varchar(255) null comment '用户昵称',\n    password varchar(255) null comment '密码',\n    create_time datetime null comment '登录时间',\n    ip varchar(20) null comment '登录 ip',\n    user_agent varchar(2048) null comment '登录 user_agent',\n    referer varchar(2048) null comment '登录 referer',\n    result varchar(255) null comment '登录结果'\n)\n    comment '登录日志';"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V28__add_multi_user.sql",
    "content": "CREATE TABLE IF NOT EXISTS user\n(\n    id                  INT AUTO_INCREMENT PRIMARY KEY COMMENT '用户ID',\n    username            VARCHAR(255) NULL COMMENT '用户名',\n    nickname            varchar(255) NULL COMMENT '昵称',\n    password            VARCHAR(32)  NULL COMMENT '用户密码',\n    enable              BIT          NULL COMMENT '是否启用',\n    create_time         DATETIME     NULL COMMENT '创建时间',\n    update_time         DATETIME     NULL COMMENT '更新时间',\n    default_permissions TEXT         NULL COMMENT '默认权限'\n) COMMENT '用户表';\n\nCREATE TABLE IF NOT EXISTS user_storage_source\n(\n    id                INT AUTO_INCREMENT PRIMARY KEY COMMENT '用户存储源ID',\n    user_id           INT  NULL COMMENT '用户ID',\n    storage_source_id INT  NULL COMMENT '存储源ID',\n    root_path         TEXT NULL COMMENT '根路径',\n    enable            BIT  NULL COMMENT '是否启用',\n    permissions        TEXT NULL COMMENT '权限列表'\n) COMMENT '用户存储源表';\n\ninsert into user (id, username, nickname, password, enable, create_time) values (1, (select value from system_config where name = 'username'), '管理员', (select value from system_config where name = 'password'), true, current_timestamp);\ninsert into user (id, username, nickname, password, enable, create_time) values (2, 'guest', '匿名用户', null, true, current_timestamp);\n\n-- 迁移管理员权限\ninsert into user_storage_source (user_id, storage_source_id, root_path, enable, permissions)\nselect 1, storage_id, '/', instr(group_concat(operator), 'available') > 0, group_concat(operator) permissions from permission_config\nwhere allow_admin = true\ngroup by storage_id;\n\n-- 迁移匿名用户权限\ninsert into user_storage_source (user_id, storage_source_id, root_path, enable, permissions)\nselect 2, storage_id, '/', instr(group_concat(operator), 'available') > 0, group_concat(operator) permissions from permission_config\nwhere allow_anonymous = true\ngroup by storage_id;"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V29__system_config_add_field_login_verify.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('siteAdminLogoTargetMode', '管理员页面点击 Logo 回到首页打开方式', '_blank');\nINSERT INTO system_config (`name`, `title`, `value`) VALUES ('siteAdminVersionOpenChangeLog', '管理员页面点击版本号打开更新日志', 'true');"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V2__download_log_modify_storage_key_field_length.sql",
    "content": "alter table download_log modify storage_key varchar(64) null comment '存储源 key';"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V30__delete_storage_source_auto_cors_config.sql",
    "content": "delete from storage_source_config where name = 'autoConfigCors';"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V31__system_config_add_field_webdav.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('webdavAllowAnonymous', '是否允许匿名访问', 'false');"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V32__system_config_delete_domain_field.sql",
    "content": "delete from system_config where name = 'domain';"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V33__storage_source_config_update_field.sql",
    "content": "UPDATE\n    storage_source_config\nSET\n    name = 'proxyPrivate'\nWHERE\n    type NOT IN ('huawei', 'doge-cloud', 'aliyun', 's3', 'qiniu', 'minio', 'tencent')\n  AND\n    name = 'isPrivate';\n\nUPDATE\n    storage_source_config\nSET\n    name = 'proxyTokenTime'\nWHERE\n    type NOT IN ('huawei', 'doge-cloud', 'aliyun', 's3', 'qiniu', 'minio', 'tencent')\n  AND\n    name = 'tokenTime';\n\nUPDATE\n    storage_source_config\nSET\n    name = 'proxyLimitSpeed'\nWHERE\n    type NOT IN ('aliyun', 'tencent')\n  AND\n    name = 'limitSpeed';"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V34__storage_source_config_update_field.sql",
    "content": "INSERT INTO storage_source_config (name, type, title, storage_id, value) select 'proxyLinkForceDownload', type, '下载链接强制下载', id, 'true' from storage_source;"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V35__system_config_add_field_login_log_mode.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('loginLogMode', '登录日志模式', 'all');"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V36__user_add_field_salt.sql",
    "content": "alter table `user` add salt varchar(32);"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V37__set_login_log_model_default_off.sql",
    "content": "UPDATE system_config SET value = 'ignoreAllPwd' WHERE name = 'loginLogMode';"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V38__update_login_log_ip_field_length.sql",
    "content": "alter table login_log modify ip varchar(64) null comment '登录 ip';"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V3__system_config_add_field_file_click_mode.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('fileClickMode', '默认文件点击模式', 'dbclick');"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V40__system_config_add_field_mobile_layout.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('mobileLayout', '移动端布局', 'full');"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V41__system_config_add_custom_office_suffix.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('customOfficeSuffix', 'Office 预览类型', 'doc,docx,csv,xls,xlsx,ppt,pptx,xlsm');"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V42__system_config_add_guest_index_html.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('guestIndexHtml', '匿名用户首页显示内容', '');"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V43__set_2fa_default_value.sql",
    "content": "UPDATE system_config SET value = '' WHERE name = 'loginVerifySecret';\nUPDATE system_config SET value = 'false' WHERE name = 'loginImgVerify';\nUPDATE system_config SET value = 'false' WHERE name = 'adminTwoFactorVerify';"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V44__system_config_add_mobile_.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`)\nselect 'mobileFileClickMode', '移动端默认文件点击模式', value\nfrom system_config\nwhere name = 'fileClickMode';"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V45__add_sso_config.sql",
    "content": "CREATE TABLE IF NOT EXISTS `sso_config`\n(\n    id                  INT AUTO_INCREMENT PRIMARY KEY COMMENT '主键 ID',\n    `provider`       varchar(255) NOT NULL COMMENT '服务商简称',\n    `name`           varchar(255) NOT NULL COMMENT '名称',\n    `icon`           text         NOT NULL COMMENT '图标, 支持url和base64',\n    `client_id`      varchar(255) NOT NULL COMMENT 'Client ID',\n    `client_secret`  varchar(255) NOT NULL COMMENT 'Client Secret',\n    `auth_url`       varchar(255) NOT NULL COMMENT '授权端点',\n    `token_url`      varchar(255) NOT NULL COMMENT 'Token 端点',\n    `user_info_url`  varchar(255) NOT NULL COMMENT '用户信息端点',\n    `scope`          varchar(255) NOT NULL COMMENT '授权范围, 可填多个, 用空格隔开',\n    `binding_field`  varchar(255) NOT NULL COMMENT '单点登录系统中用户与业务系统中用户的绑定字段',\n    `enabled`        BIT          NOT NULL COMMENT '服务商是否启用',\n    `order_num`      INT          NOT NULL COMMENT '排序字段, 越小越靠前' DEFAULT 0\n) DEFAULT CHARSET = utf8mb4\n    COMMENT = '单点登录 (OIDC/OAuth2.0) 配置信息表';"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V46__add_template_user.sql",
    "content": "INSERT INTO `user` (username, nickname, enable, create_time, update_time) VALUES ( 'template', '虚拟新用户', true, now(), now());\nUPDATE `user` SET `id` = 0 WHERE `username` = 'template';"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V47__system_config_add_force_backend_address.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('forceBackendAddress', '强制后端地址', '');"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V48__system_config_add_field_kkfileview_url.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('kkFileViewUrl', 'kkFileView 地址', '');"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V49__system_config_add_custom_kkfileview_suffix.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('customKkFileViewSuffix', 'kkFileView 预览类型', '3dm,3ds,3mf,7z,bim,bpmn,brep,cf2,dcm,dng,doc,docx,dot,dotm,dotx,dps,drawio,dwf,dwfx,dwg,dwt,dxf,emf,eml,eps,et,ett,fbx,fcstd,flv,fodt,fods,glb,gltf,gzip,ifc,iges,jar,jfif,jpg,js,md,mkv,mov,mp3,mp4,mpeg,mpg,obj,odp,ods,odt,ofd,off,ogg,otp,ots,ott,pages,pdf,php,plt,ply,png,ppt,pptx,psd,py,rar,rm,rmvb,rtf,six,stl,step,svg,swf,tar,tga,tif,tiff,ts,tsv,txt,vsd,vsdx,wav,webm,webp,wmf,wmv,wps,wpt,wrl,xla,xlam,xls,xlsm,xlsx,xlt,xltm,xmind,xml,zip');"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V4__download_log_modify_ip_field_length.sql",
    "content": "alter table download_log modify ip varchar(64) null comment '访问 ip';"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V50__system_config_add_kkfileview_open_mode.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('kkFileViewOpenMode', 'kkFileView 预览方式', 'iframe');"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V51__storage_source_config_add_refresh_token_expired_at.sql",
    "content": "INSERT INTO\n    storage_source_config (name, type, title, storage_id)\nSELECT\n    'refreshTokenExpiredAt', type, '刷新令牌过期时间', storage_id\nFROM\n    storage_source_config\nWHERE\n    name = 'refreshToken';"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V52__ststem_config_add_mobile_show_file_size.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('mobileShowSize', '移动端显示文件大小', 'true');"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V53__readme_config_add_path_mode_field.sql",
    "content": "alter table readme_config add path_mode varchar(32);\nupdate readme_config set path_mode = 'relative' where path_mode is null;"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V54__add_share_link_table.sql",
    "content": "CREATE TABLE IF NOT EXISTS `share_link`\n(\n    `id`             INT AUTO_INCREMENT PRIMARY KEY COMMENT '主键 ID',\n    `share_key`      varchar(255) NOT NULL COMMENT '分享链接 key',\n    `password`       varchar(8)   NULL COMMENT '分享密码',\n    `expire_date`    datetime     NULL COMMENT '过期时间',\n    `storage_key`    varchar(255) NOT NULL COMMENT '存储源key',\n    `share_path`     text         NOT NULL COMMENT '分享所在目录',\n    `share_item`     text         NOT NULL COMMENT '分享项目(JSON格式)',\n    `create_date`    datetime     NOT NULL COMMENT '创建时间',\n    `share_type`     varchar(20)  NOT NULL COMMENT '分享类型: FILE/FOLDER/MULTIPLE',\n    `user_id`        INT          NOT NULL COMMENT '创建分享的用户ID',\n    `download_count` INT          NOT NULL DEFAULT 0 COMMENT '下载次数',\n    `access_count`   INT          NOT NULL DEFAULT 0 COMMENT '访问次数',\n    UNIQUE KEY `idx_share_key` (`share_key`)\n) DEFAULT CHARSET = utf8mb4\n    COMMENT = '文件分享链接表';"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V55__system_config_add_secure_login_entry.sql",
    "content": "INSERT INTO system_config (`name`, `title`)\nSELECT 'secureLoginEntry', '安全登录入口'\nFROM DUAL\nWHERE NOT EXISTS (SELECT 1 FROM system_config WHERE `name` = 'secureLoginEntry');"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V56__system_config_add_download_confirm_flags.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`)\nSELECT 'enableNormalDownloadConfirm', '普通下载是否启用确认弹窗', 'true'\nWHERE NOT EXISTS (\n    SELECT 1 FROM system_config WHERE name = 'enableNormalDownloadConfirm'\n);\n\nINSERT INTO system_config (`name`, `title`, `value`)\nSELECT 'enablePackageDownloadConfirm', '打包下载是否启用确认弹窗', 'true'\nWHERE NOT EXISTS (\n    SELECT 1 FROM system_config WHERE name = 'enablePackageDownloadConfirm'\n);\n\nINSERT INTO system_config (`name`, `title`, `value`)\nSELECT 'enableBatchDownloadConfirm', '批量下载是否启用确认弹窗', 'true'\nWHERE NOT EXISTS (\n    SELECT 1 FROM system_config WHERE name = 'enableBatchDownloadConfirm'\n);"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V57__user_add_default_share_permissions.sql",
    "content": "-- 为管理员默认补齐分享权限（createShareLink、customShareKey）\n\n-- 补齐 user.default_permissions 中的 createShareLink\nUPDATE user\nSET default_permissions = CASE\n    WHEN default_permissions IS NULL OR default_permissions = '' THEN 'createShareLink'\n    WHEN CONCAT(',', REPLACE(IFNULL(default_permissions, ''), ' ', ''), ',') NOT LIKE '%,createShareLink,%'\n        THEN CONCAT_WS(',', NULLIF(default_permissions, ''), 'createShareLink')\n    ELSE default_permissions\nEND\nWHERE id = 1\n  AND (\n        default_permissions IS NULL\n        OR default_permissions = ''\n        OR CONCAT(',', REPLACE(IFNULL(default_permissions, ''), ' ', ''), ',') NOT LIKE '%,createShareLink,%'\n    );\n\n-- 补齐 user.default_permissions 中的 customShareKey\nUPDATE user\nSET default_permissions = CASE\n    WHEN default_permissions IS NULL OR default_permissions = '' THEN 'customShareKey'\n    WHEN CONCAT(',', REPLACE(IFNULL(default_permissions, ''), ' ', ''), ',') NOT LIKE '%,customShareKey,%'\n        THEN CONCAT_WS(',', NULLIF(default_permissions, ''), 'customShareKey')\n    ELSE default_permissions\nEND\nWHERE id = 1\n  AND (\n        default_permissions IS NULL\n        OR default_permissions = ''\n        OR CONCAT(',', REPLACE(IFNULL(default_permissions, ''), ' ', ''), ',') NOT LIKE '%,customShareKey,%'\n    );\n\n-- 补齐 user_storage_source.permissions 中的 createShareLink\nUPDATE user_storage_source\nSET permissions = CASE\n    WHEN permissions IS NULL OR permissions = '' THEN 'createShareLink'\n    WHEN CONCAT(',', REPLACE(IFNULL(permissions, ''), ' ', ''), ',') NOT LIKE '%,createShareLink,%'\n        THEN CONCAT_WS(',', NULLIF(permissions, ''), 'createShareLink')\n    ELSE permissions\nEND\nWHERE user_id = 1\n  AND (\n        permissions IS NULL\n        OR permissions = ''\n        OR CONCAT(',', REPLACE(IFNULL(permissions, ''), ' ', ''), ',') NOT LIKE '%,createShareLink,%'\n    );\n\n-- 补齐 user_storage_source.permissions 中的 customShareKey\nUPDATE user_storage_source\nSET permissions = CASE\n    WHEN permissions IS NULL OR permissions = '' THEN 'customShareKey'\n    WHEN CONCAT(',', REPLACE(IFNULL(permissions, ''), ' ', ''), ',') NOT LIKE '%,customShareKey,%'\n        THEN CONCAT_WS(',', NULLIF(permissions, ''), 'customShareKey')\n    ELSE permissions\nEND\nWHERE user_id = 1\n  AND (\n        permissions IS NULL\n        OR permissions = ''\n        OR CONCAT(',', REPLACE(IFNULL(permissions, ''), ' ', ''), ',') NOT LIKE '%,customShareKey,%'\n    );\n"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V5__add_permission_config_table.sql",
    "content": "create table if not exists permission_config\n(\n    id              int auto_increment primary key ,\n    operator        varchar(32) null comment '操作',\n    allow_admin     bit         null comment '允许管理员操作',\n    allow_anonymous bit         null comment '允许匿名用户操作',\n    storage_id      int         null comment '存储源 ID'\n)\n    comment '权限设置表';"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V6__system_config_add_field_auth_code.sql",
    "content": "INSERT INTO system_config (`name`, `title`) VALUES ('authCode', '授权码');"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V7__system_config_add_field_max_file_uploads.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('maxFileUploads', '最大同时上传文件数', 5);"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V8__storage_source_add_field_compatibility_readme.sql",
    "content": "alter table storage_source add compatibility_readme bit;"
  },
  {
    "path": "src/main/resources/db/migration-mysql/V9__system_config_add_field_only_office_url.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('onlyOfficeUrl', 'onlineOffice 地址', 'http://office.zfile.vip');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/R__data.sql",
    "content": "INSERT INTO system_config (`name`, `title`) VALUES ('siteName', '站点名称');\rINSERT INTO system_config (`name`, `title`) VALUES ('username', '管理员账号');\rINSERT INTO system_config (`name`, `title`) VALUES ('password', '管理员密码');\rINSERT INTO system_config (`name`, `title`) VALUES ('domain', '站点域名');\rINSERT INTO system_config (`name`, `title`) VALUES ('customCss', '自定义 CSS');\rINSERT INTO system_config (`name`, `title`) VALUES ('customJs', '自定义 JS (可用于统计代码)');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('tableSize', '表格大小', 'small');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('showDocument', '是否显示文档', 'true');\rINSERT INTO system_config (`name`, `title`) VALUES ('announcement', '网站公告');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('showAnnouncement', '是否显示网站公告', 'true');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('layout', '页面布局', 'full');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('showLinkBtn', '是否显示生成直链按钮', 'true');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('showShortLink', '是否显示短链', 'true');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('showPathLink', '是否显示路径直链', 'true');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('installed', '是否已初始化安装', 'false');\rINSERT INTO system_config (`name`, `title`) VALUES ('avatar', '头像地址');\rINSERT INTO system_config (`name`, `title`) VALUES ('icp', 'ICP 备案号');\rINSERT INTO system_config (`name`, `title`) VALUES ('customVideoSuffix', '自定义视频文件后缀格式');\rINSERT INTO system_config (`name`, `title`) VALUES ('customImageSuffix', '自定义图像文件后缀格式');\rINSERT INTO system_config (`name`, `title`) VALUES ('customAudioSuffix', '自定义音频文件后缀格式');\rINSERT INTO system_config (`name`, `title`) VALUES ('customTextSuffix', '自定义文本文件后缀格式');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('directLinkPrefix', '直链前缀地址', 'directlink');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('refererType', '直链 Referer 防盗链类型', 'off');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('refererAllowEmpty', '直链 Referer 是否允许为空', 'true');\rINSERT INTO system_config (`name`, `title`) VALUES ('refererValue', '直链 Referer 值');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('loginVerifyMode', '登陆验证方式，支持验证码和 2FA 认证', 'off');\rINSERT INTO system_config (`name`, `title`) VALUES ('loginVerifySecret', '登陆验证 Secret');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('rootShowStorage', '根目录是否显示所有存储源', 'true');\rINSERT INTO system_config (`name`, `title`) VALUES ('frontDomain', '前端域名，前后端分离情况下需要配置');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('recordDownloadLog', '是否记录下载日志', 'true');\rINSERT INTO system_config (`name`, `title`, `value`) VALUES ('showLogin', '是否在前台显示登陆按钮', 'true');\rINSERT INTO system_config (`name`, `title`) VALUES ('rsaHexKey', 'RSA 算法 HEX 格式密钥');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V10__system_config_add_field_webdav.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('webdavEnable', '启用 WebDAV 服务', 'false');\nINSERT INTO system_config (`name`, `title`, `value`) VALUES ('webdavProxy', '是否启用 WebDAV 服务器中转下载', 'true');\nINSERT INTO system_config (`name`, `title`, `value`) VALUES ('webdavUsername', 'WebDAV 账号', '');\nINSERT INTO system_config (`name`, `title`, `value`) VALUES ('webdavPassword', 'WebDAV 密码', '');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V11__system_config_modify_field_only_office_url_to_https.sql",
    "content": "UPDATE system_config SET value = 'https://office.zfile.vip' WHERE value = 'http://office.zfile.vip';"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V12__system_config_modify_field_value_to_text.sql",
    "content": "create table system_config_dg_tmp\n(\n    id    integer\n        primary key autoincrement,\n    name  varchar(255),\n    value text,\n    title varchar(255)\n);\n\ninsert into system_config_dg_tmp(id, name, value, title)\nselect id, name, value, title\nfrom system_config;\n\ndrop table system_config;\n\nalter table system_config_dg_tmp\n    rename to system_config;"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V13__system_config_add_field_allow_path_link_anon_access.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('allowPathLinkAnonAccess', '是否允许路径直链可直接访问', 'false');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V14__system_config_add_field_load_more_size.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('maxShowSize', '默认最大显示文件数', '1000');\nINSERT INTO system_config (`name`, `title`, `value`) VALUES ('loadMoreSize', '每次加载更多文件数', '50');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V15__system_config_add_field_site_home_name.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('siteHomeName', '站点 Home 名称', '首页');\nINSERT INTO system_config (`name`, `title`, `value`) VALUES ('siteHomeLogo', '站点 Home Logo', null);\nINSERT INTO system_config (`name`, `title`, `value`) VALUES ('siteHomeLogoLink', '站点 Logo 打开链接', '/');\nINSERT INTO system_config (`name`, `title`, `value`) VALUES ('siteHomeLogoTargetMode', '站点 Logo 链接打开方式', '_blank');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V16__system_config_add_field_default_sort_field.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('defaultSortField', '默认排序字段', 'name');\nINSERT INTO system_config (`name`, `title`, `value`) VALUES ('defaultSortOrder', '默认排序字段', 'asc');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V17__system_config_add_field_link_limit_field.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('linkLimitSecond', '限制直链下载秒数', '3600');\nINSERT INTO system_config (`name`, `title`, `value`) VALUES ('linkDownloadLimit', '限制直链下载次数', '10000');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V18__download_log_add_field_download_type.sql",
    "content": "alter table download_log add download_type varchar(32);"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V19__short_link_add_field_expire_date.sql",
    "content": "alter table short_link add expire_date datetime;"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V1__Base_version.sql",
    "content": "create table if not exists storage_source\r(\r    id                            integer\r        primary key autoincrement,\r    enable                        bit          null,\r    enable_cache                  bit          null,\r    name                          varchar(255) null,\r    auto_refresh_cache            bit          null,\r    type                          varchar(64)  null,\r    search_enable                 bit          null,\r    search_ignore_case            bit          null,\r    order_num                     int          null,\r    default_switch_to_img_mode    bit          null,\r    remark                        text         null,\r    `key`                         varchar(64)  null,\r    enable_file_operator          bit          null,\r    search_mode                   varchar(32)  null,\r    enable_file_anno_operator     bit          null\r);\r\rcreate table if not exists filter_config\r(\r    id           integer\r        primary  key autoincrement,\r    storage_id   int           null,\r    expression   varchar(255)  null,\r    description varchar(255)   null,\r    mode varchar(255)           null\r);\r\rcreate table if not exists short_link\r(\r    id          integer\r        primary key autoincrement,\r    short_key   varchar(255) null,\r    url         text         null,\r    create_date datetime         null,\r    storage_id  int          null\r);\r\rcreate table if not exists storage_source_config\r(\r    id         integer\r        primary key autoincrement,\r    name       varchar(255) null,\r    type       text         null,\r    title      varchar(255) null,\r    storage_id int          null,\r    value      text         null\r);\r\rcreate table if not exists system_config\r(\r    id    integer\r        primary key autoincrement,\r    name     varchar(255) null,\r    value varchar(255) null,\r    title varchar(255) null\r);\r\rcreate table if not exists password_config\r(\r    id         integer\r        primary key autoincrement,\r    storage_id integer      null,\r    expression varchar(255) null,\r    password   varchar(255) null,\r    description varchar(255)   null\r);\r\rcreate table if not exists readme_config\r(\r    id         integer\r        primary key autoincrement,\r    storage_id integer      null,\r    expression varchar(255) null,\r    description varchar(255)   null,\r    readme_text text null,\r    display_mode varchar(32) null\r);\r\rcreate table if not exists download_log\r(\r    id                            integer\r        primary key autoincrement,\r    path        text null ,\r    storage_key varchar(32) null,\r    create_time datetime null,\r    ip varchar(20) null,\r    user_agent varchar(2048) null,\r    referer varchar(2048) null,\r    short_key varchar(255) null\r);"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V20__system_config_add_field_favicon_url_field.sql",
    "content": "INSERT INTO system_config (`name`, `title`) VALUES ('faviconUrl', '网站 favicon 图标地址');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V21__system_config_add_field_expire_times_field.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('linkExpireTimes', '短链过期时间设置', '[{ \"value\": 1, \"unit\": \"d\", \"seconds\": 86400 }]');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V22__system_config_add_field_default_save_pwd_field.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('defaultSavePwd', '是否默认记住密码', 'false');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V23__system_config_add_field_only_office_secret_field.sql",
    "content": "INSERT INTO system_config (`name`, `title`) VALUES ('onlyOfficeSecret', 'OnlyOffice Secret，用于生成 JWT Token');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V24__system_config_add_field_enable_hover_menu_field.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('enableHoverMenu', '是否启用悬浮菜单', 'true');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V25__system_config_add_field_site_access_field.sql",
    "content": "INSERT INTO system_config (`name`, `title`) VALUES ('accessIpBlocklist', 'ip 黑名单');\nINSERT INTO system_config (`name`, `title`) VALUES ('accessUaBlocklist', 'ua 黑名单');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V26__system_config_add_field_login_verify.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('loginImgVerify', '是否启用登录图片验证码', (select value == 'image' from system_config where name = 'loginVerifyMode'));\nINSERT INTO system_config (`name`, `title`, `value`) VALUES ('adminTwoFactorVerify', '是否为管理员启用双因素认证', (select value == '2fa' from system_config where name = 'loginVerifyMode'));"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V27__add_table_login_log.sql",
    "content": "create table if not exists login_log\n(\n    id                            integer\n        primary key autoincrement,\n    username varchar(255) null,\n    password varchar(255) null,\n    create_time datetime null,\n    ip varchar(64) null,\n    user_agent varchar(2048) null,\n    referer varchar(2048) null,\n    result varchar(255) null\n);"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V28__add_multi_user.sql",
    "content": "create table if not exists user\n(\n    id                            integer primary key autoincrement,\n    username                      varchar(255)  null,\n    nickname                      varchar(255)  null,\n    password                      varchar(32)   null,\n    enable                        bit           null,\n    create_time                   datetime      null,\n    update_time                   datetime      null,\n    default_permissions           text          null\n);\n\ncreate table if not exists user_storage_source\n(\n    id                            integer primary key autoincrement,\n    user_id                       int           null,\n    storage_source_id             int           null,\n    root_path                     text          null,\n    enable                        bit           null,\n    permissions                    text          null\n);\n\ninsert into user (id, username, nickname, password, enable, create_time) values (1, (select value from system_config where name = 'username'), '管理员', (select value from system_config where name = 'password'), true, datetime(CURRENT_TIMESTAMP,'localtime'));\ninsert into user (id, username, nickname, password, enable, create_time) values (2, 'guest', '匿名用户', null, true, datetime(CURRENT_TIMESTAMP,'localtime'));\n\n-- 迁移管理员权限\ninsert into user_storage_source (user_id, storage_source_id, root_path, enable, permissions)\nselect 1, storage_id, '/', instr(group_concat(operator), 'available') > 0, group_concat(operator) permissions from permission_config\nwhere allow_admin = true\ngroup by storage_id;\n\n-- 迁移匿名用户权限\ninsert into user_storage_source (user_id, storage_source_id, root_path, enable, permissions)\nselect 2, storage_id, '/', instr(group_concat(operator), 'available') > 0, group_concat(operator) permissions from permission_config\nwhere allow_anonymous = true\ngroup by storage_id;"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V29__system_config_add_field_login_verify.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('siteAdminLogoTargetMode', '管理员页面点击 Logo 回到首页打开方式', '_blank');\nINSERT INTO system_config (`name`, `title`, `value`) VALUES ('siteAdminVersionOpenChangeLog', '管理员页面点击版本号打开更新日志', 'true');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V2__download_log_modify_storage_key_field_length.sql",
    "content": "create table download_log_dg_tmp\n(\n    id          integer\n        primary key autoincrement,\n    path        text,\n    storage_key varchar(64),\n    create_time datetime,\n    ip          varchar(20),\n    user_agent  varchar(2048),\n    referer     varchar(2048),\n    short_key   varchar(255)\n);\n\ninsert into download_log_dg_tmp(id, path, storage_key, create_time, ip, user_agent, referer, short_key)\nselect id,\n       path,\n       storage_key,\n       create_time,\n       ip,\n       user_agent,\n       referer,\n       short_key\nfrom download_log;\n\ndrop table download_log;\n\nalter table download_log_dg_tmp\n    rename to download_log;"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V30__delete_storage_source_auto_cors_config.sql",
    "content": "delete from storage_source_config where name = 'autoConfigCors';"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V31__system_config_add_field_webdav.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('webdavAllowAnonymous', '是否允许匿名访问', 'false');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V32__system_config_delete_domain_field.sql",
    "content": "delete from system_config where name = 'domain';"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V33__storage_source_config_update_field.sql",
    "content": "UPDATE\n    storage_source_config\nSET\n    name = 'proxyPrivate'\nWHERE\n    type NOT IN ('huawei', 'doge-cloud', 'aliyun', 's3', 'qiniu', 'minio', 'tencent')\nAND\n    name = 'isPrivate';\n\nUPDATE\n    storage_source_config\nSET\n    name = 'proxyTokenTime'\nWHERE\n    type NOT IN ('huawei', 'doge-cloud', 'aliyun', 's3', 'qiniu', 'minio', 'tencent')\n  AND\n    name = 'tokenTime';\n\nUPDATE\n    storage_source_config\nSET\n    name = 'proxyLimitSpeed'\nWHERE\n    type NOT IN ('aliyun', 'tencent')\n  AND\n    name = 'limitSpeed';"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V34__storage_source_config_update_field.sql",
    "content": "INSERT INTO storage_source_config (name, type, title, storage_id, value) select 'proxyLinkForceDownload', type, '下载链接强制下载', id, 'true' from storage_source;"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V35__system_config_add_field_login_log_mode.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('loginLogMode', '登录日志模式', 'all');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V36__user_add_field_salt.sql",
    "content": "alter table \"user\" add salt varchar(32);"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V37__fix_user_create_time_field_to_timestamp.sql",
    "content": "UPDATE \"user\" SET create_time = (strftime('%s', create_time) - 28800) * 1000 WHERE typeof(create_time) = 'text';"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V38__set_login_log_model_default_off.sql",
    "content": "UPDATE system_config SET value = 'ignoreAllPwd' WHERE name = 'loginLogMode';"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V3__system_config_add_field_file_click_mode.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('fileClickMode', '默认文件点击模式', 'dbclick');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V40__system_config_add_field_mobile_layout.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('mobileLayout', '移动端布局', 'full');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V41__system_config_add_custom_office_suffix.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('customOfficeSuffix', 'Office 预览类型', 'doc,docx,csv,xls,xlsx,ppt,pptx,xlsm');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V42__system_config_add_guest_index_html.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('guestIndexHtml', '匿名用户首页显示内容', '');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V43__set_2fa_default_value.sql",
    "content": "UPDATE system_config SET value = '' WHERE name = 'loginVerifySecret';\nUPDATE system_config SET value = 'false' WHERE name = 'loginImgVerify';\nUPDATE system_config SET value = 'false' WHERE name = 'adminTwoFactorVerify';"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V44__system_config_add_mobile_.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`)\nselect 'mobileFileClickMode', '移动端默认文件点击模式', value\nfrom system_config\nwhere name = 'fileClickMode';"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V45__add_sso_config.sql",
    "content": "create table if not exists sso_config\n(\n    id             integer primary key autoincrement,\n    provider       varchar(255) not null,\n    name           varchar(255) not null,\n    icon           text         not null,\n    client_id      varchar(255) not null,\n    client_secret  varchar(255) not null,\n    auth_url       varchar(255) not null,\n    token_url      varchar(255) not null,\n    user_info_url  varchar(255) not null,\n    scope          varchar(255) not null,\n    binding_field  varchar(255) not null,\n    enabled        bit      not null,\n    order_num      int default 0 not null\n);"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V46__add_template_user.sql",
    "content": "INSERT INTO `user` (id, username, nickname, enable, create_time, update_time) VALUES (0, 'template', '虚拟新用户', true, datetime(CURRENT_TIMESTAMP,'localtime'), datetime(CURRENT_TIMESTAMP,'localtime'));"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V47__system_config_add_force_backend_address.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('forceBackendAddress', '强制后端地址', '');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V48__system_config_add_field_kkfileview_url.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('kkFileViewUrl', 'kkFileView 地址', '');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V49__system_config_add_custom_kkfileview_suffix.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('customKkFileViewSuffix', 'kkFileView 预览类型', '3dm,3ds,3mf,7z,bim,bpmn,brep,cf2,dcm,dng,doc,docx,dot,dotm,dotx,dps,drawio,dwf,dwfx,dwg,dwt,dxf,emf,eml,eps,et,ett,fbx,fcstd,flv,fodt,fods,glb,gltf,gzip,ifc,iges,jar,jfif,jpg,js,md,mkv,mov,mp3,mp4,mpeg,mpg,obj,odp,ods,odt,ofd,off,ogg,otp,ots,ott,pages,pdf,php,plt,ply,png,ppt,pptx,psd,py,rar,rm,rmvb,rtf,six,stl,step,svg,swf,tar,tga,tif,tiff,ts,tsv,txt,vsd,vsdx,wav,webm,webp,wmf,wmv,wps,wpt,wrl,xla,xlam,xls,xlsm,xlsx,xlt,xltm,xmind,xml,zip');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V4__download_log_modify_ip_field_length.sql",
    "content": "create table download_log_dg_tmp\n(\n    id          integer\n        primary key autoincrement,\n    path        text,\n    storage_key varchar(64),\n    create_time datetime,\n    ip          varchar(64),\n    user_agent  varchar(2048),\n    referer     varchar(2048),\n    short_key   varchar(255)\n);\n\ninsert into download_log_dg_tmp(id, path, storage_key, create_time, ip, user_agent, referer, short_key)\nselect id,\n       path,\n       storage_key,\n       create_time,\n       ip,\n       user_agent,\n       referer,\n       short_key\nfrom download_log;\n\ndrop table download_log;\n\nalter table download_log_dg_tmp\n    rename to download_log;"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V50__system_config_add_kkfileview_open_mode.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('kkFileViewOpenMode', 'kkFileView 预览方式', 'iframe');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V51__storage_source_config_add_refresh_token_expired_at.sql",
    "content": "INSERT INTO\n    storage_source_config (name, type, title, storage_id)\nSELECT\n    'refreshTokenExpiredAt', type, '刷新令牌过期时间', storage_id\nFROM\n    storage_source_config\nWHERE\n    name = 'refreshToken';"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V52__ststem_config_add_mobile_show_file_size.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('mobileShowSize', '移动端显示文件大小', 'true');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V53__readme_config_add_path_mode_field.sql",
    "content": "alter table readme_config add path_mode varchar(32);\nupdate readme_config set path_mode = 'relative' where path_mode is null;"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V54__add_share_link_table.sql",
    "content": "create table share_link\n(\n    id          integer primary key autoincrement,\n    share_key   varchar(255),  -- 分享链接 key\n    password    varchar(8),    -- 分享密码\n    expire_date datetime,      -- 过期时间\n    storage_key varchar(255),  -- 存储源key\n    share_path  text,          -- 分享所在目录\n    share_item  text,          -- 分享项目(JSON格式)\n    create_date datetime,      -- 创建时间\n    share_type     varchar(20),-- 分享类型: FILE/FOLDER/MULTIPLE\n    user_id        integer,    -- 创建分享的用户ID\n    download_count integer default 0, -- 下载次数\n    access_count   integer default 0 -- 访问次数\n);\n\ncreate unique index idx_share_key on share_link(share_key);"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V55__system_config_add_secure_login_entry.sql",
    "content": "INSERT INTO system_config (`name`, `title`)\nSELECT 'secureLoginEntry', '安全登录入口'\nWHERE NOT EXISTS (SELECT 1 FROM system_config WHERE `name` = 'secureLoginEntry');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V56__system_config_add_download_confirm_flags.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`)\nSELECT 'enableNormalDownloadConfirm', '普通下载是否启用确认弹窗', 'true'\nWHERE NOT EXISTS (\n    SELECT 1 FROM system_config WHERE name = 'enableNormalDownloadConfirm'\n);\n\nINSERT INTO system_config (`name`, `title`, `value`)\nSELECT 'enablePackageDownloadConfirm', '打包下载是否启用确认弹窗', 'true'\nWHERE NOT EXISTS (\n    SELECT 1 FROM system_config WHERE name = 'enablePackageDownloadConfirm'\n);\n\nINSERT INTO system_config (`name`, `title`, `value`)\nSELECT 'enableBatchDownloadConfirm', '批量下载是否启用确认弹窗', 'true'\nWHERE NOT EXISTS (\n    SELECT 1 FROM system_config WHERE name = 'enableBatchDownloadConfirm'\n);"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V57__user_add_default_share_permissions.sql",
    "content": "-- 为管理员默认补齐分享权限（createShareLink、customShareKey）\n\n-- 补齐 user.default_permissions 中的 createShareLink\nUPDATE user\nSET default_permissions = CASE\n    WHEN default_permissions IS NULL OR trim(coalesce(default_permissions, '')) = '' THEN 'createShareLink'\n    WHEN instr(',' || replace(coalesce(default_permissions, ''), ' ', '') || ',', ',createShareLink,') = 0 THEN default_permissions || ',createShareLink'\n    ELSE default_permissions\nEND\nWHERE id = 1\n  AND (\n        default_permissions IS NULL\n        OR trim(coalesce(default_permissions, '')) = ''\n        OR instr(',' || replace(coalesce(default_permissions, ''), ' ', '') || ',', ',createShareLink,') = 0\n    );\n\n-- 补齐 user.default_permissions 中的 customShareKey\nUPDATE user\nSET default_permissions = CASE\n    WHEN default_permissions IS NULL OR trim(coalesce(default_permissions, '')) = '' THEN 'customShareKey'\n    WHEN instr(',' || replace(coalesce(default_permissions, ''), ' ', '') || ',', ',customShareKey,') = 0 THEN default_permissions || ',customShareKey'\n    ELSE default_permissions\nEND\nWHERE id = 1\n  AND (\n        default_permissions IS NULL\n        OR trim(coalesce(default_permissions, '')) = ''\n        OR instr(',' || replace(coalesce(default_permissions, ''), ' ', '') || ',', ',customShareKey,') = 0\n    );\n\n-- 补齐 user_storage_source.permissions 中的 createShareLink\nUPDATE user_storage_source\nSET permissions = CASE\n    WHEN permissions IS NULL OR trim(coalesce(permissions, '')) = '' THEN 'createShareLink'\n    WHEN instr(',' || replace(coalesce(permissions, ''), ' ', '') || ',', ',createShareLink,') = 0 THEN permissions || ',createShareLink'\n    ELSE permissions\nEND\nWHERE user_id = 1\n  AND (\n        permissions IS NULL\n        OR trim(coalesce(permissions, '')) = ''\n        OR instr(',' || replace(coalesce(permissions, ''), ' ', '') || ',', ',createShareLink,') = 0\n    );\n\n-- 补齐 user_storage_source.permissions 中的 customShareKey\nUPDATE user_storage_source\nSET permissions = CASE\n    WHEN permissions IS NULL OR trim(coalesce(permissions, '')) = '' THEN 'customShareKey'\n    WHEN instr(',' || replace(coalesce(permissions, ''), ' ', '') || ',', ',customShareKey,') = 0 THEN permissions || ',customShareKey'\n    ELSE permissions\nEND\nWHERE user_id = 1\n  AND (\n        permissions IS NULL\n        OR trim(coalesce(permissions, '')) = ''\n        OR instr(',' || replace(coalesce(permissions, ''), ' ', '') || ',', ',customShareKey,') = 0\n    );\n"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V5__add_permission_config_table.sql",
    "content": "create table if not exists permission_config\n(\n    id                            integer\n        primary key autoincrement,\n    operator        varchar(32) null,\n    allow_admin     bit         null,\n    allow_anonymous bit         null,\n    storage_id      int         null\n);"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V6__system_config_add_field_auth_code.sql",
    "content": "INSERT INTO system_config (`name`, `title`) VALUES ('authCode', '授权码');"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V7__system_config_add_field_max_file_uploads.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('maxFileUploads', '最大同时上传文件数', 5);"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V8__storage_source_add_field_compatibility_readme.sql",
    "content": "alter table storage_source add compatibility_readme bit;"
  },
  {
    "path": "src/main/resources/db/migration-sqlite/V9__system_config_add_field_only_office_url.sql",
    "content": "INSERT INTO system_config (`name`, `title`, `value`) VALUES ('onlyOfficeUrl', 'onlineOffice 地址', 'http://office.zfile.vip');"
  },
  {
    "path": "src/main/resources/logback-spring.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n    scan：当此属性设置为true时，配置文件如果发生改变，将会被重新加载，默认值为true。\n    scanPeriod：设置监测配置文件是否有修改的时间间隔，如果没有给出时间单位，默认单位是毫秒当scan为true时，此属性生效。默认的时间间隔为1分钟。\n    debug：当此属性设置为true时，将打印出logback内部日志信息，实时查看logback运行状态。默认值为false。\n-->\n<configuration scan=\"false\" scanPeriod=\"60 seconds\" debug=\"false\">\n\n    <statusListener class=\"ch.qos.logback.core.status.NopStatusListener\" />\n\n    <conversionRule conversionWord=\"clr\" converterClass=\"org.springframework.boot.logging.logback.ColorConverter\"/>\n    <conversionRule conversionWord=\"wex\"\n                    converterClass=\"org.springframework.boot.logging.logback.WhitespaceThrowableProxyConverter\"/>\n    <conversionRule conversionWord=\"wEx\"\n                    converterClass=\"org.springframework.boot.logging.logback.ExtendedWhitespaceThrowableProxyConverter\"/>\n\n    <property resource=\"application.properties\"/>\n\n    <!-- 单文件最大大小 -->\n    <property name=\"maxFileSize\" value=\"10MB\"/>\n    <!-- 最大保存时间（天） -->\n    <property name=\"maxHistory\" value=\"15\"/>\n    <!-- DEBUG 日志最大保存时间（天） -->\n    <property name=\"debugMaxHistory\" value=\"3\"/>\n    <!-- 日志文件 pattern -->\n    <property name=\"log-file-pattern\" value=\"%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd HH:mm:ss.SSS}} ${LOG_LEVEL_PATTERN:-%5p} ${PID:- } --- [%36.36X{traceId}] [%10.10X{user}] [%15.15X{ip}] [%t] %-40.40logger{39} : %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}\"/>\n\n    <!-- 定义日志的根目录 -->\n    <springProperty scope=\"context\" name=\"LOG_HOME\" source=\"zfile.log.path\"/>\n    <!-- 定义日志编码 -->\n    <springProperty scope=\"context\" name=\"LOG_ENCODER\" source=\"zfile.log.encoder\"/>\n\n    <!-- 定义应用名，用于日志文件前缀 -->\n    <property name=\"appName\" value=\"zfile\"/>\n\n    <!-- ch.qos.logback.core.ConsoleAppender 表示控制台输出 -->\n    <appender name=\"console\" class=\"ch.qos.logback.core.ConsoleAppender\">\n        <!-- 生产模式控制台最低只输出 INFO 级别日志 -->\n        <encoder>\n            <pattern>%clr(%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd HH:mm:ss.SSS}}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} [%36.36X{traceId}] [%10.10X{user}] [%15.15X{ip}] %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(%-6L){yellow} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}</pattern>\n            <charset>${LOG_ENCODER}</charset>\n        </encoder>\n        <filter class=\"ch.qos.logback.classic.filter.ThresholdFilter\">\n            <level>INFO</level>\n        </filter>\n    </appender>\n\n    <!-- 只记录 debug 日志，每天滚动一次，最多保留 ${debugMaxHistory} 天，文件最大 ${maxFileSize}，总文件最大 1GB -->\n    <appender name=\"debug_file\" class=\"ch.qos.logback.core.rolling.RollingFileAppender\">\n        <!-- 指定日志文件的名称 -->\n        <file>${LOG_HOME}/${appName}-debug.log</file>\n\n        <rollingPolicy class=\"ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy\">\n            <fileNamePattern>${LOG_HOME}/%d{yyyy-MM-dd}/${appName}-debug-%d{yyyy-MM-dd}-%i.gz</fileNamePattern>\n            <MaxHistory>${debugMaxHistory}</MaxHistory>\n            <totalSizeCap>1GB</totalSizeCap>\n            <maxFileSize>${maxFileSize}</maxFileSize>\n        </rollingPolicy>\n\n        <encoder>\n            <pattern>${log-file-pattern}</pattern>\n        </encoder>\n\n        <filter class=\"ch.qos.logback.classic.filter.LevelFilter\">\n            <level>DEBUG</level>\n            <onMatch>ACCEPT</onMatch>\n            <onMismatch>DENY</onMismatch>\n        </filter>\n    </appender>\n\n    <!-- 记录 info 日志及以上级别的日志，每天滚动一次，最多保留 ${maxHistory} 天，文件最大 ${maxFileSize} -->\n    <appender name=\"info_file\" class=\"ch.qos.logback.core.rolling.RollingFileAppender\">\n        <!-- 指定日志文件的名称 -->\n        <file>${LOG_HOME}/${appName}.log</file>\n\n        <rollingPolicy class=\"ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy\">\n            <fileNamePattern>${LOG_HOME}/%d{yyyy-MM-dd}/${appName}-info-%d{yyyy-MM-dd}-%i.gz</fileNamePattern>\n            <MaxHistory>${maxHistory}</MaxHistory>\n            <maxFileSize>${maxFileSize}</maxFileSize>\n        </rollingPolicy>\n\n        <encoder>\n            <pattern>${log-file-pattern}</pattern>\n        </encoder>\n\n        <filter class=\"ch.qos.logback.classic.filter.ThresholdFilter\">\n            <level>INFO</level>\n        </filter>\n    </appender>\n\n    <!-- 只记录 warn 日志，每天滚动一次，最多保留 ${maxHistory} 天，文件最大 ${maxFileSize} -->\n    <appender name=\"warn_file\" class=\"ch.qos.logback.core.rolling.RollingFileAppender\">\n        <!-- 指定日志文件的名称 -->\n        <file>${LOG_HOME}/${appName}-warn.log</file>\n\n        <rollingPolicy class=\"ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy\">\n            <fileNamePattern>${LOG_HOME}/%d{yyyy-MM-dd}/${appName}-warn-%d{yyyy-MM-dd}-%i.gz</fileNamePattern>\n            <MaxHistory>${maxHistory}</MaxHistory>\n            <maxFileSize>${maxFileSize}</maxFileSize>\n        </rollingPolicy>\n\n        <encoder>\n            <pattern>${log-file-pattern}</pattern>\n        </encoder>\n\n        <filter class=\"ch.qos.logback.classic.filter.LevelFilter\">\n            <level>WARN</level>\n            <onMatch>ACCEPT</onMatch>\n            <onMismatch>DENY</onMismatch>\n        </filter>\n    </appender>\n\n    <!-- 只记录 error 日志，每天滚动一次，最多保留 ${maxHistory} 天，文件最大 ${maxFileSize} -->\n    <appender name=\"error_file\" class=\"ch.qos.logback.core.rolling.RollingFileAppender\">\n        <!-- 指定日志文件的名称 -->\n        <file>${LOG_HOME}/${appName}-error.log</file>\n\n        <rollingPolicy class=\"ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy\">\n            <fileNamePattern>${LOG_HOME}/%d{yyyy-MM-dd}/${appName}-error-%d{yyyy-MM-dd}-%i.gz</fileNamePattern>\n            <MaxHistory>${maxHistory}</MaxHistory>\n            <maxFileSize>${maxFileSize}</maxFileSize>\n        </rollingPolicy>\n\n        <encoder>\n            <pattern>${log-file-pattern}</pattern>\n        </encoder>\n\n        <filter class=\"ch.qos.logback.classic.filter.LevelFilter\">\n            <level>ERROR</level>\n            <onMatch>ACCEPT</onMatch>\n            <onMismatch>DENY</onMismatch>\n        </filter>\n    </appender>\n\n    <!-- 控制台输出日志级别 -->\n    <root level=\"info\">\n        <appender-ref ref=\"console\"/>\n        <appender-ref ref=\"info_file\"/>\n        <appender-ref ref=\"warn_file\"/>\n        <appender-ref ref=\"error_file\"/>\n    </root>\n\n    <!--\n     logger 主要用于存放日志对象，也可以定义日志类型、级别\n     name：表示匹配的logger类型前缀，也就是包的前半部分\n     level：要记录的日志级别，包括 TRACE < DEBUG < INFO < WARN < ERROR\n     additivity：作用在于children-logger是否使用 rootLogger配置的appender进行输出，\n         false：表示只用当前logger的appender-ref，\n         true： 表示当前logger的appender-ref和rootLogger的appender-ref都有效\n     -->\n\n    <!-- 指定 ZFile 输出的日志到文件中 -->\n    <logger name=\"im.zhaojun.zfile\" level=\"DEBUG\" additivity=\"true\">\n        <appender-ref ref=\"debug_file\"/>\n    </logger>\n\n    <logger name=\"springfox\" level=\"info\" />\n    <logger name=\"org.springframework\" level=\"info\" />\n    <logger name=\"com.zaxxer\" level=\"info\" />\n\n    <logger name=\"com.github.sardine.impl.io.HttpMethodReleaseInputStream\" level=\"error\" />\n\n</configuration>"
  },
  {
    "path": "src/main/resources/mapper/DownloadLogMapper.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE mapper PUBLIC \"-//mybatis.org//DTD Mapper 3.0//EN\" \"http://mybatis.org/dtd/mybatis-3-mapper.dtd\">\n<mapper namespace=\"im.zhaojun.zfile.module.log.mapper.DownloadLogMapper\">\n  <resultMap id=\"BaseResultMap\" type=\"im.zhaojun.zfile.module.log.model.entity.DownloadLog\">\n    <!--@mbg.generated-->\n    <!--@Table `download_log`-->\n    <id column=\"id\" jdbcType=\"INTEGER\" property=\"id\" />\n    <result column=\"path\" jdbcType=\"LONGVARCHAR\" property=\"path\" />\n    <result column=\"short_key\" jdbcType=\"VARCHAR\" property=\"shortKey\"/>\n    <result column=\"storage_key\" jdbcType=\"VARCHAR\" property=\"storageKey\" />\n    <result column=\"create_time\" jdbcType=\"TIMESTAMP\" property=\"createTime\" />\n    <result column=\"ip\" jdbcType=\"VARCHAR\" property=\"ip\" />\n    <result column=\"user_agent\" jdbcType=\"VARCHAR\" property=\"userAgent\" />\n    <result column=\"referer\" jdbcType=\"VARCHAR\" property=\"referer\" />\n    <result column=\"download_type\" jdbcType=\"VARCHAR\" property=\"downloadType\" />\n  </resultMap>\n  <sql id=\"Base_Column_List\">\n    <!--@mbg.generated-->\n    `id`, `path`, `storage_key`, `create_time`, `ip`, `user_agent`, `referer`, `short_key`, `download_type`\n  </sql>\n\n  <delete id=\"deleteByStorageKey\">\n      delete from download_log where storage_key = #{storageKey}\n    </delete>\n\n    <delete id=\"deleteExpireShortLinkLog\" databaseId=\"sqlite\">\n        DELETE FROM\n            download_log\n        WHERE\n            short_key in (\n                select short_key from short_link where expire_date &lt;= strftime('%s', 'now') * 1000\n        )\n    </delete>\n\n    <delete id=\"deleteExpireShortLinkLog\" databaseId=\"mysql\">\n        DELETE FROM\n            download_log\n        WHERE\n            short_key in (\n                select short_key from short_link where expire_date &lt;= NOW()\n        )\n    </delete>\n\n</mapper>"
  },
  {
    "path": "src/main/resources/mapper/FilterConfigMapper.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE mapper PUBLIC \"-//mybatis.org//DTD Mapper 3.0//EN\" \"http://mybatis.org/dtd/mybatis-3-mapper.dtd\">\n<mapper namespace=\"im.zhaojun.zfile.module.filter.mapper.FilterConfigMapper\">\n    <resultMap id=\"BaseResultMap\" type=\"im.zhaojun.zfile.module.filter.model.entity.FilterConfig\">\n        <!--@mbg.generated-->\n        <!--@Table filter_config-->\n        <id column=\"id\" jdbcType=\"INTEGER\" property=\"id\"/>\n        <result column=\"storage_id\" jdbcType=\"INTEGER\" property=\"storageId\"/>\n        <result column=\"expression\" jdbcType=\"VARCHAR\" property=\"expression\"/>\n        <result column=\"mode\" jdbcType=\"VARCHAR\" property=\"mode\"/>\n        <result column=\"description\" jdbcType=\"VARCHAR\" property=\"description\" />\n    </resultMap>\n    <sql id=\"Base_Column_List\">\n        <!--@mbg.generated-->\n        id, storage_id, expression, mode, description\n    </sql>\n\n<!--auto generated by MybatisCodeHelper on 2021-07-15-->\n    <select id=\"findByStorageId\" resultMap=\"BaseResultMap\">\n        select\n        <include refid=\"Base_Column_List\"/>\n        from filter_config\n        where storage_id=#{storageId,jdbcType=INTEGER}\n    </select>\n\n<!--auto generated by MybatisCodeHelper on 2021-07-15-->\n    <delete id=\"deleteByStorageId\">\n        delete from filter_config\n        where storage_id=#{storageId,jdbcType=INTEGER}\n    </delete>\n\n<!--auto generated by MybatisCodeHelper on 2022-02-13-->\n    <select id=\"findByStorageIdAndInaccessible\" resultMap=\"BaseResultMap\">\n        select\n        <include refid=\"Base_Column_List\"/>\n        from filter_config\n        where `storage_id`=#{storageId,jdbcType=INTEGER}\n        and `mode` = 'inaccessible'\n    </select>\n\n    <!--auto generated by MybatisCodeHelper on 2022-02-13-->\n    <select id=\"findByStorageIdAndDisableDownload\" resultMap=\"BaseResultMap\">\n        select\n        <include refid=\"Base_Column_List\"/>\n        from filter_config\n        where `storage_id`=#{storageId,jdbcType=INTEGER}\n        and `mode` = 'disable_download'\n    </select>\n</mapper>"
  },
  {
    "path": "src/main/resources/mapper/LoginLogMapper.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE mapper PUBLIC \"-//mybatis.org//DTD Mapper 3.0//EN\" \"http://mybatis.org/dtd/mybatis-3-mapper.dtd\">\n<mapper namespace=\"im.zhaojun.zfile.module.log.mapper.LoginLogMapper\">\n  <resultMap id=\"BaseResultMap\" type=\"im.zhaojun.zfile.module.log.model.entity.LoginLog\">\n    <!--@mbg.generated-->\n    <!--@Table login_log-->\n    <id column=\"id\" jdbcType=\"INTEGER\" property=\"id\" />\n    <result column=\"username\" jdbcType=\"VARCHAR\" property=\"username\" />\n    <result column=\"password\" jdbcType=\"VARCHAR\" property=\"password\" />\n    <result column=\"create_time\" jdbcType=\"TIMESTAMP\" property=\"createTime\" />\n    <result column=\"ip\" jdbcType=\"VARCHAR\" property=\"ip\" />\n    <result column=\"user_agent\" jdbcType=\"VARCHAR\" property=\"userAgent\" />\n    <result column=\"referer\" jdbcType=\"VARCHAR\" property=\"referer\" />\n    <result column=\"result\" jdbcType=\"VARCHAR\" property=\"result\" />\n  </resultMap>\n  <sql id=\"Base_Column_List\">\n    <!--@mbg.generated-->\n    id, `username`, `password`, `create_time`, `ip`, `user_agent`, `referer`, `result`\n  </sql>\n</mapper>"
  },
  {
    "path": "src/main/resources/mapper/PasswordConfigMapper.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE mapper PUBLIC \"-//mybatis.org//DTD Mapper 3.0//EN\" \"http://mybatis.org/dtd/mybatis-3-mapper.dtd\">\n<mapper namespace=\"im.zhaojun.zfile.module.password.mapper.PasswordConfigMapper\">\n  <resultMap id=\"BaseResultMap\" type=\"im.zhaojun.zfile.module.password.model.entity.PasswordConfig\">\n    <!--@mbg.generated-->\n    <!--@Table \"password_config\"-->\n    <id column=\"id\" jdbcType=\"INTEGER\" property=\"id\" />\n    <result column=\"storage_id\" jdbcType=\"INTEGER\" property=\"storageId\" />\n    <result column=\"expression\" jdbcType=\"VARCHAR\" property=\"expression\" />\n    <result column=\"password\" jdbcType=\"VARCHAR\" property=\"password\" />\n    <result column=\"description\" jdbcType=\"VARCHAR\" property=\"description\" />\n  </resultMap>\n  <sql id=\"Base_Column_List\">\n    <!--@mbg.generated-->\n    `id`, `storage_id`, `expression`, `password`, `description`\n  </sql>\n\n  <!--auto generated by MybatisCodeHelper on 2022-01-29-->\n  <select id=\"findByStorageId\" resultMap=\"BaseResultMap\">\n    select\n    <include refid=\"Base_Column_List\"/>\n    from password_config\n    where storage_id=#{storageId,jdbcType=INTEGER}\n  </select>\n\n  <!--auto generated by MybatisCodeHelper on 2022-01-29-->\n  <delete id=\"deleteByStorageId\">\n    delete from password_config\n    where storage_id=#{storageId,jdbcType=INTEGER}\n  </delete>\n\n</mapper>"
  },
  {
    "path": "src/main/resources/mapper/PermissionConfigMapper.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE mapper PUBLIC \"-//mybatis.org//DTD Mapper 3.0//EN\" \"http://mybatis.org/dtd/mybatis-3-mapper.dtd\">\n<mapper namespace=\"im.zhaojun.zfile.module.permission.mapper.PermissionConfigMapper\">\n  <resultMap id=\"BaseResultMap\" type=\"im.zhaojun.zfile.module.permission.model.entity.PermissionConfig\">\n    <!--@mbg.generated-->\n    <!--@Table `permission_config`-->\n    <id column=\"id\" jdbcType=\"INTEGER\" property=\"id\" />\n    <result column=\"operator\" jdbcType=\"VARCHAR\" property=\"operator\" />\n    <result column=\"allow_admin\" jdbcType=\"BIT\" property=\"allowAdmin\" />\n    <result column=\"allow_anonymous\" jdbcType=\"BIT\" property=\"allowAnonymous\" />\n    <result column=\"storage_id\" jdbcType=\"INTEGER\" property=\"storageId\" />\n  </resultMap>\n  <sql id=\"Base_Column_List\">\n    <!--@mbg.generated-->\n    `id`, `operator`, `allow_admin`, `allow_anonymous`, `storage_id`\n  </sql>\n\n  <select id=\"findByStorageId\" resultMap=\"BaseResultMap\">\n    select\n    <include refid=\"Base_Column_List\"/>\n    from permission_config\n    where storage_id=#{storageId,jdbcType=INTEGER}\n  </select>\n\n  <delete id=\"deleteByStorageId\">\n    delete from permission_config\n    where storage_id=#{storageId,jdbcType=INTEGER}\n  </delete>\n\n</mapper>"
  },
  {
    "path": "src/main/resources/mapper/ReadmeConfigMapper.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE mapper PUBLIC \"-//mybatis.org//DTD Mapper 3.0//EN\" \"http://mybatis.org/dtd/mybatis-3-mapper.dtd\">\n<mapper namespace=\"im.zhaojun.zfile.module.readme.mapper.ReadmeConfigMapper\">\n  <resultMap id=\"BaseResultMap\" type=\"im.zhaojun.zfile.module.readme.model.entity.ReadmeConfig\">\n    <!--@mbg.generated-->\n    <!--@Table `readme_config`-->\n    <id column=\"id\" jdbcType=\"INTEGER\" property=\"id\" />\n    <result column=\"storage_id\" jdbcType=\"INTEGER\" property=\"storageId\" />\n    <result column=\"expression\" jdbcType=\"VARCHAR\" property=\"expression\" />\n    <result column=\"readme_text\" jdbcType=\"LONGVARCHAR\" property=\"readmeText\" />\n    <result column=\"description\" jdbcType=\"VARCHAR\" property=\"description\" />\n    <result column=\"display_mode\" jdbcType=\"VARCHAR\" property=\"displayMode\" />\n    <result column=\"path_mode\" jdbcType=\"VARCHAR\" property=\"pathMode\" />\n  </resultMap>\n  <sql id=\"Base_Column_List\">\n    <!--@mbg.generated-->\n    `id`, `storage_id`, `expression`, `readme_text`, `description`, `display_mode`, `path_mode`\n  </sql>\n\n  <!--auto generated by MybatisCodeHelper on 2021-07-15-->\n  <select id=\"findByStorageId\" resultMap=\"BaseResultMap\">\n    select\n    <include refid=\"Base_Column_List\"/>\n    from readme_config\n    where storage_id=#{storageId,jdbcType=INTEGER}\n  </select>\n\n  <!--auto generated by MybatisCodeHelper on 2021-07-15-->\n  <delete id=\"deleteByStorageId\">\n    delete from readme_config\n    where storage_id=#{storageId,jdbcType=INTEGER}\n  </delete>\n\n</mapper>"
  },
  {
    "path": "src/main/resources/mapper/ShareLinkMapper.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE mapper PUBLIC \"-//mybatis.org//DTD Mapper 3.0//EN\" \"http://mybatis.org/dtd/mybatis-3-mapper.dtd\">\n<mapper namespace=\"im.zhaojun.zfile.module.share.mapper.ShareLinkMapper\">\n\n    <resultMap id=\"BaseResultMap\" type=\"im.zhaojun.zfile.module.share.model.entity.ShareLink\">\n        <!--@mbg.generated-->\n        <!--@Table share_link-->\n        <id column=\"id\" jdbcType=\"INTEGER\" property=\"id\"/>\n        <result column=\"share_key\" jdbcType=\"VARCHAR\" property=\"shareKey\"/>\n        <result column=\"password\" jdbcType=\"VARCHAR\" property=\"password\"/>\n        <result column=\"expire_date\" jdbcType=\"TIMESTAMP\" property=\"expireDate\"/>\n        <result column=\"storage_key\" jdbcType=\"VARCHAR\" property=\"storageKey\"/>\n        <result column=\"share_path\" jdbcType=\"LONGVARCHAR\" property=\"sharePath\"/>\n        <result column=\"share_item\" jdbcType=\"LONGVARCHAR\" property=\"shareItem\"/>\n        <result column=\"create_date\" jdbcType=\"TIMESTAMP\" property=\"createDate\"/>\n        <result column=\"share_type\" jdbcType=\"VARCHAR\" property=\"shareType\"/>\n        <result column=\"user_id\" jdbcType=\"INTEGER\" property=\"userId\"/>\n        <result column=\"download_count\" jdbcType=\"INTEGER\" property=\"downloadCount\"/>\n        <result column=\"access_count\" jdbcType=\"INTEGER\" property=\"accessCount\"/>\n    </resultMap>\n\n    <sql id=\"Base_Column_List\">\n        <!--@mbg.generated-->\n        id,\n        share_key,\n        `password`,\n        expire_date,\n        storage_key,\n        share_path,\n        share_item,\n        create_date,\n        share_type,\n        user_id,\n        download_count,\n        access_count\n    </sql>\n\n    <select id=\"getByShareKey\" resultMap=\"BaseResultMap\">\n        select\n            <include refid=\"Base_Column_List\"/>\n        from\n            share_link\n        where\n            share_key = #{shareKey}\n    </select>\n\n    <select id=\"getByUserId\" resultMap=\"BaseResultMap\">\n        select\n            <include refid=\"Base_Column_List\"/>\n        from\n            share_link\n        where\n            user_id = #{userId}\n        order by create_date desc\n    </select>\n\n    <update id=\"incrementAccessCount\">\n        update share_link\n        set access_count = COALESCE(access_count, 0) + #{increment}\n        where share_key = #{shareKey}\n    </update>\n\n    <update id=\"incrementDownloadCount\">\n        update share_link\n        set download_count = COALESCE(download_count, 0) + #{increment}\n        where share_key = #{shareKey}\n    </update>\n\n    <delete id=\"deleteExpiredLinks\">\n        delete from share_link\n        where expire_date is not null\n          and expire_date &lt;= #{currentTime}\n    </delete>\n\n    <delete id=\"deleteExpiredLinksByUserId\">\n        delete from share_link\n        where user_id = #{userId}\n          and expire_date is not null\n          and expire_date &lt;= #{currentTime}\n    </delete>\n\n</mapper>"
  },
  {
    "path": "src/main/resources/mapper/ShortLinkMapper.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE mapper PUBLIC \"-//mybatis.org//DTD Mapper 3.0//EN\" \"http://mybatis.org/dtd/mybatis-3-mapper.dtd\">\n<mapper namespace=\"im.zhaojun.zfile.module.link.mapper.ShortLinkMapper\">\n    <resultMap id=\"BaseResultMap\" type=\"im.zhaojun.zfile.module.link.model.entity.ShortLink\">\n        <!--@mbg.generated-->\n        <!--@Table short_link-->\n        <id column=\"id\" jdbcType=\"INTEGER\" property=\"id\"/>\n        <result column=\"short_key\" jdbcType=\"VARCHAR\" property=\"shortKey\"/>\n        <result column=\"url\" jdbcType=\"LONGVARCHAR\" property=\"url\"/>\n        <result column=\"create_date\" jdbcType=\"TIMESTAMP\" property=\"createDate\"/>\n        <result column=\"storage_id\" jdbcType=\"INTEGER\" property=\"storageId\"/>\n        <result column=\"expire_date\" jdbcType=\"TIMESTAMP\" property=\"expireDate\"/>\n    </resultMap>\n    <sql id=\"Base_Column_List\">\n        <!--@mbg.generated-->\n        id, short_key, url, create_date, storage_id, expire_date\n    </sql>\n\n    <update id=\"updateUrlStorageId\">\n        update short_link set url = replace(url, #{updateSubPath}, #{newSubPath})\n    </update>\n\n<!--auto generated by MybatisCodeHelper on 2021-07-15-->\n    <select id=\"findByKey\" resultMap=\"BaseResultMap\">\n        select\n        <include refid=\"Base_Column_List\"/>\n        from short_link\n        where `short_key`=#{key,jdbcType=VARCHAR}\n    </select>\n\n    <delete id=\"deleteByStorageId\">\n        delete from short_link where storage_id = #{storageId}\n    </delete>\n\n    <select id=\"findByStorageIdAndUrl\" resultMap=\"BaseResultMap\">\n        SELECT\n            <include refid=\"Base_Column_List\"/>\n        FROM\n            short_link\n        WHERE\n            storage_id = #{storageId} and url = #{url}\n        <if test=\"expireDate != null\">\n            and expire_date = #{expireDate}\n        </if>\n            limit 1\n    </select>\n\n    <delete id=\"deleteExpireLink\" databaseId=\"sqlite\">\n        DELETE FROM\n            short_link\n        WHERE\n            expire_date &lt;= strftime('%s', 'now') * 1000\n    </delete>\n\n    <delete id=\"deleteExpireLink\" databaseId=\"mysql\">\n        DELETE FROM\n            short_link\n        WHERE\n            expire_date &lt;= NOW()\n    </delete>\n</mapper>"
  },
  {
    "path": "src/main/resources/mapper/SsoConfigMapper.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE mapper PUBLIC \"-//mybatis.org//DTD Mapper 3.0//EN\" \"http://mybatis.org/dtd/mybatis-3-mapper.dtd\">\n<mapper namespace=\"im.zhaojun.zfile.module.sso.mapper.SsoConfigMapper\">\n    <resultMap id=\"BaseResultMap\" type=\"im.zhaojun.zfile.module.sso.model.entity.SsoConfig\">\n        <id column=\"id\" jdbcType=\"INTEGER\" property=\"id\" />\n        <result column=\"provider\" property=\"provider\" jdbcType=\"VARCHAR\"/>\n        <result column=\"name\" property=\"name\" jdbcType=\"VARCHAR\"/>\n        <result column=\"icon\" property=\"icon\" jdbcType=\"VARCHAR\"/>\n        <result column=\"client_id\" property=\"clientId\" jdbcType=\"VARCHAR\"/>\n        <result column=\"client_secret\" property=\"clientSecret\" jdbcType=\"VARCHAR\"/>\n        <result column=\"auth_url\" property=\"authUrl\" jdbcType=\"VARCHAR\"/>\n        <result column=\"token_url\" property=\"tokenUrl\" jdbcType=\"VARCHAR\"/>\n        <result column=\"user_info_url\" property=\"userInfoUrl\" jdbcType=\"VARCHAR\"/>\n        <result column=\"scope\" property=\"scope\" jdbcType=\"VARCHAR\"/>\n        <result column=\"binding_field\" property=\"bindingField\" jdbcType=\"VARCHAR\"/>\n        <result column=\"enabled\" property=\"enabled\" jdbcType=\"BIT\"/>\n        <result column=\"order_num\" property=\"orderNum\" jdbcType=\"INTEGER\"/>\n    </resultMap>\n\n    <sql id=\"Base_Column_List\">\n        `id`,\n        `provider`,\n        `name`,\n        `icon`,\n        `client_id`,\n        `client_secret`,\n        `auth_url`,\n        `token_url`,\n        `user_info_url`,\n        `scope`,\n        `binding_field`,\n        `enabled`,\n        `order_num`\n    </sql>\n\n    <select id=\"findByProvider\" resultMap=\"BaseResultMap\">\n        select\n        <include refid=\"Base_Column_List\"/>\n        from sso_config\n        where provider = #{provider}\n        limit 1\n    </select>\n\n    <select id=\"findAll\" resultMap=\"BaseResultMap\">\n        select\n        <include refid=\"Base_Column_List\"/>\n        from sso_config\n        order by order_num, id\n    </select>\n\n    <select id=\"countByProvider\" resultType=\"int\">\n        select count(1)\n        from sso_config\n        where provider = #{provider}\n        <if test=\"ignoreId != null\">\n            and id != #{ignoreId}\n        </if>\n    </select>\n\n    <select id=\"findAllLoginItems\" resultType=\"im.zhaojun.zfile.module.sso.model.response.SsoLoginItemResponse\">\n        select\n            `provider` as `provider`,\n            `name` as `name`,\n            `icon` as `icon`\n        from\n            sso_config\n        where\n            enabled = 1\n        order by\n            order_num, id\n    </select>\n</mapper>\n"
  },
  {
    "path": "src/main/resources/mapper/StorageConfigMapper.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE mapper PUBLIC \"-//mybatis.org//DTD Mapper 3.0//EN\" \"http://mybatis.org/dtd/mybatis-3-mapper.dtd\">\n<mapper namespace=\"im.zhaojun.zfile.module.storage.mapper.StorageSourceConfigMapper\">\n    <resultMap id=\"BaseResultMap\" type=\"im.zhaojun.zfile.module.storage.model.entity.StorageSourceConfig\">\n        <!--@mbg.generated-->\n        <!--@Table storage_source_config-->\n        <id column=\"id\" jdbcType=\"INTEGER\" property=\"id\"/>\n        <result column=\"name\" jdbcType=\"VARCHAR\" property=\"name\"/>\n        <result column=\"type\" jdbcType=\"LONGVARCHAR\" property=\"type\"/>\n        <result column=\"title\" jdbcType=\"VARCHAR\" property=\"title\"/>\n        <result column=\"storage_id\" jdbcType=\"INTEGER\" property=\"storageId\"/>\n        <result column=\"value\" jdbcType=\"LONGVARCHAR\" property=\"value\"/>\n    </resultMap>\n    <sql id=\"Base_Column_List\">\n        <!--@mbg.generated-->\n        id, `name`, `type`, title, storage_id, `value`\n    </sql>\n\n<!--auto generated by MybatisCodeHelper on 2021-07-15-->\n    <select id=\"findByTypeOrderById\" resultMap=\"BaseResultMap\">\n        select\n        <include refid=\"Base_Column_List\"/>\n        from storage_source_config\n        where `type`=#{type,jdbcType=LONGVARCHAR} order by id\n    </select>\n\n<!--auto generated by MybatisCodeHelper on 2021-07-15-->\n    <select id=\"findByStorageIdOrderById\" resultMap=\"BaseResultMap\">\n        select\n        <include refid=\"Base_Column_List\"/>\n        from storage_source_config\n        where storage_id=#{storageId,jdbcType=INTEGER} order by id\n    </select>\n\n<!--auto generated by MybatisCodeHelper on 2021-07-15-->\n    <delete id=\"deleteByStorageId\">\n        delete from storage_source_config\n        where storage_id=#{storageId,jdbcType=INTEGER}\n    </delete>\n\n<!--auto generated by MybatisCodeHelper on 2021-07-15-->\n    <insert id=\"insertList\" useGeneratedKeys=\"true\" keyProperty=\"id\">\n        INSERT INTO storage_source_config(\n        name,\n        type,\n        title,\n        storage_id,\n        value\n        )VALUES\n        <foreach collection=\"list\" item=\"element\" index=\"index\" separator=\",\">\n            (\n            #{element.name,jdbcType=VARCHAR},\n            #{element.type,jdbcType=LONGVARCHAR},\n            #{element.title,jdbcType=VARCHAR},\n            #{element.storageId,jdbcType=INTEGER},\n            #{element.value,jdbcType=LONGVARCHAR}\n            )\n        </foreach>\n    </insert>\n</mapper>"
  },
  {
    "path": "src/main/resources/mapper/StorageSourceMapper.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE mapper PUBLIC \"-//mybatis.org//DTD Mapper 3.0//EN\" \"http://mybatis.org/dtd/mybatis-3-mapper.dtd\">\n<mapper namespace=\"im.zhaojun.zfile.module.storage.mapper.StorageSourceMapper\">\n  <resultMap id=\"BaseResultMap\" type=\"im.zhaojun.zfile.module.storage.model.entity.StorageSource\">\n    <!--@mbg.generated-->\n    <!--@Table \"storage_source\"-->\n    <id column=\"id\" jdbcType=\"INTEGER\" property=\"id\" />\n    <result column=\"enable\" jdbcType=\"BIT\" property=\"enable\" />\n    <result column=\"enable_cache\" jdbcType=\"BIT\" property=\"enableCache\" />\n    <result column=\"name\" jdbcType=\"VARCHAR\" property=\"name\" />\n    <result column=\"auto_refresh_cache\" jdbcType=\"BIT\" property=\"autoRefreshCache\" />\n    <result column=\"type\" jdbcType=\"VARCHAR\" property=\"type\" />\n    <result column=\"search_enable\" jdbcType=\"BIT\" property=\"searchEnable\" />\n    <result column=\"search_ignore_case\" jdbcType=\"BIT\" property=\"searchIgnoreCase\" />\n    <result column=\"order_num\" jdbcType=\"INTEGER\" property=\"orderNum\" />\n    <result column=\"default_switch_to_img_mode\" jdbcType=\"BIT\" property=\"defaultSwitchToImgMode\" />\n    <result column=\"remark\" jdbcType=\"VARCHAR\" property=\"remark\" />\n    <result column=\"key\" jdbcType=\"VARCHAR\" property=\"key\" />\n    <result column=\"enable_file_operator\" jdbcType=\"VARCHAR\" property=\"enableFileOperator\" />\n    <result column=\"search_mode\" jdbcType=\"VARCHAR\" property=\"searchMode\" />\n    <result column=\"enable_file_anno_operator\" jdbcType=\"BIT\" property=\"enableFileAnnoOperator\" />\n      <result column=\"compatibility_readme\" jdbcType=\"BIT\" property=\"compatibilityReadme\" />\n  </resultMap>\n  <sql id=\"Base_Column_List\">\n    <!--@mbg.generated-->\n    `id`, `enable`, `enable_cache`, `name`, `auto_refresh_cache`, `type`, `search_enable`,\n    `search_ignore_case`, `order_num`, `default_switch_to_img_mode`,\n    `remark`, `key`, `enable_file_operator`, `search_mode`, `enable_file_anno_operator`, `compatibility_readme`\n  </sql>\n\n    <select id=\"findUserEnableList\" resultMap=\"BaseResultMap\">\n        select\n            ss.name, ss.key, ss.type, ss.search_enable, ss.default_switch_to_img_mode\n        from\n            storage_source ss\n        left join\n            user_storage_source uss\n        on ss.id = uss.storage_source_id\n        left join\n            user u\n        on uss.user_id = u.id\n        where\n            ss.enable = true\n        and\n            uss.user_id = #{userId}\n        and\n            uss.enable = true\n        and\n            u.enable = true\n        order by ifnull(order_num, -1), ss.id desc\n    </select>\n\n<!--auto generated by MybatisCodeHelper on 2021-07-15-->\n    <select id=\"findAllOrderByOrderNum\" resultMap=\"BaseResultMap\">\n        select\n        <include refid=\"Base_Column_List\" />\n        from storage_source order by ifnull(order_num, -1), id desc\n    </select>\n\n    <select id=\"selectMaxId\" resultType=\"java.lang.Integer\">\n        select max(id) max from storage_source\n    </select>\n\n<!--auto generated by MybatisCodeHelper on 2021-07-15-->\n    <select id=\"findByType\" resultMap=\"BaseResultMap\">\n        select\n        <include refid=\"Base_Column_List\" />\n        from storage_source\n        where `type`=#{type,jdbcType=VARCHAR}\n    </select>\n\n    <update id=\"updateSetOrderNumById\">\n        update storage_source set order_num = #{orderNum} where id = #{id}\n    </update>\n\n  <select id=\"findByStorageKey\" resultMap=\"BaseResultMap\">\n      select\n      <include refid=\"Base_Column_List\" />\n      from storage_source\n      where `key`=#{storageKey,jdbcType=VARCHAR}\n    </select>\n\n  <select id=\"findIdByStorageKey\" resultType=\"java.lang.Integer\">\n      select id from storage_source where `key`=#{storageKey,jdbcType=VARCHAR}\n    </select>\n\n<!--auto generated by MybatisCodeHelper on 2022-06-13-->\n  <select id=\"findKeyById\" resultType=\"java.lang.String\">\n        select `key`\n        from storage_source\n        where `id`=#{id,jdbcType=INTEGER}\n    </select>\n</mapper>"
  },
  {
    "path": "src/main/resources/mapper/SystemConfigMapper.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE mapper PUBLIC \"-//mybatis.org//DTD Mapper 3.0//EN\" \"http://mybatis.org/dtd/mybatis-3-mapper.dtd\">\n<mapper namespace=\"im.zhaojun.zfile.module.config.mapper.SystemConfigMapper\">\n    <resultMap id=\"BaseResultMap\" type=\"im.zhaojun.zfile.module.config.model.entity.SystemConfig\">\n        <!--@mbg.generated-->\n        <!--@Table system_config-->\n        <id column=\"id\" jdbcType=\"INTEGER\" property=\"id\"/>\n        <result column=\"name\" jdbcType=\"VARCHAR\" property=\"name\"/>\n        <result column=\"value\" jdbcType=\"VARCHAR\" property=\"value\"/>\n        <result column=\"title\" jdbcType=\"VARCHAR\" property=\"title\"/>\n    </resultMap>\n    <sql id=\"Base_Column_List\">\n        <!--@mbg.generated-->\n        id, `name`, `value`, title\n    </sql>\n\n    <select id=\"findAll\" resultMap=\"BaseResultMap\">\n        select <include refid=\"Base_Column_List\"/> from system_config\n    </select>\n\n<!--auto generated by MybatisCodeHelper on 2021-07-15-->\n    <select id=\"findByName\" resultMap=\"BaseResultMap\">\n        select\n        <include refid=\"Base_Column_List\"/>\n        from system_config\n        where `name`=#{name,jdbcType=VARCHAR}\n    </select>\n\n<!--auto generated by MybatisCodeHelper on 2021-07-15-->\n    <insert id=\"saveAll\" useGeneratedKeys=\"true\" keyProperty=\"id\">\n        INSERT INTO system_config(\n        name,\n        value,\n        title\n        )VALUES\n        <foreach collection=\"list\" item=\"element\" index=\"index\" separator=\",\">\n            (\n            #{element.name,jdbcType=VARCHAR},\n            #{element.value,jdbcType=VARCHAR},\n            #{element.title,jdbcType=VARCHAR}\n            )\n        </foreach>\n    </insert>\n</mapper>"
  },
  {
    "path": "src/main/resources/mapper/UserMapper.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE mapper PUBLIC \"-//mybatis.org//DTD Mapper 3.0//EN\" \"http://mybatis.org/dtd/mybatis-3-mapper.dtd\">\n<mapper namespace=\"im.zhaojun.zfile.module.user.mapper.UserMapper\">\n  <resultMap id=\"BaseResultMap\" type=\"im.zhaojun.zfile.module.user.model.entity.User\">\n    <!--@mbg.generated-->\n    <!--@Table \"user\"-->\n    <id column=\"id\" jdbcType=\"INTEGER\" property=\"id\" />\n    <result column=\"username\" jdbcType=\"VARCHAR\" property=\"username\" />\n    <result column=\"nickname\" jdbcType=\"VARCHAR\" property=\"nickname\" />\n    <result column=\"password\" jdbcType=\"VARCHAR\" property=\"password\" />\n    <result column=\"salt\" jdbcType=\"VARCHAR\" property=\"salt\" />\n    <result column=\"enable\" jdbcType=\"BIT\" property=\"enable\" />\n    <result column=\"create_time\" jdbcType=\"TIMESTAMP\" property=\"createTime\" />\n    <result column=\"update_time\" jdbcType=\"TIMESTAMP\" property=\"updateTime\" />\n    <result column=\"default_permissions\" jdbcType=\"VARCHAR\" property=\"defaultPermissions\" typeHandler=\"im.zhaojun.zfile.core.config.mybatis.CollectionStrTypeHandler\" />\n  </resultMap>\n  <sql id=\"Base_Column_List\">\n    <!--@mbg.generated-->\n    id, `username`, `nickname`, `password`, salt, `enable`, create_time, update_time, default_permissions\n  </sql>\n\n  <select id=\"findIdByUsername\" resultType=\"int\">\n    select\n         id\n    from\n        user\n    where\n        username = #{username}\n  </select>\n\n  <select id=\"countByUsername\" resultType=\"int\">\n    select count(1)\n    from user\n    where username = #{username}\n    <if test=\"ignoreId != null\">\n      and id != #{ignoreId}\n    </if>\n  </select>\n\n  <update id=\"updateUserNameAndPwdById\">\n    update\n        user\n    set\n        username = #{username},\n        password = #{password}\n    where\n        id = #{id}\n  </update>\n</mapper>"
  },
  {
    "path": "src/main/resources/mapper/UserStorageSourceMapper.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE mapper PUBLIC \"-//mybatis.org//DTD Mapper 3.0//EN\" \"http://mybatis.org/dtd/mybatis-3-mapper.dtd\">\n<mapper namespace=\"im.zhaojun.zfile.module.user.mapper.UserStorageSourceMapper\">\n    <resultMap id=\"BaseResultMap\" type=\"im.zhaojun.zfile.module.user.model.entity.UserStorageSource\">\n        <!--@mbg.generated-->\n        <!--@Table user_storage_source-->\n        <id column=\"id\" jdbcType=\"INTEGER\" property=\"id\"/>\n        <result column=\"user_id\" jdbcType=\"INTEGER\" property=\"userId\"/>\n        <result column=\"storage_source_id\" jdbcType=\"INTEGER\" property=\"storageSourceId\"/>\n        <result column=\"root_path\" jdbcType=\"LONGVARCHAR\" property=\"rootPath\"/>\n        <result column=\"enable\" jdbcType=\"BIT\" property=\"enable\"/>\n        <result column=\"permissions\" jdbcType=\"VARCHAR\" property=\"permissions\"\n                typeHandler=\"im.zhaojun.zfile.core.config.mybatis.CollectionStrTypeHandler\"/>\n    </resultMap>\n\n    <resultMap id=\"UserStorageSourceDetailDTOResultMap\"\n               type=\"im.zhaojun.zfile.module.user.model.dto.UserStorageSourceDetailDTO\">\n        <!--@mbg.generated-->\n        <!--@Table user_storage_source-->\n        <id column=\"id\" jdbcType=\"INTEGER\" property=\"id\"/>\n        <result column=\"user_id\" jdbcType=\"INTEGER\" property=\"userId\"/>\n        <result column=\"storage_source_id\" jdbcType=\"INTEGER\" property=\"storageSourceId\"/>\n        <result column=\"root_path\" jdbcType=\"LONGVARCHAR\" property=\"rootPath\"/>\n        <result column=\"enable\" jdbcType=\"BIT\" property=\"enable\"/>\n        <result column=\"storage_source_name\" jdbcType=\"VARCHAR\" property=\"storageSourceName\"/>\n        <result column=\"storage_source_type\" jdbcType=\"VARCHAR\" property=\"storageSourceType\"/>\n        <result column=\"permissions\" jdbcType=\"VARCHAR\" property=\"permissions\"\n                typeHandler=\"im.zhaojun.zfile.core.config.mybatis.CollectionStrTypeHandler\"/>\n    </resultMap>\n\n    <sql id=\"Base_Column_List\">\n        <!--@mbg.generated-->\n        id, user_id, storage_source_id, root_path, `enable`, permissions\n    </sql>\n\n    <delete id=\"deleteByUserId\">\n        <!--@mbg.generated-->\n        delete\n        from user_storage_source\n        where user_id = #{userId,jdbcType=INTEGER}\n    </delete>\n\n    <delete id=\"deleteByStorageId\">\n        <!--@mbg.generated-->\n        delete\n        from user_storage_source\n        where storage_source_id = #{storageId,jdbcType=INTEGER}\n    </delete>\n\n    <select id=\"getDTOListByUserId\" resultMap=\"UserStorageSourceDetailDTOResultMap\">\n        <!--@mbg.generated-->\n        select uss.id,\n               uss.user_id,\n               uss.storage_source_id,\n               uss.root_path,\n               uss.enable,\n               ss.name as storage_source_name,\n               ss.type as storage_source_type,\n               uss.permissions\n        from\n            user_storage_source uss\n        left join\n            storage_source ss\n        on\n            uss.storage_source_id = ss.id\n        where\n            user_id = #{userId,jdbcType=INTEGER}\n    </select>\n\n    <select id=\"getByUserIdAndStorageId\" resultMap=\"BaseResultMap\">\n        <!--@mbg.generated-->\n        select\n        <include refid=\"Base_Column_List\"/>\n        from user_storage_source\n        where user_id = #{userId,jdbcType=INTEGER}\n          and storage_source_id = #{storageId,jdbcType=INTEGER}\n    </select>\n\n    <select id=\"selectByStorageId\" resultMap=\"BaseResultMap\">\n        select *\n        from user_storage_source\n        where storage_source_id = #{storageId,jdbcType=INTEGER}\n    </select>\n\n    <select id=\"selectByUserId\" resultMap=\"BaseResultMap\">\n        select *\n        from user_storage_source\n        where user_id = #{userId,jdbcType=INTEGER}\n    </select>\n</mapper>\n"
  },
  {
    "path": "src/main/resources/templates/callback.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\" xmlns:th=\"http://www.thymeleaf.org\">\n<head>\n    <meta charset=\"UTF-8\">\n    <script src=\"https://cdn.jun6.net/uPic/2022/08/15/tailwind.js\">\n    </script>\n    <script src=\"https://lf6-cdn-tos.bytecdntp.com/cdn/expire-1-M/jquery/1.8.0/jquery-1.8.0.min.js\"></script>\n    <script src=\"https://lf3-cdn-tos.bytecdntp.com/cdn/expire-1-M/clipboard.js/2.0.10/clipboard.min.js\"></script>\n    <script src=\"https://lf3-cdn-tos.bytecdntp.com/cdn/expire-1-M/layer/3.5.1/layer.min.js\"></script>\n    <title>ZFile 令牌获取结果</title>\n</head>\n<body class=\"w-full h-full\">\n<div class=\"h-full min-h-screen bg-gray-100 text-gray-900 flex justify-center py-10\">\n    <div class=\"flex flex-1 max-w-screen-lg px-8 lg:0\">\n        <div class=\"w-full\">\n            <div class=\"relative overflow-auto\">\n                <div class=\"rounded mx-auto bg-white shadow py-5 px-6\">\n                    <div class=\"text-2xl text-center mb-4\" th:if=\"${type == null}\">ZFile OneDrive / SharePoint 令牌获取结果</div>\n                    <div class=\"text-2xl text-center mb-4\" th:if=\"${type != null}\" th:text=\"${type} + '令牌获取结果'\"></div>\n\n\n                    <form class=\"space-y-6\">\n                        <div th:if=\"${oauth2Token.success}\" class=\"text-right\">\n                            <span>状态：</span>\n                            <span class=\"text-green-500\">获取成功</span>\n                        </div>\n                        <div th:if=\"${oauth2Token.success == false}\" class=\"text-right\">\n                            <span>状态：</span>\n                            <span class=\"text-red-500\">获取失败</span>\n                        </div>\n\n                        <div th:if=\"${oauth2Token.success == false && oauth2Token.body.contains('AADSTS65001')}\" class=\"text-red-500 font-bold\">\n                            检测到您可能是 ZFile 3.x 版本获取的令牌，因为一些兼容性问题，您需要点击以下链接重新获取令牌，或者升级 4.x 版本解决。\n                            <a target=\"_blank\" class=\"text-blue-400 block\" href=\"https://demo.zfile.vip/onedrive/china-authorize\" th:if=\"${type.equals('OneDrive 世纪互联')}\">https://demo.zfile.vip/onedrive/china-authorize</a>\n                            <a target=\"_blank\" class=\"text-blue-400 block\" href=\"https://demo.zfile.vip/onedrive/authorize\" th:if=\"${type.equals('OneDrive 国际版')}\">https://demo.zfile.vip/onedrive/authorize</a>\n                        </div>\n\n                        <div>\n                            <label for=\"accessToken\" class=\"block text-sm font-medium text-slate-700\">AccessToken (访问令牌)</label>\n                            <div class=\"mt-1\">\n                                <input th:value=\"${oauth2Token.accessToken}\" type=\"text\" name=\"accessToken\" id=\"accessToken\" class=\"px-3 py-2 bg-white border shadow-sm border-slate-300 placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:ring-sky-500 block w-full rounded-md sm:text-sm focus:ring-1 invalid:border-pink-500 invalid:text-pink-600 focus:invalid:border-pink-500 focus:invalid:ring-pink-500\">\n                            </div>\n                        </div>\n                        <div>\n                            <label for=\"refreshToken\" class=\"block text-sm font-medium text-slate-700\">RefreshToken (刷新令牌)</label>\n                            <div class=\"mt-1\">\n                                <input th:value=\"${oauth2Token.refreshToken}\" type=\"text\" name=\"refreshToken\" id=\"refreshToken\" class=\"px-3 py-2 bg-white border shadow-sm border-slate-300 placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:ring-sky-500 block w-full rounded-md sm:text-sm focus:ring-1 invalid:border-pink-500 invalid:text-pink-600 focus:invalid:border-pink-500 focus:invalid:ring-pink-500\">\n                            </div>\n                        </div>\n                        <div class=\"border-t-4 border-dashed\">\n                        </div>\n                        <div class=\"text-sm text-gray-500\">\n                            tips: 以下为诊断信息，如获取成功请忽略，获取失败无法自行解决时请截图下方所有内容发送给开发者，github: <a target=\"_blank\" class=\"text-blue-500\" href=\"https://github.com/zfile-dev/zfile/issues\">https://github.com/zfile-dev/zfile/issues</a>。\n                        </div>\n                        <div>\n                            <label for=\"clientId\" class=\"block text-sm font-medium text-slate-700\">clientId (api id)</label>\n                            <div class=\"mt-1\">\n                                <input th:value=\"${oauth2Token.clientId}\" type=\"text\" name=\"clientId\" id=\"clientId\" class=\"px-3 py-2 bg-white border shadow-sm border-slate-300 placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:ring-sky-500 block w-full rounded-md sm:text-sm focus:ring-1 invalid:border-pink-500 invalid:text-pink-600 focus:invalid:border-pink-500 focus:invalid:ring-pink-500\">\n                            </div>\n                        </div>\n                        <div>\n                            <label for=\"clientSecret\" class=\"block text-sm font-medium text-slate-700\">clientSecret (api 密钥)</label>\n                            <div class=\"mt-1\">\n                                <input th:value=\"${oauth2Token.clientSecret}\" type=\"text\" name=\"clientSecret\" id=\"clientSecret\" class=\"px-3 py-2 bg-white border shadow-sm border-slate-300 placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:ring-sky-500 block w-full rounded-md sm:text-sm focus:ring-1 invalid:border-pink-500 invalid:text-pink-600 focus:invalid:border-pink-500 focus:invalid:ring-pink-500\">\n                            </div>\n                        </div>\n                        <div>\n                            <label for=\"redirectUri\" class=\"block text-sm font-medium text-slate-700\">redirectUri (回调地址)</label>\n                            <div class=\"mt-1\">\n                                <input th:value=\"${oauth2Token.redirectUri}\" type=\"text\" name=\"redirectUri\" id=\"redirectUri\" class=\"px-3 py-2 bg-white border shadow-sm border-slate-300 placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:ring-sky-500 block w-full rounded-md sm:text-sm focus:ring-1 invalid:border-pink-500 invalid:text-pink-600 focus:invalid:border-pink-500 focus:invalid:ring-pink-500\">\n                            </div>\n                        </div>\n                        <div>\n                            <label for=\"body\" class=\"block text-sm font-medium text-slate-700\">响应体 (api 返回的完整信息)</label>\n                            <div class=\"mt-1\">\n                                <textarea th:text=\"${oauth2Token.body}\" rows=\"10\" name=\"body\" id=\"body\" class=\"px-3 py-2 bg-white border shadow-sm border-slate-300 placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:ring-sky-500 block w-full rounded-md sm:text-sm focus:ring-1 invalid:border-pink-500 invalid:text-pink-600 focus:invalid:border-pink-500 focus:invalid:ring-pink-500\"></textarea>\n                            </div>\n                        </div>\n                    </form>\n                </div>\n            </div>\n        </div>\n    </div>\n</div>\n</body>\n\n<script type=\"application/javascript\">\n    let clipboard = new ClipboardJS('input, textarea', {\n        target: function(trigger) {\n            console.log(trigger.value)\n            return trigger;\n        }\n    });\n\n    clipboard.on('success', function(e) {\n        console.info('Action:', e.action);\n        console.info('Text:', e.text);\n        console.info('Trigger:', e.trigger);\n        layer.msg('复制成功', {icon: 1})\n        e.trigger.select();\n        e.clearSelection();\n    });\n\n    clipboard.on('error', function(e) {\n        console.error('Action:', e.action);\n        console.error('Trigger:', e.trigger);\n        layer.msg('复制失败，请手动复制', {icon: 2})\n    });\n</script>\n</html>"
  },
  {
    "path": "src/main/resources/templates/error/404.html",
    "content": "<!DOCTYPE html>\n<html>\n<head>\n    <meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\">\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge,chrome=1\">\n    <title>404 ERROR</title>\n    <style type=\"text/css\">\n        body, div, h3, h4, li, ol {\n            margin: 0;\n            padding: 0\n        }\n\n        body {\n            font: 14px/1.5 'Microsoft YaHei', '微软雅黑', Helvetica, Sans-serif;\n            min-width: 1200px;\n            background: #f0f1f3;\n        }\n\n        :focus {\n            outline: 0\n        }\n\n        h3, h4, strong {\n            font-weight: 700\n        }\n\n        a {\n            color: #428bca;\n            text-decoration: none\n        }\n\n        a:hover {\n            text-decoration: underline\n        }\n\n        .error-page {\n            background: #f0f1f3;\n            padding: 80px 0 180px\n        }\n\n        .error-page-container {\n            position: relative;\n            z-index: 1\n        }\n\n        .error-page-main {\n            position: relative;\n            background: #f9f9f9;\n            margin: 0 auto;\n            width: 617px;\n            -ms-box-sizing: border-box;\n            -webkit-box-sizing: border-box;\n            -moz-box-sizing: border-box;\n            box-sizing: border-box;\n            padding: 50px 50px 70px\n        }\n\n        .error-page-main:before {\n            content: '';\n            display: block;\n            background: url(img/errorPageBorder.png?1427783409637);\n            height: 7px;\n            position: absolute;\n            top: -7px;\n            width: 100%;\n            left: 0\n        }\n\n        .error-page-main h3 {\n            font-size: 24px;\n            font-weight: 400;\n            border-bottom: 1px solid #d0d0d0\n        }\n\n        .error-page-main h3 strong {\n            font-size: 54px;\n            font-weight: 400;\n            margin-right: 20px\n        }\n\n        .error-page-main h4 {\n            font-size: 20px;\n            font-weight: 400;\n            color: #333\n        }\n\n        .error-page-actions {\n            font-size: 0;\n            z-index: 100\n        }\n\n        .error-page-actions div {\n            font-size: 14px;\n            display: inline-block;\n            padding: 30px 0 0 10px;\n            width: 50%;\n            -ms-box-sizing: border-box;\n            -webkit-box-sizing: border-box;\n            -moz-box-sizing: border-box;\n            box-sizing: border-box;\n            color: #838383\n        }\n\n        .error-page-actions ol {\n            list-style: decimal;\n            padding-left: 20px\n        }\n\n        .error-page-actions li {\n            line-height: 2.5em\n        }\n\n        .error-page-actions:before {\n            content: '';\n            display: block;\n            position: absolute;\n            z-index: -1;\n            bottom: 17px;\n            left: 50px;\n            width: 200px;\n            height: 10px;\n            -moz-box-shadow: 4px 5px 31px 11px #999;\n            -webkit-box-shadow: 4px 5px 31px 11px #999;\n            box-shadow: 4px 5px 31px 11px #999;\n            -moz-transform: rotate(-4deg);\n            -webkit-transform: rotate(-4deg);\n            -ms-transform: rotate(-4deg);\n            -o-transform: rotate(-4deg);\n            transform: rotate(-4deg)\n        }\n\n        .error-page-actions:after {\n            content: '';\n            display: block;\n            position: absolute;\n            z-index: -1;\n            bottom: 17px;\n            right: 50px;\n            width: 200px;\n            height: 10px;\n            -moz-box-shadow: 4px 5px 31px 11px #999;\n            -webkit-box-shadow: 4px 5px 31px 11px #999;\n            box-shadow: 4px 5px 31px 11px #999;\n            -moz-transform: rotate(4deg);\n            -webkit-transform: rotate(4deg);\n            -ms-transform: rotate(4deg);\n            -o-transform: rotate(4deg);\n            transform: rotate(4deg)\n        }\n    </style>\n</head>\n<body>\n<div class=\"error-page\">\n    <div class=\"error-page-container\">\n        <div class=\"error-page-main\">\n            <h3>\n                <strong>404</strong>很抱歉，您要访问的文件/页面不存在！\n            </h3>\n            <div class=\"error-page-actions\">\n                <div>\n                    <h4>可能原因：</h4>\n                    <ol>\n                        <li>网络信号差不稳定</li>\n                        <li>找不到请求的页面</li>\n                        <li>输入的网址不正确</li>\n                    </ol>\n                </div>\n                <div>\n                    <h4>可以尝试：</h4>\n                    <ol>\n                        <li><a href=\"#\" onclick=\"backHomePage()\">返回首页</a></li>\n                        <li><a href=\"https://github.com/zhaojun1998/zfile/issues\" target=\"_blank\">留言反馈</a></li>\n                        <li><a href=\"#\">联系站长</a></li>\n                    </ol>\n                </div>\n            </div>\n        </div>\n    </div>\n</div>\n\n<script>\n    function backHomePage() {\n        window.location.href = window.location.origin;\n    }\n</script>\n</body>\n</html>"
  }
]