Explorar el Código

bump: 发布1.7.2

* feat: 支持对象存储

* docs: 更新文档

* docs: 补充关于代码分支及预装数据表说明的文档

* chore: 升级至1.7.2

* feat: 默认集成文件上传功能

* feat: 规范服务调用,新增SysLogService.adminInsert供SysLog切面使用

* chore: schema目录更名为db

* chore: 更新默认脚手架初始化SQL,预置文件上传功能|
docs: 更新README

* docs: Web 全局异常处理国际化消息

* refactor: schema目录更名为db

* refactor: 重构SysLogAspect

* chore: 引入国际化配套翻译值

* chore: 完善DB初始化及版本更新SQL文件

* pref: 减少HashMap扩容次数 & docs: 补充注释

* bump: 发布1.7.2
Uncarbon hace 3 años
padre
commit
b4fb1ad3d0
Se han modificado 37 ficheros con 1627 adiciones y 34 borrados
  1. 2 6
      README.md
  2. 2 1
      attachments/README.md
  3. 50 0
      attachments/db/MySQL/helio_boot.sql
  4. 48 0
      attachments/db/MySQL/upgrade/1.7.1_to_1.7.2.sql
  5. 98 0
      attachments/db/PostgreSQL/helio_boot.sql
  6. 94 0
      attachments/db/PostgreSQL/upgrade/1.7.1_to_1.7.2.sql
  7. 26 2
      pom.xml
  8. 10 0
      src/main/java/cc/uncarbon/module/oss/config/FileStorageConfiguration.java
  9. 10 0
      src/main/java/cc/uncarbon/module/oss/constant/OssConstant.java
  10. 86 0
      src/main/java/cc/uncarbon/module/oss/entity/OssFileInfoEntity.java
  11. 26 0
      src/main/java/cc/uncarbon/module/oss/enums/OssErrorEnum.java
  12. 42 0
      src/main/java/cc/uncarbon/module/oss/facade/OssUploadDownloadFacade.java
  13. 104 0
      src/main/java/cc/uncarbon/module/oss/facade/impl/OssUploadDownloadFacadeImpl.java
  14. 16 0
      src/main/java/cc/uncarbon/module/oss/mapper/OssFileInfoMapper.java
  15. 60 0
      src/main/java/cc/uncarbon/module/oss/model/request/AdminInsertOrUpdateOssFileInfoDTO.java
  16. 51 0
      src/main/java/cc/uncarbon/module/oss/model/request/AdminListOssFileInfoDTO.java
  17. 40 0
      src/main/java/cc/uncarbon/module/oss/model/request/UploadFileAttributeDTO.java
  18. 39 0
      src/main/java/cc/uncarbon/module/oss/model/response/OssFileDownloadReplyBO.java
  19. 88 0
      src/main/java/cc/uncarbon/module/oss/model/response/OssFileInfoBO.java
  20. 34 0
      src/main/java/cc/uncarbon/module/oss/model/response/OssFileUploadResultVO.java
  21. 253 0
      src/main/java/cc/uncarbon/module/oss/service/OssFileInfoService.java
  22. 32 0
      src/main/java/cc/uncarbon/module/oss/util/URLUtil.java
  23. 67 0
      src/main/java/cc/uncarbon/module/oss/web/AdminOssFileInfoController.java
  24. 130 0
      src/main/java/cc/uncarbon/module/oss/web/AdminOssUploadDownloadController.java
  25. 7 0
      src/main/java/cc/uncarbon/module/sys/annotation/SysLog.java
  26. 59 17
      src/main/java/cc/uncarbon/module/sys/aspect/SysLogAspect.java
  27. 3 3
      src/main/java/cc/uncarbon/module/sys/aspect/extension/SysLogAspectExtension.java
  28. 4 4
      src/main/java/cc/uncarbon/module/sys/aspect/extension/SysLogAspectExtensionForSysUserLogin.java
  29. 5 0
      src/main/java/cc/uncarbon/module/sys/enums/SysLogStatusEnum.java
  30. 44 0
      src/main/java/cc/uncarbon/module/sys/model/request/AdminInsertSysLogDTO.java
  31. 17 0
      src/main/java/cc/uncarbon/module/sys/service/SysLogService.java
  32. 1 1
      src/main/java/cc/uncarbon/module/sys/service/SysMenuService.java
  33. 23 0
      src/main/resources/application-dev.yml
  34. 6 0
      src/main/resources/application.yml
  35. 13 0
      src/main/resources/i18n/messages.properties
  36. 24 0
      src/main/resources/i18n/messages_en_US.properties
  37. 13 0
      src/main/resources/i18n/messages_zh_CN.properties

+ 2 - 6
README.md

@@ -1,12 +1,13 @@
 # helio-boot
 
 ## 项目介绍
