Browse Source

将 oss 项目的 0ac2b14a1d 版本合并到 tnb 项目, oss 项目不再更新

reghao 7 months ago
parent
commit
6aac1c5a0f
100 changed files with 6225 additions and 0 deletions
  1. 36 0
      oss-api/pom.xml
  2. 55 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/constant/ChannelAction.java
  3. 56 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/constant/ObjectScope.java
  4. 39 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/constant/ObjectSize.java
  5. 67 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/constant/ObjectType.java
  6. 15 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/constant/SupportedMedia.java
  7. 11 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/constant/VideoUrlType.java
  8. 21 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/NodeProperties.java
  9. 27 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/ObjectChannel.java
  10. 43 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/ObjectInfo.java
  11. 26 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/ObjectMeta.java
  12. 21 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/ServerInfo.java
  13. 27 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/StoreNodeDto.java
  14. 22 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/disk/DiskPartition.java
  15. 22 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/disk/DiskStore.java
  16. 44 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/disk/DiskVolume.java
  17. 32 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/media/AudioInfo.java
  18. 21 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/media/AudioUrl.java
  19. 29 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/media/ConvertedImageInfo.java
  20. 33 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/media/ImageInfo.java
  21. 24 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/media/ImageUrlDto.java
  22. 42 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/media/VideoInfo.java
  23. 24 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/media/VideoUrlDto.java
  24. 28 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/iface/ConsoleService.java
  25. 42 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/iface/StoreService.java
  26. 34 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/rest/UploadFilePart.java
  27. 30 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/rest/UploadFileRet.java
  28. 31 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/rest/UploadPrepare.java
  29. 21 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/rest/UploadPrepareRet.java
  30. 39 0
      oss-api/src/main/java/cn/reghao/tnb/oss/api/rest/UploadedPart.java
  31. 42 0
      oss-sdk/pom.xml
  32. 172 0
      oss-sdk/src/main/java/cn/reghao/tnb/oss/sdk/MultiPartBodyPublisher.java
  33. 216 0
      oss-sdk/src/main/java/cn/reghao/tnb/oss/sdk/ObjectMultipartUploadService.java
  34. 461 0
      oss-sdk/src/main/java/cn/reghao/tnb/oss/sdk/OssConsoleClient.java
  35. 222 0
      oss-sdk/src/main/java/cn/reghao/tnb/oss/sdk/OssStoreClient.java
  36. 16 0
      oss-sdk/src/main/java/cn/reghao/tnb/oss/sdk/model/OssConsoleConfig.java
  37. 43 0
      oss-sdk/src/test/java/OssConsoleClientTest.java
  38. 7 0
      oss-store/Dockerfile
  39. 14 0
      oss-store/bin/oss.yml
  40. 11 0
      oss-store/bin/shutdown.sh
  41. 11 0
      oss-store/bin/start.sh
  42. 160 0
      oss-store/pom.xml
  43. 11 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/OssStoreApplication.java
  44. 68 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/config/CacheConfig.java
  45. 50 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/config/DubboServiceConfig.java
  46. 61 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/config/SpringLifecycle.java
  47. 62 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/config/exception/ControllerExceptionHandler.java
  48. 43 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/config/exception/FilterExceptionHandler.java
  49. 66 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/config/inerceptor/AccessLogInterceptor.java
  50. 50 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/config/inerceptor/TokenFilter.java
  51. 68 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/config/mybatis/DataSourceConfig.java
  52. 83 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/config/mybatis/PageListInterceptor.java
  53. 25 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/config/props/DubboProperties.java
  54. 23 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/config/props/OssProperties.java
  55. 34 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/config/props/SpringProperties.java
  56. 61 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/config/web/PutMessageConverter.java
  57. 72 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/config/web/WebConfig.java
  58. 133 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/controller/ObjectGetController.java
  59. 116 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/controller/ObjectMultipartUploadController.java
  60. 135 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/controller/ObjectUploadController.java
  61. 18 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/db/mapper/DataBlockMapper.java
  62. 36 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/db/mapper/FileMetaMapper.java
  63. 16 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/db/mapper/FileMultipartMapper.java
  64. 16 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/db/mapper/FilePartMapper.java
  65. 47 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/db/repository/FilePartRepository.java
  66. 132 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/db/repository/ObjectRepository.java
  67. 17 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/disk/LoadBalancer.java
  68. 72 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/disk/LocalStore.java
  69. 84 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/disk/LocalStores.java
  70. 37 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/disk/StoreDir.java
  71. 26 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/disk/SubDirCount.java
  72. 15 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/model/dto/ContentRange.java
  73. 35 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/model/po/DataBlock.java
  74. 83 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/model/po/FileMeta.java
  75. 35 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/model/po/FileMultipart.java
  76. 24 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/model/po/FilePart.java
  77. 13 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/model/vo/ImageObject.java
  78. 17 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/model/vo/ObjectProp.java
  79. 36 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/model/vo/ObjectResult.java
  80. 228 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/rpc/StoreServiceImpl.java
  81. 48 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/service/ChannelValidateService.java
  82. 45 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/service/ConsoleServiceWrapper.java
  83. 66 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/service/DiskService.java
  84. 161 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/service/FileStoreService.java
  85. 238 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/service/GetObjectService.java
  86. 164 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/service/ObjectMultipartUploadService.java
  87. 95 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/service/ObjectNameService.java
  88. 113 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/service/PutObjectService.java
  89. 48 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/service/SignService.java
  90. 211 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/task/MediaFileProcessor.java
  91. 65 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/task/VideoFile.java
  92. 196 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/task/VideoFileProcessor.java
  93. 34 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/tomcat/GlobalServlet.java
  94. 83 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/tomcat/TomcatStarter.java
  95. 27 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/util/AuthContext.java
  96. 64 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/util/FileType.java
  97. 20 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/util/ObjectUtil.java
  98. 32 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/util/SignatureUtil.java
  99. 19 0
      oss-store/src/main/java/cn/reghao/tnb/oss/store/util/StringUtil.java
  100. 11 0
      oss-store/src/main/resources/application-dev.yml

+ 36 - 0
oss-api/pom.xml

@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <artifactId>tnb</artifactId>
+        <groupId>cn.reghao.tnb</groupId>
+        <version>1.0.0</version>
+    </parent>
+
+    <artifactId>oss-api</artifactId>
+    <version>1.0.0-SNAPSHOT</version>
+
+    <properties>
+        <maven.compiler.source>11</maven.compiler.source>
+        <maven.compiler.target>11</maven.compiler.target>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+            <version>1.18.6</version>
+            <optional>true</optional>
+        </dependency>
+
+        <dependency>
+            <groupId>javax.validation</groupId>
+            <artifactId>validation-api</artifactId>
+            <version>2.0.1.Final</version>
+        </dependency>
+    </dependencies>
+</project>

+ 55 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/constant/ChannelAction.java

@@ -0,0 +1,55 @@
+package cn.reghao.tnb.oss.api.constant;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * @author reghao
+ * @date 2023-10-18 13:15:45
+ */
+public enum ChannelAction {
+    access(1, "访问"),
+    download(2, "下载"),
+    upload(3, "上传"),
+    delete(4, "删除"),
+    all(5, "all");
+
+    private final int code;
+    private final String desc;
+
+    private static Map<Integer, String> descMap = new HashMap<>();
+    static {
+        for (ChannelAction scope : ChannelAction.values()) {
+            descMap.put(scope.code, scope.desc);
+        }
+    }
+
+    ChannelAction(Integer code, String desc) {
+        this.code = code;
+        this.desc = desc;
+    }
+
+    public String getName() {
+        return this.name();
+    }
+
+    /**
+     * 提供给 @ValidEnum 调用
+     *
+     * @param
+     * @return
+     * @date 2023-10-11 14:44:42
+     */
+    public int getValue() {
+        return this.code;
+    }
+
+    public Integer getCode() {
+        return code;
+    }
+
+    // TODO 第一次调用时会初始化 descMap
+    public static String getDescByCode(int code) {
+        return descMap.get(code);
+    }
+}

+ 56 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/constant/ObjectScope.java

@@ -0,0 +1,56 @@
+package cn.reghao.tnb.oss.api.constant;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 描述资源的可见范围
+ *
+ * @author reghao
+ * @date 2023-05-19 18:15:45
+ */
+public enum ObjectScope {
+    PRIVATE(1, "本人可见"),
+    PUBLIC(2, "所有人可见");
+
+    private final int code;
+    private final String desc;
+    private static final Map<Integer, ObjectScope> map = new HashMap<>();
+    static {
+        for (ObjectScope scope : ObjectScope.values()) {
+            map.put(scope.code, scope);
+        }
+    }
+
+    ObjectScope(Integer code, String desc) {
+        this.code = code;
+        this.desc = desc;
+    }
+
+    public String getName() {
+        return this.name();
+    }
+
+    /**
+     * 提供给 @ValidEnum 调用
+     *
+     * @param
+     * @return
+     * @date 2023-10-11 14:44:42
+     */
+    public int getValue() {
+        return this.code;
+    }
+
+    public Integer getCode() {
+        return code;
+    }
+
+    public String getDesc() {
+        return desc;
+    }
+
+    public static ObjectScope getByCode(int code) {
+        return map.get(code);
+    }
+}

+ 39 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/constant/ObjectSize.java

@@ -0,0 +1,39 @@
+package cn.reghao.tnb.oss.api.constant;
+
+/**
+ * @author reghao
+ * @date 2024-10-23 10:49:04
+ */
+public enum ObjectSize {
+    mb10(1024L*1024*10, "10MB"),
+    mb100(1024L*1024*100, "100MB"),
+    gb1(1024L*1024*1024, "1GB"),
+    gb10(1024L*1024*1024*10, "10GB"),
+    gb20(1024L*1024*1024*20, "20GB");
+
+    private final long size;
+    private final String desc;
+    ObjectSize(long size, String desc) {
+        this.size = size;
+        this.desc = desc;
+    }
+
+    /**
+     * 提供给 @ValidEnum 调用
+     *
+     * @param
+     * @return
+     * @date 2023-10-11 14:44:42
+     */
+    public long getValue() {
+        return this.size;
+    }
+
+    public long getSize() {
+        return size;
+    }
+
+    public String getDesc() {
+        return desc;
+    }
+}

+ 67 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/constant/ObjectType.java

@@ -0,0 +1,67 @@
+package cn.reghao.tnb.oss.api.constant;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * @author reghao
+ * @date 2023-06-13 15:09:09
+ */
+public enum ObjectType {
+    Dir(1000, "Dir"),
+    Image(1001, "Image"),
+    Video(1002, "Video"),
+    Audio(1003, "Audio"),
+    Text(1004, "Text"),
+    Other(1005, "Application"),
+    Any(1006, "*/*");
+
+    private final int code;
+    // contentType 的值不能改变, 代码中会使用它作为前缀和上传文件的 content-type 进行比较
+    private final String contentType;
+    ObjectType(int code, String contentType) {
+        this.code = code;
+        this.contentType = contentType;
+    }
+
+    private static Map<Integer, ObjectType> map = new HashMap<>();
+    static {
+        for (ObjectType type : ObjectType.values()) {
+            map.put(type.code, type);
+        }
+    }
+
+    private static Map<Integer, String> descMap = new HashMap<>();
+    static {
+        for (ObjectType objectType : ObjectType.values()) {
+            descMap.put(objectType.code, objectType.contentType);
+        }
+    }
+
+    public static ObjectType getByCode(int code) {
+        return map.get(code);
+    }
+
+    /**
+     * 提供给 @ValidEnum 调用
+     *
+     * @param
+     * @return
+     * @date 2023-10-11 14:44:42
+     */
+    public int getValue() {
+        return this.code;
+    }
+
+    public int getCode() {
+        return code;
+    }
+
+    public String getDesc() {
+        return contentType;
+    }
+
+    public static String getDescByCode(int code) {
+        return descMap.get(code);
+    }
+}

+ 15 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/constant/SupportedMedia.java

@@ -0,0 +1,15 @@
+package cn.reghao.tnb.oss.api.constant;
+
+import java.util.Set;
+
+/**
+ * 系统支持的媒体文件格式
+ *
+ * @author reghao
+ * @date 2023-10-09 10:34:42
+ */
+public class SupportedMedia {
+    public static final Set<String> videoCodecs = Set.of("h264");
+    public static final Set<String> audioCodecs = Set.of("aac", "mp3");
+    public static final Set<String> imageFormats = Set.of("jpeg", "jpg", "webp", "gif", "png");
+}

+ 11 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/constant/VideoUrlType.java

@@ -0,0 +1,11 @@
+package cn.reghao.tnb.oss.api.constant;
+
+/**
+ * 视频 URL 类型
+ *
+ * @author reghao
+ * @date 2021-12-28 15:49:19
+ */
+public enum VideoUrlType {
+    mp4, hls, dash, flv
+}

+ 21 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/NodeProperties.java

@@ -0,0 +1,21 @@
+package cn.reghao.tnb.oss.api.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.io.Serializable;
+
+/**
+ * @author reghao
+ * @date 2024-03-01 09:22:17
+ */
+@AllArgsConstructor
+@Getter
+public class NodeProperties implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private String domain;
+    private String secretKey;
+    private String referer;
+    private int owner;
+}

+ 27 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/ObjectChannel.java

@@ -0,0 +1,27 @@
+package cn.reghao.tnb.oss.api.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.io.Serializable;
+
+/**
+ * @author reghao
+ * @date 2024-02-26 10:08:12
+ */
+@AllArgsConstructor
+@Getter
+public class ObjectChannel implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private int id;
+    private int channelCode;
+    private String prefix;
+    private long maxSize;
+    private int fileType;
+    private boolean setUrl;
+    private boolean setCallback;
+    private int scope;
+    private String domain;
+    private int createBy;
+}

+ 43 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/ObjectInfo.java

@@ -0,0 +1,43 @@
+package cn.reghao.tnb.oss.api.dto;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.io.Serializable;
+
+/**
+ * @author reghao
+ * @date 2023-11-29 18:11:13
+ */
+@NoArgsConstructor
+@Setter
+@Getter
+public class ObjectInfo implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private String objectId;
+    private String objectName;
+    private int fileType;
+    private String filename;
+    private long size;
+    private String url;
+    private int scope;
+    private String sha256sum;
+    private String updateTime;
+
+    public ObjectInfo(String objectId, String objectName, int fileType, String filename, long size, int scope, String sha256sum) {
+        this.objectId = objectId;
+        this.objectName = objectName;
+        this.fileType = fileType;
+        this.filename = filename;
+        this.size = size;
+        this.url = objectName;
+        this.scope = scope;
+        this.sha256sum = sha256sum;
+    }
+
+    public void setUrl(String url) {
+        this.url = url;
+    }
+}

+ 26 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/ObjectMeta.java

@@ -0,0 +1,26 @@
+package cn.reghao.tnb.oss.api.dto;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.io.Serializable;
+
+/**
+ * @author reghao
+ * @date 2023-04-30 12:17:28
+ */
+@Setter
+@Getter
+public class ObjectMeta implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private int id;
+    private String contentId;
+    private String objectName;
+    private String objectId;
+    private String absolutePath;
+    private long size;
+    private String contentType;
+    private int scope;
+    private int uploadBy;
+}

+ 21 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/ServerInfo.java

@@ -0,0 +1,21 @@
+package cn.reghao.tnb.oss.api.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.io.Serializable;
+
+/**
+ * @author reghao
+ * @date 2023-08-23 11:41:55
+ */
+@AllArgsConstructor
+@Getter
+public class ServerInfo implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private String ossUrl;
+    private int channelCode;
+    private long maxSize;
+    private String token;
+}

+ 27 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/StoreNodeDto.java

@@ -0,0 +1,27 @@
+package cn.reghao.tnb.oss.api.dto;
+
+import cn.reghao.tnb.oss.api.dto.disk.DiskVolume;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2024-03-04 17:12:14
+ */
+@AllArgsConstructor
+@NoArgsConstructor
+@Setter
+@Getter
+public class StoreNodeDto implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private String nodeAddr;
+    private Integer httpPort;
+    private Integer rpcPort;
+    private List<DiskVolume> diskVolumes;
+}

+ 22 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/disk/DiskPartition.java

@@ -0,0 +1,22 @@
+package cn.reghao.tnb.oss.api.dto.disk;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.io.Serializable;
+
+/**
+ * @author reghao
+ * @date 2024-10-21 09:50:10
+ */
+@AllArgsConstructor
+@Getter
+public class DiskPartition implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private String partitionName;
+    private String mountPoint;
+    private String blockId;
+    private String fsType;
+    private long partitionSize;
+}

+ 22 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/disk/DiskStore.java

@@ -0,0 +1,22 @@
+package cn.reghao.tnb.oss.api.dto.disk;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2024-10-21 09:50:23
+ */
+@AllArgsConstructor
+@Getter
+public class DiskStore implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private String model;
+    private String name;
+    private long size;
+    private List<DiskPartition> partitions;
+}

+ 44 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/disk/DiskVolume.java

@@ -0,0 +1,44 @@
+package cn.reghao.tnb.oss.api.dto.disk;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.io.Serializable;
+
+/**
+ * @author reghao
+ * @date 2024-10-21 11:29:28
+ */
+@AllArgsConstructor
+@Getter
+public class DiskVolume implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private String name;
+    private String volume;
+    private String mountPoint;
+    private String fsType;
+    private String blockId;
+    private long totalSpace;
+    private long availSpace;
+    private long totalInode;
+    private long availInode;
+    private String storeDir;
+
+    public DiskVolume(String name, String volume, String mountPoint, String fsType, String blockId,
+                      long totalSpace, long availSpace, long totalInode, long availInode) {
+        this.name = name;
+        this.volume = volume;
+        this.mountPoint = mountPoint;
+        this.fsType = fsType;
+        this.blockId = blockId;
+        this.totalSpace = totalSpace;
+        this.availSpace = availSpace;
+        this.totalInode = totalInode;
+        this.availInode = availInode;
+    }
+
+    public void setStoreDir(String storeDir) {
+        this.storeDir = storeDir;
+    }
+}

+ 32 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/media/AudioInfo.java

@@ -0,0 +1,32 @@
+package cn.reghao.tnb.oss.api.dto.media;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.io.Serializable;
+
+/**
+ * @author reghao
+ * @date 2023-08-28 17:00:56
+ */
+@AllArgsConstructor
+@NoArgsConstructor
+@Setter
+@Getter
+public class AudioInfo implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private String audioFileId;
+    private String objectId;
+    private int duration;
+    private String codec;
+    private Long bitRate;
+    private String url;
+    private Long size;
+
+    public void setUrl(String url) {
+        this.url = url;
+    }
+}

+ 21 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/media/AudioUrl.java

@@ -0,0 +1,21 @@
+package cn.reghao.tnb.oss.api.dto.media;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.io.Serializable;
+
+/**
+ * @author reghao
+ * @date 2023-10-12 15:07:44
+ */
+@Setter
+@Getter
+public class AudioUrl implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private String audioFileId;
+    private String codec;
+    private Long bitRate;
+    private String url;
+}

+ 29 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/media/ConvertedImageInfo.java

@@ -0,0 +1,29 @@
+package cn.reghao.tnb.oss.api.dto.media;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.io.Serializable;
+
+/**
+ * @author reghao
+ * @date 2024-03-08 17:07:23
+ */
+@AllArgsConstructor
+@NoArgsConstructor
+@Setter
+@Getter
+public class ConvertedImageInfo implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private String imageFileId;
+    private String objectId;
+    private String format;
+    private String url;
+
+    public void setUrl(String url) {
+        this.url = url;
+    }
+}

+ 33 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/media/ImageInfo.java

@@ -0,0 +1,33 @@
+package cn.reghao.tnb.oss.api.dto.media;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.io.Serializable;
+
+/**
+ * @author reghao
+ * @date 2024-03-08 10:56:27
+ */
+@AllArgsConstructor
+@NoArgsConstructor
+@Setter
+@Getter
+public class ImageInfo implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    // 原始文件的 objectId
+    private String imageFileId;
+    private String objectId;
+    private String format;
+    private String url;
+    private Integer width;
+    private Integer height;
+    private Long size;
+
+    public void setUrl(String url) {
+        this.url = url;
+    }
+}

+ 24 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/media/ImageUrlDto.java

@@ -0,0 +1,24 @@
+package cn.reghao.tnb.oss.api.dto.media;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.io.Serializable;
+
+/**
+ * @author reghao
+ * @date 2023-08-28 15:50:38
+ */
+@Setter
+@Getter
+public class ImageUrlDto implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private String imageFileId;
+    private String originalUrl;
+    private String thumbnailUrl;
+
+    public ImageUrlDto(String imageFileId) {
+        this.imageFileId = imageFileId;
+    }
+}

+ 42 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/media/VideoInfo.java

@@ -0,0 +1,42 @@
+package cn.reghao.tnb.oss.api.dto.media;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * @author reghao
+ * @date 2023-01-11 10:41:53
+ */
+@AllArgsConstructor
+@NoArgsConstructor
+@Setter
+@Getter
+public class VideoInfo implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private String videoFileId;
+    private String objectId;
+    private String videoCodec;
+    private Long vbitRate;
+    private String audioCodec;
+    private Long abitRate;
+    private String formatName;
+    private String urlType;
+    private String url;
+    private String quality;
+    private Integer width;
+    private Integer height;
+    // 单位秒
+    private Integer duration;
+    private LocalDateTime createTime;
+    private Long size;
+
+    public void setUrl(String url) {
+        this.url = url;
+    }
+}

+ 24 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/dto/media/VideoUrlDto.java

@@ -0,0 +1,24 @@
+package cn.reghao.tnb.oss.api.dto.media;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.io.Serializable;
+
+/**
+ * @author reghao
+ * @date 2023-01-10 09:51:53
+ */
+@Getter
+@Setter
+public class VideoUrlDto implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private String objectId;
+    private int channelCode;
+    private String type;
+    private String url;
+    private int width;
+    private int height;
+    private String quality;
+}

+ 28 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/iface/ConsoleService.java

@@ -0,0 +1,28 @@
+package cn.reghao.tnb.oss.api.iface;
+
+import cn.reghao.tnb.oss.api.dto.NodeProperties;
+import cn.reghao.tnb.oss.api.dto.ObjectChannel;
+import cn.reghao.tnb.oss.api.dto.ServerInfo;
+import cn.reghao.tnb.oss.api.dto.StoreNodeDto;
+
+/**
+ * 获取由 oss-console 管理的 StoreNode 配置
+ * 由 oss-console 实现, oss-store 调用
+ *
+ * @author reghao
+ * @date 2024-07-02 10:55:56
+ */
+public interface ConsoleService {
+    void registerNode(StoreNodeDto storeNodeDto);
+    NodeProperties getNodeProperties(String domain);
+    ObjectChannel getChannelByCode(int owner, int channelCode);
+    Integer getChannelCodeByUrl(int owner, String url);
+    /**
+     * 本方法由 oss-console 本地调用, oss-store 不会使用
+     *
+     * @param
+     * @return
+     * @date 2025-08-16 23:08:330
+     */
+    ServerInfo getUploadStore(int channelCode);
+}

+ 42 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/iface/StoreService.java

@@ -0,0 +1,42 @@
+package cn.reghao.tnb.oss.api.iface;
+
+import cn.reghao.jutil.jdk.db.PageList;
+import cn.reghao.tnb.oss.api.dto.ObjectInfo;
+import cn.reghao.tnb.oss.api.dto.disk.DiskVolume;
+import cn.reghao.tnb.oss.api.dto.media.AudioInfo;
+import cn.reghao.tnb.oss.api.dto.media.ConvertedImageInfo;
+import cn.reghao.tnb.oss.api.dto.media.ImageInfo;
+import cn.reghao.tnb.oss.api.dto.media.VideoInfo;
+
+import java.util.List;
+
+/**
+ * 对 oss-store 进行操作
+ * 由 oss-store 实现, oss-console 调用
+ *
+ * @author reghao
+ * @date 2023-08-01 14:51:50
+ */
+public interface StoreService {
+    List<DiskVolume> getDiskVolumes();
+    String getUploadToken(int channelCode, int owner, int expire);
+
+    void createChannel(int owner, String channelPrefix, int scope);
+    int countChannelObjects(String channelPrefix, int owner);
+
+    void setObjectUpload(String objectId);
+    void setObjectScope(String objectId, int scope);
+    void deleteByObjectId(String objectId);
+    void deleteByObjectName(String objectName, int owner);
+
+    ObjectInfo getObjectInfo(String objectId) throws Exception;
+    String getSignedUrl(String domain, int owner, String objectId, int expire) throws Exception;
+    String getSignedUrl(String objectUrl, int owner, int expire) throws Exception;
+
+    VideoInfo getVideoInfo(String objectId) throws Exception;
+    ImageInfo getImageInfo(String objectId) throws Exception;
+    ConvertedImageInfo getWebpInfo(String objectId) throws Exception;
+    AudioInfo getAudioInfo(String objectId) throws Exception;
+
+    PageList<ObjectInfo> getByPrefix(int owner, String objectName, int pn, int ps);
+}

+ 34 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/rest/UploadFilePart.java

@@ -0,0 +1,34 @@
+package cn.reghao.tnb.oss.api.rest;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.io.Serializable;
+
+/**
+ * @author reghao
+ * @date 2022-04-25 10:42:38
+ */
+@AllArgsConstructor
+@Getter
+@Setter
+public class UploadFilePart implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private int channelCode;
+    // 文件标识
+    private String identifier;
+    private String filename;
+    private String relativePath;
+    // 文件大小
+    private long totalSize;
+    // 分片文件大小
+    private long chunkSize;
+    // 分片文件数量
+    private long totalChunks;
+    // 当前分片文件索引
+    private int chunkNumber;
+    // 当前分片文件大小
+    private int currentChunkSize;
+}

+ 30 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/rest/UploadFileRet.java

@@ -0,0 +1,30 @@
+package cn.reghao.tnb.oss.api.rest;
+
+import lombok.Getter;
+
+import java.io.Serializable;
+
+/**
+ * @author reghao
+ * @date 2023-05-23 11:11:32
+ */
+@Getter
+public class UploadFileRet implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private final String uploadId;
+    private final String url;
+    private final boolean merged;
+
+    public UploadFileRet(String uploadId) {
+        this.uploadId = uploadId;
+        this.url = null;
+        this.merged = false;
+    }
+
+    public UploadFileRet(String uploadId, String url) {
+        this.uploadId = uploadId;
+        this.url = url;
+        this.merged = true;
+    }
+}

+ 31 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/rest/UploadPrepare.java

@@ -0,0 +1,31 @@
+package cn.reghao.tnb.oss.api.rest;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import java.io.Serializable;
+
+/**
+ * 分片文件
+ *
+ * @author reghao
+ * @date 2021-11-23 10:23:00
+ */
+@Setter
+@Getter
+public class UploadPrepare implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    @NotNull
+    private int channelCode;
+    @NotBlank
+    private String filename;
+    @NotNull
+    private long size;
+    @NotBlank
+    private String sha256sum;
+    @NotBlank
+    private String contentType;
+}

+ 21 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/rest/UploadPrepareRet.java

@@ -0,0 +1,21 @@
+package cn.reghao.tnb.oss.api.rest;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.io.Serializable;
+
+/**
+ * @author reghao
+ * @date 2022-04-21 09:27:55
+ */
+@AllArgsConstructor
+@Getter
+public class UploadPrepareRet implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private String uploadId;
+    private long splitSize;
+    private boolean exist;
+    private String url;
+}

+ 39 - 0
oss-api/src/main/java/cn/reghao/tnb/oss/api/rest/UploadedPart.java

@@ -0,0 +1,39 @@
+package cn.reghao.tnb.oss.api.rest;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.io.Serializable;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2023-05-24 09:23:25
+ */
+@Setter
+@Getter
+public class UploadedPart implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private boolean needMerge;
+    private List<Integer> uploaded;
+    private boolean skipUpload;
+    private String uploadId;
+    private String url;
+
+    public UploadedPart(String uploadId, String url) {
+        this.uploadId = uploadId;
+        this.url = url;
+        this.skipUpload = true;
+    }
+
+    public UploadedPart() {
+        this.skipUpload = false;
+        this.uploaded = Collections.emptyList();
+    }
+
+    public void setUploaded(List<Integer> uploaded) {
+        this.uploaded = uploaded;
+    }
+}

+ 42 - 0
oss-sdk/pom.xml

@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <artifactId>tnb</artifactId>
+        <groupId>cn.reghao.tnb</groupId>
+        <version>1.0.0</version>
+    </parent>
+
+    <artifactId>oss-sdk</artifactId>
+    <version>1.0.0-SNAPSHOT</version>
+
+    <properties>
+        <maven.compiler.source>11</maven.compiler.source>
+        <maven.compiler.target>11</maven.compiler.target>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>cn.reghao.tnb</groupId>
+            <artifactId>oss-api</artifactId>
+            <version>1.0.0-SNAPSHOT</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+            <version>1.18.6</version>
+            <optional>true</optional>
+        </dependency>
+
+        <dependency>
+            <groupId>javax.validation</groupId>
+            <artifactId>validation-api</artifactId>
+            <version>2.0.1.Final</version>
+        </dependency>
+    </dependencies>
+</project>

+ 172 - 0
oss-sdk/src/main/java/cn/reghao/tnb/oss/sdk/MultiPartBodyPublisher.java

@@ -0,0 +1,172 @@
+package cn.reghao.tnb.oss.sdk;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.net.http.HttpRequest;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.function.Supplier;
+
+/**
+ * https://stackoverflow.com/questions/46392160/java-9-httpclient-send-a-multipart-form-data-request
+ *
+ * @author reghao
+ * @date 2022-11-22 16:02:02
+ */
+public class MultiPartBodyPublisher {
+    private List<PartsSpecification> partsSpecificationList = new ArrayList<>();
+    private String boundary = UUID.randomUUID().toString();
+
+    public HttpRequest.BodyPublisher build() {
+        if (partsSpecificationList.size() == 0) {
+            throw new IllegalStateException("Must have at least one part to build multipart message.");
+        }
+        addFinalBoundaryPart();
+        return HttpRequest.BodyPublishers.ofByteArrays(PartsIterator::new);
+    }
+
+    public String getBoundary() {
+        return boundary;
+    }
+
+    public MultiPartBodyPublisher addPart(String name, String value) {
+        PartsSpecification newPart = new PartsSpecification();
+        newPart.type = PartsSpecification.TYPE.STRING;
+        newPart.name = name;
+        newPart.value = value;
+        partsSpecificationList.add(newPart);
+        return this;
+    }
+
+    public MultiPartBodyPublisher addPart(String name, Path value) {
+        PartsSpecification newPart = new PartsSpecification();
+        newPart.type = PartsSpecification.TYPE.FILE;
+        newPart.name = name;
+        newPart.path = value;
+        partsSpecificationList.add(newPart);
+        return this;
+    }
+
+    public MultiPartBodyPublisher addPart(String name, Supplier<InputStream> value, String filename, String contentType) {
+        PartsSpecification newPart = new PartsSpecification();
+        newPart.type = PartsSpecification.TYPE.STREAM;
+        newPart.name = name;
+        newPart.stream = value;
+        newPart.filename = filename;
+        newPart.contentType = contentType;
+        partsSpecificationList.add(newPart);
+        return this;
+    }
+
+    private void addFinalBoundaryPart() {
+        PartsSpecification newPart = new PartsSpecification();
+        newPart.type = PartsSpecification.TYPE.FINAL_BOUNDARY;
+        newPart.value = "--" + boundary + "--";
+        partsSpecificationList.add(newPart);
+    }
+
+    static class PartsSpecification {
+
+        public enum TYPE {
+            STRING, FILE, STREAM, FINAL_BOUNDARY
+        }
+
+        TYPE type;
+        String name;
+        String value;
+        Path path;
+        Supplier<InputStream> stream;
+        String filename;
+        String contentType;
+
+    }
+
+    class PartsIterator implements Iterator<byte[]> {
+
+        private Iterator<PartsSpecification> iter;
+        private InputStream currentFileInput;
+
+        private boolean done;
+        private byte[] next;
+
+        PartsIterator() {
+            iter = partsSpecificationList.iterator();
+        }
+
+        @Override
+        public boolean hasNext() {
+            if (done) return false;
+            if (next != null) return true;
+            try {
+                next = computeNext();
+            } catch (IOException e) {
+                throw new UncheckedIOException(e);
+            }
+            if (next == null) {
+                done = true;
+                return false;
+            }
+            return true;
+        }
+
+        @Override
+        public byte[] next() {
+            if (!hasNext()) throw new NoSuchElementException();
+            byte[] res = next;
+            next = null;
+            return res;
+        }
+
+        private byte[] computeNext() throws IOException {
+            if (currentFileInput == null) {
+                if (!iter.hasNext()) return null;
+                PartsSpecification nextPart = iter.next();
+                if (PartsSpecification.TYPE.STRING.equals(nextPart.type)) {
+                    String part =
+                            "--" + boundary + "\r\n" +
+                                    "Content-Disposition: form-data; name=" + nextPart.name + "\r\n" +
+                                    "Content-Type: text/plain; charset=UTF-8\r\n\r\n" +
+                                    nextPart.value + "\r\n";
+                    return part.getBytes(StandardCharsets.UTF_8);
+                }
+                if (PartsSpecification.TYPE.FINAL_BOUNDARY.equals(nextPart.type)) {
+                    return nextPart.value.getBytes(StandardCharsets.UTF_8);
+                }
+                String filename;
+                String contentType;
+                if (PartsSpecification.TYPE.FILE.equals(nextPart.type)) {
+                    Path path = nextPart.path;
+                    filename = path.getFileName().toString();
+                    contentType = Files.probeContentType(path);
+                    if (contentType == null) contentType = "application/octet-stream";
+                    currentFileInput = Files.newInputStream(path);
+                } else {
+                    filename = nextPart.filename;
+                    contentType = nextPart.contentType;
+                    if (contentType == null) contentType = "application/octet-stream";
+                    currentFileInput = nextPart.stream.get();
+                }
+                String partHeader =
+                        "--" + boundary + "\r\n" +
+                                "Content-Disposition: form-data; name=" + nextPart.name + "; filename=" + filename + "\r\n" +
+                                "Content-Type: " + contentType + "\r\n\r\n";
+                return partHeader.getBytes(StandardCharsets.UTF_8);
+            } else {
+                byte[] buf = new byte[8192];
+                int r = currentFileInput.read(buf);
+                if (r > 0) {
+                    byte[] actualBytes = new byte[r];
+                    System.arraycopy(buf, 0, actualBytes, 0, r);
+                    return actualBytes;
+                } else {
+                    currentFileInput.close();
+                    currentFileInput = null;
+                    return "\r\n".getBytes(StandardCharsets.UTF_8);
+                }
+            }
+        }
+    }
+}

+ 216 - 0
oss-sdk/src/main/java/cn/reghao/tnb/oss/sdk/ObjectMultipartUploadService.java