-`helio-boot`基于 Spring Boot 2.7.x,是一款预置SaaS、RBAC能力的单体项目脚手架,适合初学者学习、老手接外包干私活、初创企业快速上马项目
+`helio-boot`基于 Spring Boot 2.7.x,是一款预置SaaS、RBAC能力的单体项目脚手架,适合初学者学习 JavaWeb 开发的良好实践
 
 JDK compatibility: 1.8 - 17
 
 【[官方文档](https://helio.uncarbon.cc/)】 
 【[主要技术栈依赖](https://helio.uncarbon.cc/#/i18n/zh-CN/helio-starters/dependencies)】
+【[快速启动步骤](https://helio.uncarbon.cc/#/i18n/zh-CN/helio-boot/quick-start)】
 【[前端演示站](https://helio-demo.uncarbon.cc/)】
 
 需要先安装 `MySQL`、`Redis` 等必需中间件
@@ -18,8 +19,3 @@ JDK compatibility: 1.8 - 17
 |----------------------|-----------------------------------------------------------------------------|------------------------------------------------------------|--------------------------------------------------------------|
 | helio-generator      | 可一键生成单体or微服务版的前、后端代码,减少无谓的重复劳动                                              | [Gitee](https://gitee.com/uncarbon97/helio-generator)      | [GitHub](https://github.com/uncarbon97/helio-generator)      |
 | helio-admin-vue-vben | 基于[Vue vben admin](https://github.com/anncwb/vue-vben-admin) 改造适配的前端框架,开箱即用 | [Gitee](https://gitee.com/uncarbon97/helio-admin-vue-vben) | [GitHub](https://github.com/uncarbon97/helio-admin-vue-vben) |
-
-## 代码分支
-> 对应数据表说明请见`attachments/schema/README.md`
-- 分支`feature/oss_support`:支持对象存储(文件上传、下载)
-- 分支`demo/library`:图书管理系统(后台管理示例)

+ 2 - 1
attachments/schema/README.md → attachments/README.md

@@ -1,4 +1,5 @@
 > ## [快速启动步骤](https://helio.uncarbon.cc/#/i18n/zh-CN/helio-boot/quick-start)
 
 1. 本目录存放的是 DB 预装数据表 schema,请根据自己的 DB 架构选择导入
-2. `helio_boot.sql`: 基础数据表,使用脚手架则必须导入
+2. `upgrade`目录存放脚手架版本升级时的 DB 变更
+3. `helio_boot.sql`: 基础数据表,使用脚手架则必须导入

+ 50 - 0
attachments/schema/MySQL/helio_boot.sql → attachments/db/MySQL/helio_boot.sql

@@ -439,3 +439,53 @@ COMMIT;
 
 SET
 FOREIGN_KEY_CHECKS = 1;
+
+
+-- v1.7.2 - Uncarbon - 默认内置文件上传功能
+-- 建表 oss_file_info
+DROP TABLE IF EXISTS `oss_file_info`;
+CREATE TABLE `oss_file_info`
+(
+    `id`                bigint(20) NOT NULL COMMENT '主键ID',
+    `tenant_id`         bigint(20) NULL DEFAULT NULL COMMENT '租户ID',
+    `revision`          bigint(20) NOT NULL DEFAULT 1 COMMENT '乐观锁',
+    `del_flag`          tinyint(4) UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除标识',
+    `created_at`        datetime(0) NOT NULL COMMENT '创建时刻',
+    `created_by`        varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '创建者',
+    `updated_at`        datetime(0) NOT NULL COMMENT '更新时刻',
+    `updated_by`        varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '更新者',
+    `storage_platform`  varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci  NOT NULL COMMENT '存储平台',
+    `storage_base_path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '基础存储路径',
+    `storage_path`      varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '存储路径',
+    `storage_filename`  varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '存储文件名',
+    `original_filename` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '原始文件名',
+    `extend_name`       varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci  NOT NULL COMMENT '扩展名',
+    `file_size`         bigint(20) NOT NULL COMMENT '文件大小',
+    `md5`               varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci  NOT NULL COMMENT 'MD5',
+    `classified`        varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '文件类别',
+    `direct_url`        varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '对象存储直链',
+    PRIMARY KEY (`id`) USING BTREE
+) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '上传文件信息' ROW_FORMAT = Dynamic;
+
+-- v1.7.2 - Uncarbon - 新增上传文件信息后台管理菜单
+INSERT INTO `sys_menu` (`id`, `tenant_id`, `revision`, `del_flag`, `created_at`, `created_by`, `updated_at`,
+                        `updated_by`, `title`, `parent_id`, `type`, `permission`, `icon`, `sort`, `status`, `component`,
+                        `external_link`)
+VALUES (20220922152710, NULL, 1, 0, '2022-12-25 23:09:30', 'admin', '2022-12-26 22:02:31', 'admin', '文件管理', 0, 0,
+        'Oss', 'ant-design:file-outlined', 4, 1, NULL, '');
+INSERT INTO `sys_menu` (`id`, `tenant_id`, `revision`, `del_flag`, `created_at`, `created_by`, `updated_at`,
+                        `updated_by`, `title`, `parent_id`, `type`, `permission`, `icon`, `sort`, `status`, `component`,
+                        `external_link`)
+VALUES (20220922152714, NULL, 1, 0, '2022-09-22 15:27:14', 'helio-generator', '2022-12-26 22:03:14', 'admin',
+        '上传文件信息管理', 20220922152710, 1, 'OssFileInfo', 'ant-design:save-twotone', 1, 1, '/oss/OssFileInfo/index',
+        '');
+INSERT INTO `sys_menu` (`id`, `tenant_id`, `revision`, `del_flag`, `created_at`, `created_by`, `updated_at`,
+                        `updated_by`, `title`, `parent_id`, `type`, `permission`, `icon`, `sort`, `status`, `component`,
+                        `external_link`)
+VALUES (20220922152715, NULL, 1, 0, '2022-09-22 15:27:14', 'helio-generator', '2022-12-26 22:01:58', 'admin', '查询',
+        20220922152714, 2, 'OssFileInfo:retrieve', NULL, 1, 1, NULL, '');
+INSERT INTO `sys_menu` (`id`, `tenant_id`, `revision`, `del_flag`, `created_at`, `created_by`, `updated_at`,
+                        `updated_by`, `title`, `parent_id`, `type`, `permission`, `icon`, `sort`, `status`, `component`,
+                        `external_link`)
+VALUES (20220922152718, NULL, 1, 0, '2022-09-22 15:27:14', 'helio-generator', '2022-12-26 22:02:09', 'admin', '删除',
+        20220922152714, 2, 'OssFileInfo:delete', NULL, 2, 1, NULL, '');

+ 48 - 0
attachments/db/MySQL/upgrade/1.7.1_to_1.7.2.sql

@@ -0,0 +1,48 @@
+-- v1.7.2 - Uncarbon - 默认内置文件上传功能
+-- 建表 oss_file_info
+DROP TABLE IF EXISTS `oss_file_info`;
+CREATE TABLE `oss_file_info`
+(
+    `id`                bigint(20) NOT NULL COMMENT '主键ID',
+    `tenant_id`         bigint(20) NULL DEFAULT NULL COMMENT '租户ID',
+    `revision`          bigint(20) NOT NULL DEFAULT 1 COMMENT '乐观锁',
+    `del_flag`          tinyint(4) UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除标识',
+    `created_at`        datetime(0) NOT NULL COMMENT '创建时刻',
+    `created_by`        varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '创建者',
+    `updated_at`        datetime(0) NOT NULL COMMENT '更新时刻',
+    `updated_by`        varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '更新者',
+    `storage_platform`  varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci  NOT NULL COMMENT '存储平台',
+    `storage_base_path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '基础存储路径',
+    `storage_path`      varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '存储路径',
+    `storage_filename`  varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '存储文件名',
+    `original_filename` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '原始文件名',
+    `extend_name`       varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci  NOT NULL COMMENT '扩展名',
+    `file_size`         bigint(20) NOT NULL COMMENT '文件大小',
+    `md5`               varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci  NOT NULL COMMENT 'MD5',
+    `classified`        varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '文件类别',
+    `direct_url`        varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '对象存储直链',
+    PRIMARY KEY (`id`) USING BTREE
+) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '上传文件信息' ROW_FORMAT = Dynamic;
+
+-- v1.7.2 - Uncarbon - 新增上传文件信息后台管理菜单
+INSERT INTO `sys_menu` (`id`, `tenant_id`, `revision`, `del_flag`, `created_at`, `created_by`, `updated_at`,
+                        `updated_by`, `title`, `parent_id`, `type`, `permission`, `icon`, `sort`, `status`, `component`,
+                        `external_link`)
+VALUES (20220922152710, NULL, 1, 0, '2022-12-25 23:09:30', 'admin', '2022-12-26 22:02:31', 'admin', '文件管理', 0, 0,
+        'Oss', 'ant-design:file-outlined', 4, 1, NULL, '');
+INSERT INTO `sys_menu` (`id`, `tenant_id`, `revision`, `del_flag`, `created_at`, `created_by`, `updated_at`,
+                        `updated_by`, `title`, `parent_id`, `type`, `permission`, `icon`, `sort`, `status`, `component`,
+                        `external_link`)
+VALUES (20220922152714, NULL, 1, 0, '2022-09-22 15:27:14', 'helio-generator', '2022-12-26 22:03:14', 'admin',
+        '上传文件信息管理', 20220922152710, 1, 'OssFileInfo', 'ant-design:save-twotone', 1, 1, '/oss/OssFileInfo/index',
+        '');
+INSERT INTO `sys_menu` (`id`, `tenant_id`, `revision`, `del_flag`, `created_at`, `created_by`, `updated_at`,
+                        `updated_by`, `title`, `parent_id`, `type`, `permission`, `icon`, `sort`, `status`, `component`,
+                        `external_link`)
+VALUES (20220922152715, NULL, 1, 0, '2022-09-22 15:27:14', 'helio-generator', '2022-12-26 22:01:58', 'admin', '查询',
+        20220922152714, 2, 'OssFileInfo:retrieve', NULL, 1, 1, NULL, '');
+INSERT INTO `sys_menu` (`id`, `tenant_id`, `revision`, `del_flag`, `created_at`, `created_by`, `updated_at`,
+                        `updated_by`, `title`, `parent_id`, `type`, `permission`, `icon`, `sort`, `status`, `component`,
+                        `external_link`)
+VALUES (20220922152718, NULL, 1, 0, '2022-09-22 15:27:14', 'helio-generator', '2022-12-26 22:02:09', 'admin', '删除',
+        20220922152714, 2, 'OssFileInfo:delete', NULL, 2, 1, NULL, '');

+ 98 - 0
attachments/schema/PostgreSQL/helio_boot.sql → attachments/db/PostgreSQL/helio_boot.sql

@@ -871,3 +871,101 @@ ALTER TABLE "sys_user_role_relation"
 -- ----------------------------
 ALTER TABLE "数据表模板"
     ADD CONSTRAINT "数据表模板_pkey" PRIMARY KEY ("id");
+
+
+-- v1.7.2 - Uncarbon - 默认内置文件上传功能
+-- 建表 oss_file_info
+DROP TABLE IF EXISTS "oss_file_info";
+CREATE TABLE "oss_file_info"
+(
+    "id"                int8         NOT NULL,
+    "tenant_id"         int8,
+    "revision"          int8         NOT NULL,
+    "del_flag"          int2         NOT NULL,
+    "created_at"        timestamp(6) NOT NULL,
+    "created_by"        varchar(255),
+    "updated_at"        timestamp(6) NOT NULL,
+    "updated_by"        varchar(255),
+    "storage_platform"  varchar(50)  NOT NULL,
+    "storage_base_path" varchar(255) NOT NULL,
+    "storage_path"      varchar(512) NOT NULL,
+    "storage_filename"  varchar(255) NOT NULL,
+    "original_filename" varchar(255) NOT NULL,
+    "extend_name"       varchar(16)  NOT NULL,
+    "file_size"         int8         NOT NULL,
+    "md5"               varchar(32)  NOT NULL,
+    "classified"        varchar(50),
+    "direct_url"        varchar(512)
+)
+;
+COMMENT
+ON COLUMN "oss_file_info"."id" IS '主键ID';
+COMMENT
+ON COLUMN "oss_file_info"."tenant_id" IS '租户ID';
+COMMENT
+ON COLUMN "oss_file_info"."revision" IS '乐观锁';
+COMMENT
+ON COLUMN "oss_file_info"."del_flag" IS '逻辑删除标识';
+COMMENT
+ON COLUMN "oss_file_info"."created_at" IS '创建时刻';
+COMMENT
+ON COLUMN "oss_file_info"."created_by" IS '创建者';
+COMMENT
+ON COLUMN "oss_file_info"."updated_at" IS '更新时刻';
+COMMENT
+ON COLUMN "oss_file_info"."updated_by" IS '更新者';
+COMMENT
+ON COLUMN "oss_file_info"."storage_platform" IS '存储平台';
+COMMENT
+ON COLUMN "oss_file_info"."storage_base_path" IS '基础存储路径';
+COMMENT
+ON COLUMN "oss_file_info"."storage_path" IS '存储路径';
+COMMENT
+ON COLUMN "oss_file_info"."storage_filename" IS '存储文件名';
+COMMENT
+ON COLUMN "oss_file_info"."original_filename" IS '原始文件名';
+COMMENT
+ON COLUMN "oss_file_info"."extend_name" IS '扩展名';
+COMMENT
+ON COLUMN "oss_file_info"."file_size" IS '文件大小';
+COMMENT
+ON COLUMN "oss_file_info"."md5" IS 'MD5';
+COMMENT
+ON COLUMN "oss_file_info"."classified" IS '文件类别';
+COMMENT
+ON COLUMN "oss_file_info"."direct_url" IS '对象存储直链';
+COMMENT
+ON TABLE "oss_file_info" IS '上传文件信息';
+
+-- 主键
+ALTER TABLE "oss_file_info"
+    ADD CONSTRAINT "oss_file_info_pkey" PRIMARY KEY ("id");
+
+
+-- v1.7.2 - Uncarbon - 新增上传文件信息管理菜单
+INSERT INTO "sys_menu"("id", "tenant_id", "revision", "del_flag", "created_at", "created_by", "updated_at",
+                       "updated_by", "title", "parent_id", "type", "permission", "icon", "sort", "status", "component",
+                       "external_link")
+VALUES (20220922152714, NULL, 1, 0, '2022-09-22 15:27:14', 'helio-generator', '2022-09-22 15:27:14',
+        'helio-generator', '上传文件信息管理', 0, 1, 'OssFileInfo', 'ant-design:flag-outlined', 100, 1,
+        '/oss/OssFileInfo/index', '');
+INSERT INTO "sys_menu"("id", "tenant_id", "revision", "del_flag", "created_at", "created_by", "updated_at",
+                       "updated_by", "title", "parent_id", "type", "permission", "icon", "sort", "status", "component",
+                       "external_link")
+VALUES (20220922152715, NULL, 1, 0, '2022-09-22 15:27:14', 'helio-generator', '2022-09-22 15:27:14',
+        'helio-generator', '查询', 1572848462892752896, 2, 'OssFileInfo:retrieve', NULL, 1, 1, NULL, '');
+INSERT INTO "sys_menu"("id", "tenant_id", "revision", "del_flag", "created_at", "created_by", "updated_at",
+                       "updated_by", "title", "parent_id", "type", "permission", "icon", "sort", "status", "component",
+                       "external_link")
+VALUES (20220922152716, NULL, 1, 1, '2022-09-22 15:27:14', 'helio-generator', '2022-09-22 15:35:48', 'admin',
+        '新增', 1572848462892752896, 2, 'OssFileInfo:create', NULL, 2, 1, NULL, '');
+INSERT INTO "sys_menu"("id", "tenant_id", "revision", "del_flag", "created_at", "created_by", "updated_at",
+                       "updated_by", "title", "parent_id", "type", "permission", "icon", "sort", "status", "component",
+                       "external_link")
+VALUES (20220922152717, NULL, 1, 1, '2022-09-22 15:27:14', 'helio-generator', '2022-09-22 15:35:55', 'admin',
+        '编辑', 1572848462892752896, 2, 'OssFileInfo:update', NULL, 4, 1, NULL, '');
+INSERT INTO "sys_menu"("id", "tenant_id", "revision", "del_flag", "created_at", "created_by", "updated_at",
+                       "updated_by", "title", "parent_id", "type", "permission", "icon", "sort", "status", "component",
+                       "external_link")
+VALUES (20220922152718, NULL, 1, 0, '2022-09-22 15:27:14', 'helio-generator', '2022-09-22 15:36:03', 'admin',
+        '删除', 1572848462892752896, 2, 'OssFileInfo:delete', NULL, 2, 1, NULL, '');

+ 94 - 0
attachments/db/PostgreSQL/upgrade/1.7.1_to_1.7.2.sql

@@ -0,0 +1,94 @@
+-- 建表 oss_file_info
+DROP TABLE IF EXISTS "oss_file_info";
+CREATE TABLE "oss_file_info"
+(
+    "id"                int8         NOT NULL,
+    "tenant_id"         int8,
+    "revision"          int8         NOT NULL,
+    "del_flag"          int2         NOT NULL,
+    "created_at"        timestamp(6) NOT NULL,
+    "created_by"        varchar(255),
+    "updated_at"        timestamp(6) NOT NULL,
+    "updated_by"        varchar(255),
+    "storage_platform"  varchar(50)  NOT NULL,
+    "storage_base_path" varchar(255) NOT NULL,
+    "storage_path"      varchar(512) NOT NULL,
+    "storage_filename"  varchar(255) NOT NULL,
+    "original_filename" varchar(255) NOT NULL,
+    "extend_name"       varchar(16)  NOT NULL,
+    "file_size"         int8         NOT NULL,
+    "md5"               varchar(32)  NOT NULL,
+    "classified"        varchar(50),
+    "direct_url"        varchar(512)
+)
+;
+COMMENT
+ON COLUMN "oss_file_info"."id" IS '主键ID';
+COMMENT
+ON COLUMN "oss_file_info"."tenant_id" IS '租户ID';
+COMMENT
+ON COLUMN "oss_file_info"."revision" IS '乐观锁';
+COMMENT
+ON COLUMN "oss_file_info"."del_flag" IS '逻辑删除标识';
+COMMENT
+ON COLUMN "oss_file_info"."created_at" IS '创建时刻';
+COMMENT
+ON COLUMN "oss_file_info"."created_by" IS '创建者';
+COMMENT
+ON COLUMN "oss_file_info"."updated_at" IS '更新时刻';
+COMMENT
+ON COLUMN "oss_file_info"."updated_by" IS '更新者';
+COMMENT
+ON COLUMN "oss_file_info"."storage_platform" IS '存储平台';
+COMMENT
+ON COLUMN "oss_file_info"."storage_base_path" IS '基础存储路径';
+COMMENT
+ON COLUMN "oss_file_info"."storage_path" IS '存储路径';
+COMMENT
+ON COLUMN "oss_file_info"."storage_filename" IS '存储文件名';
+COMMENT
+ON COLUMN "oss_file_info"."original_filename" IS '原始文件名';
+COMMENT
+ON COLUMN "oss_file_info"."extend_name" IS '扩展名';
+COMMENT
+ON COLUMN "oss_file_info"."file_size" IS '文件大小';
+COMMENT
+ON COLUMN "oss_file_info"."md5" IS 'MD5';
+COMMENT
+ON COLUMN "oss_file_info"."classified" IS '文件类别';
+COMMENT
+ON COLUMN "oss_file_info"."direct_url" IS '对象存储直链';
+COMMENT
+ON TABLE "oss_file_info" IS '上传文件信息';
+
+-- 主键
+ALTER TABLE "oss_file_info"
+    ADD CONSTRAINT "oss_file_info_pkey" PRIMARY KEY ("id");
+
+-- 增加对应后台管理菜单
+INSERT INTO "sys_menu"("id", "tenant_id", "revision", "del_flag", "created_at", "created_by", "updated_at",
+                       "updated_by", "title", "parent_id", "type", "permission", "icon", "sort", "status", "component",
+                       "external_link")
+VALUES (20220922152714, NULL, 1, 0, '2022-09-22 15:27:14', 'helio-generator', '2022-09-22 15:27:14',
+        'helio-generator', '上传文件信息管理', 0, 1, 'OssFileInfo', 'ant-design:flag-outlined', 100, 1,
+        '/oss/OssFileInfo/index', '');
+INSERT INTO "sys_menu"("id", "tenant_id", "revision", "del_flag", "created_at", "created_by", "updated_at",
+                       "updated_by", "title", "parent_id", "type", "permission", "icon", "sort", "status", "component",
+                       "external_link")
+VALUES (20220922152715, NULL, 1, 0, '2022-09-22 15:27:14', 'helio-generator', '2022-09-22 15:27:14',
+        'helio-generator', '查询', 1572848462892752896, 2, 'OssFileInfo:retrieve', NULL, 1, 1, NULL, '');
+INSERT INTO "sys_menu"("id", "tenant_id", "revision", "del_flag", "created_at", "created_by", "updated_at",
+                       "updated_by", "title", "parent_id", "type", "permission", "icon", "sort", "status", "component",
+                       "external_link")
+VALUES (20220922152716, NULL, 1, 1, '2022-09-22 15:27:14', 'helio-generator', '2022-09-22 15:35:48', 'admin',
+        '新增', 1572848462892752896, 2, 'OssFileInfo:create', NULL, 2, 1, NULL, '');
+INSERT INTO "sys_menu"("id", "tenant_id", "revision", "del_flag", "created_at", "created_by", "updated_at",
+                       "updated_by", "title", "parent_id", "type", "permission", "icon", "sort", "status", "component",
+                       "external_link")
+VALUES (20220922152717, NULL, 1, 1, '2022-09-22 15:27:14', 'helio-generator', '2022-09-22 15:35:55', 'admin',
+        '编辑', 1572848462892752896, 2, 'OssFileInfo:update', NULL, 4, 1, NULL, '');
+INSERT INTO "sys_menu"("id", "tenant_id", "revision", "del_flag", "created_at", "created_by", "updated_at",
+                       "updated_by", "title", "parent_id", "type", "permission", "icon", "sort", "status", "component",
+                       "external_link")
+VALUES (20220922152718, NULL, 1, 0, '2022-09-22 15:27:14', 'helio-generator', '2022-09-22 15:36:03', 'admin',
+        '删除', 1572848462892752896, 2, 'OssFileInfo:delete', NULL, 2, 1, NULL, '');

+ 26 - 2
pom.xml

@@ -7,14 +7,18 @@
     <parent>
         <groupId>cc.uncarbon.framework</groupId>
         <artifactId>helio-starters</artifactId>
-        <version>1.7.1</version>
+        <version>1.7.2</version>
     </parent>
 
     <artifactId>helio-boot</artifactId>
     <!-- 程序版本号,建议每次打包发布时都调整以作区分 -->
-    <version>1.7.1</version>
+    <version>1.7.2</version>
 
     <properties>
+        <!-- 这里写其他第三方依赖 -->
+        <!-- 文件上传 -->
+        <spring-file-storage.version>0.6.1</spring-file-storage.version>
+        <minio.version>8.4.3</minio.version>
     </properties>
 
     <dependencies>
@@ -67,6 +71,26 @@
         </dependency>
 
         <!-- 这里写其他第三方依赖 -->
+        <!-- 对象存储工具类 https://gitee.com/XYW1171736840/spring-file-storage -->
+        <dependency>
+            <groupId>cn.xuyanwu</groupId>
+            <artifactId>spring-file-storage</artifactId>
+            <version>${spring-file-storage.version}</version>
+            <exclusions>
+                <!-- helio-core 就带了 hutool,不需要重复引入 -->
+                <exclusion>
+                    <groupId>cn.hutool</groupId>
+                    <artifactId>hutool-core</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+
+        <!-- MinIO 依赖包 -->
+        <dependency>
+            <groupId>io.minio</groupId>
+            <artifactId>minio</artifactId>
+            <version>${minio.version}</version>
+        </dependency>
 
     </dependencies>
 

+ 10 - 0
src/main/java/cc/uncarbon/module/oss/config/FileStorageConfiguration.java

@@ -0,0 +1,10 @@
+package cc.uncarbon.module.oss.config;
+
+import cn.xuyanwu.spring.file.storage.EnableFileStorage;
+import org.springframework.context.annotation.Configuration;
+
+
+@EnableFileStorage
+@Configuration
+public class FileStorageConfiguration {
+}

+ 10 - 0
src/main/java/cc/uncarbon/module/oss/constant/OssConstant.java

@@ -0,0 +1,10 @@
+package cc.uncarbon.module.oss.constant;
+
+public interface OssConstant {
+
+    /**
+     * 存储平台-本地存储-前缀
+     */
+    String PLATFORM_PREFIX_LOCAL = "local";
+
+}

+ 86 - 0
src/main/java/cc/uncarbon/module/oss/entity/OssFileInfoEntity.java

@@ -0,0 +1,86 @@
+package cc.uncarbon.module.oss.entity;
+
+import cc.uncarbon.framework.crud.entity.HelioBaseEntity;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import lombok.experimental.Accessors;
+import lombok.experimental.SuperBuilder;
+
+
+/**
+ * 上传文件信息
+ *
+ * @author Uncarbon
+ */
+@EqualsAndHashCode(callSuper = true)
+@Accessors(chain = true)
+@SuperBuilder
+@AllArgsConstructor
+@NoArgsConstructor
+@Data
+@TableName(value = "oss_file_info")
+public class OssFileInfoEntity extends HelioBaseEntity<Long> {
+
+    private static final long serialVersionUID = 1L;
+
+
+    @ApiModelProperty(value = "存储平台")
+    @TableField(value = "storage_platform")
+    private String storagePlatform;
+
+    @ApiModelProperty(value = "基础存储路径")
+    @TableField(value = "storage_base_path")
+    private String storageBasePath;
+
+    @ApiModelProperty(value = "存储路径")
+    @TableField(value = "storage_path")
+    private String storagePath;
+
+    @ApiModelProperty(value = "存储文件名")
+    @TableField(value = "storage_filename")
+    private String storageFilename;
+
+    @ApiModelProperty(value = "原始文件名")
+    @TableField(value = "original_filename")
+    private String originalFilename;
+
+    @ApiModelProperty(value = "扩展名")
+    @TableField(value = "extend_name")
+    private String extendName;
+
+    @ApiModelProperty(value = "文件大小")
+    @TableField(value = "file_size")
+    private Long fileSize;
+
+    @ApiModelProperty(value = "MD5")
+    @TableField(value = "md5")
+    private String md5;
+
+    @ApiModelProperty(value = "类别名")
+    @TableField(value = "classified")
+    private String classified;
+
+    @ApiModelProperty(value = "对象存储直链")
+    @TableField(value = "direct_url")
+    private String directUrl;
+
+
+    /**
+     * 取完整的存储文件名(带扩展名)
+     */
+    public String getStorageFilenameFull() {
+        return String.format("%s.%s", this.getStorageFilename(), this.getExtendName());
+    }
+
+    /**
+     * 取完整的原始文件名(带扩展名)
+     */
+    public String getOriginalFilenameFull() {
+        return String.format("%s.%s", this.getOriginalFilename(), this.getExtendName());
+    }
+}

+ 26 - 0
src/main/java/cc/uncarbon/module/oss/enums/OssErrorEnum.java

@@ -0,0 +1,26 @@
+package cc.uncarbon.module.oss.enums;
+
+import cc.uncarbon.framework.core.enums.HelioBaseEnum;
+import com.baomidou.mybatisplus.annotation.EnumValue;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+
+/**
+ * OSS异常枚举类
+ * @author Uncarbon
+ */
+@AllArgsConstructor
+@Getter
+public enum OssErrorEnum implements HelioBaseEnum<Integer> {
+
+    INVALID_ID(400, "无效ID"),
+    FILE_UPLOAD_FAILED(500, "文件上传失败,请联系管理员"),
+    FILE_DOWNLOAD_FAILED(500, "文件下载失败,请联系管理员"),
+    ;
+
+    @EnumValue
+    private final Integer value;
+    private final String label;
+
+}

+ 42 - 0
src/main/java/cc/uncarbon/module/oss/facade/OssUploadDownloadFacade.java

@@ -0,0 +1,42 @@
+package cc.uncarbon.module.oss.facade;
+
+import cc.uncarbon.framework.core.exception.BusinessException;
+import cc.uncarbon.module.oss.model.request.UploadFileAttributeDTO;
+import cc.uncarbon.module.oss.model.response.OssFileDownloadReplyBO;
+import cc.uncarbon.module.oss.model.response.OssFileInfoBO;
+import lombok.NonNull;
+
+/**
+ * 文件上传下载门面
+ *
+ * @author Uncarbon
+ */
+public interface OssUploadDownloadFacade {
+
+    /**
+     * 根据哈希值,查找是否已有文件
+     */
+    OssFileInfoBO findByHash(String md5);
+
+    /**
+     * 正常上传文件到服务端
+     *
+     * @param fileBytes 文件数据
+     * @param attr      附加属性
+     */
+    OssFileInfoBO upload(byte[] fileBytes, @NonNull UploadFileAttributeDTO attr) throws BusinessException;
+
+    /**
+     * 根据文件ID下载
+     *
+     * @throws BusinessException 业务异常,如:文件ID无效;原始文件不存在
+     */
+    OssFileDownloadReplyBO downloadById(Long fileInfoId) throws BusinessException;
+
+    /**
+     * 是否为本地存储平台
+     * @param storagePlatform 存储平台名
+     */
+    boolean isLocalPlatform(String storagePlatform);
+
+}

+ 104 - 0
src/main/java/cc/uncarbon/module/oss/facade/impl/OssUploadDownloadFacadeImpl.java

@@ -0,0 +1,104 @@
+package cc.uncarbon.module.oss.facade.impl;
+
+import cc.uncarbon.framework.core.exception.BusinessException;
+import cc.uncarbon.module.oss.enums.OssErrorEnum;
+import cc.uncarbon.module.oss.facade.OssUploadDownloadFacade;
+import cc.uncarbon.module.oss.model.request.UploadFileAttributeDTO;
+import cc.uncarbon.module.oss.model.response.OssFileDownloadReplyBO;
+import cc.uncarbon.module.oss.model.response.OssFileInfoBO;
+import cc.uncarbon.module.oss.service.OssFileInfoService;
+import cn.hutool.core.util.StrUtil;
+import cn.xuyanwu.spring.file.storage.FileInfo;
+import cn.xuyanwu.spring.file.storage.FileStorageService;
+import cn.xuyanwu.spring.file.storage.exception.FileStorageRuntimeException;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+
+/**
+ * 文件上传下载门面
+ *
+ * @author Uncarbon
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class OssUploadDownloadFacadeImpl implements OssUploadDownloadFacade {
+
+    private final OssFileInfoService ossFileInfoService;
+    private final FileStorageService fileStorageService;
+
+    @Override
+    public OssFileInfoBO findByHash(String md5) {
+        return ossFileInfoService.getOneByMd5(md5);
+    }
+
+    @Override
+    public OssFileInfoBO upload(byte[] fileBytes, @NonNull UploadFileAttributeDTO attr) throws BusinessException {
+        FileInfo fileInfo;
+        try {
+            fileInfo = fileStorageService
+                    .of(fileBytes)
+                    .setOriginalFilename(attr.getOriginalFilename())
+                    // 不手动指定,由框架自动生成存储文件名
+                    .setSaveFilename(null)
+                    .setContentType(attr.getContentType())
+                    // 如果需要缩略图
+                    // .setSaveThFilename()
+                    // .setThContentType(contentType)
+                    .upload();
+        } catch (FileStorageRuntimeException fsre) {
+            log.error("[文件上传下载门面][上传] FileStorageRuntimeException >> ", fsre);
+            throw new BusinessException(OssErrorEnum.FILE_UPLOAD_FAILED);
+        }
+
+        log.info("[文件上传下载门面][上传] 正常上传成功 >> successFileInfo={}", fileInfo);
+
+        /*
+        记录存入 DB
+         */
+        return ossFileInfoService.save(fileInfo, attr);
+    }
+
+    @Override
+    public OssFileDownloadReplyBO downloadById(Long fileInfoId) throws BusinessException {
+        OssFileInfoBO ossFileInfo = ossFileInfoService.getOneById(fileInfoId, true);
+
+        /*
+        这里请根据实际业务性质调整
+        有的业务出于安全目的,不能暴露直链,只能通过服务端代理下载后,返回 byte[]
+        有的业务没有限制,上传后文件完全可以直接通过对象存储直链下载,如此还能节约服务端上传带宽
+        但本地存储又没有直链,只能通过文件ID;
+        默认地,此处按【本地存储or对象存储直链为空:通过服务端代理下载;对象存储:通过对象存储直链下载】返回
+         */
+        boolean redirect2DirectUrl = false;
+        byte[] fileBytes = null;
+        if (
+                OssFileInfoService.isLocalPlatform(ossFileInfo.getStoragePlatform())
+                || StrUtil.isEmpty(ossFileInfo.getDirectUrl())
+        ) {
+            FileInfo fileInfo = OssFileInfoService.toFileInfo(ossFileInfo);
+            try {
+                fileBytes = fileStorageService.download(fileInfo).bytes();
+            } catch (FileStorageRuntimeException fsre) {
+                log.error("[文件上传下载门面][下载] FileStorageRuntimeException >> ", fsre);
+                throw new BusinessException(OssErrorEnum.FILE_DOWNLOAD_FAILED);
+            }
+        } else {
+            redirect2DirectUrl = true;
+        }
+        return OssFileDownloadReplyBO.builder()
+                .redirect2DirectUrl(redirect2DirectUrl)
+                .fileBytes(fileBytes)
+                .directUrl(ossFileInfo.getDirectUrl())
+                .storageFilename(ossFileInfo.getStorageFilenameFull())
+                .build();
+    }
+
+    @Override
+    public boolean isLocalPlatform(String storagePlatform) {
+        return OssFileInfoService.isLocalPlatform(storagePlatform);
+    }
+}

+ 16 - 0
src/main/java/cc/uncarbon/module/oss/mapper/OssFileInfoMapper.java

@@ -0,0 +1,16 @@
+package cc.uncarbon.module.oss.mapper;
+
+import cc.uncarbon.module.oss.entity.OssFileInfoEntity;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+
+/**
+ * 上传文件信息
+ *
+ * @author Uncarbon
+ */
+@Mapper
+public interface OssFileInfoMapper extends BaseMapper<OssFileInfoEntity> {
+
+}

+ 60 - 0
src/main/java/cc/uncarbon/module/oss/model/request/AdminInsertOrUpdateOssFileInfoDTO.java

@@ -0,0 +1,60 @@
+package cc.uncarbon.module.oss.model.request;
+
+import cc.uncarbon.module.sys.enums.GenericStatusEnum;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.experimental.Accessors;
+import lombok.experimental.SuperBuilder;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import java.io.Serializable;
+
+
+/**
+ * 后台管理-新增/编辑上传文件信息 DTO
+ *
+ * @author Uncarbon
+ */
+@Accessors(chain = true)
+@SuperBuilder
+@AllArgsConstructor
+@NoArgsConstructor
+@Data
+public class AdminInsertOrUpdateOssFileInfoDTO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty(value = "主键ID", hidden = true, notes = "仅更新时使用")
+    private Long id;
+
+    @ApiModelProperty(value = "前置路径", required = true)
+    @NotBlank(message = "前置路径不能为空")
+    private String path;
+
+    @ApiModelProperty(value = "文件名", required = true)
+    @NotBlank(message = "文件名不能为空")
+    private String filename;
+
+    @ApiModelProperty(value = "扩展名", required = true)
+    @NotBlank(message = "扩展名不能为空")
+    private String extendName;
+
+    @ApiModelProperty(value = "文件大小", required = true)
+    @NotNull(message = "文件大小不能为空")
+    private Long filesize;
+
+    @ApiModelProperty(value = "MD5", required = true)
+    @NotBlank(message = "MD5不能为空")
+    private String md5;
+
+    @ApiModelProperty(value = "状态", required = true)
+    @NotNull(message = "状态不能为空")
+    private GenericStatusEnum status;
+
+    @ApiModelProperty(value = "类别编号")
+    private Integer classified;
+
+}

+ 51 - 0
src/main/java/cc/uncarbon/module/oss/model/request/AdminListOssFileInfoDTO.java

@@ -0,0 +1,51 @@
+package cc.uncarbon.module.oss.model.request;
+
+import cc.uncarbon.framework.core.constant.HelioConstant;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.experimental.Accessors;
+import lombok.experimental.SuperBuilder;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+
+/**
+ * 后台管理-分页列表上传文件信息 DTO
+ *
+ * @author Uncarbon
+ */
+@Accessors(chain = true)
+@SuperBuilder
+@AllArgsConstructor
+@NoArgsConstructor
+@Data
+public class AdminListOssFileInfoDTO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+
+    @ApiModelProperty(value = "原始文件名(关键词)")
+    private String originalFilename;
+
+    @ApiModelProperty(value = "扩展名")
+    private String extendName;
+
+    @ApiModelProperty(value = "文件类别")
+    private String classified;
+
+    @ApiModelProperty(value = "时间区间起")
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = HelioConstant.Jackson.DATE_TIME_FORMAT)
+    @DateTimeFormat(pattern = HelioConstant.Jackson.DATE_TIME_FORMAT)
+    private LocalDateTime beginAt;
+
+    @ApiModelProperty(value = "时间区间止")
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = HelioConstant.Jackson.DATE_TIME_FORMAT)
+    @DateTimeFormat(pattern = HelioConstant.Jackson.DATE_TIME_FORMAT)
+    private LocalDateTime endAt;
+
+}

+ 40 - 0
src/main/java/cc/uncarbon/module/oss/model/request/UploadFileAttributeDTO.java

@@ -0,0 +1,40 @@
+package cc.uncarbon.module.oss.model.request;
+
+import io.swagger.annotations.ApiModelProperty;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.experimental.Accessors;
+
+import java.io.Serializable;
+
+
+/**
+ * 上传文件属性 DTO
+ *
+ * @author Uncarbon
+ */
+@Accessors(chain = true)
+@AllArgsConstructor
+@NoArgsConstructor
+@Data
+public class UploadFileAttributeDTO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty(value = "文件类别", example = "id_card=身份证 driver_license=驾驶证")
+    private String classified;
+
+    /*
+    以下字段为内部使用
+     */
+    @ApiModelProperty(value = "原始文件名", hidden = true)
+    private String originalFilename;
+
+    @ApiModelProperty(value = "MIME类型", hidden = true)
+    private String contentType;
+
+    @ApiModelProperty(value = "MD5", hidden = true)
+    private String md5;
+
+}

+ 39 - 0
src/main/java/cc/uncarbon/module/oss/model/response/OssFileDownloadReplyBO.java

@@ -0,0 +1,39 @@
+package cc.uncarbon.module.oss.model.response;
+
+import io.swagger.annotations.ApiModelProperty;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.experimental.Accessors;
+import lombok.experimental.SuperBuilder;
+
+import java.io.Serializable;
+
+
+/**
+ * 下载文件结果 BO
+ *
+ * @author Uncarbon
+ */
+@Accessors(chain = true)
+@SuperBuilder
+@AllArgsConstructor
+@NoArgsConstructor
+@Data
+public class OssFileDownloadReplyBO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty(value = "是否直接重定向到对象存储直链", notes = "如果允许客户端直接从“对象存储直链”下载,则本字段可以置 true")
+    private boolean redirect2DirectUrl;
+
+    @ApiModelProperty(value = "文件数据", notes = "如果允许客户端直接从“对象存储直链”下载,则本字段可以置空")
+    private byte[] fileBytes;
+
+    @ApiModelProperty(value = "对象存储直链")
+    private String directUrl;
+
+    @ApiModelProperty(value = "存储文件名")
+    private String storageFilename;
+
+}