@@ -0,0 +1,216 @@
+package cn.reghao.tnb.oss.sdk;
+
+import cn.reghao.jutil.jdk.http.UploadParam;
+import cn.reghao.jutil.jdk.http.WebRequest;
+import cn.reghao.jutil.jdk.http.WebResponse;
+import cn.reghao.jutil.jdk.io.FileSplitter;
+import cn.reghao.jutil.jdk.result.WebResult;
+import cn.reghao.jutil.jdk.security.DigestUtil;
+import cn.reghao.jutil.tool.json.JsonConverter;
+import cn.reghao.jutil.tool.http.DefaultWebRequest;
+import cn.reghao.tnb.oss.api.rest.UploadFilePart;
+import cn.reghao.tnb.oss.api.rest.UploadFileRet;
+import cn.reghao.tnb.oss.api.rest.UploadedPart;
+import com.google.gson.reflect.TypeToken;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.*;
+import java.lang.reflect.Type;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * @author reghao
+ * @date 2023-06-02 16:19:04
+ */
+@Slf4j
+public class ObjectMultipartUploadService {
+    private final String endpoint;
+    private final String token;
+    // 10MB
+    private final FileSplitter fileSplitter = new FileSplitter();
+    private final HttpClient httpClient = HttpClient.newBuilder().build();
+    private final WebRequest webRequest = new DefaultWebRequest();
+
+    public ObjectMultipartUploadService(String endpoint, String token) {
+        this.endpoint = endpoint;
+        this.token = token;
+    }
+
+    /**
+     * 获取已上传的分片文件
+     *
+     * @param
+     * @return
+     * @date 2024-10-29 15:43:46
+     */
+    private UploadedPart getUploadedParts(String sha256sum, int channelCode) throws Exception {
+        MultiPartBodyPublisher publisher = new MultiPartBodyPublisher();
+        publisher.addPart("sha256sum", sha256sum);
+
+        String api = endpoint + "/?multiparts";
+        HttpRequest httpRequest = HttpRequest.newBuilder(new URI(api))
+                .version(HttpClient.Version.HTTP_1_1)
+                .header("Authorization", token)
+                .GET()
+                .build();
+
+        HttpResponse<String> httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
+        String body = httpResponse.body();
+        Type type = new TypeToken<WebResult<UploadedPart>>(){}.getType();
+        WebResult<UploadedPart> webResult = JsonConverter.jsonToObject(body, type);
+        if (webResult.getCode() != 0) {
+            String errMsg = webResult.getMsg();
+            return null;
+        }
+
+        return webResult.getData();
+    }
+
+    /**
+     * 分片上传大文件
+     *
+     * @param
+     * @return
+     * @date 2024-10-29 15:44:13
+     */
+    public UploadFileRet uploadFilePart(File file, int channelCode) throws Exception {
+        String identifier = DigestUtil.sha256sum(new FileInputStream(file));
+        String filename = file.getName();
+        String relativePath = file.getAbsolutePath();
+
+        long totalSize = file.length();
+        // 分片大小在 10MB - 100MB 之间,只有最后一个分片才允许小于 10MB(这无法避免)
+        long chunkSize = fileSplitter.getPartSize();
+        long totalChunks = totalSize/chunkSize;
+        if (totalSize % chunkSize != 0) {
+            totalChunks += 1;
+        }
+
+        Map<Integer, Long> map = new HashMap<>();
+        List<Integer> failedChunkNumbers = new ArrayList<>();
+        for (int i = 0; i < totalChunks; i++) {
+            long start = i*chunkSize;
+            // 从 start 位置开始读取 chunkSize 的数据
+            byte[] part = fileSplitter.getPart(file.getAbsolutePath(), start);
+            int chunkNumber = i + 1;
+            map.put(chunkNumber, start);
+
+            int currentChunkSize = part.length;
+            UploadFilePart uploadFilePart = new UploadFilePart(channelCode, identifier, filename, relativePath,
+                    totalSize, chunkSize, totalChunks, chunkNumber, currentChunkSize);
+
+            UploadFileRet uploadFileRet = postObject(part, uploadFilePart);
+            if (uploadFileRet == null) {
+                log.info("{}:{} upload failed", chunkNumber, currentChunkSize);
+                failedChunkNumbers.add(chunkNumber);
+            } else {
+                log.info("{}:{} uploaded {} bytes", totalChunks, chunkNumber, currentChunkSize);
+                if (uploadFileRet.isMerged()) {
+                    return uploadFileRet;
+                }
+            }
+        }
+
+        // 重传上传失败的文件分片
+        if (!failedChunkNumbers.isEmpty()) {
+            for (int chunkNumber : failedChunkNumbers) {
+                long start = map.get(chunkNumber);
+                byte[] part = fileSplitter.getPart(file.getAbsolutePath(), start);
+                int currentChunkSize = part.length;
+                UploadFilePart uploadFilePart = new UploadFilePart(channelCode, identifier, filename, relativePath,
+                        totalSize, chunkSize, totalChunks, chunkNumber, currentChunkSize);
+
+                UploadFileRet uploadFileRet = postObject(part, uploadFilePart);
+                if (uploadFileRet == null) {
+                    log.info("{}:{} upload failed", chunkNumber, currentChunkSize);
+                } else {
+                    log.info("{}:{} uploaded {} bytes", totalChunks, chunkNumber, currentChunkSize);
+                    if (uploadFileRet.isMerged()) {
+                        return uploadFileRet;
+                    }
+                }
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * jdk http 实现
+     * 存在 bug: https://bugs.openjdk.org/browse/JDK-8222968
+     *
+     * @param
+     * @return
+     * @date 2023-05-24 14:56:50
+     */
+    private void postObject1(byte[] bytes, UploadFilePart uploadFilePart)
+            throws URISyntaxException, IOException, InterruptedException {
+        MultiPartBodyPublisher publisher = new MultiPartBodyPublisher();
+        ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
+        publisher.addPart("file", () -> bais, uploadFilePart.getFilename(), "")
+                //.addPart("file", Path.of(""))
+                .addPart("channelCode", uploadFilePart.getChannelCode()+"")
+                .addPart("identifier", uploadFilePart.getIdentifier())
+                .addPart("filename", uploadFilePart.getFilename())
+                .addPart("relativePath", uploadFilePart.getRelativePath())
+                .addPart("totalSize", uploadFilePart.getTotalSize()+"")
+                .addPart("chunkSize", uploadFilePart.getChunkSize()+"")
+                .addPart("currentChunkSize", uploadFilePart.getCurrentChunkSize()+"")
+                .addPart("totalChunks", uploadFilePart.getTotalChunks()+"")
+                .addPart("chunkNumber", uploadFilePart.getChunkNumber()+"");
+
+        String authorization = "";
+        String api = endpoint + "/?multipart";
+        HttpRequest httpRequest = HttpRequest.newBuilder(new URI(api))
+                .version(HttpClient.Version.HTTP_1_1)
+                .header("Authorization", authorization)
+                .header("Content-Type", "multipart/form-data; boundary=" + publisher.getBoundary())
+                .POST(publisher.build())
+                .build();
+
+        HttpResponse<String> httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
+        System.out.println();
+    }
+
+    private UploadFileRet postObject(byte[] bytes, UploadFilePart uploadFilePart) {
+        UploadParam uploadParam = new UploadParam(bytes, "");
+        Map<String, String> params = new HashMap<>();
+        params.put("channelCode", uploadFilePart.getChannelCode()+"");
+        params.put("identifier", uploadFilePart.getIdentifier());
+        params.put("filename", uploadFilePart.getFilename());
+        params.put("relativePath", uploadFilePart.getRelativePath());
+        params.put("totalSize", uploadFilePart.getTotalSize()+"");
+        params.put("chunkSize", uploadFilePart.getChunkSize()+"");
+        params.put("currentChunkSize", uploadFilePart.getCurrentChunkSize()+"");
+        params.put("totalChunks", uploadFilePart.getTotalChunks()+"");
+        params.put("chunkNumber", uploadFilePart.getChunkNumber()+"");
+        uploadParam.setTextParams(params);
+
+        String api = endpoint + "/?multipart";
+        WebResponse webResponse = webRequest.upload(api, uploadParam, token);
+        int statusCode = webResponse.getStatusCode();
+        if (statusCode != 200) {
+            log.error("请求失败");
+            return null;
+        }
+
+        String body = webResponse.getBody();
+        Type type = new TypeToken<WebResult<UploadFileRet>>(){}.getType();
+        WebResult<UploadFileRet> webResult = JsonConverter.jsonToObject(body, type);
+        if (webResult.getCode() != 0) {
+            String errMsg = webResult.getMsg();
+            log.error("请求失败 -> {}", errMsg);
+            return null;
+        }
+
+        return webResult.getData();
+    }
+}

+ 461 - 0
oss-sdk/src/main/java/cn/reghao/tnb/oss/sdk/OssConsoleClient.java

@@ -0,0 +1,461 @@
+package cn.reghao.tnb.oss.sdk;
+
+import cn.reghao.jutil.jdk.result.WebResult;
+import cn.reghao.jutil.tool.json.JsonConverter;
+import cn.reghao.tnb.oss.api.dto.ObjectInfo;
+import cn.reghao.tnb.oss.api.dto.ServerInfo;
+import cn.reghao.tnb.oss.sdk.model.OssConsoleConfig;
+import cn.reghao.tnb.oss.api.dto.media.AudioInfo;
+import cn.reghao.tnb.oss.api.dto.media.ConvertedImageInfo;
+import cn.reghao.tnb.oss.api.dto.media.ImageInfo;
+import cn.reghao.tnb.oss.api.dto.media.VideoInfo;
+import cn.reghao.tnb.oss.api.rest.UploadFileRet;
+import com.google.gson.reflect.TypeToken;
+
+import java.io.File;
+import java.lang.reflect.Type;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * @author reghao
+ * @date 2024-02-23 21:47:39
+ */
+public class OssConsoleClient {
+    private final HttpClient httpClient = HttpClient.newBuilder().build();
+    private final String endpoint;
+    private String token;
+
+    public OssConsoleClient(OssConsoleConfig ossConsoleConfig) throws Exception {
+        this.endpoint = ossConsoleConfig.getConsoleUrl();
+        auth(ossConsoleConfig.getAccessKeyId(), ossConsoleConfig.getAccessKeySecret());
+    }
+
+    // ****************************************************************************************************************
+    // oss-console 认证接口
+    // ****************************************************************************************************************
+    private void auth(String accessKeyId, String accessKeySecret) throws Exception {
+        MultiPartBodyPublisher publisher = new MultiPartBodyPublisher()
+                .addPart("accessKeyId", accessKeyId)
+                .addPart("accessKeySecret", accessKeySecret);
+
+        String api = String.format("%s/api/oss/sdk/key/auth", endpoint);
+        HttpRequest httpRequest = HttpRequest.newBuilder(new URI(api))
+                .version(HttpClient.Version.HTTP_1_1)
+                .header("content-type", "multipart/form-data; boundary=" + publisher.getBoundary())
+                .POST(publisher.build())
+                .build();
+
+        HttpResponse<String> httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
+        int statusCode = httpResponse.statusCode();
+        String body = httpResponse.body();
+        if (statusCode != 200) {
+            String errMsg = String.format("%s -> %s", statusCode, body);
+            throw new Exception(errMsg);
+        }
+
+        Type type = new TypeToken<WebResult<String>>(){}.getType();
+        WebResult<String> webResult = JsonConverter.jsonToObject(body, type);
+        if (webResult.getCode() != 0) {
+            String errMsg = String.format("%s - %s", webResult.getCode(), webResult.getMsg());
+            throw new Exception(errMsg);
+        }
+
+        this.token = webResult.getData();
+    }
+
+    // ****************************************************************************************************************
+    // oss-store 相关接口
+    // ****************************************************************************************************************
+    public ServerInfo getUploadStore(int channelCode) throws Exception {
+        String api = String.format("%s/api/oss/sdk/upload/store?channelCode=%s", endpoint, channelCode);
+        HttpRequest httpRequest = HttpRequest.newBuilder(new URI(api))
+                .header("authorization", "Bearer " + token)
+                .version(HttpClient.Version.HTTP_1_1)
+                .GET()
+                .build();
+        HttpResponse<String> httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
+        int statusCode = httpResponse.statusCode();
+        String body = httpResponse.body();
+        if (statusCode != 200) {
+            String errMsg = String.format("%s -> %s", statusCode, body);
+            throw new Exception(errMsg);
+        }
+
+        Type type = new TypeToken<WebResult<ServerInfo>>(){}.getType();
+        WebResult<ServerInfo> webResult = JsonConverter.jsonToObject(body, type);
+        if (webResult.getCode() != 0) {
+            String errMsg = String.format("%s - %s", webResult.getCode(), webResult.getMsg());
+            throw new Exception(errMsg);
+        }
+
+        return webResult.getData();
+    }
+
+    public void setObjectScope(int channelCode, String objectId, int scope) throws Exception {
+        Map<String, String> formData = new HashMap<>();
+        formData.put("channelCode", channelCode+"");
+        formData.put("scope", scope+"");
+        formData.put("objectId", objectId);
+
+        String api = String.format("%s/api/oss/sdk/object/scope", endpoint);
+        HttpRequest httpRequest = HttpRequest.newBuilder(new URI(api))
+                .header("authorization", "Bearer " + token)
+                .headers("content-type", "application/x-www-form-urlencoded")
+                .version(HttpClient.Version.HTTP_1_1)
+                .POST(HttpRequest.BodyPublishers.ofString(getFormDataAsString(formData)))
+                .build();
+        HttpResponse<String> httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
+        int statusCode = httpResponse.statusCode();
+        String body = httpResponse.body();
+        if (statusCode != 200) {
+            String errMsg = String.format("%s -> %s", statusCode, body);
+            throw new Exception(errMsg);
+        }
+
+        Type type = new TypeToken<WebResult<String>>(){}.getType();
+        WebResult<String> webResult = JsonConverter.jsonToObject(body, type);
+        if (webResult.getCode() != 0) {
+            String errMsg = String.format("%s - %s", webResult.getCode(), webResult.getMsg());
+            throw new Exception(errMsg);
+        }
+    }
+
+    private String getFormDataAsString(Map<String, String> formData) {
+        StringBuilder formBodyBuilder = new StringBuilder();
+        for (Map.Entry<String, String> singleEntry : formData.entrySet()) {
+            if (formBodyBuilder.length() > 0) {
+                formBodyBuilder.append("&");
+            }
+            formBodyBuilder.append(URLEncoder.encode(singleEntry.getKey(), StandardCharsets.UTF_8));
+            formBodyBuilder.append("=");
+            formBodyBuilder.append(URLEncoder.encode(singleEntry.getValue(), StandardCharsets.UTF_8));
+        }
+        return formBodyBuilder.toString();
+    }
+
+    public void deleteByObjectId(int channelCode, String objectId) throws Exception {
+        Map<String, String> formData = new HashMap<>();
+        formData.put("channelCode", channelCode+"");
+        formData.put("objectId", objectId);
+
+        String api = String.format("%s/api/oss/sdk/object/delete/id", endpoint);
+        HttpRequest httpRequest = HttpRequest.newBuilder(new URI(api))
+                .header("authorization", "Bearer " + token)
+                .headers("content-type", "application/x-www-form-urlencoded")
+                .version(HttpClient.Version.HTTP_1_1)
+                .POST(HttpRequest.BodyPublishers.ofString(getFormDataAsString(formData)))
+                .build();
+        HttpResponse<String> httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
+        int statusCode = httpResponse.statusCode();
+        String body = httpResponse.body();
+        if (statusCode != 200) {
+            String errMsg = String.format("%s -> %s", statusCode, body);
+            throw new Exception(errMsg);
+        }
+
+        Type type = new TypeToken<WebResult<String>>(){}.getType();
+        WebResult<String> webResult = JsonConverter.jsonToObject(body, type);
+        if (webResult.getCode() != 0) {
+            String errMsg = String.format("%s - %s", webResult.getCode(), webResult.getMsg());
+            throw new Exception(errMsg);
+        }
+    }
+
+    public void deleteByObjectUrl(String objectUrl) throws Exception {
+        Map<String, String> formData = new HashMap<>();
+        formData.put("objectUrl", objectUrl);
+
+        String api = String.format("%s/api/oss/sdk/object/delete/url", endpoint);
+        HttpRequest httpRequest = HttpRequest.newBuilder(new URI(api))
+                .header("authorization", "Bearer " + token)
+                .headers("content-type", "application/x-www-form-urlencoded")
+                .version(HttpClient.Version.HTTP_1_1)
+                .POST(HttpRequest.BodyPublishers.ofString(getFormDataAsString(formData)))
+                .build();
+        HttpResponse<String> httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
+        int statusCode = httpResponse.statusCode();
+        String body = httpResponse.body();
+        if (statusCode != 200) {
+            String errMsg = String.format("%s -> %s", statusCode, body);
+            throw new Exception(errMsg);
+        }
+
+        Type type = new TypeToken<WebResult<String>>(){}.getType();
+        WebResult<String> webResult = JsonConverter.jsonToObject(body, type);
+        if (webResult.getCode() != 0) {
+            String errMsg = String.format("%s - %s", webResult.getCode(), webResult.getMsg());
+            throw new Exception(errMsg);
+        }
+    }
+
+    public ObjectInfo getObjectInfo(int channelCode, String objectId) throws Exception {
+        String api = String.format("%s/api/oss/sdk/object/info?channelCode=%s&objectId=%s", endpoint, channelCode, objectId);
+        HttpRequest httpRequest = HttpRequest.newBuilder(new URI(api))
+                .header("authorization", "Bearer " + token)
+                .version(HttpClient.Version.HTTP_1_1)
+                .GET()
+                .build();
+        HttpResponse<String> httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
+        int statusCode = httpResponse.statusCode();
+        String body = httpResponse.body();
+        if (statusCode != 200) {
+            throw new Exception(body);
+        }
+
+        Type type = new TypeToken<WebResult<ObjectInfo>>(){}.getType();
+        WebResult<ObjectInfo> webResult = JsonConverter.jsonToObject(body, type);
+        if (webResult.getCode() != 0) {
+            throw new Exception(webResult.getMsg());
+        }
+
+        return webResult.getData();
+    }
+
+    public String getSignedUrl(int channelCode, String objectId) throws Exception {
+        String api = String.format("%s/api/oss/sdk/object/url?channelCode=%s&objectId=%s", endpoint, channelCode, objectId);
+        HttpRequest httpRequest = HttpRequest.newBuilder(new URI(api))
+                .header("authorization", "Bearer " + token)
+                .version(HttpClient.Version.HTTP_1_1)
+                .GET()
+                .build();
+        HttpResponse<String> httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
+        int statusCode = httpResponse.statusCode();
+        String body = httpResponse.body();
+        if (statusCode != 200) {
+            throw new Exception(body);
+        }
+
+        Type type = new TypeToken<WebResult<String>>(){}.getType();
+        WebResult<String> webResult = JsonConverter.jsonToObject(body, type);
+        if (webResult.getCode() != 0) {
+            String errMsg = String.format("%s - %s", webResult.getCode(), webResult.getMsg());
+            throw new Exception(errMsg);
+        }
+
+        return webResult.getData();
+    }
+
+    public String getSignedUrlByUrl(int channelCode, String objectUrl) throws Exception {
+        String api = String.format("%s/api/oss/sdk/object/signed_url?channelCode=%s&objectUrl=%s", endpoint, channelCode, objectUrl);
+        HttpRequest httpRequest = HttpRequest.newBuilder(new URI(api))
+                .header("authorization", "Bearer " + token)
+                .version(HttpClient.Version.HTTP_1_1)
+                .GET()
+                .build();
+        HttpResponse<String> httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
+        int statusCode = httpResponse.statusCode();
+        String body = httpResponse.body();
+        if (statusCode != 200) {
+            throw new Exception(body);
+        }
+
+        Type type = new TypeToken<WebResult<String>>(){}.getType();
+        WebResult<String> webResult = JsonConverter.jsonToObject(body, type);
+        if (webResult.getCode() != 0) {
+            String errMsg = String.format("%s - %s", webResult.getCode(), webResult.getMsg());
+            throw new Exception(errMsg);
+        }
+
+        return webResult.getData();
+    }
+
+    // ****************************************************************************************************************
+    // 媒体文件信息相关接口
+    // ****************************************************************************************************************
+    public VideoInfo getVideoInfo(int channelCode, String objectId) throws Exception {
+        String api = String.format("%s/api/oss/sdk/object/video/info?channelCode=%s&objectId=%s", endpoint, channelCode, objectId);
+        HttpRequest httpRequest = HttpRequest.newBuilder(new URI(api))
+                .header("authorization", "Bearer " + token)
+                .version(HttpClient.Version.HTTP_1_1)
+                .GET()
+                .build();
+        HttpResponse<String> httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
+        int statusCode = httpResponse.statusCode();
+        String body = httpResponse.body();
+        if (statusCode != 200) {
+            throw new Exception(body);
+        }
+
+        Type type = new TypeToken<WebResult<VideoInfo>>(){}.getType();
+        WebResult<VideoInfo> webResult = JsonConverter.jsonToObject(body, type);
+        if (webResult.getCode() != 0) {
+            throw new Exception(webResult.getMsg());
+        }
+
+        return webResult.getData();
+    }
+
+    public ImageInfo getImageInfo(int channelCode, String objectId) throws Exception {
+        String api = String.format("%s/api/oss/sdk/object/image/info?channelCode=%s&objectId=%s", endpoint, channelCode, objectId);
+        HttpRequest httpRequest = HttpRequest.newBuilder(new URI(api))
+                .header("authorization", "Bearer " + token)
+                .headers("content-type", "application/x-www-form-urlencoded")
+                .version(HttpClient.Version.HTTP_1_1)
+                .GET()
+                .build();
+        HttpResponse<String> httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
+        int statusCode = httpResponse.statusCode();
+        String body = httpResponse.body();
+        if (statusCode != 200) {
+            throw new Exception(body);
+        }
+
+        Type type = new TypeToken<WebResult<ImageInfo>>(){}.getType();
+        WebResult<ImageInfo> webResult = JsonConverter.jsonToObject(body, type);
+        if (webResult.getCode() != 0) {
+            throw new Exception(webResult.getMsg());
+        }
+
+        return webResult.getData();
+    }
+
+    public ConvertedImageInfo getWebpInfo(int channelCode, String objectId) throws Exception {
+        Map<String, String> formData = new HashMap<>();
+        formData.put("channelCode", ""+channelCode);
+        formData.put("objectId", objectId);
+
+        String api = String.format("%s/api/oss/sdk/object/image/webp", endpoint);
+        HttpRequest httpRequest = HttpRequest.newBuilder(new URI(api))
+                .header("authorization", "Bearer " + token)
+                .headers("content-type", "application/x-www-form-urlencoded")
+                .version(HttpClient.Version.HTTP_1_1)
+                .POST(HttpRequest.BodyPublishers.ofString(getFormDataAsString(formData)))
+                .build();
+        HttpResponse<String> httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
+        int statusCode = httpResponse.statusCode();
+        String body = httpResponse.body();
+        if (statusCode != 200) {
+            throw new Exception(body);
+        }
+
+        Type type = new TypeToken<WebResult<ConvertedImageInfo>>(){}.getType();
+        WebResult<ConvertedImageInfo> webResult = JsonConverter.jsonToObject(body, type);
+        if (webResult.getCode() != 0) {
+            throw new Exception(webResult.getMsg());
+        }
+
+        return webResult.getData();
+    }
+
+    public AudioInfo getAudioInfo(int channelCode, String objectId) throws Exception {
+        String api = String.format("%s/api/oss/sdk/object/audio/info/?channelCode=%s&objectId=%s", endpoint, channelCode, objectId);
+        HttpRequest httpRequest = HttpRequest.newBuilder(new URI(api))
+                .header("authorization", "Bearer " + token)
+                .version(HttpClient.Version.HTTP_1_1)
+                .GET()
+                .build();
+        HttpResponse<String> httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
+        int statusCode = httpResponse.statusCode();
+        String body = httpResponse.body();
+        if (statusCode != 200) {
+            throw new Exception(body);
+        }
+
+        Type type = new TypeToken<WebResult<AudioInfo>>(){}.getType();
+        WebResult<AudioInfo> webResult = JsonConverter.jsonToObject(body, type);
+        if (webResult.getCode() != 0) {
+            throw new Exception(webResult.getMsg());
+        }
+
+        return webResult.getData();
+    }
+
+    // ****************************************************************************************************************
+    // 音视频转码相关接口
+    // ****************************************************************************************************************
+    public void convertAudio(String audioFileId) throws Exception {
+        String api = String.format("%s/api/oss/sdk/object/convert/audio/%s", endpoint, audioFileId);
+        convert(api);
+    }
+
+    public void convertVideo(String videoFileId) throws Exception {
+        String api = String.format("%s/api/oss/sdk/object/convert/video/%s", endpoint, videoFileId);
+        convert(api);
+    }
+
+    private void convert(String api) throws Exception {
+        MultiPartBodyPublisher publisher = new MultiPartBodyPublisher();
+        HttpRequest httpRequest = HttpRequest.newBuilder(new URI(api))
+                .header("authorization", "Bearer " + token)
+                .version(HttpClient.Version.HTTP_1_1)
+                .POST(publisher.build())
+                .build();
+        HttpResponse<String> httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
+        int statusCode = httpResponse.statusCode();
+        String body = httpResponse.body();
+        if (statusCode != 200) {
+            throw new Exception(body);
+        }
+
+        Type type = new TypeToken<WebResult<String>>(){}.getType();
+        WebResult<String> webResult = JsonConverter.jsonToObject(body, type);
+        if (webResult.getCode() != 0) {
+            throw new Exception(webResult.getMsg());
+        }
+    }
+
+    // ****************************************************************************************************************
+    // 对象访问接口
+    // ****************************************************************************************************************
+    public UploadFileRet putObject(File file, int channelCode) throws Exception {
+        ServerInfo serverInfo = getUploadStore(channelCode);
+        if (serverInfo == null) {
+            throw new Exception("获取 server_info 失败");
+        }
+        String ossUrl = serverInfo.getOssUrl();
+        String token = serverInfo.getToken();
+
+        OssStoreClient ossStoreClient = new OssStoreClient(ossUrl);
+        UploadFileRet uploadFileRet = ossStoreClient.putObject(file, channelCode, token);
+        return uploadFileRet;
+    }
+
+    public UploadFileRet postObject(File file, int channelCode) throws Exception {
+        ServerInfo serverInfo = getUploadStore(channelCode);
+        if (serverInfo == null) {
+            throw new Exception("获取 server_info 失败");
+        }
+        String ossUrl = serverInfo.getOssUrl();
+        String token = serverInfo.getToken();
+
+        OssStoreClient ossStoreClient = new OssStoreClient(ossUrl);
+        UploadFileRet uploadFileRet = ossStoreClient.postObjectWithJdkHttp(file, channelCode, token);
+        return uploadFileRet;
+    }
+
+    public UploadFileRet postObjectByMultiparts(File file, int channelCode) throws Exception {
+        ServerInfo serverInfo = getUploadStore(channelCode);
+        if (serverInfo == null) {
+            throw new Exception("获取 server_info 失败");
+        }
+        String ossUrl = serverInfo.getOssUrl();
+        String token = serverInfo.getToken();
+
+        ObjectMultipartUploadService multipartUploadService = new ObjectMultipartUploadService(ossUrl, token);
+        UploadFileRet uploadFileRet = multipartUploadService.uploadFilePart(file, channelCode);
+        return uploadFileRet;
+    }
+
+    public boolean headObject(String objectName) {
+        return false;
+    }
+
+    public String getObject(String objectName, int channelCode, String savedDir) throws Exception {
+        ServerInfo serverInfo = getUploadStore(channelCode);
+        if (serverInfo == null) {
+            throw new Exception("获取 server_info 失败");
+        }
+        String ossUrl = serverInfo.getOssUrl();
+        String token = serverInfo.getToken();
+
+        OssStoreClient ossStoreClient = new OssStoreClient(ossUrl);
+        return ossStoreClient.getObject(objectName, savedDir);
+    }
+}

+ 222 - 0
oss-sdk/src/main/java/cn/reghao/tnb/oss/sdk/OssStoreClient.java

@@ -0,0 +1,222 @@
+package cn.reghao.tnb.oss.sdk;
+
+import cn.reghao.jutil.jdk.http.UploadParam;
+import cn.reghao.jutil.jdk.http.WebResponse;
+import cn.reghao.jutil.jdk.result.WebResult;
+import cn.reghao.jutil.jdk.security.DigestUtil;
+import cn.reghao.jutil.tool.json.JsonConverter;
+import cn.reghao.jutil.tool.http.DefaultWebRequest;
+import cn.reghao.tnb.oss.api.rest.UploadFileRet;
+import com.google.gson.reflect.TypeToken;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.io.FileUtils;
+
+import java.io.*;
+import java.lang.reflect.Type;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Supplier;
+
+/**
+ * @author reghao
+ * @date 2023-06-02 16:19:04
+ */
+@Slf4j
+public class OssStoreClient {
+    private final String endpoint;
+    private final HttpClient httpClient = HttpClient.newBuilder().build();
+    private final DefaultWebRequest webRequest = new DefaultWebRequest();
+
+    public OssStoreClient(String endpoint) {
+        this.endpoint = endpoint;
+    }
+
+    public UploadFileRet putObject(File file, int channelCode, String token) throws Exception {
+        String sha256sum = DigestUtil.sha256sum(file.getAbsolutePath());
+        String api = String.format("%s/", endpoint);
+        HttpRequest.Builder builder = HttpRequest.newBuilder(new URI(api))
+                .version(HttpClient.Version.HTTP_1_1)
+                .header("x-content-sha256sum", sha256sum)
+                .header("x-channel-id", channelCode+"")
+                .header("authorization", "Bearer " + token);
+
+        HttpRequest httpRequest = builder.PUT(HttpRequest.BodyPublishers.ofFile(Path.of(file.getAbsolutePath()))).build();
+        HttpResponse<String> httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
+        return getResult(httpResponse);
+    }
+
+    public UploadFileRet putObjectWithJdkHttp(InputStream inputStream, int channelCode, int userId) throws Exception {
+        String api = String.format("%s/", endpoint);
+        HttpRequest.Builder builder = HttpRequest.newBuilder(new URI(api))
+                .version(HttpClient.Version.HTTP_1_1)
+                .header("x-content-sha256sum", "1234567890")
+                .header("x-channel-id", channelCode+"")
+                .header("x-user-id", userId+"");
+
+        BufferedInputStream bis = new BufferedInputStream(inputStream);
+        Supplier<? extends InputStream> streamSupplier = (Supplier<BufferedInputStream>) () -> bis;
+        HttpRequest httpRequest = builder.PUT(HttpRequest.BodyPublishers.ofInputStream(streamSupplier)).build();
+        HttpResponse<String> httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
+        return getResult(httpResponse);
+    }
+
+    public UploadFileRet postObject(File file, int channelCode, String token) throws Exception {
+        String sha256sum = DigestUtil.sha256sum(file.getAbsolutePath());
+        Map<String, String> map = new HashMap<>();
+        map.put("channelCode", ""+channelCode);
+        map.put("client", "client");
+        map.put("sha256sum", sha256sum);
+        UploadParam uploadParam = new UploadParam(file, map);
+
+        String api = String.format("%s/", endpoint);
+        WebResponse webResponse = webRequest.upload(api, uploadParam, token);
+        int statusCode = webResponse.getStatusCode();
+        String body = webResponse.getBody();
+        if (statusCode != 200) {
+            String errMsg = String.format("%s -> %s", statusCode, body);
+            throw new Exception(errMsg);
+        }
+
+        Type type = new TypeToken<WebResult<UploadFileRet>>(){}.getType();
+        WebResult<UploadFileRet> webResult = JsonConverter.jsonToObject(body, type);
+        if (webResult.getCode() != 0) {
+            throw new Exception(webResult.getMsg());
+        }
+
+        return webResult.getData();
+    }
+
+    public UploadFileRet postObjectWithJdkHttp(File file, int channelCode, String token) throws Exception {
+        String sha256sum = DigestUtil.sha256sum(file.getAbsolutePath());
+        MultiPartBodyPublisher publisher = new MultiPartBodyPublisher()
+                .addPart("file", Paths.get(file.getAbsolutePath()))
+                .addPart("client", "oss-sdk-1.0")
+                .addPart("sha256sum", sha256sum)
+                .addPart("channelCode", channelCode+"");
+
+        String api = String.format("%s/", endpoint);
+        HttpRequest httpRequest = HttpRequest.newBuilder(new URI(api))
+                .version(HttpClient.Version.HTTP_1_1)
+                .header("authorization", "Bearer " + token)
+                .header("content-type", "multipart/form-data; boundary=" + publisher.getBoundary())
+                .POST(publisher.build())
+                .build();
+
+        HttpResponse<String> httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
+        return getResult(httpResponse);
+    }
+
+    private UploadFileRet getResult(HttpResponse<String> httpResponse) throws Exception {
+        int statusCode = httpResponse.statusCode();
+        String body = httpResponse.body();
+        if (statusCode != 200) {
+            String errMsg = String.format("%s -> %s", statusCode, body);
+            throw new Exception(errMsg);
+        }
+
+        Type type = new TypeToken<WebResult<UploadFileRet>>(){}.getType();
+        WebResult<UploadFileRet> webResult = JsonConverter.jsonToObject(body, type);
+        if (webResult.getCode() != 0) {
+            String errMsg = String.format("%s - %s", webResult.getCode(), webResult.getMsg());
+            throw new Exception(errMsg);
+        }
+
+        return webResult.getData();
+    }
+
+    public boolean headObject(String objectName) {
+        try {
+            String api = String.format("%s/%s", endpoint, objectName);
+            HttpRequest httpRequest = HttpRequest.newBuilder(new URI(api))
+                    .version(HttpClient.Version.HTTP_1_1)
+                    .method("HEAD", HttpRequest.BodyPublishers.noBody())
+                    .build();
+
+            HttpResponse<String> httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
+            int statusCode = httpResponse.statusCode();
+            if (statusCode == 200) {
+                return true;
+            } else if (statusCode == 404) {
+                return false;
+            }
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+
+        return false;
+    }
+
+    public boolean headObject1(String sha256sum) {
+        try {
+            String api = String.format("%s?sha256sum=%s", endpoint, sha256sum);
+            HttpRequest httpRequest = HttpRequest.newBuilder(new URI(api))
+                    .version(HttpClient.Version.HTTP_1_1)
+                    .method("HEAD", HttpRequest.BodyPublishers.noBody())
+                    .build();
+
+            HttpResponse<String> httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
+            int statusCode = httpResponse.statusCode();
+            if (statusCode == 200) {
+                return true;
+            } else if (statusCode == 404) {
+                return false;
+            }
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+
+        return false;
+    }
+
+    public String getObject(String objectName, String savedDir) {
+        try {
+            String version = "1.0.0";
+            String api = String.format("%s/%s?client=%s", endpoint, objectName, version);
+            HttpRequest httpRequest = HttpRequest.newBuilder(new URI(api))
+                    .version(HttpClient.Version.HTTP_1_1)
+                    .GET()
+                    .build();
+
+            HttpResponse<InputStream> httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofInputStream());
+            int statusCode = httpResponse.statusCode();
+            if (statusCode == 200) {
+                int idx = objectName.lastIndexOf("/");
+                String filename = objectName.substring(idx+1);
+                String savedPath = String.format("%s/%s", savedDir, filename);
+                saveFile(savedPath, httpResponse.body());
+                return savedPath;
+            } else {
+                log.error("{}", statusCode);
+            }
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+
+        return null;
+    }
+
+    private void saveFile(String filePath, InputStream in) throws IOException {
+        File file = new File(filePath);
+        File parentDir = file.getParentFile();
+        if (!parentDir.exists()) {
+            FileUtils.forceMkdir(parentDir);
+        }
+
+        FileOutputStream fos = new FileOutputStream(file);
+        // 5MB
+        int len = 1024*1024*5;
+        byte[] buf = new byte[len];
+        int readLen;
+        while ((readLen = in.read(buf, 0, len)) != -1) {
+            fos.write(buf, 0, readLen);
+        }
+
+        fos.close();
+    }
+}

+ 16 - 0
oss-sdk/src/main/java/cn/reghao/tnb/oss/sdk/model/OssConsoleConfig.java

@@ -0,0 +1,16 @@
+package cn.reghao.tnb.oss.sdk.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * @author reghao
+ * @date 2024-04-19 20:01:46
+ */
+@AllArgsConstructor
+@Getter
+public class OssConsoleConfig {
+    private String consoleUrl;
+    private String accessKeyId;
+    private String accessKeySecret;
+}

+ 43 - 0
oss-sdk/src/test/java/OssConsoleClientTest.java

@@ -0,0 +1,43 @@
+import cn.reghao.tnb.oss.sdk.model.OssConsoleConfig;
+import cn.reghao.tnb.oss.sdk.OssConsoleClient;
+import cn.reghao.tnb.oss.api.dto.media.VideoInfo;
+import cn.reghao.tnb.oss.api.rest.UploadFileRet;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.*;
+
+/**
+ * @author reghao
+ * @date 2023-06-02 16:19:04
+ */
+@Slf4j
+public class OssConsoleClientTest {
+    public static void main(String[] args) throws Exception {
+        String consoleUrl = "http://file.reghao.cn";
+        String accessKeyId = "mDM6n1Hl";
+        String accessKeySecret = "mTAzPhSS80gd0Iv2Fb";
+        OssConsoleConfig ossConsoleConfig = new OssConsoleConfig(consoleUrl, accessKeyId, accessKeySecret);
+        OssConsoleClient ossConsoleClient = new OssConsoleClient(ossConsoleConfig);
+
+        String filePath = "/home/reghao/Downloads/夏天的较量又要开始啦#换装.mp4";
+        File file = new File(filePath);
+        int channelCode = 101;
+        UploadFileRet uploadFileRet1 = ossConsoleClient.postObject(file, channelCode);
+//        UploadFileRet uploadFileRet = ossConsoleClient.putObject(file, channelCode);
+        //UploadFileRet uploadFileRet2 = ossConsoleClient.postObjectByMultiparts(file, channelCode);
+
+//        String objectName = "video/playback/28d0fd95e224499c9f2cf1d98b4551a5.flv";
+//        String localPath  = ossConsoleClient.getObject(objectName);
+
+//        String sha256sum = "1234567890";
+//        boolean exist = ossConsoleClient.headObject(sha256sum);
+
+        VideoInfo videoInfo = ossConsoleClient.getVideoInfo(channelCode, uploadFileRet1.getUploadId());
+
+        channelCode = 102;
+        String objectId = uploadFileRet1.getUploadId();
+        int loginUser = 2;
+        //ImageInfo imageInfo = ossConsoleClient.getImageInfo(channelCode, objectId);
+        System.out.println();
+    }
+}

+ 7 - 0
oss-store/Dockerfile

@@ -0,0 +1,7 @@
+FROM adoptopenjdk/openjdk11:x86_64-alpine-jre-11.0.15_10
+
+WORKDIR /app
+RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo 'Asia/Shanghai' >/etc/timezone
+COPY target/oss-store.jar /app/oss-store.jar
+
+ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/app/oss-store.jar"]

+ 14 - 0
oss-store/bin/oss.yml

@@ -0,0 +1,14 @@
+server:
+  tomcat:
+    basedir: /opt/tmp/tomcat
+spring:
+  datasource:
+    url: jdbc:mysql://localhost/tnb_oss_rdb?useSSL=false&useUnicode=true&characterEncoding=UTF-8&serverTimezone=GMT%2b8
+    username: dev
+    password: Dev@123456
+oss:
+  store-host: 127.0.0.1
+  store-dirs:
+    - /disk/13f654c8-af87-4710-aac9-7aa086c99aec/
+  console-host: 127.0.0.1
+  console-port: 6103

+ 11 - 0
oss-store/bin/shutdown.sh

@@ -0,0 +1,11 @@
+#!/bin/bash
+
+app_name='oss-store.jar'
+pid=`ps -ef | grep ${app_name} | grep -v grep | awk '{print $2}'`
+if [[ -z ${pid} ]];
+then
+  echo ${app_name}" killed"
+else
+  echo "kill "${app_name}" with pid "${pid}
+  kill -15 ${pid}
+fi

+ 11 - 0
oss-store/bin/start.sh

@@ -0,0 +1,11 @@
+#!/bin/bash
+
+app_dir=`pwd`
+app_name='oss-store.jar'
+
+# 使用 mvn clean package -Dmaven.test.skip -Ptest 生成 jar
+# 运行时加载 application.yml 和 oss.yml 两个配置文件, 分别位于 classpath 和文件系统路径
+# 外部指定的 oss.yml 文件会覆盖 resources/application-test.yml 文件
+java -jar ${app_dir}"/"${app_name} \
+--spring.config.location=classpath:/application.yml,file:${app_dir}/oss.yml \
+> console.log 2>&1 &

+ 160 - 0
oss-store/pom.xml

@@ -0,0 +1,160 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <artifactId>tnb</artifactId>
+        <groupId>cn.reghao.tnb</groupId>
+        <version>1.0.0</version>
+    </parent>
+
+    <artifactId>oss-store</artifactId>
+    <version>1.0.0</version>
+    <packaging>jar</packaging>
+
+    <properties>
+        <project.build.outputDir>${project.basedir}/bin</project.build.outputDir>
+    </properties>
+
+    <!--<dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-dependencies</artifactId>
+                <version>${springboot.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>-->
+
+    <dependencies>
+        <dependency>
+            <groupId>cn.reghao.jutil</groupId>
+            <artifactId>auth</artifactId>
+            <version>1.0.0-SNAPSHOT</version>
+        </dependency>
+
+        <dependency>
+            <groupId>cn.reghao.jutil</groupId>
+            <artifactId>web</artifactId>
+            <version>1.0.0-SNAPSHOT</version>
+        </dependency>
+        <dependency>
+            <groupId>cn.reghao.tnb</groupId>
+            <artifactId>oss-api</artifactId>
+            <version>1.0.0-SNAPSHOT</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-aop</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-cache</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.github.ben-manes.caffeine</groupId>
+            <artifactId>caffeine</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.mybatis.spring.boot</groupId>
+            <artifactId>mybatis-spring-boot-starter</artifactId>
+            <version>1.3.2</version>
+        </dependency>
+        <dependency>
+            <groupId>mysql</groupId>
+            <artifactId>mysql-connector-java</artifactId>
+            <version>8.0.17</version>
+        </dependency>
+        <dependency>
+            <groupId>com.zaxxer</groupId>
+            <artifactId>HikariCP</artifactId>
+            <version>3.3.1</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.dubbo</groupId>
+            <artifactId>dubbo-spring-boot-starter</artifactId>
+            <version>3.3.0</version>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.dubbo</groupId>
+            <artifactId>dubbo-zookeeper-spring-boot-starter</artifactId>
+            <version>3.3.0</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.tika</groupId>
+            <artifactId>tika-core</artifactId>
+            <version>2.9.1</version>
+        </dependency>
+
+        <dependency>
+            <groupId>com.github.oshi</groupId>
+            <artifactId>oshi-core</artifactId>
+            <version>6.6.3</version>
+        </dependency>
+    </dependencies>
+
+    <profiles>
+        <profile>
+            <id>dev</id>
+            <properties>
+                <profile.active>dev</profile.active>
+            </properties>
+            <activation>
+                <activeByDefault>true</activeByDefault>
+            </activation>
+        </profile>
+        <profile>
+            <id>test</id>
+            <properties>
+                <profile.active>test</profile.active>
+            </properties>
+        </profile>
+    </profiles>
+
+    <build>
+        <finalName>oss-store</finalName>
+        <resources>
+            <resource>
+                <directory>src/main/resources</directory>
+                <filtering>true</filtering>
+                <includes>
+                    <include>application.yml</include>
+                    <include>application-${profile.active}.yml</include>
+                    <include>mapper/**</include>
+                    <include>*.xml</include>
+                </includes>
+            </resource>
+        </resources>
+
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <version>${springboot.version}</version>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>repackage</goal>
+                        </goals>
+                    </execution>
+                </executions>
+                <configuration>
+                    <!-- 生成的 jar 包输出到指定目录 -->
+                    <outputDirectory>${project.build.outputDir}</outputDirectory>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 11 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/OssStoreApplication.java

@@ -0,0 +1,11 @@
+package cn.reghao.tnb.oss.store;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class OssStoreApplication {
+    public static void main(String[] args) {
+        SpringApplication.run(OssStoreApplication.class, args);
+    }
+}

+ 68 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/config/CacheConfig.java

@@ -0,0 +1,68 @@
+package cn.reghao.tnb.oss.store.config;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import org.springframework.cache.CacheManager;
+import org.springframework.cache.annotation.EnableCaching;
+
+import org.springframework.cache.caffeine.CaffeineCacheManager;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 缓存配置
+ *
+ * @author reghao
+ * @date 2023-05-22 16:31:04
+ */
+@EnableCaching
+@Configuration
+public class CacheConfig {
+    /**
+     * 为 Spring Cache 的相关注解提供一个缓存
+     *
+     * @param
+     * @return
+     * @date 2024-03-04 16:37:05
+     */
+    @Bean
+    public CacheManager cacheManager() {
+        CaffeineCacheManager cacheManager = new CaffeineCacheManager();
+        Caffeine<Object, Object> caffeineCache = Caffeine.newBuilder()
+                .initialCapacity(1000)
+                .maximumSize(10_000)
+                .expireAfterAccess(365, TimeUnit.DAYS);;
+        cacheManager.setCaffeine(caffeineCache);
+        return cacheManager;
+    }
+
+    /**
+     * 提供一个全局的独立缓存
+     *
+     * @param
+     * @return
+     * @date 2024-03-04 16:37:38
+     */
+    @Bean("caffeineCache")
+    public Cache<String, Object> caffeineCache() {
+        return Caffeine.newBuilder()
+                .initialCapacity(1000)
+                .maximumSize(10_000)
+                .expireAfterAccess(365, TimeUnit.DAYS)
+                .build();
+    }
+
+    @Bean
+    public Cache<String, String> caffeineCache1() {
+        return Caffeine.newBuilder()
+                // 设置最后一次写入或访问后经过固定时间过期
+                .expireAfterWrite(30, TimeUnit.DAYS)
+                // 初始的缓存空间大小
+                .initialCapacity(10_000)
+                // 缓存的最大条数
+                .maximumSize(100_000)
+                .build();
+    }
+}

+ 50 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/config/DubboServiceConfig.java

@@ -0,0 +1,50 @@
+package cn.reghao.tnb.oss.store.config;
+
+import cn.reghao.tnb.oss.api.iface.ConsoleService;
+import cn.reghao.tnb.oss.store.config.props.OssProperties;
+import org.apache.dubbo.config.ApplicationConfig;
+import org.apache.dubbo.config.ConsumerConfig;
+import org.apache.dubbo.config.ReferenceConfig;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * 对 RPC 服务进行初始化
+ *
+ * @author reghao
+ * @date 2024-02-28 11:11:04
+ */
+@Configuration
+public class DubboServiceConfig {
+    @Bean
+    public ConsoleService consoleService(OssProperties ossProperties) {
+        RemoteService<ConsoleService> remoteService = new RemoteService<>();
+        String host = ossProperties.getConsoleHost();
+        int port = ossProperties.getConsolePort();
+        return remoteService.getService(host, port, ConsoleService.class);
+    }
+
+    static class RemoteService<T> {
+        public T getService(String host, int port, Class<T> clazz) {
+            String serviceName = "remote-service";
+            String dubboUrl = String.format("dubbo://%s:%s/%s", host, port, clazz.getName());
+
+            // 当前应用配置
+            ApplicationConfig application = new ApplicationConfig();
+            application.setName(serviceName);
+
+            ConsumerConfig consumerConfig = new ConsumerConfig();
+            consumerConfig.setTimeout(60_000);
+
+            // 注意:ReferenceConfig为重对象,内部封装了与注册中心的连接,以及与服务提供方的连接
+            // 引用远程服务
+            // 此实例很重,封装了与注册中心的连接以及与提供者的连接,请自行缓存,否则可能造成内存和连接泄漏
+            ReferenceConfig<T> reference = new ReferenceConfig<>();
+            reference.setApplication(application);
+            reference.setInterface(clazz);
+            reference.setUrl(dubboUrl);
+            reference.setConsumer(consumerConfig);
+            return reference.get();
+        }
+    }
+}

+ 61 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/config/SpringLifecycle.java

@@ -0,0 +1,61 @@
+package cn.reghao.tnb.oss.store.config;
+
+import cn.reghao.jutil.jdk.thread.ThreadPoolWrapper;
+import cn.reghao.tnb.oss.api.dto.StoreNodeDto;
+import cn.reghao.tnb.oss.api.dto.disk.DiskVolume;
+import cn.reghao.tnb.oss.api.iface.StoreService;
+import cn.reghao.tnb.oss.store.config.props.SpringProperties;
+import cn.reghao.tnb.oss.store.service.ConsoleServiceWrapper;
+import cn.reghao.tnb.oss.store.tomcat.TomcatStarter;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.DisposableBean;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+
+/**
+ * @author reghao
+ * @date 2022-03-23 09:22:01
+ */
+@Slf4j
+@Component
+public class SpringLifecycle implements ApplicationRunner, DisposableBean {
+    private final SpringProperties springProperties;
+    private final StoreService storeService;
+    private final ConsoleServiceWrapper consoleServiceWrapper;
+    private final ExecutorService threadPool;
+    private final TomcatStarter tomcatStarter;
+
+    public SpringLifecycle(SpringProperties springProperties, StoreService storeService,
+                           ConsoleServiceWrapper consoleServiceWrapper, TomcatStarter tomcatStarter) {
+        this.springProperties = springProperties;
+        this.storeService = storeService;
+        this.consoleServiceWrapper = consoleServiceWrapper;
+        this.threadPool = ThreadPoolWrapper.threadPool("tomcat");
+        this.tomcatStarter = tomcatStarter;
+    }
+
+    @Override
+    public void run(ApplicationArguments args) {
+        //threadPool.submit(tomcatStarter);
+        registerStoreNode();
+    }
+
+    private void registerStoreNode() {
+        // 注册 oss-store 到 oss-console
+        String nodeAddr = springProperties.getNodeAddress();
+        int httpPort = springProperties.getHttpPort();
+        int rpcPort = springProperties.getRpcPort();
+        List<DiskVolume> diskVolumeList = storeService.getDiskVolumes();
+        StoreNodeDto storeNodeDto = new StoreNodeDto(nodeAddr, httpPort, rpcPort, diskVolumeList);
+        consoleServiceWrapper.registerNode(storeNodeDto);
+        log.info("StoreNode 已注册到 oss-console...");
+    }
+
+    @Override
+    public void destroy() {
+    }
+}

+ 62 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/config/exception/ControllerExceptionHandler.java

@@ -0,0 +1,62 @@
+package cn.reghao.tnb.oss.store.config.exception;
+
+import cn.reghao.jutil.jdk.exception.ExceptionUtil;
+import cn.reghao.jutil.jdk.result.WebResult;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseBody;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.stream.Collectors;
+
+/**
+ * 全局异常处理类,处理 controller 抛出的异常
+ *
+ * @author reghao
+ * @date 2019/03/21 10:25:49
+ */
+@Slf4j
+@ControllerAdvice
+public class ControllerExceptionHandler {
+    /**
+     * 处理所有 controller 上抛出的异常
+     *
+     * @date 2019-09-28 上午11:01
+     */
+    @ExceptionHandler({Exception.class})
+    @ResponseBody
+    public ResponseEntity<String> error(Exception e, HttpServletRequest request) {
+        String uri = request.getRequestURI();
+        String msg;
+        if (e instanceof NullPointerException) {
+            msg = ExceptionUtil.stackTrace(e);
+        } else {
+            msg = ExceptionUtil.errorMsg(e);
+        }
+        log.error("{} 接口抛出异常: {}", uri, msg);
+
+        String body = WebResult.errorWithMsg(msg);
+        int status = HttpStatus.INTERNAL_SERVER_ERROR.value();
+        int status1 = HttpStatus.INSUFFICIENT_STORAGE.value();
+        if (e instanceof MethodArgumentNotValidException) {
+            // 参数校验失败
+            MethodArgumentNotValidException exception = (MethodArgumentNotValidException) e;
+            String errMsg = exception.getBindingResult().getAllErrors().stream()
+                    .map(objectError -> {
+                        String message = objectError.getDefaultMessage();
+                        return message + "\n";
+                    }).collect(Collectors.joining());
+            body = WebResult.errorWithMsg(errMsg);
+        } else if (e instanceof IllegalStateException) {
+            IllegalStateException exception = (IllegalStateException) e;
+            Throwable throwable = exception.getCause();
+            body = WebResult.errorWithMsg(throwable.getMessage());
+        }
+
+        return ResponseEntity.status(status).body(body);
+    }
+}

+ 43 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/config/exception/FilterExceptionHandler.java

@@ -0,0 +1,43 @@
+package cn.reghao.tnb.oss.store.config.exception;
+
+import cn.reghao.jutil.jdk.converter.DateTimeConverter;
+import org.springframework.boot.autoconfigure.web.ErrorProperties;
+import org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController;
+import org.springframework.boot.web.servlet.error.DefaultErrorAttributes;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 处理 filter 中抛出的异常
+ * 需要配置 server.error.path=/error
+ *
+ * @author reghao
+ * @date 2020-06-19 13:34:19
+ */
+//@RestController
+public class FilterExceptionHandler extends BasicErrorController {
+    public FilterExceptionHandler() {
+        super(new DefaultErrorAttributes(), new ErrorProperties());
+    }
+
+    @Override
+    @RequestMapping(produces = {MediaType.APPLICATION_JSON_VALUE})
+    public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
+        //Map<String, Object> body = getErrorAttributes(request, isIncludeStackTrace(request, MediaType.ALL));
+        HttpStatus status = getStatus(request);
+
+        Map<String,Object> map = new HashMap<>();
+        /*map.put("code",body.get("status"));
+        map.put("msg",body.get("message"));
+        map.put("timestamp", DateTimeConverter.format(System.currentTimeMillis()));
+        map.put("data",body.get("data"));*/
+        return ResponseEntity.status(status).body(map);
+    }
+}

+ 66 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/config/inerceptor/AccessLogInterceptor.java

@@ -0,0 +1,66 @@
+package cn.reghao.tnb.oss.store.config.inerceptor;
+
+import cn.reghao.jutil.web.ServletUtil;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.lang.Nullable;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerInterceptor;
+import org.springframework.web.servlet.ModelAndView;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * 访问日志拦截器
+ *
+ * @author reghao
+ * @date 2021-12-30 12:19:07
+ */
+@Slf4j
+@Component
+public class AccessLogInterceptor implements HandlerInterceptor {
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
+        String uri = request.getRequestURI();
+        String method = request.getMethod();
+        if (method.equalsIgnoreCase("HEAD")) {
+            return true;
+        }
+
+        String userAgent = request.getHeader("user-agent");
+        String ipv4 = request.getRemoteAddr();
+        String referer = request.getHeader("referer");
+        String client = ServletUtil.getRequestParam("client", "");
+        if (!client.isBlank()) {
+            return true;
+        }
+
+        /*String host = ServletUtil.getHeader("host");
+        String referFrom = null;
+        NodeProperties nodeProperties = consoleService.getNodeProperties(host);
+        if (nodeProperties != null) {
+            referFrom = nodeProperties.getReferer();
+        }
+
+        String objectName = uri.replaceFirst("/", "");
+        if (objectName.startsWith("img/")) {
+            return true;
+        } else if (referer == null || (referFrom != null && !referer.contains(referFrom))) {
+            log.error("request object {} from {} has been blocked", uri, referer);
+            response.setStatus(403);
+            return false;
+        }*/
+
+        return true;
+    }
+
+    @Override
+    public void postHandle(HttpServletRequest request, HttpServletResponse response,
+                           Object handler, @Nullable ModelAndView modelAndView) throws Exception {
+    }
+
+    @Override
+    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
+                                Object handler, @Nullable Exception ex) throws Exception {
+    }
+}

+ 50 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/config/inerceptor/TokenFilter.java

@@ -0,0 +1,50 @@
+package cn.reghao.tnb.oss.store.config.inerceptor;
+
+import cn.reghao.tnb.oss.store.util.AuthContext;
+import cn.reghao.jutil.web.ServletUtil;
+import cn.reghao.jutil.auth.JwtUtil;
+import cn.reghao.jutil.auth.model.OssPayload;
+import com.github.benmanes.caffeine.cache.Cache;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.*;
+import java.io.IOException;
+
+/**
+ * @author reghao
+ * @date 2023-08-25 16:14:23
+ */
+@Component
+public class TokenFilter implements Filter {
+    private final Cache<String, String> cache;
+
+    public TokenFilter(Cache<String, String> cache) {
+        this.cache = cache;
+    }
+
+    @Override
+    public void init(FilterConfig filterConfig) throws ServletException {
+    }
+
+    @Override
+    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+            throws IOException, ServletException {
+        int userId = -1;
+        String token = ServletUtil.getBearerToken(request);
+        if (token != null) {
+            String secretKey = cache.getIfPresent(token);
+            if (secretKey != null) {
+                OssPayload ossPayload = JwtUtil.getOssPayload(token, secretKey);
+                userId = ossPayload.getUserId();
+            }
+        }
+
+        try (AuthContext context = new AuthContext(userId)) {
+            chain.doFilter(request, response);
+        }
+    }
+
+    @Override
+    public void destroy() {
+    }
+}

+ 68 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/config/mybatis/DataSourceConfig.java

@@ -0,0 +1,68 @@
+package cn.reghao.tnb.oss.store.config.mybatis;
+
+import org.apache.ibatis.plugin.Interceptor;
+import org.apache.ibatis.session.SqlSessionFactory;
+import org.mybatis.spring.SqlSessionFactoryBean;
+import org.mybatis.spring.SqlSessionTemplate;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
+import org.springframework.jdbc.datasource.DataSourceTransactionManager;
+import org.springframework.transaction.PlatformTransactionManager;
+
+import javax.sql.DataSource;
+
+/**
+ * MyBatis 初始化配置
+ * 若配置了多数据源,则不启用此配置
+ *
+ * @author reghao
+ * @date 2021-04-26 17:48:29
+ */
+@Configuration
+public class DataSourceConfig {
+    private final DataSource dataSource;
+
+    public DataSourceConfig(DataSource dataSource) {
+        this.dataSource = dataSource;
+    }
+
+    @Bean
+    public SqlSessionFactory sqlSessionFactory() throws Exception {
+        SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
+        factoryBean.setDataSource(dataSource);
+        factoryBean.setPlugins(new Interceptor[]{pageListInterceptor()});
+
+        org.apache.ibatis.session.Configuration configuration = new org.apache.ibatis.session.Configuration();
+        configuration.setMapUnderscoreToCamelCase(true);
+        configuration.setDefaultFetchSize(100);
+        configuration.setDefaultStatementTimeout(300);
+        factoryBean.setConfiguration(configuration);
+
+        String location = "classpath*:mapper/**.xml";
+        factoryBean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources(location));
+        return factoryBean.getObject();
+    }
+
+    /**
+     * 配置 mybatis 的分页拦截器
+     *
+     * @param
+     * @return
+     * @date 2021-12-21 下午5:23
+     */
+    @Bean
+    public PageListInterceptor pageListInterceptor() {
+        return new PageListInterceptor();
+    }
+
+    @Bean(value = "sqlSession")
+    public SqlSessionTemplate sqlSessionTemplate(SqlSessionFactory sqlSessionFactory) {
+        return new SqlSessionTemplate(sqlSessionFactory);
+    }
+
+    @Bean(value = "transactionManager")
+    public PlatformTransactionManager annotationDrivenTransactionManager() {
+        return new DataSourceTransactionManager(dataSource);
+    }
+}

+ 83 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/config/mybatis/PageListInterceptor.java

@@ -0,0 +1,83 @@
+package cn.reghao.tnb.oss.store.config.mybatis;
+
+import cn.reghao.jutil.jdk.db.Page;
+import org.apache.ibatis.binding.MapperMethod;
+import org.apache.ibatis.executor.parameter.ParameterHandler;
+import org.apache.ibatis.executor.statement.StatementHandler;
+import org.apache.ibatis.mapping.MappedStatement;
+import org.apache.ibatis.plugin.*;
+import org.apache.ibatis.reflection.MetaObject;
+import org.apache.ibatis.reflection.SystemMetaObject;
+
+import java.sql.Connection;
+import java.util.Properties;
+
+/**
+ * MyBatis 分页拦截器
+ *
+ * @author reghao
+ * @date 2021-04-26 17:40:32
+ */
+@Intercepts({@Signature(type=StatementHandler.class,method="prepare",args={Connection.class,Integer.class})})
+public class PageListInterceptor implements Interceptor {
+    private final String methodSuffix = "ByPage";
+    @Deprecated
+    private int page;
+    @Deprecated
+    private int size;
+    private String dbType;
+
+    @Override
+    public Object intercept(Invocation invocation) throws Throwable {
+        StatementHandler statementHandler = (StatementHandler)invocation.getTarget();
+        MetaObject metaObject = SystemMetaObject.forObject(statementHandler);
+        while(metaObject.hasGetter("h")){
+            Object object = metaObject.getValue("h");
+            metaObject = SystemMetaObject.forObject(object);
+        }
+
+        while(metaObject.hasGetter("target")){
+            Object object = metaObject.getValue("target");
+            metaObject = SystemMetaObject.forObject(object);
+        }
+
+        MappedStatement mappedStatement = (MappedStatement)metaObject.getValue("delegate.mappedStatement");
+        String mapId = mappedStatement.getId();
+        if (mapId.matches(String.format(".+%s$", methodSuffix))){
+            ParameterHandler parameterHandler = (ParameterHandler)metaObject.getValue("delegate.parameterHandler");
+            Object object = parameterHandler.getParameterObject();
+            Page page;
+            if (object instanceof Page) {
+                page = (Page) object;
+            } else if (object instanceof MapperMethod.ParamMap) {
+                MapperMethod.ParamMap paramMap = (MapperMethod.ParamMap) object;
+                Object param1 = paramMap.get("param1");
+                if (param1 instanceof Page) {
+                    page = (Page) param1;
+                } else {
+                    throw new Exception("byPage 方法的第一个参数不是 Page 类型");
+                }
+            } else {
+                throw new Exception("没有 Page 类型的参数");
+            }
+
+            String sql = (String) metaObject.getValue("delegate.boundSql.sql");
+            //sql += " limit "+(page-1)*size +","+size;
+            sql += String.format(" limit %s,%s", (page.getPage()-1)*page.getSize(), page.getSize());
+            metaObject.setValue("delegate.boundSql.sql", sql);
+        }
+        return invocation.proceed();
+    }
+
+    @Override
+    public Object plugin(Object target) {
+        return Plugin.wrap(target, this);
+    }
+
+    @Override
+    public void setProperties(Properties properties) {
+        String limit = properties.getProperty("limit","10");
+        this.page = Integer.parseInt(limit);
+        this.dbType = properties.getProperty("dbType", "mysql");
+    }
+}

+ 25 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/config/props/DubboProperties.java

@@ -0,0 +1,25 @@
+package cn.reghao.tnb.oss.store.config.props;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * @author reghao
+ * @date 2024-02-23 12:54:20
+ */
+@Getter
+@Setter
+@Component
+@ConfigurationProperties(prefix = "dubbo")
+public class DubboProperties {
+    private Protocol protocol;
+
+    @Setter
+    @Getter
+    static class Protocol {
+        private String name;
+        private int port;
+    }
+}

+ 23 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/config/props/OssProperties.java

@@ -0,0 +1,23 @@
+package cn.reghao.tnb.oss.store.config.props;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2021-12-30 11:01:46
+ */
+@Getter
+@Setter
+@Component
+@ConfigurationProperties(prefix = "oss")
+public class OssProperties {
+    private String storeHost;
+    private List<String> storeDirs;
+    private String consoleHost;
+    private Integer consolePort;
+}

+ 34 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/config/props/SpringProperties.java

@@ -0,0 +1,34 @@
+package cn.reghao.tnb.oss.store.config.props;
+
+import org.springframework.boot.autoconfigure.web.ServerProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * @author reghao
+ * @date 2024-02-23 12:58:00
+ */
+@Component
+public class SpringProperties {
+    private final ServerProperties serverProperties;
+    private final DubboProperties dubboProperties;
+    private final OssProperties ossProperties;
+
+    public SpringProperties(ServerProperties serverProperties, DubboProperties dubboProperties,
+                            OssProperties ossProperties) {
+        this.serverProperties = serverProperties;
+        this.dubboProperties = dubboProperties;
+        this.ossProperties = ossProperties;
+    }
+
+    public String getNodeAddress() {
+        return ossProperties.getStoreHost();
+    }
+
+    public int getHttpPort() {
+        return serverProperties.getPort();
+    }
+
+    public int getRpcPort() {
+        return dubboProperties.getProtocol().getPort();
+    }
+}

+ 61 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/config/web/PutMessageConverter.java

@@ -0,0 +1,61 @@
+package cn.reghao.tnb.oss.store.config.web;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpInputMessage;
+import org.springframework.http.HttpOutputMessage;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.AbstractHttpMessageConverter;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.http.converter.HttpMessageNotWritableException;
+
+import java.io.*;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.UUID;
+
+/**
+ * 实现 PUT 请求上传文件
+ * @author reghao
+ * @date 2022-12-28 10:03:22
+ */
+@Slf4j
+public class PutMessageConverter extends AbstractHttpMessageConverter<File> {
+    public PutMessageConverter() {
+        super(MediaType.ALL);
+    }
+
+    @Override
+    protected boolean supports(Class<?> clazz) {
+        return File.class.isAssignableFrom(clazz);
+    }
+
+    @Override
+    protected File readInternal(Class<? extends File> clazz, HttpInputMessage inputMessage)
+            throws IOException, HttpMessageNotReadableException {
+        log.info("PUT 请求上传文件");
+        InputStream inputStream = inputMessage.getBody();
+        return saveStream(inputStream);
+    }
+
+    private File saveStream(InputStream inputStream) throws IOException {
+        String tmpPath = String.format("/opt/tmp/tomcat/%s.dat", UUID.randomUUID());
+        Files.copy(inputStream, Path.of(tmpPath), StandardCopyOption.REPLACE_EXISTING);
+        File tmpFile = new File(tmpPath);
+        /*tmpFile.createNewFile();
+        FileOutputStream outputStream = new FileOutputStream(tmpFile);
+        byte[] buffer = new byte[1024];
+        int bytesRead;
+        while ((bytesRead = inputStream.read(buffer)) > 0) {
+            outputStream.write(buffer, 0, bytesRead);
+        }
+        outputStream.flush();
+        outputStream.close();*/
+        return tmpFile;
+    }
+
+    protected void writeInternal(File file, HttpOutputMessage outputMessage)
+            throws IOException, HttpMessageNotWritableException {
+        log.info("writeInternal");
+    }
+}

+ 72 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/config/web/WebConfig.java

@@ -0,0 +1,72 @@
+package cn.reghao.tnb.oss.store.config.web;
+
+import cn.reghao.tnb.oss.store.config.inerceptor.AccessLogInterceptor;
+import cn.reghao.tnb.oss.store.config.inerceptor.TokenFilter;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.http.converter.StringHttpMessageConverter;
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
+import org.springframework.http.converter.xml.Jaxb2RootElementHttpMessageConverter;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
+
+import javax.servlet.Filter;
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2021-12-30 12:34:26
+ */
+@Configuration
+public class WebConfig extends WebMvcConfigurationSupport {
+    private final AccessLogInterceptor accessLogInterceptor;
+
+    public WebConfig(AccessLogInterceptor accessLogInterceptor) {
+        this.accessLogInterceptor = accessLogInterceptor;
+    }
+
+    @Override
+    public void addInterceptors(InterceptorRegistry registry) {
+        registry.addInterceptor(accessLogInterceptor);
+    }
+
+    // 跨域处理
+    @Override
+    public void addCorsMappings(CorsRegistry registry) {
+        registry.addMapping("/**")
+                .allowedOriginPatterns("*")
+                .allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS")
+                .allowCredentials(true)
+                .maxAge(3600)
+                .allowedHeaders("*");
+    }
+
+    @Bean
+    public FilterRegistrationBean<Filter> filterRegistrationBean(TokenFilter tokenFilter) {
+        FilterRegistrationBean<Filter> registrationBean = new FilterRegistrationBean<>();
+        registrationBean.setFilter(tokenFilter);
+        registrationBean.addUrlPatterns("*");
+        return registrationBean;
+    }
+
+    /**
+     * HTTP req/resp 消息转换器
+     *
+     * @param
+     * @return
+     * @date 2022-12-29 上午9:43
+     */
+    @Override
+    public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
+        // 处理 application/octet-stream 数据
+        converters.add(new PutMessageConverter());
+        // 处理 application/json 数据
+        converters.add(new StringHttpMessageConverter());
+        converters.add(new MappingJackson2HttpMessageConverter());
+        // 处理 application/xml 数据
+        converters.add(new Jaxb2RootElementHttpMessageConverter());
+    }
+}

+ 133 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/controller/ObjectGetController.java

@@ -0,0 +1,133 @@
+package cn.reghao.tnb.oss.store.controller;
+
+import cn.reghao.jutil.web.ServletUtil;
+import cn.reghao.tnb.oss.api.constant.ChannelAction;
+import cn.reghao.tnb.oss.api.constant.ObjectScope;
+import cn.reghao.tnb.oss.api.dto.ObjectChannel;
+import cn.reghao.tnb.oss.api.dto.ObjectMeta;
+import cn.reghao.tnb.oss.store.db.repository.ObjectRepository;
+import cn.reghao.tnb.oss.store.service.ConsoleServiceWrapper;
+import cn.reghao.tnb.oss.store.service.GetObjectService;
+import cn.reghao.jutil.auth.JwtUtil;;
+import cn.reghao.tnb.oss.store.util.ObjectUtil;
+import cn.reghao.tnb.oss.store.util.SignatureUtil;
+import cn.reghao.jutil.auth.model.OssPayload;
+import com.github.benmanes.caffeine.cache.Cache;
+import org.springframework.web.bind.annotation.*;
+
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * 对象访问接口
+ *
+ * @author reghao
+ * @date 2022-12-08 20:38:33
+ */
+@RestController
+public class ObjectGetController {
+    private final GetObjectService getObjectService;
+    private final ObjectRepository objectRepository;
+    private final ConsoleServiceWrapper consoleServiceWrapper;
+    private final Cache<String, String> cache;
+
+    public ObjectGetController(GetObjectService getObjectService, ObjectRepository objectRepository,
+                               ConsoleServiceWrapper consoleServiceWrapper, Cache<String, String> cache) {
+        this.getObjectService = getObjectService;
+        this.objectRepository = objectRepository;
+        this.consoleServiceWrapper = consoleServiceWrapper;
+        this.cache = cache;
+    }
+
+    // 使用 HEAD 方法判断对象是否存在
+    @RequestMapping(value = "/**", method = RequestMethod.HEAD)
+    public void headObject(@RequestParam(value = "sha256sum", required = false) String sha256sum) throws IOException {
+        if (sha256sum != null) {
+            getObjectService.headObject1(sha256sum);
+        } else {
+            String objectName = ObjectUtil.getObjectName();
+            getObjectService.headObject(objectName);
+        }
+    }
+
+    // 通过 objectName 访问对象
+    @GetMapping(value = "/**")
+    public void getObject(@RequestParam(value = "token", required = false) String token,
+                          @RequestParam(value = "t", required = false) Long timestamp,
+                          @RequestParam(value = "nonce", required = false) String nonce,
+                          @RequestParam(value = "sign", required = false) String sign,
+                          @RequestParam(value = "client", required = false) String client) throws IOException {
+        String objectName = ObjectUtil.getObjectName();
+        String domain = ServletUtil.getHeader("host");
+        int owner = consoleServiceWrapper.getNodeProperties(domain).getOwner();
+        ObjectMeta objectMeta = objectRepository.getObjectMetaByName(objectName, owner);
+        if (objectMeta == null) {
+            getObjectService.writeResponse(HttpServletResponse.SC_NOT_FOUND);
+            return;
+        } else if (client != null && !client.isBlank()) {
+            getObjectService.getObject(objectMeta);
+            return;
+        } else if (objectMeta.getScope() == ObjectScope.PUBLIC.getCode()) {
+            getObjectService.getObject(objectMeta);
+            return;
+        }
+
+        if (token == null || timestamp == null || nonce == null || sign == null) {
+            String payload = "sign invalid";
+            getObjectService.writeResponse(HttpServletResponse.SC_FORBIDDEN, payload);
+            return;
+        }
+
+        String secretKey = cache.getIfPresent(token);
+        if (secretKey == null) {
+            String payload = "secretKey not found";
+            getObjectService.writeResponse(HttpServletResponse.SC_FORBIDDEN, payload);
+            return;
+        }
+
+        String queryString = String.format("token=%s&t=%s&nonce=%s", token, timestamp, nonce);
+        String host = ServletUtil.getHeader("host");
+        String url = String.format("//%s/%s", host, objectName);
+        String requestString = String.format("%s%s?%s", "GET", url, queryString);
+        boolean valid = SignatureUtil.valid(requestString, secretKey, sign);
+        if (!valid) {
+            String payload = "sign invalid";
+            getObjectService.writeResponse(HttpServletResponse.SC_FORBIDDEN, payload);
+            return;
+        }
+
+        long current = System.currentTimeMillis();
+        if (current > timestamp) {
+            String payload = "timestamp invalid";
+            getObjectService.writeResponse(HttpServletResponse.SC_FORBIDDEN, payload);
+            return;
+        }
+
+        OssPayload ossPayload = JwtUtil.getOssPayload(token, secretKey);
+        int loginUser = ossPayload.getUserId();
+        int channelCode = ossPayload.getChannelCode();
+        ObjectChannel objectChannel = consoleServiceWrapper.getChannelByCode(loginUser, channelCode);
+        if (objectChannel == null) {
+            String payload = String.format("channel_id %s not exist", channelCode);
+            getObjectService.writeResponse(HttpServletResponse.SC_FORBIDDEN, payload);
+            return;
+        }
+
+        String prefix = objectChannel.getPrefix();
+        if (!objectName.startsWith(prefix)) {
+            String payload = String.format("channel prefix %s not matched", prefix);
+            getObjectService.writeResponse(HttpServletResponse.SC_FORBIDDEN, payload);
+            return;
+        }
+
+        String action = ossPayload.getAction();
+        if (ChannelAction.access.getName().equals(action)) {
+            getObjectService.getObject(objectMeta);
+        } else if (ChannelAction.download.getName().equals(action)) {
+            getObjectService.writeDownloadContent(objectMeta);
+        } else {
+            String payload = String.format("channel action %s not matched", action);
+            getObjectService.writeResponse(HttpServletResponse.SC_FORBIDDEN, payload);
+        }
+    }
+}

+ 116 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/controller/ObjectMultipartUploadController.java

@@ -0,0 +1,116 @@
+package cn.reghao.tnb.oss.store.controller;
+
+import cn.reghao.jutil.web.ServletUtil;
+import cn.reghao.tnb.oss.api.dto.ObjectChannel;
+import cn.reghao.jutil.auth.model.OssPayload;
+import cn.reghao.tnb.oss.store.util.AuthContext;
+import cn.reghao.jutil.auth.JwtUtil;;
+import cn.reghao.tnb.oss.api.rest.UploadFilePart;
+import cn.reghao.tnb.oss.api.rest.UploadFileRet;
+import cn.reghao.tnb.oss.api.rest.UploadedPart;
+import cn.reghao.tnb.oss.store.service.ConsoleServiceWrapper;
+import cn.reghao.tnb.oss.store.service.ObjectMultipartUploadService;
+import cn.reghao.jutil.jdk.result.WebResult;
+import com.github.benmanes.caffeine.cache.Cache;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * 分片上传接口
+ *
+ * @author reghao
+ * @date 2022-12-08 20:40:55
+ */
+@RestController
+public class ObjectMultipartUploadController {
+    private final ObjectMultipartUploadService objectMultipartUploadService;
+    private final ConsoleServiceWrapper consoleServiceWrapper;
+    private final Cache<String, String> cache;
+
+    public ObjectMultipartUploadController(ObjectMultipartUploadService objectMultipartUploadService,
+                                           ConsoleServiceWrapper consoleServiceWrapper, Cache<String, String> cache) {
+        this.objectMultipartUploadService = objectMultipartUploadService;
+        this.consoleServiceWrapper = consoleServiceWrapper;
+        this.cache = cache;
+    }
+
+    // 获取已上传的对象分片
+    @GetMapping(value = "/", params = {"multiparts"}, produces = MediaType.APPLICATION_JSON_VALUE)
+    public ResponseEntity<String> getUploadedPart(UploadFilePart uploadFilePart) throws Exception {
+        int channelCode = uploadFilePart.getChannelCode();
+        /* permission check */
+        String token = ServletUtil.getBearerToken();
+        if (token == null) {
+            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
+                    .body(WebResult.failWithMsg("no token in request"));
+        }
+
+        String secretKey = cache.getIfPresent(token);
+        OssPayload ossPayload = JwtUtil.getOssPayload(token, secretKey);
+        String action = ossPayload.getAction();
+        if (!"upload".equals(action)) {
+            return ResponseEntity.status(HttpStatus.FORBIDDEN)
+                    .body(WebResult.failWithMsg("it's not upload token"));
+        }
+
+        int channelCode1 = ossPayload.getChannelCode();
+        if (channelCode != channelCode1) {
+            return ResponseEntity.status(HttpStatus.FORBIDDEN)
+                    .body(WebResult.failWithMsg("channel not match in token"));
+        }
+
+        int loginUser = ossPayload.getUserId();
+        ObjectChannel objectChannel = consoleServiceWrapper.getChannelByCode(loginUser, channelCode);
+        if (objectChannel == null) {
+            String errMsg = String.format("channel validate failed, channel %s not exist", channelCode);
+            return ResponseEntity.status(HttpStatus.FORBIDDEN)
+                    .body(WebResult.failWithMsg(errMsg));
+        }
+        AuthContext context = new AuthContext(loginUser);
+
+        UploadedPart uploadedPart = objectMultipartUploadService.getUploadedPart(uploadFilePart, objectChannel);
+        return ResponseEntity.status(HttpStatus.OK).body(WebResult.success(uploadedPart));
+    }
+
+    // 上传对象分片
+    @PostMapping(value = "/", params = {"multiparts"}, produces = MediaType.APPLICATION_JSON_VALUE)
+    public ResponseEntity<String> uploadPart(MultipartFile file, @Validated UploadFilePart uploadFilePart) throws Exception {
+        int channelCode = uploadFilePart.getChannelCode();
+        /* permission check */
+        String token = ServletUtil.getBearerToken();
+        if (token == null) {
+            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
+                    .body(WebResult.failWithMsg("no token in request"));
+        }
+
+        String secretKey = cache.getIfPresent(token);
+        OssPayload ossPayload = JwtUtil.getOssPayload(token, secretKey);
+        String action = ossPayload.getAction();
+        if (!"upload".equals(action)) {
+            return ResponseEntity.status(HttpStatus.FORBIDDEN)
+                    .body(WebResult.failWithMsg("it's not upload token"));
+        }
+
+        int channelCode1 = ossPayload.getChannelCode();
+        if (channelCode != channelCode1) {
+            return ResponseEntity.status(HttpStatus.FORBIDDEN)
+                    .body(WebResult.failWithMsg("channel not match in token"));
+        }
+
+        int loginUser = ossPayload.getUserId();
+        ObjectChannel objectChannel = consoleServiceWrapper.getChannelByCode(loginUser, channelCode);
+        if (objectChannel == null) {
+            String errMsg = String.format("channel validate failed, channel %s not exist", channelCode);
+            return ResponseEntity.status(HttpStatus.FORBIDDEN)
+                    .body(WebResult.failWithMsg(errMsg));
+        }
+        AuthContext context = new AuthContext(loginUser);
+
+        UploadFileRet uploadFileRet = objectMultipartUploadService.putFilePart(file.getInputStream(), uploadFilePart, objectChannel);
+        return ResponseEntity.status(HttpStatus.OK).body(WebResult.success(uploadFileRet));
+    }
+}

+ 135 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/controller/ObjectUploadController.java

@@ -0,0 +1,135 @@
+package cn.reghao.tnb.oss.store.controller;
+
+import cn.reghao.tnb.oss.api.dto.ObjectChannel;
+import cn.reghao.tnb.oss.store.service.*;
+import cn.reghao.tnb.oss.store.util.AuthContext;
+import cn.reghao.tnb.oss.api.rest.UploadFileRet;
+import cn.reghao.tnb.oss.store.model.vo.ObjectProp;
+import cn.reghao.tnb.oss.store.model.vo.ObjectResult;
+import cn.reghao.tnb.oss.store.service.*;
+import cn.reghao.jutil.auth.JwtUtil;;
+import cn.reghao.jutil.jdk.result.Result;
+import cn.reghao.jutil.jdk.result.WebResult;
+import cn.reghao.jutil.jdk.security.DigestUtil;
+import cn.reghao.jutil.web.ServletUtil;
+import cn.reghao.jutil.auth.model.OssPayload;
+import com.github.benmanes.caffeine.cache.Cache;
+import org.apache.commons.io.FileUtils;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.File;
+import java.util.UUID;
+
+/**
+ * 对象上传接口
+ *
+ * @author reghao
+ * @date 2023-05-19 16:20:12
+ */
+@RestController
+public class ObjectUploadController {
+    private final ChannelValidateService channelValidateService;
+    private final FileStoreService fileStoreService;
+    private final ObjectNameService objectNameService;
+    private final PutObjectService putObjectService;
+    private final ConsoleServiceWrapper consoleServiceWrapper;
+    private final Cache<String, String> cache;
+
+    public ObjectUploadController(ChannelValidateService channelValidateService, FileStoreService fileStoreService,
+                                  ObjectNameService objectNameService, PutObjectService putObjectService,
+                                  ConsoleServiceWrapper consoleServiceWrapper, Cache<String, String> cache) {
+        this.channelValidateService = channelValidateService;
+        this.fileStoreService = fileStoreService;
+        this.objectNameService = objectNameService;
+        this.putObjectService = putObjectService;
+        this.consoleServiceWrapper = consoleServiceWrapper;
+        this.cache = cache;
+    }
+
+    // 使用 PUT 方法上传对象
+    @PutMapping(value = "/**")
+    public String putObject(@RequestBody File file) {
+        return WebResult.failWithMsg("not implement");
+    }
+
+    // 使用 POST 方法上传对象
+    @PostMapping(value = "/", produces = MediaType.APPLICATION_JSON_VALUE)
+    public ResponseEntity<String> postObject(MultipartFile file, Integer channelCode, String objectName,
+                                             String client, String sha256sum) throws Exception {
+        /* permission check */
+        String token = ServletUtil.getBearerToken();
+        if (token == null) {
+            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
+                    .body(WebResult.failWithMsg("no token in request"));
+        }
+
+        String secretKey = cache.getIfPresent(token);
+        OssPayload ossPayload = JwtUtil.getOssPayload(token, secretKey);
+        String action = ossPayload.getAction();
+        if (!"upload".equals(action)) {
+            return ResponseEntity.status(HttpStatus.FORBIDDEN)
+                    .body(WebResult.failWithMsg("it's not upload token"));
+        }
+
+        int channelCode1 = ossPayload.getChannelCode();
+        if (channelCode != channelCode1) {
+            return ResponseEntity.status(HttpStatus.FORBIDDEN)
+                    .body(WebResult.failWithMsg("channel not match in token"));
+        }
+
+        int loginUser = ossPayload.getUserId();
+        ObjectChannel objectChannel = consoleServiceWrapper.getChannelByCode(loginUser, channelCode);
+        if (objectChannel == null) {
+            String errMsg = String.format("channel validate failed, channel %s not exist", channelCode);
+            return ResponseEntity.status(HttpStatus.FORBIDDEN)
+                    .body(WebResult.failWithMsg(errMsg));
+        }
+        AuthContext context = new AuthContext(loginUser);
+
+        /* channel validate */
+        String contentId = UUID.randomUUID().toString().replace("-", "");
+        long size = file.getSize();
+        File savedFile = fileStoreService.saveFile(file.getInputStream(), contentId, size);
+        Result result = channelValidateService.validateFile(savedFile, objectChannel);
+        if (result.getCode() != 0) {
+            FileUtils.deleteQuietly(savedFile);
+            return ResponseEntity.status(HttpStatus.FORBIDDEN).body(WebResult.result(result));
+        }
+
+        /* store file */
+        String sha256sum1 = sha256sum;
+        if (sha256sum1 == null) {
+            sha256sum1 = DigestUtil.sha256sum(savedFile.getAbsolutePath());
+        }
+
+        String filename = file.getOriginalFilename();
+        if (filename == null) {
+            filename = "";
+        }
+
+        ObjectProp objectProp = objectNameService.getObjectProp(objectChannel, filename);
+        ObjectResult objectResult = putObjectService.putObject(objectProp, contentId, savedFile, filename, sha256sum1);
+        String objectId = objectResult.getObjectId();
+        String domain = ServletUtil.getHeader("host");
+        String objectUrl = String.format("//%s/%s", domain, objectResult.getObjectName());
+        UploadFileRet uploadFileRet;
+        if (objectChannel.isSetUrl()) {
+            uploadFileRet = new UploadFileRet(objectId, objectUrl);
+        } else {
+            uploadFileRet = new UploadFileRet(objectId);
+        }
+
+        return ResponseEntity.status(HttpStatus.OK).body(WebResult.success(uploadFileRet));
+    }
+
+    // 删除对象
+    @DeleteMapping(value = "/")
+    public ResponseEntity<String> deleteObject(String objectId) {
+        //putObjectService.deleteObject(objectId);
+        return ResponseEntity.ok().build();
+    }
+}

+ 18 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/db/mapper/DataBlockMapper.java

@@ -0,0 +1,18 @@
+package cn.reghao.tnb.oss.store.db.mapper;
+
+import cn.reghao.tnb.oss.store.model.po.DataBlock;
+import cn.reghao.jutil.jdk.db.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2022-11-24 11:11:24
+ */
+@Mapper
+public interface DataBlockMapper extends BaseMapper<DataBlock> {
+    List<DataBlock> findDataBlocks(@Param("pageSize") int pageSize, @Param("nextId") int nextId);
+    DataBlock findByContentId(String contentId);
+}

+ 36 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/db/mapper/FileMetaMapper.java

@@ -0,0 +1,36 @@
+package cn.reghao.tnb.oss.store.db.mapper;
+
+import cn.reghao.jutil.jdk.db.Page;
+import cn.reghao.tnb.oss.api.dto.ObjectInfo;
+import cn.reghao.tnb.oss.store.model.po.FileMeta;
+import cn.reghao.tnb.oss.api.dto.ObjectMeta;
+import cn.reghao.jutil.jdk.db.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2022-11-24 11:11:06
+ */
+@Mapper
+public interface FileMetaMapper extends BaseMapper<FileMeta> {
+    void updateScopeByObjectName(@Param("scope") int scope, @Param("objectName") String objectName);
+    void updateObjectDeleted(FileMeta fileMeta);
+
+    FileMeta findBySha256sum(String sha256sum);
+    FileMeta findByObjectName(@Param("objectName") String objectName, @Param("owner") int owner);
+    FileMeta findByObjectId0(String objectId);
+    FileMeta findByObjectId(String objectId);
+    List<FileMeta> findByContentId(String contentId);
+    ObjectMeta findObjectMetaByName(@Param("objectName") String objectName, @Param("owner") int owner);
+    ObjectMeta findObjectMetaById(String objectId);
+    int countByPid(String pid);
+    List<ObjectInfo> findPidByPage(Page page, @Param("pid") String pid);
+
+    /******************************************************************************************************************/
+    List<FileMeta> findAll0(@Param("objectId") String objectId, @Param("max") Integer max, @Param("regex") String regex);
+    List<FileMeta> findAll2(@Param("objectId") String objectId, @Param("prefix") String prefix,
+                            @Param("start") String start, @Param("max") Integer max);
+}

+ 16 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/db/mapper/FileMultipartMapper.java

@@ -0,0 +1,16 @@
+package cn.reghao.tnb.oss.store.db.mapper;
+
+import cn.reghao.jutil.jdk.db.BaseMapper;
+import cn.reghao.tnb.oss.store.model.po.FileMultipart;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * @author reghao
+ * @date 2024-10-24 16:35:48
+ */
+@Mapper
+public interface FileMultipartMapper extends BaseMapper<FileMultipart> {
+    void updateSetUploaded(String sha256sum);
+    FileMultipart findBySha256sumAndUploadBy(@Param("sha256sum") String sha256sum, @Param("uploadBy") int uploadBy);
+}

+ 16 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/db/mapper/FilePartMapper.java

@@ -0,0 +1,16 @@
+package cn.reghao.tnb.oss.store.db.mapper;
+
+import cn.reghao.jutil.jdk.db.BaseMapper;
+import cn.reghao.tnb.oss.store.model.po.FilePart;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2024-10-24 16:36:13
+ */
+@Mapper
+public interface FilePartMapper extends BaseMapper<FilePart> {
+    List<FilePart> findByContentId(String contentId);
+}

+ 47 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/db/repository/FilePartRepository.java

@@ -0,0 +1,47 @@
+package cn.reghao.tnb.oss.store.db.repository;
+
+import cn.reghao.tnb.oss.store.util.AuthContext;
+import cn.reghao.tnb.oss.store.db.mapper.FilePartMapper;
+import cn.reghao.tnb.oss.store.db.mapper.FileMultipartMapper;
+import cn.reghao.tnb.oss.store.model.po.FilePart;
+import cn.reghao.tnb.oss.store.model.po.FileMultipart;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2024-10-24 17:05:20
+ */
+@Service
+public class FilePartRepository {
+    private final FileMultipartMapper fileMultipartMapper;
+    private final FilePartMapper filePartMapper;
+
+    public FilePartRepository(FileMultipartMapper fileMultipartMapper, FilePartMapper filePartMapper) {
+        this.fileMultipartMapper = fileMultipartMapper;
+        this.filePartMapper = filePartMapper;
+    }
+
+    public void saveFileParts(FileMultipart fileMultipart) {
+        fileMultipartMapper.save(fileMultipart);
+    }
+
+    public void saveFilePart(FilePart filePart) {
+        filePartMapper.save(filePart);
+    }
+
+    public void updateSetUploaded(String sha256sum) {
+        fileMultipartMapper.updateSetUploaded(sha256sum);
+    }
+
+    public List<FilePart> getFileParts(String contentId) {
+        return filePartMapper.findByContentId(contentId);
+    }
+
+    public FileMultipart getFileMultipart(String sha256sum) {
+        int loginUser = AuthContext.getUserId();
+        FileMultipart fileMultipart = fileMultipartMapper.findBySha256sumAndUploadBy(sha256sum, loginUser);
+        return fileMultipart;
+    }
+}

+ 132 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/db/repository/ObjectRepository.java

@@ -0,0 +1,132 @@
+package cn.reghao.tnb.oss.store.db.repository;
+
+import cn.reghao.jutil.jdk.db.Page;
+import cn.reghao.tnb.oss.api.dto.ObjectInfo;
+import cn.reghao.tnb.oss.store.db.mapper.DataBlockMapper;
+import cn.reghao.tnb.oss.store.db.mapper.FileMetaMapper;
+import cn.reghao.tnb.oss.store.model.po.DataBlock;
+import cn.reghao.tnb.oss.store.model.po.FileMeta;
+import cn.reghao.tnb.oss.api.dto.ObjectMeta;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.io.FileUtils;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.io.File;
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2023-05-22 16:48:40
+ */
+@Slf4j
+@Service
+public class ObjectRepository {
+    private final FileMetaMapper fileMetaMapper;
+    private final DataBlockMapper dataBlockMapper;
+
+    public ObjectRepository(FileMetaMapper fileMetaMapper, DataBlockMapper dataBlockMapper) {
+        this.fileMetaMapper = fileMetaMapper;
+        this.dataBlockMapper = dataBlockMapper;
+    }
+
+    public void saveFileMeta(FileMeta fileMeta) {
+        fileMetaMapper.save(fileMeta);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public void saveObject(FileMeta fileMeta, List<DataBlock> list) {
+        fileMetaMapper.save(fileMeta);
+        dataBlockMapper.saveAll(list);
+    }
+
+    @CacheEvict(cacheNames = "oss:store:objectMeta", key = "#objectName")
+    public void updateObjectScope(int scope, String objectName) {
+        fileMetaMapper.updateScopeByObjectName(scope, objectName);
+        log.info("evict {}", objectName);
+    }
+
+    @CacheEvict(cacheNames = "oss:store:objectMeta", key = "#fileMeta.objectName")
+    public void deleteObject(FileMeta fileMeta) {
+        String contentId = fileMeta.getContentId();
+        List<FileMeta> list = fileMetaMapper.findByContentId(contentId);
+        if (list.size() == 1) {
+            DataBlock dataBlock = dataBlockMapper.findByContentId(contentId);
+            String absolutePath = dataBlock.getAbsolutePath();
+
+            deleteObject(fileMeta, dataBlock);
+            FileUtils.deleteQuietly(new File(absolutePath));
+        } else {
+            fileMetaMapper.delete(fileMeta);
+        }
+    }
+
+    @CacheEvict(cacheNames = "oss:store:objectMeta", key = "#fileMeta.objectName")
+    public void updateFileMetaDeleted(FileMeta fileMeta) {
+        fileMetaMapper.updateObjectDeleted(fileMeta);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public void deleteObject(FileMeta fileMeta, DataBlock dataBlock) {
+        fileMetaMapper.delete(fileMeta);
+        dataBlockMapper.delete(dataBlock);
+    }
+
+    public FileMeta getByObjectName(String objectName, int owner) {
+        return fileMetaMapper.findByObjectName(objectName, owner);
+    }
+
+    public FileMeta getByObjectId(String objectId) {
+        return fileMetaMapper.findByObjectId(objectId);
+    }
+
+    public FileMeta getFileMeta(String objectId) {
+        return fileMetaMapper.findByObjectId0(objectId);
+    }
+
+    public FileMeta getBySha256sum(String sha256sum) {
+        return fileMetaMapper.findBySha256sum(sha256sum);
+    }
+
+    @Cacheable(cacheNames = "oss:store:objectMeta", key = "#objectName+'-'+#owner", unless = "#result == null")
+    public ObjectMeta getObjectMetaByName(String objectName, int owner) {
+        log.info("cache miss {}", objectName);
+        ObjectMeta objectMeta = fileMetaMapper.findObjectMetaByName(objectName, owner);
+        return objectMeta;
+    }
+
+    public ObjectMeta getObjectMetaById(String objectId) {
+        return fileMetaMapper.findObjectMetaById(objectId);
+    }
+
+    public int countByPid(String pid) {
+        return fileMetaMapper.countByPid(pid);
+    }
+
+    public List<ObjectInfo> getObjectsByPage(String pid, int pn, int ps) {
+        Page page = new Page(pn, ps);
+        List<ObjectInfo> list = fileMetaMapper.findPidByPage(page, pid);
+        return list;
+    }
+
+    @Deprecated
+    void getObjects() {
+        String bucket = "";
+        String prefix = "image/i/";
+        String startAfter = "image/cover/";
+        startAfter = "";
+        Integer maxKeys = 10;
+
+        StringBuilder regex = new StringBuilder();
+        //regex.append("^").append(prefix).append("([^/])+/?$");
+        regex.append(prefix);
+        List<FileMeta> list;
+        if (startAfter.isBlank()) {
+            list = fileMetaMapper.findAll0(bucket, maxKeys, regex.toString());
+        } else {
+            list = fileMetaMapper.findAll2(bucket, prefix, startAfter, maxKeys);
+        }
+    }
+}

+ 17 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/disk/LoadBalancer.java

@@ -0,0 +1,17 @@
+package cn.reghao.tnb.oss.store.disk;
+
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2022-03-22 13:53:08
+ */
+public class LoadBalancer {
+    public StoreDir getStoreDir(long fileSize) {
+        LocalStore localStore = LocalStores.getMaxStore(fileSize);
+        List<StoreDir> subDirs = LocalStores.getSubDirs(localStore.getStoreDir());
+        subDirs.sort(Comparator.comparingInt(StoreDir::getTotal));
+        return subDirs.get(0);
+    }
+}

+ 72 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/disk/LocalStore.java

@@ -0,0 +1,72 @@
+package cn.reghao.tnb.oss.store.disk;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * @author reghao
+ * @date 2022-05-23 15:00:59
+ */
+public class LocalStore {
+    // 表示一个磁盘分区
+    private final String diskVolume;
+    // 磁盘分区上文件存储的目录
+    private final String storeDir;
+    // 磁盘分区总容量
+    private final long totalSpace;
+    // 磁盘分区可用容量
+    private final AtomicLong availSpace;
+    private final long max;
+
+    public LocalStore(String diskVolume, String storeDir, long totalSpace, long availSpace, double maxPercent) {
+        this.diskVolume = diskVolume;
+        this.storeDir = storeDir;
+        this.totalSpace = totalSpace;
+        this.availSpace = new AtomicLong(availSpace);
+        BigDecimal bigDecimal1 = new BigDecimal(totalSpace*10);
+        BigDecimal bigDecimal2 = new BigDecimal(maxPercent*10);
+        this.max = bigDecimal1.divide(bigDecimal2, RoundingMode.DOWN).longValue();
+    }
+
+    public String getDiskVolume() {
+        return diskVolume;
+    }
+
+    public String getStoreDir() {
+        return storeDir;
+    }
+
+    public long getTotalSpace() {
+        return totalSpace;
+    }
+
+    public long getAvailSpace() {
+        return availSpace.get();
+    }
+
+    public void setCurrentAvailSpace(long currentAvailable) {
+        availSpace.getAndSet(currentAvailable);
+    }
+    
+    @Override
+    public int hashCode() {
+        int result = 17;
+        result = result * 31 + diskVolume.hashCode();
+        return result;
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (other == this) {
+            return true;
+        }
+
+        if (other instanceof LocalStore) {
+            LocalStore o = (LocalStore) other;
+            return o.diskVolume.equals(diskVolume);
+        } else {
+            return false;
+        }
+    }
+}

+ 84 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/disk/LocalStores.java

@@ -0,0 +1,84 @@
+package cn.reghao.tnb.oss.store.disk;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.FileStore;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
+
+/**
+ * @author reghao
+ * @date 2022-05-23 18:21:22
+ */
+public class LocalStores {
+    private static final Map<String, LocalStore> storeMap = new HashMap<>();
+    private static final Map<String, Map<String, StoreDir>> storeDirMap = new HashMap<>();
+
+    public static void init(List<String> storeDirs) throws IOException {
+        Map<String, String> map = new HashMap<>();
+        for (String storeDir : storeDirs) {
+            FileStore fileStore = Files.getFileStore(Path.of(storeDir));
+            String logicalDisk = fileStore.name();
+            String prevValue = map.putIfAbsent(logicalDisk, storeDir);
+            if (prevValue == null) {
+                long total = fileStore.getTotalSpace();
+                long available = fileStore.getUsableSpace();
+                LocalStore localStore = new LocalStore(logicalDisk, storeDir, total, available, 0.9);
+                storeMap.put(storeDir, localStore);
+                storeDirMap.computeIfAbsent(storeDir, k -> new HashMap<>());
+                createSubDirs(storeDir);
+            } else {
+                String msg = String.format("%s's mounted directory %s has already associated with %s", logicalDisk, storeDir, prevValue);
+                System.out.println(msg);
+            }
+        }
+    }
+
+    private static void createSubDirs(String storeDir) throws IOException {
+        int total = 128;
+        for (int i = 0; i < total; i++) {
+            for (int j = 0; j < total; j++) {
+                String baseDir = String.format("%s/%s/%s/", storeDir, i, j);
+                File dir = new File(baseDir);
+                if (!dir.exists() && !dir.mkdirs()) {
+                    String msg = String.format("create %s failed", dir);
+                    throw new IOException(msg);
+                }
+
+                String baseDir1 = dir.getAbsolutePath();
+                storeDirMap.get(storeDir).put(baseDir1, new StoreDir(baseDir1));
+            }
+        }
+    }
+
+    // TODO 优化算法, 处理异常
+    public static LocalStore getMaxStore(long size) {
+        Map<String, Long> map = new HashMap<>();
+        for (Map.Entry<String, LocalStore> entry : storeMap.entrySet()) {
+            String storeDir = entry.getKey();
+            LocalStore localStore = entry.getValue();
+            long currentAvailable = localStore.getAvailSpace() - size;
+            map.put(storeDir, currentAvailable);
+        }
+
+        List<String> storeDirs = new ArrayList<>();
+        // storeDirs 中的元素升序排列
+        map.entrySet().stream()
+                .sorted(Map.Entry.comparingByValue())
+                .forEachOrdered(b -> storeDirs.add(b.getKey()));
+
+        String maxDisk = storeDirs.get(storeDirs.size()-1);
+        LocalStore localStore = storeMap.get(maxDisk);
+        localStore.setCurrentAvailSpace(map.get(maxDisk));
+        return localStore;
+    }
+    
+    public static List<StoreDir> getSubDirs(String storeDir) {
+        return new ArrayList<>(storeDirMap.get(storeDir).values());
+    }
+
+    public static List<LocalStore> getLocalStores() {
+        return new ArrayList<>(storeMap.values());
+    }
+}

+ 37 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/disk/StoreDir.java

@@ -0,0 +1,37 @@
+package cn.reghao.tnb.oss.store.disk;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * @author reghao
+ * @date 2022-05-23 23:31:21
+ */
+public class StoreDir {
+    private final String baseDir;
+    private final AtomicInteger total;
+
+    public StoreDir(String baseDir) {
+        this.baseDir = baseDir;
+        this.total = new AtomicInteger(0);
+    }
+
+    public String getBaseDir() {
+        return baseDir;
+    }
+
+    public void setTotal(int total) {
+        this.total.setPlain(total);
+    }
+
+    public int getTotal() {
+        return total.get();
+    }
+
+    public void incr() {
+        this.total.incrementAndGet();
+    }
+
+    public void decr() {
+        this.total.decrementAndGet();
+    }
+}

+ 26 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/disk/SubDirCount.java

@@ -0,0 +1,26 @@
+package cn.reghao.tnb.oss.store.disk;
+
+/**
+ * @author reghao
+ * @date 2023-11-01 21:38:59
+ */
+public class SubDirCount {
+    private String relativeDir;
+    private int total;
+
+    public void setRelativeDir(String relativeDir) {
+        this.relativeDir = relativeDir;
+    }
+
+    public String getRelativeDir() {
+        return relativeDir;
+    }
+
+    public void setTotal(int total) {
+        this.total = total;
+    }
+
+    public int getTotal() {
+        return total;
+    }
+}

+ 15 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/model/dto/ContentRange.java

@@ -0,0 +1,15 @@
+package cn.reghao.tnb.oss.store.model.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * @author reghao
+ * @date 2022-11-28 11:27:27
+ */
+@AllArgsConstructor
+@Getter
+public class ContentRange {
+    private long start;
+    private long end;
+}

+ 35 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/model/po/DataBlock.java

@@ -0,0 +1,35 @@
+package cn.reghao.tnb.oss.store.model.po;
+
+import cn.reghao.jutil.jdk.db.BaseObject;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/**
+ * @author reghao
+ * @date 2022-11-24 10:25:18
+ */
+@NoArgsConstructor
+@Setter
+@Getter
+public class DataBlock extends BaseObject<Integer> {
+    private String contentId;
+    private String blockId;
+    private String host;
+    private String absolutePath;
+    private long size;
+    //private String baseDir;
+    //private String relativeDir;
+    //private int index;
+    //private long start;
+    //private long end;
+    //private String objectId;
+
+    public DataBlock(String contentId, String blockId, String host, String absolutePath, long size) {
+        this.contentId = contentId;
+        this.blockId = blockId;
+        this.host = host;
+        this.absolutePath = absolutePath;
+        this.size = size;
+    }
+}

+ 83 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/model/po/FileMeta.java

@@ -0,0 +1,83 @@
+package cn.reghao.tnb.oss.store.model.po;
+
+import cn.reghao.tnb.oss.store.util.AuthContext;
+import cn.reghao.jutil.jdk.db.BaseObject;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/**
+ * 文件元数据
+ *
+ * @author reghao
+ * @date 2022-11-21 10:53:10
+ */
+@NoArgsConstructor
+@Setter
+@Getter
+public class FileMeta extends BaseObject<Integer> {
+    private String objectName;
+    private String objectId;
+    private String pid;
+    private String contentId;
+    private String sha256sum;
+    private String filename;
+    private Long size;
+    private Integer fileType;
+    private String contentType;
+    private Integer scope;
+    private Integer uploadBy;
+
+    // 目录对象
+    public FileMeta(int owner, String objectName, String objectId, String filename, String pid, int scope) {
+        this.objectName = objectName;
+        this.objectId = objectId;
+        this.contentId = "0";
+        this.filename = filename;
+        this.size = 0L;
+        this.fileType = 1000;
+        this.contentType = "0";
+        this.sha256sum = "0";
+        this.pid = pid;
+        this.uploadBy = owner;
+        this.scope = scope;
+    }
+
+    public FileMeta(String objectName, String objectId, String contentId, String filename, long size,
+                    int fileType, String contentType, String sha256sum, String pid, int scope, boolean deleted) {
+        this.objectName = objectName;
+        this.objectId = objectId;
+        this.contentId = contentId;
+        this.filename = filename;
+        this.size = size;
+        this.fileType = fileType;
+        this.contentType = contentType;
+        this.sha256sum = sha256sum;
+        this.pid = pid;
+        this.uploadBy = AuthContext.getUserId();
+        this.scope = scope;
+        this.deleted = deleted;
+    }
+
+    /**
+     * 复制已存在的 object 时调用
+     *
+     * @param
+     * @return
+     * @date 2025-10-25 13:10:924
+     */
+    public FileMeta(String objectName, String objectId, String filename, FileMeta fileMeta, String pid, int scope, boolean deleted) {
+        this.objectName = objectName;
+        this.objectId = objectId;
+        this.contentId = fileMeta.getContentId();
+        this.filename = filename;
+        this.size = fileMeta.getSize();
+        this.fileType = fileMeta.getFileType();
+        this.contentType = fileMeta.getContentType();
+        this.sha256sum = fileMeta.getSha256sum();
+        this.pid = pid;
+        this.uploadBy = AuthContext.getUserId();
+        this.scope = scope;
+        this.deleted = deleted;
+    }
+}

+ 35 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/model/po/FileMultipart.java

@@ -0,0 +1,35 @@
+package cn.reghao.tnb.oss.store.model.po;
+
+import cn.reghao.jutil.jdk.db.BaseObject;
+import cn.reghao.tnb.oss.store.util.AuthContext;
+import cn.reghao.tnb.oss.api.rest.UploadFilePart;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * @author reghao
+ * @date 2024-10-24 16:33:16
+ */
+@NoArgsConstructor
+@Getter
+public class FileMultipart extends BaseObject<Integer> {
+    private String contentId;
+    private String sha256sum;
+    private String absolutePath;
+    private Long totalSize;
+    private Integer chunkSize;
+    private Integer totalChunks;
+    private Boolean uploaded;
+    private Integer uploadBy;
+
+    public FileMultipart(String contentId, UploadFilePart uploadFilePart, String absolutePath) {
+        this.contentId = contentId;
+        this.sha256sum = uploadFilePart.getIdentifier();
+        this.absolutePath = absolutePath;
+        this.totalSize = uploadFilePart.getTotalSize();
+        this.chunkSize = (int)uploadFilePart.getChunkSize();
+        this.totalChunks = (int)uploadFilePart.getTotalChunks();
+        this.uploaded = false;
+        this.uploadBy = AuthContext.getUserId();
+    }
+}

+ 24 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/model/po/FilePart.java

@@ -0,0 +1,24 @@
+package cn.reghao.tnb.oss.store.model.po;
+
+import cn.reghao.jutil.jdk.db.BaseObject;
+import cn.reghao.tnb.oss.api.rest.UploadFilePart;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * @author reghao
+ * @date 2024-10-24 16:33:24
+ */
+@NoArgsConstructor
+@Getter
+public class FilePart extends BaseObject<Integer> {
+    private String contentId;
+    private Integer chunkNumber;
+    private Integer currentChunkSize;
+
+    public FilePart(String contentId, UploadFilePart uploadFilePart) {
+        this.contentId = contentId;
+        this.chunkNumber = uploadFilePart.getChunkNumber();
+        this.currentChunkSize = uploadFilePart.getCurrentChunkSize();
+    }
+}

+ 13 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/model/vo/ImageObject.java

@@ -0,0 +1,13 @@
+package cn.reghao.tnb.oss.store.model.vo;
+
+import lombok.Getter;
+
+/**
+ * @author reghao
+ * @date 2023-09-10 18:02:28
+ */
+@Getter
+public class ImageObject {
+    private String imageFileId;
+    private String objectId;
+}

+ 17 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/model/vo/ObjectProp.java

@@ -0,0 +1,17 @@
+package cn.reghao.tnb.oss.store.model.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * @author reghao
+ * @date 2023-06-02 16:13:50
+ */
+@AllArgsConstructor
+@Getter
+public class ObjectProp {
+    private String objectName;
+    private int scope;
+    private String pid;
+    private boolean setCallback;
+}

+ 36 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/model/vo/ObjectResult.java

@@ -0,0 +1,36 @@
+package cn.reghao.tnb.oss.store.model.vo;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author reghao
+ * @date 2023-06-11 01:29:47
+ */
+@Setter
+@Getter
+public class ObjectResult {
+    private String objectName;
+    private String objectId;
+    private int fileType;
+    private String absolutePath;
+    private boolean duplicate;
+    private String dupObjectId;
+
+    public ObjectResult(String objectName, String objectId, int fileType, String absolutePath) {
+        this.objectName = objectName;
+        this.objectId = objectId;
+        this.fileType = fileType;
+        this.absolutePath = absolutePath;
+        this.duplicate = false;
+    }
+
+    public ObjectResult(String objectName, String objectId, int fileType, String absolutePath, String dupObjectId) {
+        this.objectName = objectName;
+        this.objectId = objectId;
+        this.fileType = fileType;
+        this.absolutePath = absolutePath;
+        this.duplicate = true;
+        this.dupObjectId = dupObjectId;
+    }
+}

+ 228 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/rpc/StoreServiceImpl.java

@@ -0,0 +1,228 @@
+package cn.reghao.tnb.oss.store.rpc;
+
+import cn.reghao.jutil.jdk.db.PageList;
+import cn.reghao.jutil.auth.model.OssPayload;
+import cn.reghao.jutil.jdk.security.RandomString;
+import cn.reghao.tnb.oss.api.constant.ChannelAction;
+import cn.reghao.tnb.oss.api.constant.ObjectScope;
+import cn.reghao.tnb.oss.api.dto.disk.DiskVolume;
+import cn.reghao.tnb.oss.api.dto.ObjectInfo;
+import cn.reghao.tnb.oss.api.dto.media.AudioInfo;
+import cn.reghao.tnb.oss.api.dto.media.ConvertedImageInfo;
+import cn.reghao.tnb.oss.api.dto.media.ImageInfo;
+import cn.reghao.tnb.oss.api.dto.media.VideoInfo;
+import cn.reghao.jutil.auth.JwtUtil;;
+import cn.reghao.tnb.oss.store.db.repository.ObjectRepository;
+import cn.reghao.tnb.oss.store.model.po.FileMeta;
+import cn.reghao.tnb.oss.api.dto.ObjectMeta;
+import cn.reghao.tnb.oss.store.service.DiskService;
+import cn.reghao.tnb.oss.store.service.FileStoreService;
+import cn.reghao.tnb.oss.store.service.ObjectNameService;
+import cn.reghao.tnb.oss.api.iface.StoreService;
+import cn.reghao.tnb.oss.store.service.SignService;
+import cn.reghao.tnb.oss.store.task.MediaFileProcessor;
+import com.github.benmanes.caffeine.cache.Cache;
+import org.apache.dubbo.config.annotation.DubboService;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 获取存储节点信息
+ * oss-console 调用
+ *
+ * @author reghao
+ * @date 2023-08-01 14:54:23
+ */
+@DubboService
+@Service
+public class StoreServiceImpl implements StoreService {
+    private final ObjectNameService objectNameService;
+    private final FileStoreService fileStoreService;
+    private final ObjectRepository objectRepository;
+    private final SignService signService;
+    private final MediaFileProcessor mediaFileProcessor;
+    private final Cache<String, String> cache;
+    private final DiskService diskService;
+
+    public StoreServiceImpl(ObjectNameService objectNameService, FileStoreService fileStoreService,
+                            ObjectRepository objectRepository, SignService signService,
+                            MediaFileProcessor mediaFileProcessor, Cache<String, String> cache,
+                            DiskService diskService) {
+        this.objectNameService = objectNameService;
+        this.fileStoreService = fileStoreService;
+        this.objectRepository = objectRepository;
+        this.signService = signService;
+        this.mediaFileProcessor = mediaFileProcessor;
+        this.cache = cache;
+        this.diskService = diskService;
+    }
+
+    @Override
+    public List<DiskVolume> getDiskVolumes() {
+        Map<String, DiskVolume> map = diskService.getFileStore().stream()
+                .collect(Collectors.toMap(DiskVolume::getVolume, k -> k));
+        List<DiskVolume> list = fileStoreService.getStoreDisks().stream()
+                .map(localStore -> {
+                    String volume = localStore.getDiskVolume();
+                    String storeDir = localStore.getStoreDir();
+                    DiskVolume diskVolume = map.get(volume);
+                    diskVolume.setStoreDir(storeDir);
+                    return diskVolume;
+                })
+                .collect(Collectors.toList());
+        return list;
+    }
+
+    @Override
+    public String getUploadToken(int channelCode, int owner, int expire) {
+        String secretKey = RandomString.getString(128);
+        String action = ChannelAction.upload.getName();
+        long expireAt = System.currentTimeMillis() + expire*1000L;
+        OssPayload ossPayload = new OssPayload(channelCode, action, owner);
+        String uploadToken = JwtUtil.createOssToken(ossPayload, expireAt, secretKey);
+        cache.put(uploadToken, secretKey);
+        return uploadToken;
+    }
+
+    @Override
+    public void createChannel(int owner, String channelPrefix, int scope) {
+        ObjectScope objectScope = ObjectScope.getByCode(scope);
+        if (objectScope == null) {
+        }
+        objectNameService.createParentDirs(owner, channelPrefix, scope);
+    }
+
+    @Override
+    public int countChannelObjects(String channelPrefix, int owner) {
+        FileMeta fileMeta = objectRepository.getByObjectName(channelPrefix, owner);
+        int total = 0;
+        if (fileMeta != null) {
+            total = objectRepository.countByPid(fileMeta.getObjectId());
+        }
+
+        return total;
+    }
+
+    @Override
+    public void setObjectUpload(String objectId) {
+        FileMeta fileMeta = objectRepository.getFileMeta(objectId);
+        if (fileMeta != null) {
+            fileMeta.setDeleted(false);
+            objectRepository.updateFileMetaDeleted(fileMeta);
+        }
+    }
+
+    @Override
+    public void setObjectScope(String objectId, int scope) {
+        FileMeta fileMeta = objectRepository.getByObjectId(objectId);
+        objectRepository.updateObjectScope(scope, fileMeta.getObjectName());
+    }
+
+    @Override
+    public void deleteByObjectId(String objectId) {
+        FileMeta fileMeta = objectRepository.getByObjectId(objectId);
+        if (fileMeta != null) {
+            fileMeta.setDeleted(true);
+            objectRepository.updateFileMetaDeleted(fileMeta);
+        }
+    }
+
+    @Override
+    public void deleteByObjectName(String objectName, int owner) {
+        FileMeta fileMeta = objectRepository.getByObjectName(objectName, owner);
+        if (fileMeta != null) {
+            fileMeta.setDeleted(true);
+            objectRepository.updateFileMetaDeleted(fileMeta);
+        }
+    }
+
+    @Override
+    public ObjectInfo getObjectInfo(String objectId) throws Exception {
+        FileMeta fileMeta = objectRepository.getByObjectId(objectId);
+        if (fileMeta == null) {
+            String errMsg = String.format("%s not exist in oss-store", objectId);
+            throw new Exception(errMsg);
+        }
+
+        return getObjectInfo(fileMeta);
+    }
+
+    private ObjectInfo getObjectInfo(FileMeta fileMeta) {
+        String objectId = fileMeta.getObjectId();
+        String objectName = fileMeta.getObjectName();
+        int fileType = fileMeta.getFileType();
+        String filename = fileMeta.getFilename();
+        long size = fileMeta.getSize();
+        int scope = fileMeta.getScope();
+        String sha256sum = fileMeta.getSha256sum();
+        return new ObjectInfo(objectId, objectName, fileType, filename, size, scope, sha256sum);
+    }
+
+    @Override
+    public String getSignedUrl(String domain, int owner, String objectId, int expire) throws Exception {
+        ObjectMeta objectMeta = objectRepository.getObjectMetaById(objectId);
+        if (objectMeta == null) {
+            String errMsg = String.format("objectId %s not exist in oss-store", objectId);
+            throw new Exception(errMsg);
+        }
+
+        String url = String.format("//%s/%s", domain, objectMeta.getObjectName());
+        return signService.getSignedUrl(owner, url, expire);
+    }
+
+    @Override
+    public String getSignedUrl(String objectUrl, int owner, int expire) throws Exception {
+        // //oss.reghao.cn/file/abc/def
+        String objectUrl1 = objectUrl.replace("//", "");
+        int idx = objectUrl1.indexOf("/");
+        String domain = objectUrl1.substring(0, idx);
+        String objectName = objectUrl1.substring(idx+1);
+        ObjectMeta objectMeta = objectRepository.getObjectMetaByName(objectName, owner);
+        if (objectMeta == null) {
+            String errMsg = String.format("objectName %s not exist in oss-store", objectName);
+            throw new Exception(errMsg);
+        }
+        return signService.getSignedUrl(owner, objectUrl, expire);
+    }
+
+    @Override
+    public VideoInfo getVideoInfo(String objectId) throws Exception {
+        VideoInfo videoInfo = mediaFileProcessor.getVideoInfo(objectId);
+        return videoInfo;
+    }
+
+    @Override
+    public ImageInfo getImageInfo(String objectId) throws Exception {
+        ImageInfo imageInfo = mediaFileProcessor.getImageInfo(objectId);
+        return imageInfo;
+    }
+
+    @Override
+    public ConvertedImageInfo getWebpInfo(String objectId) throws Exception {
+        return mediaFileProcessor.getWebpInfo(objectId);
+    }
+
+    @Override
+    public AudioInfo getAudioInfo(String objectId) throws Exception {
+        AudioInfo audioInfo = mediaFileProcessor.getAudioInfo(objectId);
+        return audioInfo;
+    }
+
+    @Override
+    public PageList<ObjectInfo> getByPrefix(int owner, String objectName, int pn, int ps) {
+        String pid = "0";
+        if (!objectName.isBlank()) {
+            FileMeta fileMeta = objectRepository.getByObjectName(objectName, owner);
+            if (fileMeta != null) {
+                pid = fileMeta.getObjectId();
+            }
+        }
+
+        int total = objectRepository.countByPid(pid);
+        List<ObjectInfo> list = objectRepository.getObjectsByPage(pid, pn, ps);
+        return PageList.pageList(pn, ps, total, list);
+    }
+}

+ 48 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/service/ChannelValidateService.java

@@ -0,0 +1,48 @@
+package cn.reghao.tnb.oss.store.service;
+
+import cn.reghao.tnb.oss.api.constant.ObjectType;
+import cn.reghao.tnb.oss.api.dto.ObjectChannel;
+import cn.reghao.tnb.oss.store.util.FileType;
+import cn.reghao.jutil.jdk.result.Result;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.io.File;
+import java.util.Locale;
+
+/**
+ * @author reghao
+ * @date 2023-07-10 17:03:55
+ */
+@Slf4j
+@Service
+public class ChannelValidateService {
+    public Result validateFile(File file, ObjectChannel objectChannel) {
+        int fileType = objectChannel.getFileType();
+        ObjectType objectType = ObjectType.getByCode(fileType);
+        if (objectType == null) {
+            String errMsg = String.format("channel validate failed, file_type %s not exist", fileType);
+            return Result.fail(errMsg);
+        }
+
+        long maxSize = objectChannel.getMaxSize();
+        long len = file.length();
+        if (len > maxSize) {
+            String errMsg = String.format("channel validate failed, the size of %s file bigger than %s bytes", objectType.name(), maxSize);
+            return Result.fail(errMsg);
+        }
+
+        if (fileType == ObjectType.Any.getCode()) {
+            return Result.success();
+        }
+
+        String contentType = ObjectType.getDescByCode(fileType).toLowerCase(Locale.ROOT);
+        String mediaType = FileType.getMediaType(file.getAbsolutePath());
+        if (!mediaType.startsWith(contentType)) {
+            String errMsg = String.format("channel validate failed, the format of file is not %s", contentType);
+            return Result.fail(errMsg);
+        }
+
+        return Result.success();
+    }
+}

+ 45 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/service/ConsoleServiceWrapper.java

@@ -0,0 +1,45 @@
+package cn.reghao.tnb.oss.store.service;
+
+import cn.reghao.tnb.oss.api.dto.NodeProperties;
+import cn.reghao.tnb.oss.api.dto.ObjectChannel;
+import cn.reghao.tnb.oss.api.dto.StoreNodeDto;
+import cn.reghao.tnb.oss.api.iface.ConsoleService;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.PostConstruct;
+
+/**
+ * 对 rpc 服务 ConsoleService 的一个包装, 目前只是简单调用 ConsoleService 的接口, 后期可添加缓存减少对 rpc 接口的调用
+ *
+ * @author reghao
+ * @date 2024-12-09 10:39:08
+ */
+@Service
+public class ConsoleServiceWrapper {
+    private final ConsoleService consoleService;
+
+    public ConsoleServiceWrapper(ConsoleService consoleService) {
+        this.consoleService = consoleService;
+    }
+
+    @PostConstruct
+    public void initCache() {
+    }
+
+    public void registerNode(StoreNodeDto storeNodeDto) {
+        consoleService.registerNode(storeNodeDto);
+    }
+
+    public ObjectChannel getChannelByCode(int owner, int channelCode) {
+        return consoleService.getChannelByCode(owner, channelCode);
+    }
+
+    public NodeProperties getNodeProperties(String domain) {
+        NodeProperties nodeProperties = consoleService.getNodeProperties(domain);
+        return nodeProperties;
+    }
+
+    public Integer getChannelCodeByUrl(int owner, String url) {
+        return consoleService.getChannelCodeByUrl(owner, url);
+    }
+}

+ 66 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/service/DiskService.java

@@ -0,0 +1,66 @@
+package cn.reghao.tnb.oss.store.service;
+
+import cn.reghao.tnb.oss.api.dto.disk.DiskPartition;
+import cn.reghao.tnb.oss.api.dto.disk.DiskStore;
+import cn.reghao.tnb.oss.api.dto.disk.DiskVolume;
+import org.springframework.stereotype.Service;
+import oshi.SystemInfo;
+import oshi.hardware.HardwareAbstractionLayer;
+import oshi.software.os.FileSystem;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * @author reghao
+ * @date 2024-10-21 09:57:18
+ */
+@Service
+public class DiskService {
+    private final HardwareAbstractionLayer hal;
+    private final FileSystem fs;
+
+    public DiskService() {
+        SystemInfo si = new oshi.SystemInfo();
+        this.hal = si.getHardware();
+        this.fs = si.getOperatingSystem().getFileSystem();
+    }
+
+    public List<DiskVolume> getFileStore() {
+        List<DiskVolume> list = fs.getFileStores().stream()
+                .filter(osFileStore -> !osFileStore.getUUID().isBlank())
+                .map(osFileStore -> {
+                    String name = osFileStore.getName();
+                    String volume = osFileStore.getVolume();
+                    String mountPoint = osFileStore.getMount();
+                    String blockId = osFileStore.getUUID();
+                    String fsType = osFileStore.getType();
+                    long totalSpace = osFileStore.getTotalSpace();
+                    long usableSpace = osFileStore.getUsableSpace();
+                    long freeSpace = osFileStore.getFreeSpace();
+                    long totalInodes = osFileStore.getTotalInodes();
+                    long freeInodes = osFileStore.getFreeInodes();
+                    return new DiskVolume(name, volume, mountPoint, fsType, blockId,
+                            totalSpace, usableSpace, totalInodes, freeInodes);
+                }).collect(Collectors.toList());
+        return list;
+    }
+
+    public List<DiskStore> getDiskStore() {
+        List<DiskStore> diskStores = hal.getDiskStores().stream().map(hwDiskStore -> {
+            String model = hwDiskStore.getModel();
+            String name = hwDiskStore.getName();
+            long size = hwDiskStore.getSize();
+            List<DiskPartition> partitions = hwDiskStore.getPartitions().stream().map(hwPartition -> {
+                String partitionName = hwPartition.getIdentification();
+                String mountPoint = hwPartition.getMountPoint();
+                String blockId = hwPartition.getUuid();
+                String fsType = hwPartition.getType();
+                long partitionSize = hwPartition.getSize();
+                return new DiskPartition(partitionName, mountPoint, blockId, fsType, partitionSize);
+            }).collect(Collectors.toList());
+            return new DiskStore(model, name, size, partitions);
+        }).collect(Collectors.toList());
+        return diskStores;
+    }
+}

+ 161 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/service/FileStoreService.java

@@ -0,0 +1,161 @@
+package cn.reghao.tnb.oss.store.service;
+
+import cn.reghao.tnb.oss.store.disk.LoadBalancer;
+import cn.reghao.tnb.oss.store.disk.LocalStore;
+import cn.reghao.tnb.oss.store.disk.LocalStores;
+import cn.reghao.tnb.oss.store.disk.StoreDir;
+import cn.reghao.tnb.oss.store.config.props.OssProperties;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.io.FileUtils;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.PostConstruct;
+import java.io.*;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.util.*;
+
+import static java.nio.file.StandardOpenOption.*;
+
+/**
+ * @author reghao
+ * @date 2022-04-26 15:09:06
+ */
+@Slf4j
+@Service
+public class FileStoreService {
+    private final LoadBalancer loadBalancer;
+    private final OssProperties ossProperties;
+
+    public FileStoreService(OssProperties ossProperties) {
+        this.loadBalancer = new LoadBalancer();
+        this.ossProperties = ossProperties;
+    }
+
+    @PostConstruct
+    public void initLocalStore() throws IOException {
+        log.info("初始化本地磁盘...");
+        List<String> storeDirs = ossProperties.getStoreDirs();
+        for (String storeDir : storeDirs) {
+            File dir = new File(storeDir);
+            if (!dir.exists()) {
+                dir.mkdirs();
+            }
+        }
+        LocalStores.init(storeDirs);
+
+        /*for (String diskDir : storeDirs) {
+            Map<String, Integer> map = new HashMap<>();
+            List<SubDirCount> list = dataBlockMapper.findSubDirCount("");
+            list.forEach(subDirCount -> {
+                String relativeDir = subDirCount.getRelativeDir();
+                String storeDir = diskDir + relativeDir;
+                File file = new File(storeDir);
+
+                int total = subDirCount.getTotal();
+                map.put(file.getAbsolutePath(), total);
+            });
+
+            LocalStores.initStoreDirs(diskDir, map);
+        }*/
+        log.info("本地磁盘数据初始化完成...");
+    }
+
+    // TODO 返回实时磁盘容量
+    public List<LocalStore> getStoreDisks() {
+        return LocalStores.getLocalStores();
+    }
+
+    public String genFilePath(String contentId, long size, String suffix) {
+        StoreDir storeDir = loadBalancer.getStoreDir(size);
+        String fileDir = storeDir.getBaseDir();
+        return String.format("%s/%s%s", fileDir, contentId, suffix);
+    }
+
+    public void createSparseFile(String absolutePath, long len) throws IOException {
+        File file = new File(absolutePath);
+        FileUtils.forceMkdirParent(file);
+        // 创建一个稀疏文件
+        Files.newByteChannel(Paths.get(absolutePath), EnumSet.of(CREATE_NEW, WRITE, SPARSE));
+        RandomAccessFile raf = new RandomAccessFile(file, "rw");
+        raf.setLength(len);
+        raf.close();
+    }
+
+    public void writeToFile(InputStream in, String absolutePath, long pos) throws IOException {
+        RandomAccessFile raf = new RandomAccessFile(absolutePath, "rw");
+        raf.seek(pos);
+        byte[] buf = new byte[1024*1024];
+        int len;
+        while ((len = in.read(buf)) != -1) {
+            raf.write(buf, 0, len);
+        }
+        raf.close();
+        in.close();
+    }
+
+    public File saveFile(byte[] bytes, String contentId) throws IOException {
+        String absolutePath = genFilePath(contentId, bytes.length, "");
+        File file = new File(absolutePath);
+        if (file.exists()) {
+            throw new IOException(absolutePath + " exist");
+        }
+
+        File parentDir = file.getParentFile();
+        if (!parentDir.exists()) {
+            FileUtils.forceMkdir(parentDir);
+        }
+
+        FileOutputStream fos = new FileOutputStream(file);
+        fos.write(bytes);
+        fos.close();
+        return file;
+    }
+
+    public File saveFile(File srcFile, String contentId) throws IOException {
+        String absolutePath = genFilePath(contentId, srcFile.length(), "");
+        File file = new File(absolutePath);
+        if (file.exists()) {
+            throw new IOException(absolutePath + " exist");
+        }
+
+        Files.move(Path.of(srcFile.getAbsolutePath()), Path.of(absolutePath), StandardCopyOption.REPLACE_EXISTING);
+        return file;
+    }
+
+    public File saveFile(InputStream inputStream, String contentId, long size) throws IOException {
+        String absolutePath = genFilePath(contentId, size, "");
+        File file = new File(absolutePath);
+        if (file.exists()) {
+            throw new IOException(absolutePath + " exist");
+        }
+
+        Files.copy(inputStream, Path.of(absolutePath), StandardCopyOption.REPLACE_EXISTING);
+        return file;
+    }
+
+    public void saveFile(InputStream inputStream, String absolutePath) throws IOException {
+        File file = new File(absolutePath);
+        if (file.exists()) {
+            throw new IOException(absolutePath + " exist");
+        }
+
+        File parentDir = file.getParentFile();
+        if (!parentDir.exists()) {
+            FileUtils.forceMkdir(parentDir);
+        }
+
+        FileOutputStream fos = new FileOutputStream(file);
+        // 1MB
+        int len = 1024*1024;
+        byte[] buf = new byte[len];
+        int readLen;
+        while ((readLen = inputStream.read(buf, 0, len)) != -1) {
+            fos.write(buf, 0, readLen);
+        }
+        fos.close();
+        inputStream.close();
+    }
+}

+ 238 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/service/GetObjectService.java

@@ -0,0 +1,238 @@
+package cn.reghao.tnb.oss.store.service;
+
+import cn.reghao.tnb.oss.api.dto.NodeProperties;
+import cn.reghao.tnb.oss.store.db.repository.ObjectRepository;
+import cn.reghao.tnb.oss.store.model.dto.ContentRange;
+import cn.reghao.tnb.oss.store.model.po.FileMeta;
+import cn.reghao.tnb.oss.api.dto.ObjectMeta;
+import cn.reghao.jutil.web.ServletUtil;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import javax.servlet.http.HttpServletResponse;
+import java.io.*;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * @author reghao
+ * @date 2023-05-10 13:28:18
+ */
+@Slf4j
+@Service
+public class GetObjectService {
+    // 1MB
+    private final int bufSize = 1024*1024;
+    private final ObjectRepository objectRepository;
+    private final ConsoleServiceWrapper consoleServiceWrapper;
+
+    public GetObjectService(ObjectRepository objectRepository, ConsoleServiceWrapper consoleServiceWrapper) {
+        this.objectRepository = objectRepository;
+        this.consoleServiceWrapper = consoleServiceWrapper;
+    }
+    
+    public void headObject(String objectName) throws IOException {
+        HttpServletResponse response = ServletUtil.getResponse();
+        String domain = ServletUtil.getHeader("host");
+        int owner = consoleServiceWrapper.getNodeProperties(domain).getOwner();
+        FileMeta fileMeta = objectRepository.getByObjectName(objectName, owner);
+        if (fileMeta == null) {
+            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
+        } else {
+            response.setStatus(HttpServletResponse.SC_OK);
+            String contentType = fileMeta.getContentType();
+            long contentLength = fileMeta.getSize();
+            String eTag = fileMeta.getSha256sum();
+            response.setHeader("content-type", contentType);
+            response.setHeader("content-length", ""+contentLength);
+            response.setHeader("eTag", eTag);
+        }
+
+        OutputStream outputStream = response.getOutputStream();
+        outputStream.flush();
+        outputStream.close();
+    }
+
+    public void headObject1(String sha256sum) throws IOException {
+        HttpServletResponse response = ServletUtil.getResponse();
+        FileMeta fileMeta = objectRepository.getBySha256sum(sha256sum);
+        if (fileMeta == null) {
+            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
+        } else {
+            response.setStatus(HttpServletResponse.SC_OK);
+            String contentType = fileMeta.getContentType();
+            long contentLength = fileMeta.getSize();
+            String eTag = fileMeta.getSha256sum();
+            response.setHeader("content-type", contentType);
+            response.setHeader("content-length", ""+contentLength);
+            response.setHeader("eTag", eTag);
+        }
+
+        OutputStream outputStream = response.getOutputStream();
+        outputStream.flush();
+        outputStream.close();
+    }
+
+    public void getObject(ObjectMeta objectMeta) throws IOException {
+        String absolutePath = objectMeta.getAbsolutePath();
+        File file = new File(absolutePath);
+        if (!file.exists()) {
+            writeNotFound();
+            return;
+        }
+
+        String host = ServletUtil.getRequest().getHeader("host");
+        long len = objectMeta.getSize();
+        String range = ServletUtil.getRequest().getHeader("range");
+        if (range != null) {
+            ContentRange contentRange = parseContentRange(range, len);
+            writeContentRange(objectMeta, contentRange);
+        } else {
+            String domain = host;
+            NodeProperties nodeProperties = consoleServiceWrapper.getNodeProperties(host);
+            if (nodeProperties != null) {
+                domain = nodeProperties.getDomain();
+            }
+
+            if (host.contains(domain)) {
+                writeWholeContent(objectMeta);
+            } else {
+                writeDownloadContent(objectMeta);
+            }
+        }
+    }
+
+    /*public void downloadObject(String objectName) throws IOException {
+        ObjectMeta objectMeta = objectRepository.getObjectMeta(objectName);
+        if (objectMeta == null) {
+            writeResponse(HttpServletResponse.SC_NOT_FOUND);
+            return;
+        }
+
+        writeDownloadContent(objectMeta);
+    }*/
+
+    public void writeResponse(int statusCode) throws IOException {
+        HttpServletResponse response = ServletUtil.getResponse();
+        response.setStatus(statusCode);
+        OutputStream outputStream = response.getOutputStream();
+        outputStream.flush();
+        outputStream.close();
+    }
+
+    public void writeResponse(int statusCode, String payload) throws IOException {
+        HttpServletResponse response = ServletUtil.getResponse();
+        response.setStatus(statusCode);
+        response.setContentType("text/html");
+        response.setHeader("Content-Length", ""+payload.length());
+
+        OutputStream outputStream = response.getOutputStream();
+        outputStream.write(payload.getBytes(StandardCharsets.UTF_8));
+        outputStream.flush();
+        outputStream.close();
+    }
+
+    private ContentRange parseContentRange(String range, long len) {
+        String rangeStr = StringUtils.trimAllWhitespace(range);
+        String[] arr = rangeStr.replace("bytes=", "").split("-");
+        long start = Long.parseLong(arr[0]);
+        long end;
+        if (arr.length == 2) {
+            end = Long.parseLong(arr[1]);
+        } else {
+            long l = len - start;
+            end = Math.min(l, bufSize) + start;
+        }
+
+        return new ContentRange(start, end);
+    }
+
+    private void writeContentRange(ObjectMeta objectMeta, ContentRange contentRange) throws IOException {
+        long start = contentRange.getStart();
+        long end = contentRange.getEnd();
+        long contentLength = objectMeta.getSize();
+        HttpServletResponse response = ServletUtil.getResponse();
+        response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
+        response.setContentType(objectMeta.getContentType());
+        response.setHeader("Accept-Ranges", "bytes");
+        response.setHeader("Content-Length", ""+(end-start));
+        response.setHeader("Content-Range", "bytes "+start+"-"+(end-1)+"/"+contentLength);
+
+        String absolutePath = objectMeta.getAbsolutePath();
+        OutputStream outputStream = response.getOutputStream();
+        writeResponse(outputStream, absolutePath, start, end);
+    }
+
+    public void writeDownloadContent(ObjectMeta objectMeta) throws IOException {
+        HttpServletResponse response = ServletUtil.getResponse();
+        response.setStatus(HttpServletResponse.SC_OK);
+        response.setContentType(objectMeta.getContentType());
+        response.setHeader("Content-Length", ""+objectMeta.getSize());
+        /*response.setHeader("Content-Length", ""+(len-start));
+        response.setHeader("Accept-Ranges", "bytes");
+        response.setHeader("Content-Range", "bytes "+start+"-"+(len-1)+"/"+len);*/
+        String filename = objectRepository.getByObjectId(objectMeta.getObjectId()).getFilename();
+        String value = String.format("attachment;filename=%s", URLEncoder.encode(filename, StandardCharsets.UTF_8.toString()));
+        response.setHeader("Content-Disposition", value);
+        response.setHeader("Access-Control-Expose-Headers", "Content-Disposition");
+
+        OutputStream outputStream = response.getOutputStream();
+        writeResponse(outputStream, objectMeta.getAbsolutePath(), 0, objectMeta.getSize());
+    }
+
+    private void writeWholeContent(ObjectMeta objectMeta) throws IOException {
+        HttpServletResponse response = ServletUtil.getResponse();
+        response.setStatus(HttpServletResponse.SC_OK);
+        response.setContentType(objectMeta.getContentType());
+        response.setHeader("Content-Length", ""+objectMeta.getSize());
+
+        OutputStream outputStream = response.getOutputStream();
+        writeResponse(outputStream, objectMeta.getAbsolutePath(), 0, objectMeta.getSize());
+    }
+
+    private void writeNotFound() throws IOException {
+        HttpServletResponse response = ServletUtil.getResponse();
+        response.setStatus(HttpServletResponse.SC_NOT_FOUND);
+        /*response.setContentType(objectMeta.getContentType());
+        response.setHeader("Content-Length", ""+objectMeta.getSize());*/
+
+        OutputStream outputStream = response.getOutputStream();
+        outputStream.flush();
+        outputStream.close();
+    }
+
+    private void writeResponse(OutputStream outputStream, String absolutePath, long start, long end) throws IOException {
+        RandomAccessFile raf = new RandomAccessFile(absolutePath, "r");
+        raf.seek(start);
+
+        long len = end-start+1;
+        if (len < bufSize) {
+            int len1 = (int) len;
+            byte[] buf1 = new byte[len1];
+            int readLen1 = raf.read(buf1, 0, len1);
+            outputStream.write(buf1, 0, readLen1);
+        } else {
+            byte[] buf = new byte[bufSize];
+            long totalRead = 0;
+            int readLen;
+            while ((readLen = raf.read(buf, 0, bufSize)) != -1) {
+                outputStream.write(buf, 0, readLen);
+                totalRead += readLen;
+
+                long left = len - totalRead;
+                if (left < bufSize) {
+                    int left1 = (int) left;
+                    byte[] buf1 = new byte[left1];
+                    int readLen1 = raf.read(buf1, 0, left1);
+                    outputStream.write(buf1, 0, readLen1);
+                    break;
+                }
+            }
+        }
+
+        outputStream.flush();
+        outputStream.close();
+        raf.close();
+    }
+}

+ 164 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/service/ObjectMultipartUploadService.java

@@ -0,0 +1,164 @@
+package cn.reghao.tnb.oss.store.service;
+
+import cn.reghao.jutil.web.ServletUtil;
+import cn.reghao.tnb.oss.api.dto.ObjectChannel;
+import cn.reghao.tnb.oss.api.rest.UploadFilePart;
+import cn.reghao.tnb.oss.api.rest.UploadFileRet;
+import cn.reghao.tnb.oss.api.rest.UploadedPart;
+import cn.reghao.tnb.oss.store.db.repository.FilePartRepository;
+import cn.reghao.tnb.oss.store.db.repository.ObjectRepository;
+import cn.reghao.tnb.oss.store.model.po.FilePart;
+import cn.reghao.tnb.oss.store.model.po.FileMultipart;
+import cn.reghao.tnb.oss.store.model.vo.ObjectProp;
+import cn.reghao.tnb.oss.store.model.vo.ObjectResult;
+import cn.reghao.tnb.oss.store.util.StringUtil;
+import cn.reghao.jutil.jdk.security.DigestUtil;
+import cn.reghao.tnb.oss.store.model.po.FileMeta;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.io.*;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * @author reghao
+ * @date 2023-05-19 10:09:01
+ */
+@Slf4j
+@Service
+public class ObjectMultipartUploadService {
+    private final ObjectRepository objectRepository;
+    private final FileStoreService fileStoreService;
+    private final ObjectNameService objectNameService;
+    private final PutObjectService putObjectService;
+    private final FilePartRepository filePartRepository;
+
+    public ObjectMultipartUploadService(ObjectRepository objectRepository, FileStoreService fileStoreService,
+                                        ObjectNameService objectNameService, PutObjectService putObjectService,
+                                        FilePartRepository filePartRepository) {
+        this.objectRepository = objectRepository;
+        this.fileStoreService = fileStoreService;
+        this.objectNameService = objectNameService;
+        this.putObjectService = putObjectService;
+        this.filePartRepository = filePartRepository;
+    }
+
+    public UploadedPart getUploadedPart(UploadFilePart uploadFilePart, ObjectChannel objectChannel) throws Exception {
+        String sha256sum = uploadFilePart.getIdentifier();
+        FileMeta fileMeta = objectRepository.getBySha256sum(sha256sum);
+        if (fileMeta != null) {
+            String filename = uploadFilePart.getFilename();
+            String objectName = fileMeta.getObjectName();
+            String suffix = StringUtil.getSuffix(objectName);
+
+            ObjectProp objectProp = objectNameService.getObjectProp(objectChannel, suffix);
+            ObjectResult objectResult = putObjectService.copyObject(objectProp, filename, fileMeta);
+
+            String objectId = objectResult.getObjectId();
+            String objectName1 = objectResult.getObjectName();
+            String host = ServletUtil.getHeader("host");
+            String url = String.format("//%s/%s", host, objectName1);
+            return new UploadedPart(objectId, url);
+        }
+
+        UploadedPart uploadedPart = new UploadedPart();
+        FileMultipart fileMultipart = filePartRepository.getFileMultipart(sha256sum);
+        if (fileMultipart == null) {
+            return uploadedPart;
+        }
+
+        String contentId = fileMultipart.getContentId();
+        List<Integer> list = filePartRepository.getFileParts(contentId).stream()
+                .map(FilePart::getChunkNumber)
+                .collect(Collectors.toList());
+        uploadedPart.setUploaded(list);
+        return uploadedPart;
+    }
+
+    /**
+     * 处理通过 HTTP 请求提交的分片文件
+     *
+     * @param
+     * @return
+     * @date 2023-05-19 10:27:01
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public synchronized UploadFileRet putFilePart(InputStream inputStream,
+                                                  UploadFilePart uploadFilePart,
+                                                  ObjectChannel objectChannel) throws Exception {
+        String filename = uploadFilePart.getFilename();
+        long totalSize = uploadFilePart.getTotalSize();
+        long chunkSize = uploadFilePart.getChunkSize();
+        int currentPartSize = uploadFilePart.getCurrentChunkSize();
+        long totalParts = uploadFilePart.getTotalChunks();
+        long chunkNumber = uploadFilePart.getChunkNumber();
+
+        String sha256sum = uploadFilePart.getIdentifier();
+        FileMeta fileMeta = objectRepository.getBySha256sum(sha256sum);
+        if (fileMeta != null) {
+            String objectName = fileMeta.getObjectName();
+            String suffix = StringUtil.getSuffix(objectName);
+
+            ObjectProp objectProp = objectNameService.getObjectProp(objectChannel, suffix);
+            ObjectResult objectResult = putObjectService.copyObject(objectProp, filename, fileMeta);
+
+            String objectId = objectResult.getObjectId();
+            String objectName1 = objectResult.getObjectName();
+            String host = ServletUtil.getHeader("host");
+            String url = String.format("//%s/%s", host, objectName1);
+            return new UploadFileRet(objectId, url);
+        }
+
+        List<FilePart> list = new ArrayList<>();
+        String contentId;
+        FileMultipart fileMultipart = filePartRepository.getFileMultipart(sha256sum);
+        if (fileMultipart == null) {
+            contentId = UUID.randomUUID().toString().replace("-", "");
+            String absolutePath = fileStoreService.genFilePath(contentId, totalSize, "");
+            fileStoreService.createSparseFile(absolutePath, totalSize);
+
+            fileMultipart = new FileMultipart(contentId, uploadFilePart, absolutePath);
+            filePartRepository.saveFileParts(fileMultipart);
+        } else {
+            contentId = fileMultipart.getContentId();
+            list = filePartRepository.getFileParts(contentId);
+        }
+
+        FilePart filePart = new FilePart(contentId, uploadFilePart);
+        filePartRepository.saveFilePart(filePart);
+        long pos = (chunkNumber-1) * chunkSize;
+        String absolutePath = filePartRepository.getFileMultipart(sha256sum).getAbsolutePath();
+        fileStoreService.writeToFile(inputStream, absolutePath, pos);
+        if (list.size()+1 != totalParts) {
+            return new UploadFileRet(sha256sum);
+        } else {
+            return mergeFileParts(absolutePath, sha256sum, objectChannel, filename);
+        }
+    }
+
+    private UploadFileRet mergeFileParts(String absolutePath,
+                                         String sha256sum,
+                                         ObjectChannel objectChannel,
+                                         String filename) throws Exception {
+        FileInputStream fis = new FileInputStream(absolutePath);
+        String sha256sumMerged = DigestUtil.sha256sum(fis);
+        if (!sha256sum.equals(sha256sumMerged)) {
+            throw new Exception("分片合并文件的 sha256sum 与原文件不一致!");
+        }
+
+        log.info("合并的文件 {}", absolutePath);
+        String contentId = filePartRepository.getFileMultipart(sha256sum).getContentId();
+        String suffix = StringUtil.getSuffix(filename);
+        ObjectProp objectProp = objectNameService.getObjectProp(objectChannel, suffix);
+        File savedFile = new File(absolutePath);
+        ObjectResult objectResult = putObjectService.putObject(objectProp, contentId, savedFile, filename, sha256sum);
+
+        String objectId = objectResult.getObjectId();
+        filePartRepository.updateSetUploaded(sha256sum);
+        String host = ServletUtil.getHeader("host");
+        String url = String.format("//%s/%s", host, objectProp.getObjectName());
+        return new UploadFileRet(objectId, url);
+    }
+}

+ 95 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/service/ObjectNameService.java

@@ -0,0 +1,95 @@
+package cn.reghao.tnb.oss.store.service;
+
+import cn.reghao.jutil.web.ServletUtil;
+import cn.reghao.tnb.oss.api.dto.ObjectChannel;
+import cn.reghao.tnb.oss.store.db.repository.ObjectRepository;
+import cn.reghao.tnb.oss.store.model.po.FileMeta;
+import cn.reghao.tnb.oss.store.model.vo.ObjectProp;
+import cn.reghao.tnb.oss.store.util.StringUtil;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * @author reghao
+ * @date 2023-06-02 16:22:50
+ */
+@Service
+public class ObjectNameService {
+    private final ObjectRepository objectRepository;
+    private final ConsoleServiceWrapper consoleServiceWrapper;
+
+    public ObjectNameService(ObjectRepository objectRepository, ConsoleServiceWrapper consoleServiceWrapper) {
+        this.objectRepository = objectRepository;
+        this.consoleServiceWrapper = consoleServiceWrapper;
+    }
+
+    public ObjectProp getObjectProp(ObjectChannel channel, String filename) throws Exception {
+        int scope = channel.getScope();
+        String suffix = StringUtil.getSuffix(filename);
+        String objectPrefix = channel.getPrefix();
+        String objectName = objectPrefix + UUID.randomUUID().toString().replace("-", "") + suffix;
+        String domain = ServletUtil.getHeader("host");
+        int owner = consoleServiceWrapper.getNodeProperties(domain).getOwner();
+        FileMeta fileMeta = objectRepository.getByObjectName(objectPrefix, owner);
+        if (fileMeta == null) {
+            String errMsg = String.format("objectPrefix %s 不合法", objectPrefix);
+            throw new Exception(errMsg);
+        }
+
+        String pid = fileMeta.getObjectId();
+        return new ObjectProp(objectName, scope, pid, channel.isSetCallback());
+    }
+
+    public ObjectProp getObjectProp(String originalObjectName, String suffix) {
+        int idx = originalObjectName.lastIndexOf("/");
+        String prefix = originalObjectName.substring(0, idx+1);
+        String domain = ServletUtil.getHeader("host");
+        int owner = consoleServiceWrapper.getNodeProperties(domain).getOwner();
+        FileMeta fileMeta = objectRepository.getByObjectName(originalObjectName, owner);
+        int scope = fileMeta.getScope();
+        String pid = fileMeta.getPid();
+
+        String objectName = prefix + UUID.randomUUID().toString().replace("-", "") + suffix;
+        return new ObjectProp(objectName, scope, pid, fileMeta.getDeleted());
+    }
+
+    public String getObjectNameFromOriginal(String originalObjectName, String suffix) {
+        int idx = originalObjectName.lastIndexOf("/");
+        String prefix = originalObjectName.substring(0, idx+1);
+        String id = UUID.randomUUID().toString().replace("-", "");
+        return prefix + id + suffix;
+    }
+
+    public void createParentDirs(int owner, String objectName, int scope) {
+        List<String> list = getSortedParent(objectName);
+        for (int i = 0; i < list.size(); i++) {
+            String parentName = list.get(i);
+            FileMeta fileMeta = objectRepository.getByObjectName(parentName, owner);
+            if (fileMeta == null) {
+                String pid;
+                if (i == 0) {
+                    pid = "0";
+                } else {
+                    pid = objectRepository.getByObjectName(list.get(i-1), owner).getObjectId();
+                }
+                String objectId = UUID.randomUUID().toString().replace("-", "");
+                String[] names = parentName.split("/");
+                String filename = names[names.length-1];
+                objectRepository.saveFileMeta(new FileMeta(owner, parentName, objectId, filename, pid, scope));
+            }
+        }
+    }
+
+    private List<String> getSortedParent(String objectName) {
+        String[] arr = objectName.split("/");
+        List<String> list = new ArrayList<>();
+        list.add(arr[0] + "/");
+        for (int i = 1; i < arr.length; i++) {
+            list.add(list.get(i-1) + arr[i] + "/");
+        }
+        return list;
+    }
+}

+ 113 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/service/PutObjectService.java

@@ -0,0 +1,113 @@
+package cn.reghao.tnb.oss.store.service;
+
+import cn.reghao.jutil.jdk.security.DigestUtil;
+import cn.reghao.jutil.web.ServletUtil;
+import cn.reghao.tnb.oss.store.config.props.OssProperties;
+import cn.reghao.tnb.oss.store.db.repository.ObjectRepository;
+import cn.reghao.tnb.oss.store.model.po.DataBlock;
+import cn.reghao.tnb.oss.store.model.po.FileMeta;
+import cn.reghao.tnb.oss.store.model.vo.ObjectProp;
+import cn.reghao.tnb.oss.store.model.vo.ObjectResult;
+import cn.reghao.tnb.oss.store.util.FileType;
+import cn.reghao.tnb.oss.store.util.StringUtil;
+import cn.reghao.tnb.oss.api.dto.ObjectMeta;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.io.FileUtils;
+import org.springframework.stereotype.Service;
+
+import java.io.*;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * @author reghao
+ * @date 2023-05-10 13:28:18
+ */
+@Slf4j
+@Service
+public class PutObjectService {
+    private final ObjectRepository objectRepository;
+    private final ObjectNameService objectNameService;
+    private final String nodeAddress;
+    private final ConsoleServiceWrapper consoleServiceWrapper;
+
+    public PutObjectService(ObjectRepository objectRepository, ObjectNameService objectNameService,
+                            OssProperties ossProperties, ConsoleServiceWrapper consoleServiceWrapper) {
+        this.objectRepository = objectRepository;
+        this.objectNameService = objectNameService;
+        this.nodeAddress = ossProperties.getStoreHost();
+        this.consoleServiceWrapper = consoleServiceWrapper;
+    }
+
+    public ObjectResult putObject(ObjectProp objectProp, String contentId, File savedFile, String originalFilename, String sha256sum) {
+        String pid = objectProp.getPid();
+        String objectName = objectProp.getObjectName();
+        FileMeta fileMeta = objectRepository.getBySha256sum(sha256sum);
+        if (fileMeta != null) {
+            FileUtils.deleteQuietly(savedFile);
+            return copyObject(objectProp, originalFilename, fileMeta);
+        } else {
+            String savedPath = savedFile.getAbsolutePath();
+            long size = savedFile.length();
+            String objectId = UUID.randomUUID().toString().replace("-", "");
+            String contentType = FileType.getMediaType(savedPath);
+            int fileType = FileType.getFileType(contentType);
+            int scope = objectProp.getScope();
+            fileMeta = new FileMeta(objectName, objectId, contentId, originalFilename, size,
+                    fileType, contentType, sha256sum, pid, scope, objectProp.isSetCallback());
+            String blockId = UUID.randomUUID().toString();
+            List<DataBlock> list = List.of(new DataBlock(contentId, blockId, nodeAddress, savedPath, size));
+            objectRepository.saveObject(fileMeta, list);
+            return new ObjectResult(objectName, objectId, fileType, savedPath);
+        }
+    }
+
+    public ObjectResult putObject(String originalObjectName, String contentId, String suffix, File savedFile) throws Exception {
+        ObjectProp objectProp = objectNameService.getObjectProp(originalObjectName, suffix);
+        String sha256sum = DigestUtil.sha256sum(savedFile.getAbsolutePath());
+        return putObject(objectProp, contentId, savedFile, "", sha256sum);
+    }
+
+    public void putObject(String objectName, byte[] bytes) {
+    }
+
+    public ObjectResult copyObject(ObjectProp objectProp, String filename, FileMeta fileMeta) {
+        String dupObjectId = fileMeta.getObjectId();
+        String domain = ServletUtil.getHeader("host");
+        int owner = consoleServiceWrapper.getNodeProperties(domain).getOwner();
+        ObjectMeta objectMeta = objectRepository.getObjectMetaByName(fileMeta.getObjectName(), owner);
+
+        int fileType = fileMeta.getFileType();
+        String savedPath = objectMeta.getAbsolutePath();
+        String objectName = objectProp.getObjectName();
+        String objectId = UUID.randomUUID().toString().replace("-", "");
+        int scope = objectProp.getScope();
+        String pid = objectProp.getPid();
+        FileMeta fileMeta1 = new FileMeta(objectName, objectId, filename, fileMeta, pid, scope, objectProp.isSetCallback());
+        objectRepository.saveFileMeta(fileMeta1);
+        return new ObjectResult(objectName, objectId, fileType, savedPath, dupObjectId);
+    }
+
+    @Deprecated
+    public ObjectResult copyFromObjectId(String fromObjectId) {
+        FileMeta fileMeta = objectRepository.getByObjectId(fromObjectId);
+        int fileType = fileMeta.getFileType();
+
+        String fromObjectName = fileMeta.getObjectName();
+        String suffix = StringUtil.getSuffix(fromObjectName);
+        String filename = fileMeta.getFilename();
+        String savedPath = "";
+        int scope = fileMeta.getScope();
+        String toObjectName = objectNameService.getObjectNameFromOriginal(fromObjectName, suffix);
+        String toObjectId = UUID.randomUUID().toString().replace("-", "");
+        String pid = fileMeta.getPid();
+        FileMeta fileMeta1 = new FileMeta(toObjectName, toObjectId, filename, fileMeta, pid, scope, false);
+        objectRepository.saveFileMeta(fileMeta1);
+        return new ObjectResult(toObjectName, toObjectId, fileType, savedPath);
+    }
+
+    public void deleteObject(String objectId) {
+        FileMeta fileMeta = objectRepository.getByObjectId(objectId);
+        objectRepository.deleteObject(fileMeta);
+    }
+}

+ 48 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/service/SignService.java

@@ -0,0 +1,48 @@
+package cn.reghao.tnb.oss.store.service;
+
+import cn.reghao.jutil.jdk.security.RandomString;
+import cn.reghao.jutil.auth.JwtUtil;;
+import cn.reghao.tnb.oss.store.util.SignatureUtil;
+import cn.reghao.tnb.oss.api.constant.ChannelAction;
+import cn.reghao.jutil.auth.model.OssPayload;
+import com.github.benmanes.caffeine.cache.Cache;
+import org.springframework.stereotype.Service;
+
+import java.util.UUID;
+
+/**
+ * url 签名服务
+ *
+ * @author reghao
+ * @date 2023-10-18 14:16:01
+ */
+@Service
+public class SignService {
+    private final ConsoleServiceWrapper consoleServiceWrapper;
+    private final Cache<String, String> cache;
+
+    public SignService(ConsoleServiceWrapper consoleServiceWrapper, Cache<String, String> cache) {
+        this.consoleServiceWrapper = consoleServiceWrapper;
+        this.cache = cache;
+    }
+
+    public String getSignedUrl(int loginUser, String url, int expire) {
+        long timestamp = System.currentTimeMillis() + expire*1000L;
+        int channelCode = consoleServiceWrapper.getChannelCodeByUrl(loginUser, url);
+
+        String action1 = ChannelAction.download.getName();
+        String action = ChannelAction.access.getName();
+        OssPayload ossPayload = new OssPayload(channelCode, action, loginUser);
+
+        //String secretKey = consoleService.getSecretKey();
+        String secretKey = RandomString.getString(128);
+        String token = JwtUtil.createOssToken(ossPayload, timestamp, secretKey);
+        cache.put(token, secretKey);
+
+        String nonce = UUID.randomUUID().toString();
+        String queryString = String.format("token=%s&t=%s&nonce=%s", token, timestamp, nonce);
+        String requestString = String.format("%s%s?%s", "GET", url, queryString);
+        String sign = SignatureUtil.sign(requestString, secretKey);
+        return String.format("%s?token=%s&t=%s&nonce=%s&sign=%s", url, token, timestamp, nonce, sign);
+    }
+}

+ 211 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/task/MediaFileProcessor.java

@@ -0,0 +1,211 @@
+package cn.reghao.tnb.oss.store.task;
+
+import cn.reghao.jutil.jdk.shell.FFmpegWrapper;
+import cn.reghao.jutil.tool.media.ImageOps;
+import cn.reghao.jutil.jdk.model.media.MediaQuality;
+import cn.reghao.jutil.jdk.model.media.MediaResolution;
+import cn.reghao.jutil.jdk.model.media.AudioProps;
+import cn.reghao.jutil.jdk.model.media.MediaProps;
+import cn.reghao.jutil.jdk.model.media.VideoProps;
+import cn.reghao.tnb.oss.api.dto.ObjectMeta;
+import cn.reghao.tnb.oss.api.dto.media.AudioInfo;
+import cn.reghao.tnb.oss.api.dto.media.ConvertedImageInfo;
+import cn.reghao.tnb.oss.api.dto.media.ImageInfo;
+import cn.reghao.tnb.oss.api.dto.media.VideoInfo;
+import cn.reghao.tnb.oss.store.db.repository.ObjectRepository;
+import cn.reghao.tnb.oss.store.model.vo.ObjectResult;
+import cn.reghao.tnb.oss.store.service.FileStoreService;
+import cn.reghao.tnb.oss.store.service.PutObjectService;
+import cn.reghao.tnb.oss.store.util.FileType;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.imageio.IIOException;
+import java.io.File;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+/**
+ * @author reghao
+ * @date 2023-01-11 10:40:17
+ */
+@Slf4j
+@Service
+public class MediaFileProcessor {
+    private final ObjectRepository objectRepository;
+    private final FileStoreService fileStoreService;
+    private final PutObjectService putObjectService;
+
+    public MediaFileProcessor(ObjectRepository objectRepository, FileStoreService fileStoreService,
+                              PutObjectService putObjectService) {
+        this.objectRepository = objectRepository;
+        this.fileStoreService = fileStoreService;
+        this.putObjectService = putObjectService;
+    }
+
+    public VideoInfo getVideoInfo(String videoFileId) throws Exception {
+        ObjectMeta objectMeta = objectRepository.getObjectMetaById(videoFileId);
+        if (objectMeta == null) {
+            String errMsg = String.format("%s not exist in oss-store", videoFileId);
+            throw new Exception(errMsg);
+        }
+
+        String absolutePath = objectMeta.getAbsolutePath();
+        MediaProps mediaProps = FFmpegWrapper.getMediaProps(absolutePath);
+        if (mediaProps == null) {
+            String errMsg = String.format("%s 的 FFmpeg 媒体信息为 null", videoFileId);
+            throw new Exception(errMsg);
+        }
+
+        VideoProps videoProps = mediaProps.getVideoProps();
+        if (videoProps == null) {
+            String errMsg = String.format("%s 的 FFmpeg 视频信息为 null", videoFileId);
+            throw new Exception(errMsg);
+        }
+
+        String videoCodec = videoProps.getCodecName();
+        long vbitRate = videoProps.getBitRate();
+
+        String audioCodec = null;
+        long abitRate = 0;
+        AudioProps audioProps1 = mediaProps.getAudioProps();
+        if (audioProps1 != null) {
+            audioCodec = audioProps1.getCodecName();
+            abitRate = audioProps1.getBitRate();
+        }
+        String formatName = mediaProps.getFormatName();
+
+        String objectId = videoFileId;
+        String objectName = objectMeta.getObjectName();
+        int width = videoProps.getCodedWidth().intValue();
+        int height = videoProps.getCodedHeight().intValue();
+        int duration = videoProps.getDuration().intValue();
+        MediaResolution mediaResolution = MediaQuality.getQuality(width, height);
+        String quality = mediaResolution.getQualityStr();
+        String urlType = FileType.getVideoUrlType(absolutePath);
+        LocalDateTime createTime = mediaProps.getCreateTime();
+        long size = objectMeta.getSize();
+
+        return new VideoInfo(videoFileId, objectId, videoCodec, vbitRate, audioCodec, abitRate, formatName,
+                urlType, objectName, quality, width, height, duration, createTime, size);
+    }
+
+    /**
+     * 抛出的 javax.imageio.IIOException 异常在 dubbo 中会出错
+     * [Serialization Security] Serialized class javax.imageio.IIOException is not in allow list. Current mode is `STRICT`, will disallow to deserialize it by default. Please add it into security/serialize.allowlist or follow FAQ to configure it.
+     *
+     * @param
+     * @return
+     * @date 2025-08-17 00:08:482
+     */
+    public ImageInfo getImageInfo(String imageFileId) throws Exception {
+        try {
+            ObjectMeta objectMeta = objectRepository.getObjectMetaById(imageFileId);
+            if (objectMeta == null) {
+                String errMsg = String.format("%s not exist in oss-store", imageFileId);
+                throw new Exception(errMsg);
+            }
+
+            String absolutePath = objectMeta.getAbsolutePath();
+            File file = new File(absolutePath);
+            String format = ImageOps.getFormat(file);
+            ImageOps.Size size = ImageOps.info(new File(absolutePath));
+            int width = size.getWidth();
+            int height = size.getHeight();
+            String objectId = imageFileId;
+            String objectName = objectMeta.getObjectName();
+            long size1 = objectMeta.getSize();
+
+            ImageInfo imageInfo = new ImageInfo(imageFileId, objectId, format, objectName, width, height, size1);
+            return imageInfo;
+        } catch (IIOException iioException) {
+            String msg = iioException.getMessage();
+            throw new Exception(msg);
+        }
+    }
+
+    public List<ConvertedImageInfo> getWebpInfos(List<String> imageFileIds) {
+        return imageFileIds.stream()
+                .map(imageFileId -> {
+                    try {
+                        return getWebpInfo(imageFileId);
+                    } catch (Exception e) {
+                        e.printStackTrace();
+                    }
+                    return null;
+                })
+                .filter(Objects::nonNull)
+                .collect(Collectors.toList());
+    }
+
+    public ConvertedImageInfo getWebpInfo(String imageFileId) throws Exception {
+        ObjectMeta objectMeta = objectRepository.getObjectMetaById(imageFileId);
+        if (objectMeta == null) {
+            log.error("{} not exist", imageFileId);
+            return null;
+        }
+
+        String originalObjectName = objectMeta.getObjectName();
+        String absolutePath = objectMeta.getAbsolutePath();
+        File srcFile = new File(absolutePath);
+
+        String format = "webp";
+        String suffix = "." + format;
+        String contentId = UUID.randomUUID().toString().replace("-", "");
+        String destPath = fileStoreService.genFilePath(contentId, srcFile.length(), suffix);
+        File destFile = new File(destPath);
+        ImageOps.convert2webp(srcFile, destFile);
+
+        if (destFile.exists()) {
+            ObjectResult objectResult1 = putObjectService.putObject(originalObjectName, contentId, suffix, destFile);
+            String objectName1 = objectResult1.getObjectName();
+            String objectId1 = objectResult1.getObjectId();
+            ConvertedImageInfo convertedImageInfo = new ConvertedImageInfo(imageFileId, objectId1, format, objectName1);
+            return convertedImageInfo;
+        } else {
+            log.error("image conversion failed");
+            return null;
+        }
+    }
+
+    public List<ImageInfo> getImagesInfo(List<String> imageFileIds) {
+        return imageFileIds.stream()
+                .map(imageFileId -> {
+                    try {
+                        return getImageInfo(imageFileId);
+                    } catch (Exception e) {
+                        e.printStackTrace();
+                    }
+                    return null;
+                })
+                .filter(Objects::nonNull)
+                .collect(Collectors.toList());
+    }
+
+    public AudioInfo getAudioInfo(String audioFileId) throws Exception {
+        ObjectMeta objectMeta = objectRepository.getObjectMetaById(audioFileId);
+        if (objectMeta == null) {
+            String errMsg = String.format("%s not exist in oss-store", audioFileId);
+            throw new Exception(errMsg);
+        }
+
+        String absolutePath = objectMeta.getAbsolutePath();
+        MediaProps mediaProps = FFmpegWrapper.getMediaProps(absolutePath);
+        if (mediaProps == null || mediaProps.getAudioProps() == null) {
+            String errMsg = String.format("%s 的 FFmpeg 音频信息为 null", audioFileId);
+            throw new Exception(errMsg);
+        }
+
+        AudioProps audioProps = mediaProps.getAudioProps();
+        String audioCodec = audioProps.getCodecName();
+        int duration = audioProps.getDuration().intValue();
+        long bitRate = audioProps.getBitRate();
+        String objectId = audioFileId;
+        String objectName = objectMeta.getObjectName();
+        long size = objectMeta.getSize();
+        return new AudioInfo(audioFileId, objectId, duration, audioCodec, bitRate, objectName, size);
+    }
+}

+ 65 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/task/VideoFile.java

@@ -0,0 +1,65 @@
+package cn.reghao.tnb.oss.store.task;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/**
+ * 视频文件
+ *
+ * @author reghao
+ * @date 2021-11-22 10:21:15
+ */
+@NoArgsConstructor
+@Setter
+@Getter
+public class VideoFile {
+    // 原始文件的 objectId
+    private String videoFileId;
+    private String objectId;
+    private String videoCodec;
+    private Long vbitRate;
+    private String audioCodec;
+    private Long abitRate;
+    private String urlType;
+    private String url;
+    private String quality;
+    private Integer width;
+    private Integer height;
+    private Boolean horizontal;
+    // 单位秒
+    private Integer duration;
+
+    public VideoFile(String videoFileId, String objectId, String videoCodec, long vbitRate, String audioCodec, long abitRate,
+                     String urlType, String url, String quality, int width, int height, int duration) {
+        this.videoFileId = videoFileId;
+        this.objectId = objectId;
+        this.videoCodec = videoCodec;
+        this.vbitRate = vbitRate;
+        this.audioCodec = audioCodec;
+        this.abitRate = abitRate;
+        this.urlType = urlType;
+        this.url = url;
+        this.quality = quality;
+        this.width = width;
+        this.height = height;
+        this.horizontal = width>height;
+        this.duration = duration;
+    }
+
+    public VideoFile(String videoFileId, String objectId, String url, VideoFile videoFile) {
+        this.videoFileId = videoFileId;
+        this.objectId = objectId;
+        this.videoCodec = videoFile.getVideoCodec();
+        this.vbitRate = videoFile.getVbitRate();
+        this.audioCodec = videoFile.getAudioCodec();
+        this.abitRate = videoFile.getAbitRate();
+        this.urlType = videoFile.getUrlType();
+        this.url = url;
+        this.quality = videoFile.getQuality();
+        this.width = videoFile.getWidth();
+        this.height = videoFile.getHeight();
+        this.horizontal = videoFile.getHorizontal();
+        this.duration = videoFile.getDuration();
+    }
+}

+ 196 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/task/VideoFileProcessor.java

@@ -0,0 +1,196 @@
+package cn.reghao.tnb.oss.store.task;
+
+import cn.reghao.jutil.jdk.security.DigestUtil;
+import cn.reghao.tnb.oss.api.dto.ObjectMeta;
+import cn.reghao.tnb.oss.api.rest.UploadFileRet;
+import cn.reghao.tnb.oss.store.db.repository.ObjectRepository;
+import cn.reghao.tnb.oss.store.model.vo.ObjectProp;
+import cn.reghao.tnb.oss.store.service.FileStoreService;
+import cn.reghao.tnb.oss.store.service.PutObjectService;
+import cn.reghao.jutil.jdk.model.media.AudioProps;
+import cn.reghao.jutil.jdk.model.media.MediaProps;
+import cn.reghao.jutil.jdk.model.media.VideoProps;
+import cn.reghao.tnb.oss.store.model.vo.ObjectResult;
+import cn.reghao.tnb.oss.store.util.FileType;
+import cn.reghao.tnb.oss.store.service.ObjectNameService;
+import cn.reghao.jutil.jdk.shell.FFmpegWrapper;
+import cn.reghao.jutil.jdk.model.media.MediaQuality;
+import cn.reghao.jutil.jdk.model.media.MediaResolution;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * @author reghao
+ * @date 2023-01-11 10:40:17
+ */
+@Slf4j
+@Service
+public class VideoFileProcessor {
+    private final ObjectNameService objectNameService;
+    private final PutObjectService putObjectService;
+    private final ObjectRepository objectRepository;
+    private final FileStoreService fileStoreService;
+
+    public VideoFileProcessor(ObjectNameService objectNameService, PutObjectService putObjectService,
+                              ObjectRepository objectRepository, FileStoreService fileStoreService) {
+        this.objectNameService = objectNameService;
+        this.putObjectService = putObjectService;
+        this.objectRepository = objectRepository;
+        this.fileStoreService = fileStoreService;
+    }
+
+    public UploadFileRet process(ObjectResult objectResult) throws Exception {
+        String objectName = objectResult.getObjectName();
+        String objectId = objectResult.getObjectId();
+        String videoFileId = objectId;
+        boolean duplicate = objectResult.isDuplicate();
+        if (duplicate) {
+            String dupObjectId = objectResult.getDupObjectId();
+            //List<VideoFile> videoFiles = videoRepository.getVideoFiles(dupObjectId);
+            List<VideoFile> videoFiles = new ArrayList<>();
+            VideoFile videoFile = videoFiles.get(0);
+            VideoFile videoFile1 = new VideoFile(videoFileId, objectId, objectName, videoFile);
+            List<VideoFile> list = new ArrayList<>();
+            list.add(videoFile1);
+
+            if (videoFiles.size() > 1) {
+                for (int i = 1; i < videoFiles.size(); i++) {
+                    VideoFile videoFile2 = videoFiles.get(i);
+                    ObjectResult objectResult1 = putObjectService.copyFromObjectId(videoFile2.getObjectId());
+                    String objectId1 = objectResult1.getObjectId();
+                    String objectName1 = objectResult1.getObjectName();
+                    list.add(new VideoFile(videoFileId, objectId1, objectName1, videoFile2));
+                }
+            }
+
+            //videoRepository.saveVideoFiles(list);
+            return new UploadFileRet(videoFileId, null);
+        }
+
+        String absolutePath = objectResult.getAbsolutePath();
+        MediaProps mediaProps = FFmpegWrapper.getMediaProps(absolutePath);
+        if (mediaProps == null) {
+            String errMsg = String.format("%s 的 FFmpeg 媒体信息为 null", objectName);
+            throw new Exception(errMsg);
+        }
+
+        VideoProps videoProps = mediaProps.getVideoProps();
+        if (videoProps == null) {
+            String errMsg = String.format("%s 的 FFmpeg 视频信息为 null", objectName);
+            throw new Exception(errMsg);
+        }
+
+        String videoCodec = videoProps.getCodecName();
+        long vbitRate = videoProps.getBitRate();
+
+        String audioCodec = null;
+        long abitRate = 0;
+        AudioProps audioProps1 = mediaProps.getAudioProps();
+        if (audioProps1 != null) {
+            audioCodec = audioProps1.getCodecName();
+            abitRate = audioProps1.getBitRate();
+        }
+
+        /*if (videoCodecs.contains(videoCodec)) {
+            AudioProps audioProps = mediaProps.getAudioProps();
+            if (audioProps != null && !audioCodecs.contains(audioProps.getCodecName())) {
+                log.error("{} 对象的音频非 aac&mp3 编码, 暂不处理", objectName);
+                return null;
+            }
+        } else {
+            log.error("{} 对象的视频非 h264 编码, 暂不处理", objectName);
+            return null;
+        }*/
+        int width = videoProps.getCodedWidth().intValue();
+        int height = videoProps.getCodedHeight().intValue();
+        int duration = videoProps.getDuration().intValue();
+        MediaResolution mediaResolution = MediaQuality.getQuality(width, height);
+        String quality = mediaResolution.getQualityStr();
+        String urlType = FileType.getVideoUrlType(absolutePath);
+        VideoFile videoFile = new VideoFile(videoFileId, objectId, videoCodec, vbitRate, audioCodec, abitRate,
+                urlType, objectName, quality, width, height, duration);
+        //videoRepository.saveVideoFiles(List.of(videoFile));
+        return new UploadFileRet(videoFileId, null);
+    }
+
+    class ConvertVideoTask implements Runnable {
+        private final VideoFile videoFile;
+
+        public ConvertVideoTask(VideoFile videoFile) {
+            this.videoFile = videoFile;
+        }
+
+        public void run() {
+            String videoFileId = videoFile.getVideoFileId();
+            log.info("convert video file {}", videoFileId);
+            int width = videoFile.getWidth();
+            int height = videoFile.getHeight();
+
+            ObjectMeta objectMeta = objectRepository.getObjectMetaById(videoFileId);
+            String originalObjectName = objectMeta.getObjectName();
+            String srcPath = objectMeta.getAbsolutePath();
+            File srcFile = new File(srcPath);
+
+            String contentId = UUID.randomUUID().toString().replace("-", "");
+            String suffix = ".mp4";
+            String destPath = fileStoreService.genFilePath(contentId, srcFile.length(), suffix);
+            try {
+                File savedFile = new File(destPath);
+                if (savedFile.exists()) {
+                    throw new IOException(destPath + " exist");
+                }
+
+                String format = suffix.replace(".", "");
+                int ret = FFmpegWrapper.formatCovert(srcFile.getAbsolutePath(), destPath, format);
+                if (ret != 0) {
+                    throw new Exception("视频转码失败");
+                }
+                String sha256sum = DigestUtil.sha256sum(destPath);
+
+                ObjectProp objectProp = objectNameService.getObjectProp(originalObjectName, suffix);
+                String originalFilename = "converted_video";
+                ObjectResult objectResult = putObjectService.putObject(objectProp, contentId, savedFile, originalFilename, sha256sum);
+
+                String objectId = objectResult.getObjectId();
+                String urlType = FileType.getVideoUrlType(destPath);
+
+                MediaProps mediaProps = FFmpegWrapper.getMediaProps(destPath);
+                if (mediaProps == null) {
+                    log.error("{} 的 FFmpeg 媒体信息为 null", destPath);
+                    return;
+                }
+
+                VideoProps videoProps = mediaProps.getVideoProps();
+                if (videoProps == null) {
+                    log.error("{} 的 FFmpeg 视频信息为 null", destPath);
+                    return;
+                }
+
+                String videoCodec = videoProps.getCodecName();
+                long vbitRate = videoProps.getBitRate();
+
+                String audioCodec = null;
+                long abitRate = 0;
+                AudioProps audioProps1 = mediaProps.getAudioProps();
+                if (audioProps1 != null) {
+                    audioCodec = audioProps1.getCodecName();
+                    abitRate = audioProps1.getBitRate();
+                }
+
+                int duration = videoFile.getDuration();
+                MediaResolution mediaResolution = MediaQuality.getQuality(width, height);
+                String quality = mediaResolution.getQualityStr();
+                VideoFile videoFile1 = new VideoFile(videoFileId, objectId, videoCodec, vbitRate, audioCodec, abitRate,
+                        urlType, objectResult.getObjectName(), quality, width, height, duration);
+            } catch (Exception e) {
+                e.printStackTrace();
+            }
+        }
+    }
+}

+ 34 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/tomcat/GlobalServlet.java

@@ -0,0 +1,34 @@
+package cn.reghao.tnb.oss.store.tomcat;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.Writer;
+
+/**
+ * @author reghao
+ * @date 2024-12-19 11:39:47
+ */
+@Slf4j
+@Component
+public class GlobalServlet extends HttpServlet {
+    @Override
+    protected void service(HttpServletRequest request, HttpServletResponse response)
+            throws ServletException, IOException {
+        String uri = request.getRequestURI();
+        log.info("request uri -> {}", uri);
+
+        response.setCharacterEncoding("UTF-8");
+        response.setContentType("text/plain");
+        response.setHeader("Server", "Embedded Tomcat");
+        try (Writer writer = response.getWriter()) {
+            writer.write("Hello, Embedded Tomcat!");
+            writer.flush();
+        }
+    }
+}

+ 83 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/tomcat/TomcatStarter.java

@@ -0,0 +1,83 @@
+package cn.reghao.tnb.oss.store.tomcat;
+
+import org.apache.catalina.Context;
+import org.apache.catalina.LifecycleException;
+import org.apache.catalina.connector.Connector;
+import org.apache.catalina.startup.Tomcat;
+import org.springframework.stereotype.Component;
+
+import java.io.File;
+
+/**
+ * @author reghao
+ * @date 2024-12-19 11:28:16
+ */
+@Component
+public class TomcatStarter implements Runnable {
+    private final int port;
+    private final String baseDir;
+    private final Tomcat tomcat;
+    private final GlobalServlet globalServlet;
+
+    public TomcatStarter(GlobalServlet globalServlet) {
+        this.port = 8080;
+        this.baseDir = String.format("/opt/tmp/tomcat%s", port);
+        this.tomcat = new Tomcat();
+        this.globalServlet = globalServlet;
+    }
+
+    public void run() {
+        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+                    try {
+                        tomcat.destroy();
+                    } catch (LifecycleException e) {
+                        e.printStackTrace();
+                    }
+                })
+        );
+        try {
+            init();
+        } catch (LifecycleException e) {
+            e.printStackTrace();
+        }
+    }
+
+    private void configDir(String baseDir) {
+        File root = new File(baseDir);
+        if (!root.isDirectory() && !root.mkdirs()) {
+            throw new RuntimeException("请提供Tomcat工作目录");
+        }
+        String path4root = root.getAbsolutePath();
+        tomcat.setBaseDir(path4root);
+        File webapps = new File(path4root + "/webapps");
+        if (!webapps.isDirectory() && !webapps.mkdirs()) {
+            throw new RuntimeException("无法创建webapps目录");
+        }
+    }
+
+    private void init() throws LifecycleException {
+        // 设置工作目录
+        tomcat.setBaseDir(baseDir);
+        configDir(baseDir);
+
+        // 主机名, 将生成目录: {工作目录}/work/Tomcat/{主机名}/ROOT
+        tomcat.setHostname("localhost");
+        System.out.println("工作目录: " + tomcat.getServer().getCatalinaBase().getAbsolutePath());
+
+        tomcat.setPort(port);
+        Connector conn = tomcat.getConnector(); // Tomcat 9.0 必须调用 Tomcat#getConnector() 方法之后才会监听端口
+        System.out.println("连接器设置完成: " + conn);
+
+        // contextPath要使用的上下文映射,""表示根上下文
+        // docBase上下文的基础目录,用于静态文件。相对于服务器主目录必须存在 ({主目录}/webapps/{docBase})
+        String docBase = "";
+        Context ctx = tomcat.addContext("", /*{webapps}/~*/ null);
+
+        Tomcat.addServlet(ctx, "globalServlet", globalServlet);
+        ctx.addServletMappingDecoded("/", "globalServlet");
+
+        tomcat.start();
+        System.out.println("tomcat 已启动");
+        tomcat.getServer().await();
+    }
+}

+ 27 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/util/AuthContext.java

@@ -0,0 +1,27 @@
+package cn.reghao.tnb.oss.store.util;
+
+/**
+ * @author reghao
+ * @date 2023-06-02 10:48:59
+ */
+public class AuthContext implements AutoCloseable {
+    static final ThreadLocal<Integer> CURRENT = new ThreadLocal<>();
+
+    public AuthContext(int user) {
+        CURRENT.set(user);
+    }
+
+    @Deprecated
+    public static int getUser() {
+        return CURRENT.get();
+    }
+
+    public static int getUserId() {
+        return CURRENT.get();
+    }
+
+    @Override
+    public void close() {
+        CURRENT.remove();
+    }
+}

+ 64 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/util/FileType.java

@@ -0,0 +1,64 @@
+package cn.reghao.tnb.oss.store.util;
+
+import cn.reghao.jutil.jdk.shell.Shell;
+import cn.reghao.tnb.oss.api.constant.ObjectType;
+import cn.reghao.tnb.oss.api.constant.VideoUrlType;
+import org.apache.tika.Tika;
+import org.apache.tika.metadata.Metadata;
+
+import java.io.FileInputStream;
+
+/**
+ * @author reghao
+ * @date 2023-06-12 22:47:06
+ */
+public class FileType {
+    private final static Tika tika = new Tika();
+
+    public static String getMediaType1(String src) {
+        String cmd = String.format("/bin/file -b --mime-type \"%s\"", src);
+        return Shell.execWithResult(cmd);
+    }
+
+    public static String getMediaType(String src) {
+        try {
+            FileInputStream fis = new FileInputStream(src);
+            Metadata metadata = new Metadata();
+            tika.parse(fis, metadata);
+            String mediaType = metadata.get("Content-Type");
+            if (mediaType != null) {
+                return mediaType;
+            }
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+
+        return "application/octet-stream";
+    }
+
+    public static int getFileType(String contentType) {
+        int fileType = ObjectType.Other.getCode();
+        if (contentType == null) {
+            return fileType;
+        } else if (contentType.startsWith("image")) {
+            fileType = ObjectType.Image.getCode();
+        } else if (contentType.startsWith("video")) {
+            fileType = ObjectType.Video.getCode();
+        } else if (contentType.startsWith("audio")) {
+            fileType = ObjectType.Audio.getCode();
+        } else if (contentType.startsWith("text")) {
+            fileType = ObjectType.Text.getCode();
+        }
+        return fileType;
+    }
+
+    public static String getVideoUrlType(String src) {
+        String mediaType = getMediaType(src);
+        String urlType = VideoUrlType.mp4.name();
+        if (mediaType.endsWith("flv")) {
+            urlType = VideoUrlType.flv.name();
+        }
+
+        return urlType;
+    }
+}

+ 20 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/util/ObjectUtil.java

@@ -0,0 +1,20 @@
+package cn.reghao.tnb.oss.store.util;
+
+import cn.reghao.jutil.web.ServletUtil;
+
+import javax.servlet.http.HttpServletRequest;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * @author reghao
+ * @date 2023-05-25 09:34:49
+ */
+public class ObjectUtil {
+    public static String getObjectName() {
+        HttpServletRequest servletRequest = ServletUtil.getRequest();
+        String uri = servletRequest.getRequestURI();
+        String uri1 = URLDecoder.decode(uri, StandardCharsets.UTF_8);
+        return uri1.replaceFirst("/", "");
+    }
+}

+ 32 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/util/SignatureUtil.java

@@ -0,0 +1,32 @@
+package cn.reghao.tnb.oss.store.util;
+
+import cn.reghao.jutil.jdk.converter.ByteHex;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * @author reghao
+ * @date 2023-10-17 14:57:33
+ */
+public class SignatureUtil {
+    private static final String ALGORITHM = "HmacSHA256";
+
+    public static String sign(String message, String secret) {
+        try {
+            Mac hmac = Mac.getInstance(ALGORITHM);
+            SecretKeySpec secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), ALGORITHM);
+            hmac.init(secretKey);
+            byte[] bytes = hmac.doFinal(message.getBytes(StandardCharsets.UTF_8));
+            return ByteHex.bytes2Hex(bytes);
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+        return null;
+    }
+
+    public static boolean valid(String message, String secret, String signature) {
+        return signature != null && signature.equals(sign(message, secret));
+    }
+}

+ 19 - 0
oss-store/src/main/java/cn/reghao/tnb/oss/store/util/StringUtil.java

@@ -0,0 +1,19 @@
+package cn.reghao.tnb.oss.store.util;
+
+/**
+ * @author reghao
+ * @date 2022-01-06 13:02:04
+ */
+public class StringUtil {
+    /**
+     * 返回格式为 .mp4 形式的后缀名
+     *
+     * @param
+     * @return
+     * @date 2021-12-08 下午3:32
+     */
+    public static String getSuffix(String filename) {
+        int idx = filename.lastIndexOf(".");
+        return idx == -1 ? "" : filename.substring(idx);
+    }
+}

+ 11 - 0
oss-store/src/main/resources/application-dev.yml

@@ -0,0 +1,11 @@
+spring:
+  datasource:
+    url: jdbc:mysql://localhost/tnb_oss_rdb?useSSL=false&useUnicode=true&characterEncoding=UTF-8&serverTimezone=GMT%2b8
+    username: dev
+    password: Dev@123456
+oss:
+  store-host: 127.0.0.1
+  store-dirs:
+    - /disk/2/
+  console-host: 127.0.0.1
+  console-port: 6103

Some files were not shown because too many files changed in this diff