+ 88 - 0
src/main/java/cc/uncarbon/module/oss/model/response/OssFileInfoBO.java

@@ -0,0 +1,88 @@
+package cc.uncarbon.module.oss.model.response;
+
+import cc.uncarbon.framework.core.constant.HelioConstant;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.experimental.Accessors;
+import lombok.experimental.SuperBuilder;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+
+/**
+ * 上传文件信息 BO
+ *
+ * @author Uncarbon
+ */
+@Accessors(chain = true)
+@SuperBuilder
+@AllArgsConstructor
+@NoArgsConstructor
+@Data
+public class OssFileInfoBO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty(value = "主键ID")
+    private Long id;
+
+    @ApiModelProperty(value = "创建时刻")
+    @DateTimeFormat(pattern = HelioConstant.Jackson.DATE_TIME_FORMAT)
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = HelioConstant.Jackson.DATE_TIME_FORMAT)
+    private LocalDateTime createdAt;
+
+    @ApiModelProperty(value = "更新时刻")
+    @DateTimeFormat(pattern = HelioConstant.Jackson.DATE_TIME_FORMAT)
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = HelioConstant.Jackson.DATE_TIME_FORMAT)
+    private LocalDateTime updatedAt;
+
+    @ApiModelProperty(value = "存储平台")
+    private String storagePlatform;
+
+    @ApiModelProperty(value = "基础存储路径")
+    private String storageBasePath;
+
+    @ApiModelProperty(value = "存储路径")
+    private String storagePath;
+
+    @ApiModelProperty(value = "存储文件名")
+    private String storageFilename;
+
+    @ApiModelProperty(value = "原始文件名")
+    private String originalFilename;
+
+    @ApiModelProperty(value = "扩展名")
+    private String extendName;
+
+    @ApiModelProperty(value = "文件大小")
+    private Long fileSize;
+
+    @ApiModelProperty(value = "MD5")
+    private String md5;
+
+    @ApiModelProperty(value = "类别编号")
+    private String classified;
+
+    @ApiModelProperty(value = "对象存储直链")
+    private String directUrl;
+
+    /**
+     * 取完整的存储文件名(带扩展名)
+     */
+    public String getStorageFilenameFull() {
+        return String.format("%s.%s", this.getStorageFilename(), this.getExtendName());
+    }
+
+    /**
+     * 取完整的原始文件名(带扩展名)
+     */
+    public String getOriginalFilenameFull() {
+        return String.format("%s.%s", this.getOriginalFilename(), this.getExtendName());
+    }
+
+}

+ 34 - 0
src/main/java/cc/uncarbon/module/oss/model/response/OssFileUploadResultVO.java

@@ -0,0 +1,34 @@
+package cc.uncarbon.module.oss.model.response;
+
+import io.swagger.annotations.ApiModelProperty;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.experimental.Accessors;
+
+import java.io.Serializable;
+
+
+/**
+ * 上传文件结果 VO
+ *
+ * @author Uncarbon
+ */
+@Accessors(chain = true)
+@AllArgsConstructor
+@NoArgsConstructor
+@Data
+public class OssFileUploadResultVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty(value = "文件ID")
+    private Long fileId;
+
+    @ApiModelProperty(value = "存储文件名")
+    private String filename;
+
+    @ApiModelProperty(value = "完整外链")
+    private String url;
+
+}

+ 253 - 0
src/main/java/cc/uncarbon/module/oss/service/OssFileInfoService.java

@@ -0,0 +1,253 @@
+package cc.uncarbon.module.oss.service;
+
+import cc.uncarbon.framework.core.constant.HelioConstant;
+import cc.uncarbon.framework.core.exception.BusinessException;
+import cc.uncarbon.framework.core.page.PageParam;
+import cc.uncarbon.framework.core.page.PageResult;
+import cc.uncarbon.framework.crud.service.impl.HelioBaseServiceImpl;
+import cc.uncarbon.module.oss.constant.OssConstant;
+import cc.uncarbon.module.oss.entity.OssFileInfoEntity;
+import cc.uncarbon.module.oss.enums.OssErrorEnum;
+import cc.uncarbon.module.oss.mapper.OssFileInfoMapper;
+import cc.uncarbon.module.oss.model.request.AdminListOssFileInfoDTO;
+import cc.uncarbon.module.oss.model.request.UploadFileAttributeDTO;
+import cc.uncarbon.module.oss.model.response.OssFileInfoBO;
+import cn.hutool.core.bean.BeanUtil;
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.util.ObjectUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.xuyanwu.spring.file.storage.FileInfo;
+import cn.xuyanwu.spring.file.storage.FileStorageService;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+
+/**
+ * 上传文件信息
+ *
+ * @author Uncarbon
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class OssFileInfoService extends HelioBaseServiceImpl<OssFileInfoMapper, OssFileInfoEntity> {
+
+    private final FileStorageService fileStorageService;
+
+
+    /**
+     * 后台管理-分页列表
+     */
+    public PageResult<OssFileInfoBO> adminList(PageParam pageParam, AdminListOssFileInfoDTO dto) {
+        Page<OssFileInfoEntity> entityPage = this.page(
+                new Page<>(pageParam.getPageNum(), pageParam.getPageSize()),
+                new QueryWrapper<OssFileInfoEntity>()
+                        .lambda()
+                        // 原始文件名
+                        .like(StrUtil.isNotBlank(dto.getOriginalFilename()), OssFileInfoEntity::getOriginalFilename, StrUtil.cleanBlank(dto.getOriginalFilename()))
+                        // 扩展名
+                        .eq(StrUtil.isNotBlank(dto.getExtendName()), OssFileInfoEntity::getExtendName, StrUtil.cleanBlank(dto.getExtendName()))
+                        // 文件类别
+                        .eq(StrUtil.isNotBlank(dto.getClassified()), OssFileInfoEntity::getClassified, StrUtil.cleanBlank(dto.getClassified()))
+                        // 时间区间
+                        .between(ObjectUtil.isNotNull(dto.getBeginAt()) && ObjectUtil.isNotNull(dto.getEndAt()), OssFileInfoEntity::getCreatedAt, dto.getBeginAt(), dto.getEndAt())
+                        // 排序
+                        .orderByDesc(OssFileInfoEntity::getCreatedAt)
+        );
+
+        return this.entityPage2BOPage(entityPage);
+    }
+
+    /**
+     * 根据 ID 取详情
+     *
+     * @param id 主键ID
+     * @return null or BO
+     */
+    public OssFileInfoBO getOneById(Long id) {
+        return this.getOneById(id, false);
+    }
+
+    /**
+     * 根据 ID 取详情
+     *
+     * @param id               主键ID
+     * @param throwIfInvalidId 是否在 ID 无效时抛出异常
+     * @return null or BO
+     */
+    public OssFileInfoBO getOneById(Long id, boolean throwIfInvalidId) throws BusinessException {
+        OssFileInfoEntity entity = this.getById(id);
+        if (throwIfInvalidId) {
+            OssErrorEnum.INVALID_ID.assertNotNull(entity);
+        }
+
+        return this.entity2BO(entity);
+    }
+
+    /**
+     * 后台管理-删除
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public void adminDelete(Collection<Long> ids) {
+        log.info("[后台管理-删除上传文件信息] >> ids={}", ids);
+
+        // 1. 删除原始文件
+        List<OssFileInfoEntity> entityList = this.listByIds(ids);
+        for (OssFileInfoEntity entity : entityList) {
+            fileStorageService.delete(toFileInfo(entity));
+        }
+
+        // 2. 删除文件记录
+        this.removeByIds(ids);
+    }
+
+    /**
+     * 根据 MD5 取文件信息
+     */
+    public OssFileInfoBO getOneByMd5(String md5) {
+        OssFileInfoEntity entity = this.getOne(
+                new QueryWrapper<OssFileInfoEntity>()
+                        .lambda()
+                        .eq(OssFileInfoEntity::getMd5, md5)
+                        .last(HelioConstant.CRUD.SQL_LIMIT_1)
+        );
+
+        return this.entity2BO(entity);
+    }
+
+    /**
+     * 上传成功后,保存文件记录
+     *
+     * @return 文件ID
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public OssFileInfoBO save(@NonNull FileInfo fileInfo, @NonNull UploadFileAttributeDTO attr) {
+        OssFileInfoEntity entity = new OssFileInfoEntity()
+                .setStoragePlatform(fileInfo.getPlatform())
+                .setStorageBasePath(fileInfo.getBasePath())
+                .setStoragePath(fileInfo.getPath())
+                // 需要裁切掉后缀名
+                .setStorageFilename(FileUtil.getPrefix(fileInfo.getFilename()))
+                .setOriginalFilename(FileUtil.getPrefix(fileInfo.getOriginalFilename()))
+                .setExtendName(fileInfo.getExt())
+                .setFileSize(fileInfo.getSize())
+                .setMd5(attr.getMd5())
+                .setClassified(attr.getClassified());
+
+        if (!isLocalPlatform(fileInfo.getPlatform())) {
+            // 非本地存储,保存对象存储直链
+            entity.setDirectUrl(fileInfo.getUrl());
+        }
+        this.save(entity);
+
+        return this.entity2BO(entity);
+    }
+
+    /**
+     * 转换为 FileInfo 对象
+     */
+    public static FileInfo toFileInfo(OssFileInfoBO source) {
+        FileInfo fileInfo = new FileInfo();
+        fileInfo.setPlatform(source.getStoragePlatform());
+        fileInfo.setBasePath(source.getStorageBasePath());
+        fileInfo.setPath(source.getStoragePath());
+        fileInfo.setFilename(source.getStorageFilenameFull());
+        fileInfo.setSize(source.getFileSize());
+        fileInfo.setOriginalFilename(source.getOriginalFilenameFull());
+        return fileInfo;
+    }
+
+    /**
+     * 转换为 FileInfo 对象
+     */
+    public static FileInfo toFileInfo(OssFileInfoEntity source) {
+        FileInfo fileInfo = new FileInfo();
+        fileInfo.setPlatform(source.getStoragePlatform());
+        fileInfo.setBasePath(source.getStorageBasePath());
+        fileInfo.setPath(source.getStoragePath());
+        fileInfo.setFilename(source.getStorageFilenameFull());
+        fileInfo.setSize(source.getFileSize());
+        fileInfo.setOriginalFilename(source.getOriginalFilenameFull());
+        return fileInfo;
+    }
+
+    /**
+     * 是否为本地存储平台
+     * @param storagePlatform 存储平台名
+     */
+    public static boolean isLocalPlatform(String storagePlatform) {
+        return StrUtil.startWith(storagePlatform, OssConstant.PLATFORM_PREFIX_LOCAL);
+    }
+
+    /*
+    ----------------------------------------------------------------
+                        私有方法 private methods
+    ----------------------------------------------------------------
+     */
+
+    /**
+     * 实体转 BO
+     *
+     * @param entity 实体
+     * @return BO
+     */
+    private OssFileInfoBO entity2BO(OssFileInfoEntity entity) {
+        if (entity == null) {
+            return null;
+        }
+
+        OssFileInfoBO bo = new OssFileInfoBO();
+        BeanUtil.copyProperties(entity, bo);
+
+        // 可以在此处为BO填充字段
+
+        return bo;
+    }
+
+    /**
+     * 实体 List 转 BO List
+     *
+     * @param entityList 实体 List
+     * @return BO List
+     */
+    private List<OssFileInfoBO> entityList2BOs(List<OssFileInfoEntity> entityList) {
+        if (CollUtil.isEmpty(entityList)) {
+            return Collections.emptyList();
+        }
+
+        // 深拷贝
+        List<OssFileInfoBO> ret = new ArrayList<>(entityList.size());
+        entityList.forEach(
+                entity -> ret.add(this.entity2BO(entity))
+        );
+
+        return ret;
+    }
+
+    /**
+     * 实体分页转 BO 分页
+     *
+     * @param entityPage 实体分页
+     * @return BO 分页
+     */
+    private PageResult<OssFileInfoBO> entityPage2BOPage(Page<OssFileInfoEntity> entityPage) {
+        return new PageResult<OssFileInfoBO>()
+                .setCurrent(entityPage.getCurrent())
+                .setSize(entityPage.getSize())
+                .setTotal(entityPage.getTotal())
+                .setRecords(this.entityList2BOs(entityPage.getRecords()))
+                ;
+    }
+
+}

+ 32 - 0
src/main/java/cc/uncarbon/module/oss/util/URLUtil.java

@@ -0,0 +1,32 @@
+package cc.uncarbon.module.oss.util;
+
+import cn.hutool.core.util.StrUtil;
+
+import java.net.MalformedURLException;
+import java.net.URL;
+
+/**
+ * 定制 URLUtil
+ * 继承自 cn.hutool.core.util.URLUtil
+ *
+ * @author Uncarbon
+ */
+public class URLUtil extends cn.hutool.core.util.URLUtil {
+
+    /**
+     * 取完整域名(protocol + domain + port),最后不带"/"
+     * @param urlStr URL文本
+     */
+    public static String getFullDomain(String urlStr) {
+        try {
+            URL url = new URL(urlStr);
+            return String.format("%s://%s:%s", url.getProtocol(), url.getHost(), url.getPort());
+        } catch (MalformedURLException e) {
+            // 走兜底方案
+        }
+
+        // 兜底方案,直接替换掉 path 部分
+        return StrUtil.replace(urlStr, getPath(urlStr), StrUtil.EMPTY);
+    }
+
+}

+ 67 - 0
src/main/java/cc/uncarbon/module/oss/web/AdminOssFileInfoController.java

@@ -0,0 +1,67 @@
+package cc.uncarbon.module.oss.web;
+
+import cc.uncarbon.framework.core.constant.HelioConstant;
+import cc.uncarbon.framework.core.page.PageParam;
+import cc.uncarbon.framework.core.page.PageResult;
+import cc.uncarbon.framework.web.model.request.IdsDTO;
+import cc.uncarbon.framework.web.model.response.ApiResult;
+import cc.uncarbon.module.oss.model.request.AdminListOssFileInfoDTO;
+import cc.uncarbon.module.oss.model.response.OssFileInfoBO;
+import cc.uncarbon.module.oss.service.OssFileInfoService;
+import cc.uncarbon.module.sys.constant.SysConstant;
+import cc.uncarbon.module.sys.util.AdminStpUtil;
+import cn.dev33.satoken.annotation.SaCheckLogin;
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.*;
+
+import javax.validation.Valid;
+
+
+/**
+ * 后台管理-上传文件信息管理接口
+ *
+ * @author Uncarbon
+ */
+@RequiredArgsConstructor
+@SaCheckLogin(type = AdminStpUtil.TYPE)
+@Slf4j
+@Api(value = "上传文件信息管理接口", tags = {"上传文件信息管理接口"})
+@RequestMapping(SysConstant.SYS_MODULE_CONTEXT_PATH + HelioConstant.Version.HTTP_API_VERSION_V1 + "/oss/file/infos")
+@RestController
+public class AdminOssFileInfoController {
+
+    // 功能权限串前缀
+    private static final String PERMISSION_PREFIX = "OssFileInfo:";
+
+    private final OssFileInfoService ossFileInfoService;
+
+
+    @SaCheckPermission(type = AdminStpUtil.TYPE, value = PERMISSION_PREFIX + HelioConstant.Permission.RETRIEVE)
+    @ApiOperation(value = "分页列表", produces = MediaType.APPLICATION_JSON_VALUE)
+    @GetMapping
+    public ApiResult<PageResult<OssFileInfoBO>> list(PageParam pageParam, AdminListOssFileInfoDTO dto) {
+        return ApiResult.data(ossFileInfoService.adminList(pageParam, dto));
+    }
+
+    @SaCheckPermission(type = AdminStpUtil.TYPE, value = PERMISSION_PREFIX + HelioConstant.Permission.RETRIEVE)
+    @ApiOperation(value = "详情", produces = MediaType.APPLICATION_JSON_VALUE)
+    @GetMapping(value = "/{id}")
+    public ApiResult<OssFileInfoBO> getById(@PathVariable Long id) {
+        return ApiResult.data(ossFileInfoService.getOneById(id, true));
+    }
+
+    @SaCheckPermission(type = AdminStpUtil.TYPE, value = PERMISSION_PREFIX + HelioConstant.Permission.DELETE)
+    @ApiOperation(value = "删除", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
+    @DeleteMapping
+    public ApiResult<?> delete(@RequestBody @Valid IdsDTO<Long> dto) {
+        ossFileInfoService.adminDelete(dto.getIds());
+
+        return ApiResult.success();
+    }
+
+}

+ 130 - 0
src/main/java/cc/uncarbon/module/oss/web/AdminOssUploadDownloadController.java

@@ -0,0 +1,130 @@
+package cc.uncarbon.module.oss.web;
+
+import cc.uncarbon.framework.core.constant.HelioConstant;
+import cc.uncarbon.framework.web.model.response.ApiResult;
+import cc.uncarbon.module.oss.facade.OssUploadDownloadFacade;
+import cc.uncarbon.module.oss.model.request.UploadFileAttributeDTO;
+import cc.uncarbon.module.oss.model.response.OssFileDownloadReplyBO;
+import cc.uncarbon.module.oss.model.response.OssFileInfoBO;
+import cc.uncarbon.module.oss.model.response.OssFileUploadResultVO;
+import cc.uncarbon.module.sys.constant.SysConstant;
+import cc.uncarbon.module.sys.util.AdminStpUtil;
+import cn.dev33.satoken.annotation.SaCheckLogin;
+import cn.hutool.core.io.IoUtil;
+import cn.hutool.core.util.CharsetUtil;
+import cn.hutool.core.util.ObjectUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.crypto.digest.DigestUtil;
+import cn.hutool.http.Header;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.validation.Valid;
+import java.io.IOException;
+import java.net.URLEncoder;
+
+
+/**
+ * 后台管理专用 - 上传、下载文件接口
+ *
+ * @author Uncarbon
+ */
+@RequiredArgsConstructor
+@Slf4j
+@Api(value = "【后台管理】上传、下载文件接口", tags = {"【后台管理】上传、下载文件接口"})
+@RequestMapping(SysConstant.SYS_MODULE_CONTEXT_PATH + HelioConstant.Version.HTTP_API_VERSION_V1 + "/oss/files")
+@RestController
+public class AdminOssUploadDownloadController {
+
+    private final OssUploadDownloadFacade ossUploadDownloadFacade;
+
+
+    @ApiOperation(value = "上传文件", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+    @PostMapping
+    // 约束:登录后才能上传   👇 后台管理对应的鉴权工具类
+    @SaCheckLogin(type = AdminStpUtil.TYPE)
+    public ApiResult<OssFileUploadResultVO> upload(
+            @RequestPart MultipartFile file, @RequestPart(required = false) @Valid UploadFileAttributeDTO attr,
+            HttpServletRequest request
+    ) throws IOException {
+         /*
+         1. 已存在相同 MD5 文件,直接返回 URL
+         */
+        String md5 = DigestUtil.md5Hex(file.getBytes());
+        OssFileInfoBO bo = ossUploadDownloadFacade.findByHash(md5);
+        if (bo == null) {
+
+            /*
+            2. 普通上传
+             */
+            attr = ObjectUtil.defaultIfNull(attr, UploadFileAttributeDTO::new);
+            attr
+                    .setOriginalFilename(file.getOriginalFilename())
+                    .setContentType(file.getContentType())
+                    .setMd5(md5)
+            ;
+            bo = ossUploadDownloadFacade.upload(file.getBytes(), attr);
+        }
+
+        return ApiResult.data(this.toUploadResult(bo, request.getRequestURL().toString()));
+    }
+
+    @ApiOperation(value = "下载文件(根据文件ID)", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+    @GetMapping(value = "/{id}")
+    // 如果需要登录后才能下载,请解禁下方注解;注意是👇 后台管理对应的鉴权工具类
+    // @SaCheckLogin(type = AdminStpUtil.TYPE)
+    public void download(@PathVariable Long id, HttpServletResponse response) throws IOException {
+        OssFileDownloadReplyBO reply = ossUploadDownloadFacade.downloadById(id);
+
+        if (reply.isRedirect2DirectUrl()) {
+            // 302重定向
+            response.sendRedirect(reply.getDirectUrl());
+            return;
+        }
+
+        // 普通下载
+        response.setHeader(Header.CONTENT_TYPE.getValue(), MediaType.APPLICATION_OCTET_STREAM_VALUE);
+        response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
+        String downFileName = URLEncoder.encode(reply.getStorageFilename(), CharsetUtil.UTF_8);
+        response.setHeader(Header.CONTENT_DISPOSITION.getValue(), "attachment;filename=" + downFileName);
+
+        IoUtil.write(response.getOutputStream(), false, reply.getFileBytes());
+    }
+
+    /**
+     * 将 OssFileInfoBO 转换为 OssFileUploadResultVO
+     */
+    private OssFileUploadResultVO toUploadResult(OssFileInfoBO ossFileInfo, String requestUrl) {
+        OssFileUploadResultVO ret = new OssFileUploadResultVO()
+                .setFileId(ossFileInfo.getId())
+                .setFilename(ossFileInfo.getStorageFilenameFull());
+
+        /*
+        这里请根据实际业务性质调整
+        有的业务出于安全目的,上传后文件只能通过文件ID才能下载
+        有的业务没有限制,上传后文件完全可以直接通过对象存储直链下载
+        但本地存储又没有直链,只能通过文件ID;
+        默认地,此处按【本地存储or对象存储直链为空:通过文件ID下载;对象存储:通过对象存储直链下载】返回 url
+         */
+        if (
+                ossUploadDownloadFacade.isLocalPlatform(ossFileInfo.getStoragePlatform())
+                        || StrUtil.isEmpty(ossFileInfo.getDirectUrl())
+        ) {
+            ret.setUrl(
+                    // 默认接口风格为 RESTful,下载即为最后拼接“/{文件ID}”
+                    String.format("%s/%s", requestUrl, ossFileInfo.getId())
+            );
+        } else {
+            ret.setUrl(ossFileInfo.getDirectUrl());
+        }
+
+        return ret;
+    }
+}

+ 7 - 0
src/main/java/cc/uncarbon/module/sys/annotation/SysLog.java

@@ -19,4 +19,11 @@ public @interface SysLog {
      */
     String value() default "";
 
+    /**
+     * 是否同步保存至系统日志数据表中
+     * true = 同步保存:如果开启了事务/事务注解,若系统日志保存失败,则会抛出异常触发回滚,使得本次操作也失败
+     * false = 异步保存:若系统日志保存失败,不影响本次操作
+     */
+    boolean syncSaving() default false;
+
 }

+ 59 - 17
src/main/java/cc/uncarbon/module/sys/aspect/SysLogAspect.java

@@ -5,11 +5,12 @@ import cc.uncarbon.framework.satoken.util.IPUtil;
 import cc.uncarbon.module.sys.annotation.SysLog;
 import cc.uncarbon.module.sys.aspect.extension.SysLogAspectExtension;
 import cc.uncarbon.module.sys.constant.SysConstant;
-import cc.uncarbon.module.sys.entity.SysLogEntity;
 import cc.uncarbon.module.sys.enums.SysLogStatusEnum;
+import cc.uncarbon.module.sys.model.request.AdminInsertSysLogDTO;
 import cc.uncarbon.module.sys.service.SysLogService;
 import cn.hutool.core.bean.BeanUtil;
 import cn.hutool.core.bean.copier.CopyOptions;
+import cn.hutool.core.text.StrPool;
 import cn.hutool.json.JSONUtil;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -57,15 +58,51 @@ public class SysLogAspect {
 
     @Around("sysLogPointcut()")
     public Object sysLogAround(ProceedingJoinPoint point) throws Throwable {
-        // --------------------Begin @SysLog--------------------
-
-        Object executeResult;
-        executeResult = point.proceed();
+        // 切面点执行结果
+        Object executeResult = null;
+        // 切面点执行是否成功
+        boolean executeSuccessFlag = false;
+        // 切面点执行过程中抛出的异常
+        Exception executeFailedException = null;
+        try {
+            executeResult = point.proceed();
+            executeSuccessFlag = true;
+        } catch (Exception e) {
+            // store it
+            executeFailedException = e;
+        }
 
+        /*
+        记录对应系统日志
+         */
         MethodSignature methodSignature = (MethodSignature) point.getSignature();
         SysLog sysLogAnnotation = methodSignature.getMethod().getAnnotation(SysLog.class);
 
-        SysLogEntity sysLogEntity = new SysLogEntity()
+        if (sysLogAnnotation.syncSaving()) {
+            this.sysLogSaving(point, methodSignature, sysLogAnnotation, executeSuccessFlag);
+        } else {
+            this.sysLogSavingAsync(point, methodSignature, sysLogAnnotation, executeSuccessFlag);
+        }
+
+        /*
+        如果执行过程中存在异常则抛出
+         */
+        if (executeFailedException != null) {
+            throw executeFailedException;
+        }
+
+        return executeResult;
+    }
+
+    /**
+     * 同步保存系统日志
+     */
+    private void sysLogSaving(ProceedingJoinPoint point,
+                              MethodSignature methodSignature,
+                              SysLog sysLogAnnotation,
+                              boolean executeSuccessFlag
+    ) {
+        AdminInsertSysLogDTO dto = new AdminInsertSysLogDTO()
                 // 记录操作人
                 .setUserId(UserContextHolder.getUserId())
                 .setUsername(UserContextHolder.getUserName())
@@ -78,14 +115,14 @@ public class SysLogAspect {
         记录请求参数
          */
         HashMap<Object, Object> afterMasked = new HashMap<>();
-        sysLogEntity.setParams(Arrays.stream(point.getArgs()).map(
+        dto.setParams(Arrays.stream(point.getArgs()).map(
                 each -> {
                     // 先去除敏感字段后再入库
                     afterMasked.clear();
                     BeanUtil.copyProperties(each, afterMasked, copyOptions4MaskingArgs);
                     return JSONUtil.toJsonStr(afterMasked);
                 }
-        ).collect(Collectors.joining(",")));
+        ).collect(Collectors.joining(StrPool.COMMA)));
 
         /*
         记录IP地址
@@ -99,25 +136,30 @@ public class SysLogAspect {
                 ip = IPUtil.getClientIPAddress(requestAttributes.getRequest());
             }
         }
-        sysLogEntity.setIp(ip);
+        dto.setIp(ip);
 
         // 记录状态
-        sysLogEntity.setStatus(SysLogStatusEnum.SUCCESS);
+        dto.setStatus(executeSuccessFlag ? SysLogStatusEnum.SUCCESS : SysLogStatusEnum.FAILED);
 
         // 执行扩展 - 保存到 DB 前
         for (SysLogAspectExtension extension : extensions) {
-            extension.beforeSaving(sysLogAnnotation, point, sysLogEntity);
+            extension.beforeSaving(sysLogAnnotation, point, dto);
         }
 
-        this.callSysLogServiceSave(sysLogEntity);
-        // --------------------End @SysLog--------------------
-
-        return executeResult;
+        // 保存系统日志
+        sysLogService.adminInsert(dto);
     }
 
+    /**
+     * 异步保存系统日志
+     */
     @Async(value = "taskExecutor")
-    void callSysLogServiceSave(SysLogEntity sysLogEntity) {
-        sysLogService.save(sysLogEntity);
+    public void sysLogSavingAsync(ProceedingJoinPoint point,
+                                  MethodSignature methodSignature,
+                                  SysLog sysLogAnnotation,
+                                  boolean executeSuccessFlag
+    ) {
+        this.sysLogSaving(point, methodSignature, sysLogAnnotation, executeSuccessFlag);
     }
 
 }

+ 3 - 3
src/main/java/cc/uncarbon/module/sys/aspect/extension/SysLogAspectExtension.java

@@ -1,7 +1,7 @@
 package cc.uncarbon.module.sys.aspect.extension;
 
 import cc.uncarbon.module.sys.annotation.SysLog;
-import cc.uncarbon.module.sys.entity.SysLogEntity;
+import cc.uncarbon.module.sys.model.request.AdminInsertSysLogDTO;
 import org.aspectj.lang.ProceedingJoinPoint;
 
 /**
@@ -15,8 +15,8 @@ public interface SysLogAspectExtension {
      * 保存到 DB 前
      * @param sysLogAnnotation 注解
      * @param point 切面 point
-     * @param sysLogEntity 日志实体对象
+     * @param insertSysLogDTO 日志请求体
      */
-    void beforeSaving(SysLog sysLogAnnotation, ProceedingJoinPoint point, SysLogEntity sysLogEntity);
+    void beforeSaving(SysLog sysLogAnnotation, ProceedingJoinPoint point, AdminInsertSysLogDTO insertSysLogDTO);
 
 }

+ 4 - 4
src/main/java/cc/uncarbon/module/sys/aspect/extension/SysLogAspectExtensionForSysUserLogin.java

@@ -2,7 +2,7 @@ package cc.uncarbon.module.sys.aspect.extension;
 
 import cc.uncarbon.module.sys.annotation.SysLog;
 import cc.uncarbon.module.sys.constant.SysConstant;
-import cc.uncarbon.module.sys.entity.SysLogEntity;
+import cc.uncarbon.module.sys.model.request.AdminInsertSysLogDTO;
 import cc.uncarbon.module.sys.model.request.SysUserLoginDTO;
 import org.aspectj.lang.ProceedingJoinPoint;
 import org.springframework.stereotype.Component;
@@ -16,14 +16,14 @@ import org.springframework.stereotype.Component;
 public class SysLogAspectExtensionForSysUserLogin implements SysLogAspectExtension {
 
     @Override
-    public void beforeSaving(SysLog sysLogAnnotation, ProceedingJoinPoint point, SysLogEntity sysLogEntity) {
+    public void beforeSaving(SysLog sysLogAnnotation, ProceedingJoinPoint point, AdminInsertSysLogDTO insertSysLogDTO) {
         if (SysConstant.SysLogOperation.SYS_USER_LOGIN.equals(sysLogAnnotation.value())) {
             // 仅针对特定注解扩展
             for (Object arg : point.getArgs()) {
                 if (arg instanceof SysUserLoginDTO) {
                     SysUserLoginDTO dto = (SysUserLoginDTO) arg;
-                    sysLogEntity.setUsername(dto.getUsername());
-                    sysLogEntity.setIp(dto.getClientIP());
+                    insertSysLogDTO.setUsername(dto.getUsername());
+                    insertSysLogDTO.setIp(dto.getClientIP());
                 }
             }
         }

+ 5 - 0
src/main/java/cc/uncarbon/module/sys/enums/SysLogStatusEnum.java

@@ -23,6 +23,11 @@ public enum SysLogStatusEnum implements HelioBaseEnum<Integer> {
      * 成功
      */
     SUCCESS(1, "成功"),
+
+    /**
+     * 失败
+     */
+    FAILED(2, "失败"),
     ;
 
     @EnumValue

+ 44 - 0
src/main/java/cc/uncarbon/module/sys/model/request/AdminInsertSysLogDTO.java

@@ -0,0 +1,44 @@
+package cc.uncarbon.module.sys.model.request;
+
+import cc.uncarbon.module.sys.enums.SysLogStatusEnum;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.experimental.Accessors;
+
+import java.io.Serializable;
+
+
+/**
+ * 后台管理-新增操作日志
+ * @author Uncarbon
+ */
+@Accessors(chain = true)
+@AllArgsConstructor
+@NoArgsConstructor
+@Data
+public class AdminInsertSysLogDTO implements Serializable {
+
+    @ApiModelProperty(value = "用户ID")
+    private Long userId;
+
+    @ApiModelProperty(value = "用户账号")
+    private String username;
+
+    @ApiModelProperty(value = "操作内容")
+    private String operation;
+
+    @ApiModelProperty(value = "请求方法")
+    private String method;
+
+    @ApiModelProperty(value = "请求参数")
+    private String params;
+
+    @ApiModelProperty(value = "IP地址")
+    private String ip;
+
+    @ApiModelProperty(value = "状态")
+    private SysLogStatusEnum status;
+
+}

+ 17 - 0
src/main/java/cc/uncarbon/module/sys/service/SysLogService.java

@@ -7,6 +7,7 @@ import cc.uncarbon.framework.crud.service.impl.HelioBaseServiceImpl;
 import cc.uncarbon.module.sys.entity.SysLogEntity;
 import cc.uncarbon.module.sys.enums.SysErrorEnum;
 import cc.uncarbon.module.sys.mapper.SysLogMapper;
+import cc.uncarbon.module.sys.model.request.AdminInsertSysLogDTO;
 import cc.uncarbon.module.sys.model.request.AdminListSysLogDTO;
 import cc.uncarbon.module.sys.model.response.SysLogBO;
 import cn.hutool.core.bean.BeanUtil;
@@ -18,6 +19,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
 import java.util.ArrayList;
 import java.util.Collections;
@@ -84,6 +86,21 @@ public class SysLogService extends HelioBaseServiceImpl<SysLogMapper, SysLogEnti
         return this.entity2BO(entity);
     }
 
+    /**
+     * 后台管理-新增
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public Long adminInsert(AdminInsertSysLogDTO dto) {
+        log.info("[后台管理-新增操作日志] >> 入参={}", dto);
+
+        SysLogEntity entity = new SysLogEntity();
+        BeanUtil.copyProperties(dto, entity);
+
+        this.save(entity);
+
+        return entity.getId();
+    }
+
     /*
     ----------------------------------------------------------------
                         私有方法 private methods

+ 1 - 1
src/main/java/cc/uncarbon/module/sys/service/SysMenuService.java

@@ -173,7 +173,7 @@ public class SysMenuService extends HelioBaseServiceImpl<SysMenuMapper, SysMenuE
             return Collections.emptyMap();
         }
 
-        HashMap<Long, Set<String>> ret = new HashMap<>(roleIds.size());
+        HashMap<Long, Set<String>> ret = new HashMap<>(roleIds.size(), 1);
 
         roleIds.forEach(
                 roleId -> {

+ 23 - 0
src/main/resources/application-dev.yml

@@ -29,6 +29,29 @@ spring:
     password:
     # Redis数据库序号
     database: 1
+  file-storage:
+    # 文档链接: https://spring-file-storage.xuyanwu.cn/#/%E5%BF%AB%E9%80%9F%E5%85%A5%E9%97%A8?id=%e9%85%8d%e7%bd%ae
+    # 默认使用 minio-1 存储平台
+    default-platform: minio-1
+    # 缩略图后缀
+    thumbnail-suffix: ".min.jpg"
+    local-plus: # 本地存储升级版,不使用的情况下可以不写
+      - platform: local-plus-1 # 存储平台标识
+        enable-storage: true  #启用存储
+        enable-access: false #启用访问(线上请使用 Nginx 配置,效率更高)
+        domain: "" # 访问域名,例如:“http://127.0.0.1:8030/”,注意后面要和 path-patterns 保持一致,“/”结尾,本地存储建议使用相对路径,方便后期更换域名
+        base-path: / # 基础路径
+        path-patterns: /** # 访问路径
+        storage-path: (实际本地存储路径,如 D:/local-plus-1) # 实际本地存储路径
+    minio: # MinIO,由于 MinIO SDK 支持 AWS S3,其它兼容 AWS S3 协议的存储平台也都可配置在这里
+      - platform: minio-1 # 存储平台标识
+        enable-storage: true  # 启用存储
+        access-key: (MinIO AK)
+        secret-key: (MinIO SK)
+        end-point: http://127.0.0.1:9000
+        bucket-name: helio
+        domain: http://127.0.0.1:9000/helio/ # 访问域名,注意“/”结尾,例如:http://minio.abc.com/abc/
+        base-path: / # 基础路径
 
 knife4j:
   # 生产环境务必设为true

+ 6 - 0
src/main/resources/application.yml

@@ -17,6 +17,9 @@ spring:
     pathmatch:
       # Boot 2.6+ 默认的路由匹配策略,Swagger 暂不兼容,使用老策略
       matching-strategy: ant_path_matcher
+  messages:
+    # 国际化资源目录,一般不做修改
+    basename: i18n/messages
 
 helio:
   crud:
@@ -52,6 +55,9 @@ helio:
       - sys_role_menu_relation
       - sys_user_dept_relation
       - sys_user_role_relation
+  i18n:
+    # 是否对返回消息启用国际化翻译
+    enabled: false
 
 knife4j:
   # 保持true

+ 13 - 0
src/main/resources/i18n/messages.properties

@@ -0,0 +1,13 @@
+# 具体请参阅文档:进阶使用-国际化
+#-- Web 全局异常处理国际化消息 - 默认值
+GLOBAL__NO_LOGIN=请您先登录
+GLOBAL__PERMISSION_NOT_MATCH=您权限不足
+GLOBAL__ROLE_NOT_MATCH=您与要求角色不符
+GLOBAL__NOT_FOUND=你迷路啦
+GLOBAL__UNACCEPTABLE_PARAMETERS=错误参数格式或值
+GLOBAL__METHOD_NOT_ALLOWED=错误的请求方式
+GLOBAL__INTERNAL_ERROR=请稍后再试
+
+#-- SysErrorEnum 不指定则使用原有定义的label作为提示消息
+
+#-- OssErrorEnum

+ 24 - 0
src/main/resources/i18n/messages_en_US.properties

@@ -0,0 +1,24 @@
+#-- Web 全局异常处理国际化消息 - 美式英语
+GLOBAL__NO_LOGIN=No login
+GLOBAL__PERMISSION_NOT_MATCH=Permission not match
+GLOBAL__ROLE_NOT_MATCH=Role not match
+GLOBAL__NOT_FOUND=Page not found
+GLOBAL__UNACCEPTABLE_PARAMETERS=Unacceptable parameters
+GLOBAL__METHOD_NOT_ALLOWED=Method not allowed
+GLOBAL__INTERNAL_ERROR=Please try again later
+
+#-- SysErrorEnum
+SysErrorEnum.INVALID_ID=Invalid id
+SysErrorEnum.INCORRECT_PIN_OR_PWD=Incorrect username or password
+SysErrorEnum.BANNED_USER=Banned user
+SysErrorEnum.INVALID_TENANT=Invalid tenant
+SysErrorEnum.DISABLED_TENANT=Disabled tenant
+SysErrorEnum.INCORRECT_OLD_PASSWORD=Old password is incorrect
+SysErrorEnum.NO_ROLE_AVAILABLE_FOR_CURRENT_USER=No role available for current user
+SysErrorEnum.NO_MENU_AVAILABLE_FOR_CURRENT_ROLE=No menu available for current role
+SysErrorEnum.UUID_CANNOT_BE_BLANK=UUID cannot be blank
+
+#-- OssErrorEnum
+OssErrorEnum.INVALID_ID=Invalid id
+OssErrorEnum.FILE_UPLOAD_FAILED=File upload failed, please contact administrator
+OssErrorEnum.FILE_DOWNLOAD_FAILED=File download failed, please contact administrator

+ 13 - 0
src/main/resources/i18n/messages_zh_CN.properties

@@ -0,0 +1,13 @@
+#-- Web 全局异常处理国际化消息 - 中文简体
+GLOBAL__NO_LOGIN=请您先登录
+GLOBAL__PERMISSION_NOT_MATCH=您权限不足
+GLOBAL__ROLE_NOT_MATCH=您与要求角色不符
+GLOBAL__NOT_FOUND=你迷路啦
+GLOBAL__UNACCEPTABLE_PARAMETERS=错误参数格式或值
+GLOBAL__METHOD_NOT_ALLOWED=错误的请求方式
+GLOBAL__INTERNAL_ERROR=请稍后再试
+
+#-- SysErrorEnum 不指定则使用原有定义的label作为提示消息
+# SysErrorEnum.INCORRECT_PIN_OR_PWD=你账号或者密码是不是有啥子输错了噢?
+
+#-- OssErrorEnum