feat(system/file): 新增支持文件回收站

This commit is contained in:
2025-11-16 20:52:33 +08:00
parent 95c1776606
commit 41583ea61b
17 changed files with 525 additions and 80 deletions

View File

@@ -61,6 +61,10 @@ VALUES
(1116, '下载', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:file:download', 6, 1, 1, NOW()),
(1117, '创建文件夹', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:file:createDir', 7, 1, 1, NOW()),
(1118, '计算文件夹大小', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:file:calcDirSize', 8, 1, 1, NOW()),
(1119, '回收站文件列表', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:fileRecycle:list', 9, 1, 1, NOW()),
(1120, '还原回收站文件', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:fileRecycle:restore', 10, 1, 1, NOW()),
(1121, '删除回收站文件', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:fileRecycle:delete', 11, 1, 1, NOW()),
(1122, '清空回收站', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:fileRecycle:clean', 12, 1, 1, NOW()),
(1130, '字典管理', 1000, 2, '/system/dict', 'SystemDict', 'system/dict/index', NULL, 'bookmark', b'0', b'0', b'0', NULL, 7, 1, 1, NOW()),
(1131, '列表', 1130, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:dict:list', 1, 1, 1, NOW()),
@@ -278,10 +282,10 @@ INSERT INTO `sys_role_dept` (`role_id`, `dept_id`) VALUES (547888897925840927, 5
-- 初始化默认存储
INSERT INTO `sys_storage`
(`id`, `name`, `code`, `type`, `access_key`, `secret_key`, `endpoint`, `bucket_name`, `domain`, `description`, `is_default`, `sort`, `status`, `create_user`, `create_time`)
(`id`, `name`, `code`, `type`, `access_key`, `secret_key`, `endpoint`, `bucket_name`, `domain`, `recycle_bin_enabled`, `recycle_bin_path`, `description`, `is_default`, `sort`, `status`, `create_user`, `create_time`)
VALUES
(1, '开发环境', 'local_dev', 1, NULL, NULL, NULL, 'C:/continew-admin/data/file/', 'http://localhost:8000/file/', '本地存储', b'1', 1, 1, 1, NOW()),
(2, '生产环境', 'local_prod', 1, NULL, NULL, NULL, '../data/file/', 'http://api.continew.top/file/', '本地存储', b'0', 2, 2, 1, NOW());
(1, '开发环境', 'local_dev', 1, NULL, NULL, NULL, 'C:/continew-admin/data/file/', 'http://localhost:8000/file/', b'1', '.RECYCLE.BIN/', '本地存储', b'1', 1, 1, 1, NOW()),
(2, '生产环境', 'local_prod', 1, NULL, NULL, NULL, '../data/file/', 'http://api.continew.top/file/', b'1', '.RECYCLE.BIN/', '本地存储', b'0', 2, 2, 1, NOW());
-- 初始化客户端数据
INSERT INTO `sys_client`

View File

@@ -283,24 +283,26 @@ CREATE TABLE IF NOT EXISTS `sys_notice_log` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公告日志表';
CREATE TABLE IF NOT EXISTS `sys_storage` (
`id` bigint(20) AUTO_INCREMENT COMMENT 'ID',
`name` varchar(100) NOT NULL COMMENT '名称',
`code` varchar(30) NOT NULL COMMENT '编码',
`type` tinyint(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '类型1本地存储2对象存储',
`access_key` varchar(255) DEFAULT NULL COMMENT 'Access Key',
`secret_key` varchar(255) DEFAULT NULL COMMENT 'Secret Key',
`endpoint` varchar(255) DEFAULT NULL COMMENT 'Endpoint',
`bucket_name` varchar(255) NOT NULL COMMENT 'Bucket',
`domain` varchar(255) DEFAULT NULL COMMENT '域名',
`description` varchar(200) DEFAULT NULL COMMENT '描述',
`is_default` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否为默认存储',
`sort` int NOT NULL DEFAULT 999 COMMENT '排序',
`status` tinyint(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '状态1启用2禁用',
`create_user` bigint(20) NOT NULL COMMENT '创建人',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_user` bigint(20) DEFAULT NULL COMMENT '修改',
`update_time` datetime DEFAULT NULL COMMENT '修改时间',
`deleted` bigint(20) NOT NULL DEFAULT 0 COMMENT '是否已删除0id',
`id` bigint(20) AUTO_INCREMENT COMMENT 'ID',
`name` varchar(100) NOT NULL COMMENT '名称',
`code` varchar(30) NOT NULL COMMENT '编码',
`type` tinyint(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '类型1本地存储2对象存储',
`access_key` varchar(255) DEFAULT NULL COMMENT 'Access Key',
`secret_key` varchar(255) DEFAULT NULL COMMENT 'Secret Key',
`endpoint` varchar(255) DEFAULT NULL COMMENT 'Endpoint',
`bucket_name` varchar(255) NOT NULL COMMENT 'Bucket',
`domain` varchar(255) DEFAULT NULL COMMENT '域名',
`recycle_bin_enabled` bit(1) NOT NULL DEFAULT b'1' COMMENT '启用回收站',
`recycle_bin_path` varchar(255) DEFAULT NULL COMMENT '回收站路径',
`description` varchar(200) DEFAULT NULL COMMENT '描述',
`is_default` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否为默认存储',
`sort` int NOT NULL DEFAULT 999 COMMENT '排序',
`status` tinyint(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '状态1启用2禁用',
`create_user` bigint(20) NOT NULL COMMENT '创建',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_user` bigint(20) DEFAULT NULL COMMENT '修改人',
`update_time` datetime DEFAULT NULL COMMENT '修改时间',
`deleted` bigint(20) NOT NULL DEFAULT 0 COMMENT '是否已删除0id',
PRIMARY KEY (`id`),
UNIQUE INDEX `uk_code`(`code`, `deleted`),
INDEX `idx_create_user`(`create_user`),

View File

@@ -61,6 +61,10 @@ VALUES
(1116, '下载', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:file:download', 6, 1, 1, NOW()),
(1117, '创建文件夹', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:file:createDir', 7, 1, 1, NOW()),
(1118, '计算文件夹大小', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:file:calcDirSize', 8, 1, 1, NOW()),
(1119, '回收站文件列表', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:fileRecycle:list', 9, 1, 1, NOW()),
(1120, '还原回收站文件', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:fileRecycle:restore', 10, 1, 1, NOW()),
(1121, '删除回收站文件', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:fileRecycle:delete', 11, 1, 1, NOW()),
(1122, '清空回收站', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:fileRecycle:clean', 12, 1, 1, NOW()),
(1130, '字典管理', 1000, 2, '/system/dict', 'SystemDict', 'system/dict/index', NULL, 'bookmark', false, false, false, NULL, 7, 1, 1, NOW()),
(1131, '列表', 1130, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:dict:list', 1, 1, 1, NOW()),
@@ -278,10 +282,10 @@ INSERT INTO "sys_role_dept" ("role_id", "dept_id") VALUES (547888897925840927, 5
-- 初始化默认存储
INSERT INTO "sys_storage"
("id", "name", "code", "type", "access_key", "secret_key", "endpoint", "bucket_name", "domain", "description", "is_default", "sort", "status", "create_user", "create_time")
("id", "name", "code", "type", "access_key", "secret_key", "endpoint", "bucket_name", "domain", "recycle_bin_enabled", "recycle_bin_path", "description", "is_default", "sort", "status", "create_user", "create_time")
VALUES
(1, '开发环境', 'local_dev', 1, NULL, NULL, NULL, 'C:/continew-admin/data/file/', 'http://localhost:8000/file/', '本地存储', true, 1, 1, 1, NOW()),
(2, '生产环境', 'local_prod', 1, NULL, NULL, NULL, '../data/file/', 'http://api.continew.top/file/', '本地存储', false, 2, 2, 1, NOW());
(1, '开发环境', 'local_dev', 1, NULL, NULL, NULL, 'C:/continew-admin/data/file/', 'http://localhost:8000/file/', true, '.RECYCLE.BIN/', '本地存储', true, 1, 1, 1, NOW()),
(2, '生产环境', 'local_prod', 1, NULL, NULL, NULL, '../data/file/', 'http://api.continew.top/file/', true, '.RECYCLE.BIN/', '本地存储', false, 2, 2, 1, NOW());
-- 初始化客户端数据
INSERT INTO "sys_client"

View File

@@ -466,49 +466,53 @@ COMMENT ON COLUMN "sys_notice_log"."read_time" IS '读取时间';
COMMENT ON TABLE "sys_notice_log" IS '公告日志表';
CREATE TABLE IF NOT EXISTS "sys_storage" (
"id" int8 NOT NULL,
"name" varchar(100) NOT NULL,
"code" varchar(30) NOT NULL,
"type" int2 NOT NULL DEFAULT 1,
"access_key" varchar(255) DEFAULT NULL,
"secret_key" varchar(255) DEFAULT NULL,
"endpoint" varchar(255) DEFAULT NULL,
"bucket_name" varchar(255) NOT NULL,
"domain" varchar(255) DEFAULT NULL,
"description" varchar(200) DEFAULT NULL,
"is_default" bool NOT NULL DEFAULT false,
"sort" int4 NOT NULL DEFAULT 999,
"status" int2 NOT NULL DEFAULT 1,
"create_user" int8 NOT NULL,
"create_time" timestamp NOT NULL,
"update_user" int8 DEFAULT NULL,
"update_time" timestamp DEFAULT NULL,
"deleted" int8 NOT NULL DEFAULT 0,
"id" int8 NOT NULL,
"name" varchar(100) NOT NULL,
"code" varchar(30) NOT NULL,
"type" int2 NOT NULL DEFAULT 1,
"access_key" varchar(255) DEFAULT NULL,
"secret_key" varchar(255) DEFAULT NULL,
"endpoint" varchar(255) DEFAULT NULL,
"bucket_name" varchar(255) NOT NULL,
"domain" varchar(255) DEFAULT NULL,
"recycle_bin_enabled" bool NOT NULL DEFAULT true,
"recycle_bin_path" varchar(255) DEFAULT NULL,
"description" varchar(200) DEFAULT NULL,
"is_default" bool NOT NULL DEFAULT false,
"sort" int4 NOT NULL DEFAULT 999,
"status" int2 NOT NULL DEFAULT 1,
"create_user" int8 NOT NULL,
"create_time" timestamp NOT NULL,
"update_user" int8 DEFAULT NULL,
"update_time" timestamp DEFAULT NULL,
"deleted" int8 NOT NULL DEFAULT 0,
PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "uk_storage_code" ON "sys_storage" ("code", "deleted");
CREATE INDEX "idx_storage_create_user" ON "sys_storage" ("create_user");
CREATE INDEX "idx_storage_update_user" ON "sys_storage" ("update_user");
CREATE INDEX "idx_storage_deleted" ON "sys_storage" ("deleted");
COMMENT ON COLUMN "sys_storage"."id" IS 'ID';
COMMENT ON COLUMN "sys_storage"."name" IS '名称';
COMMENT ON COLUMN "sys_storage"."code" IS '编码';
COMMENT ON COLUMN "sys_storage"."type" IS '类型1本地存储2对象存储';
COMMENT ON COLUMN "sys_storage"."access_key" IS 'Access Key';
COMMENT ON COLUMN "sys_storage"."secret_key" IS 'Secret Key';
COMMENT ON COLUMN "sys_storage"."endpoint" IS 'Endpoint';
COMMENT ON COLUMN "sys_storage"."bucket_name" IS 'Bucket';
COMMENT ON COLUMN "sys_storage"."domain" IS '域名';
COMMENT ON COLUMN "sys_storage"."description" IS '描述';
COMMENT ON COLUMN "sys_storage"."is_default" IS '是否为默认存储';
COMMENT ON COLUMN "sys_storage"."sort" IS '排序';
COMMENT ON COLUMN "sys_storage"."status" IS '状态1启用2禁用';
COMMENT ON COLUMN "sys_storage"."create_user" IS '创建人';
COMMENT ON COLUMN "sys_storage"."create_time" IS '创建时间';
COMMENT ON COLUMN "sys_storage"."update_user" IS '修改';
COMMENT ON COLUMN "sys_storage"."update_time" IS '修改时间';
COMMENT ON COLUMN "sys_storage"."deleted" IS '是否已删除0id';
COMMENT ON TABLE "sys_storage" IS '存储表';
COMMENT ON COLUMN "sys_storage"."id" IS 'ID';
COMMENT ON COLUMN "sys_storage"."name" IS '名称';
COMMENT ON COLUMN "sys_storage"."code" IS '编码';
COMMENT ON COLUMN "sys_storage"."type" IS '类型1本地存储2对象存储';
COMMENT ON COLUMN "sys_storage"."access_key" IS 'Access Key';
COMMENT ON COLUMN "sys_storage"."secret_key" IS 'Secret Key';
COMMENT ON COLUMN "sys_storage"."endpoint" IS 'Endpoint';
COMMENT ON COLUMN "sys_storage"."bucket_name" IS 'Bucket';
COMMENT ON COLUMN "sys_storage"."domain" IS '域名';
COMMENT ON COLUMN "sys_storage"."recycle_bin_enabled" IS '启用回收站';
COMMENT ON COLUMN "sys_storage"."recycle_bin_path" IS '回收站路径';
COMMENT ON COLUMN "sys_storage"."description" IS '描述';
COMMENT ON COLUMN "sys_storage"."is_default" IS '是否为默认存储';
COMMENT ON COLUMN "sys_storage"."sort" IS '排序';
COMMENT ON COLUMN "sys_storage"."status" IS '状态1启用2禁用';
COMMENT ON COLUMN "sys_storage"."create_user" IS '创建';
COMMENT ON COLUMN "sys_storage"."create_time" IS '创建时间';
COMMENT ON COLUMN "sys_storage"."update_user" IS '修改人';
COMMENT ON COLUMN "sys_storage"."update_time" IS '修改时间';
COMMENT ON COLUMN "sys_storage"."deleted" IS '是否已删除0id';
COMMENT ON TABLE "sys_storage" IS '存储表';
CREATE TABLE IF NOT EXISTS "sys_file" (
"id" int8 NOT NULL,

View File

@@ -37,6 +37,7 @@ import top.continew.starter.core.util.URLUtils;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
@@ -55,6 +56,9 @@ public class FileRecorderImpl implements FileRecorder {
@Override
public boolean save(FileInfo fileInfo) {
if (fileInfo.getAttr() == null) {
return true;
}
// 保存文件信息
FileDO file = new FileDO(fileInfo);
StorageDO storage = (StorageDO)fileInfo.getAttr().get(ClassUtil.getClassName(StorageDO.class, false));
@@ -87,7 +91,7 @@ public class FileRecorderImpl implements FileRecorder {
public boolean delete(String url) {
FileDO file = this.getFileByUrl(url);
if (file == null) {
return false;
return true;
}
return fileMapper.lambdaUpdate().eq(FileDO::getId, file.getId()).remove();
}
@@ -131,7 +135,7 @@ public class FileRecorderImpl implements FileRecorder {
// 结合存储配置进行匹配
List<StorageDO> storageList = storageMapper.selectByIds(CollUtils.mapToList(list, FileDO::getStorageId));
Map<Long, StorageDO> storageMap = storageList.stream()
.collect(Collectors.toMap(StorageDO::getId, storage -> storage));
.collect(Collectors.toMap(StorageDO::getId, Function.identity(), (existing, replacement) -> existing));
return list.stream().filter(file -> {
// http://localhost:8000/file/user/avatar/6825e687db4174e7a297a5f8.png => http://localhost:8000/file/user/avatar
String urlPrefix = StrUtil.subBefore(url, StringConstants.SLASH, true);

View File

@@ -0,0 +1,72 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.admin.system.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import top.continew.admin.system.model.query.FileQuery;
import top.continew.admin.system.model.resp.file.FileResp;
import top.continew.admin.system.service.FileRecycleService;
import top.continew.starter.extension.crud.model.query.PageQuery;
import top.continew.starter.extension.crud.model.resp.PageResp;
/**
* 文件回收站管理 API
*
* @author Charles7c
* @since 2025/11/11 21:28
*/
@Tag(name = "文件回收站管理 API")
@RestController
@RequiredArgsConstructor
@RequestMapping("/system/file/recycle")
public class FileRecycleController {
private final FileRecycleService baseService;
@Operation(summary = "分页查询列表", description = "分页查询列表")
@SaCheckPermission("system:fileRecycle:list")
@GetMapping
public PageResp<FileResp> page(@Valid FileQuery query, @Valid PageQuery pageQuery) {
return baseService.page(query, pageQuery);
}
@Operation(summary = "还原文件", description = "还原文件")
@SaCheckPermission("system:fileRecycle:restore")
@PutMapping("/restore/{id}")
public void restore(@PathVariable Long id) {
baseService.restore(id);
}
@Operation(summary = "删除文件", description = "删除文件")
@SaCheckPermission("system:fileRecycle:delete")
@DeleteMapping("/{id}")
public void delete(@PathVariable Long id) {
baseService.delete(id);
}
@Operation(summary = "清空回收站", description = "清空回收站")
@SaCheckPermission("system:fileRecycle:clean")
@DeleteMapping("/clean")
public void clean() {
baseService.clean();
}
}

View File

@@ -89,5 +89,10 @@ public enum StorageTypeEnum implements BaseEnum<Integer> {
if (StrUtil.isNotBlank(req.getDomain())) {
req.setDomain(StrUtil.appendIfMissing(req.getDomain(), StringConstants.SLASH));
}
// 回收站路径需要以 “/” 结尾
if (Boolean.TRUE.equals(req.getRecycleBinEnabled())) {
req.setRecycleBinPath(StrUtil.appendIfMissing(StrUtil.removePrefix(req
.getRecycleBinPath(), StringConstants.SLASH), StringConstants.SLASH));
}
}
}

View File

@@ -16,8 +16,14 @@
package top.continew.admin.system.mapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Constants;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import top.continew.admin.system.model.entity.FileDO;
import top.continew.admin.system.model.resp.file.FileStatisticsResp;
import top.continew.starter.data.mapper.BaseMapper;
@@ -40,4 +46,57 @@ public interface FileMapper extends BaseMapper<FileDO> {
*/
@Select("SELECT type, COUNT(1) number, SUM(size) size FROM sys_file WHERE deleted = 0 AND type != 0 GROUP BY type")
List<FileStatisticsResp> statistics();
/**
* 分页查询回收站列表
*
* @param page 分页条件
* @param queryWrapper 查询条件
* @return 回收站分页列表信息
*/
@Select("SELECT * FROM sys_file ${ew.customSqlSegment}")
Page<FileDO> selectPageInRecycleBin(@Param("page") IPage<FileDO> page,
@Param(Constants.WRAPPER) LambdaQueryWrapper<FileDO> queryWrapper);
/**
* 根据 ID 查询(文件已进入回收站)
*
* @param id ID
* @return 文件信息
*/
@Select("SELECT * FROM sys_file WHERE id = #{id} AND deleted = 1")
FileDO selectByIdInRecycleBin(@Param("id") Long id);
/**
* 查询回收站文件列表
*
* @return 回收站文件列表
*/
@Select("SELECT * FROM sys_file WHERE deleted = 1")
List<FileDO> selectListInRecycleBin();
/**
* 从回收站恢复文件
*
* @param id ID
* @param userId 用户 ID
*/
@Update("UPDATE sys_file SET deleted = 0, update_user = #{userId}, update_time = NOW() WHERE id = #{id}")
void restoreInRecycleBin(@Param("id") Long id, @Param("userId") Long userId);
/**
* 删除文件(不进入回收站)
*
* @param ids ID 列表
* @param userId 用户 ID
*/
void deleteWithoutRecycleBin(@Param("ids") List<Long> ids, @Param("userId") Long userId);
/**
* 清空文件回收站
*
* @param userId 用户 ID
*/
@Update("UPDATE sys_file SET deleted = id, update_user = #{userId}, update_time = NOW() WHERE deleted = 1")
void cleanRecycleBin(@Param("userId") Long userId);
}

View File

@@ -19,6 +19,7 @@ package top.continew.admin.system.model.entity;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.NoArgsConstructor;
@@ -114,6 +115,12 @@ public class FileDO extends BaseDO {
*/
private Long storageId;
/**
* 是否已删除01回收站
*/
@TableLogic(value = "0", delval = "1")
private Long deleted;
/**
* 基于 {@link FileInfo} 构建文件信息对象
*
@@ -143,7 +150,7 @@ public class FileDO extends BaseDO {
/**
* 转换为 {@link FileInfo} 文件信息对象
*
* @param storage 存储配置信息
* @param storage 存储配置
* @return {@link FileInfo} 文件信息对象
*/
public FileInfo toFileInfo(StorageDO storage) {

View File

@@ -22,8 +22,8 @@ import cn.hutool.core.util.StrUtil;
import cn.hutool.core.util.URLUtil;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import top.continew.admin.common.enums.DisEnableStatusEnum;
import top.continew.admin.common.base.model.entity.BaseDO;
import top.continew.admin.common.enums.DisEnableStatusEnum;
import top.continew.admin.system.enums.StorageTypeEnum;
import top.continew.starter.core.constant.StringConstants;
import top.continew.starter.encrypt.field.annotation.FieldEncrypt;
@@ -86,6 +86,16 @@ public class StorageDO extends BaseDO {
*/
private String domain;
/**
* 启用回收站
*/
private Boolean recycleBinEnabled;
/**
* 回收站路径
*/
private String recycleBinPath;
/**
* 描述
*/

View File

@@ -16,6 +16,8 @@
package top.continew.admin.system.model.req;
import cn.sticki.spel.validator.constrain.SpelNotBlank;
import cn.sticki.spel.validator.jakarta.SpelValid;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
@@ -38,6 +40,7 @@ import java.io.Serializable;
* @since 2023/12/26 22:09
*/
@Data
@SpelValid
@Schema(description = "存储创建或修改请求参数")
public class StorageReq implements Serializable {
@@ -109,6 +112,20 @@ public class StorageReq implements Serializable {
@NotBlank(message = "访问路径不能为空", groups = ValidationGroup.Storage.Local.class)
private String domain;
/**
* 启用回收站
*/
@Schema(description = "启用回收站", example = "true")
@NotNull(message = "启用回收站无效")
private Boolean recycleBinEnabled;
/**
* 回收站路径
*/
@Schema(description = "回收站路径", example = ".RECYCLE.BIN/")
@SpelNotBlank(condition = "#this.recycleBinEnabled == true", message = "回收站路径不能为空")
private String recycleBinPath;
/**
* 排序
*/

View File

@@ -85,6 +85,18 @@ public class StorageResp extends BaseDetailResp {
@Schema(description = "域名", example = "http://localhost:8000/file")
private String domain;
/**
* 启用回收站
*/
@Schema(description = "启用回收站", example = "true")
private Boolean recycleBinEnabled;
/**
* 回收站路径
*/
@Schema(description = "回收站路径", example = ".RECYCLE.BIN/")
private String recycleBinPath;
/**
* 描述
*/

View File

@@ -0,0 +1,60 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.admin.system.service;
import jakarta.validation.Valid;
import top.continew.admin.system.model.query.FileQuery;
import top.continew.admin.system.model.resp.file.FileResp;
import top.continew.starter.extension.crud.model.query.PageQuery;
import top.continew.starter.extension.crud.model.resp.PageResp;
/**
* 文件回收站业务接口
*
* @author Charles7c
* @since 2025/11/11 21:28
*/
public interface FileRecycleService {
/**
* 分页查询列表
*
* @param query 查询参数
* @param pageQuery 分页参数
* @return 文件列表
*/
PageResp<FileResp> page(@Valid FileQuery query, @Valid PageQuery pageQuery);
/**
* 还原文件
*
* @param id ID
*/
void restore(Long id);
/**
* 删除文件
*
* @param id ID
*/
void delete(Long id);
/**
* 清空回收站
*/
void clean();
}

View File

@@ -0,0 +1,132 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.admin.system.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import org.dromara.x.file.storage.core.FileInfo;
import org.dromara.x.file.storage.core.FileStorageService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import top.continew.admin.common.context.UserContextHolder;
import top.continew.admin.system.mapper.FileMapper;
import top.continew.admin.system.model.entity.FileDO;
import top.continew.admin.system.model.entity.StorageDO;
import top.continew.admin.system.model.query.FileQuery;
import top.continew.admin.system.model.resp.file.FileResp;
import top.continew.admin.system.service.FileRecycleService;
import top.continew.admin.system.service.StorageService;
import top.continew.starter.core.constant.StringConstants;
import top.continew.starter.core.exception.BusinessException;
import top.continew.starter.data.util.QueryWrapperHelper;
import top.continew.starter.extension.crud.model.query.PageQuery;
import top.continew.starter.extension.crud.model.resp.PageResp;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 文件回收站业务实现
*
* @author Charles7c
* @since 2025/11/11 21:28
*/
@Service
@RequiredArgsConstructor
public class FileRecycleServiceImpl implements FileRecycleService {
private final FileMapper fileMapper;
private final StorageService storageService;
private final FileStorageService fileStorageService;
@Override
public PageResp<FileResp> page(FileQuery query, PageQuery pageQuery) {
QueryWrapper<FileDO> queryWrapper = QueryWrapperHelper.build(query, pageQuery.getSort());
Page<FileDO> page = fileMapper.selectPageInRecycleBin(new Page<>(pageQuery.getPage(), pageQuery
.getSize()), queryWrapper.lambda().eq(FileDO::getDeleted, 1L));
return PageResp.build(page, FileResp.class);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void restore(Long id) {
FileDO file = this.getById(id);
// 恢复记录
fileMapper.restoreInRecycleBin(id, UserContextHolder.getUserId());
// 还原文件
StorageDO storage = storageService.getById(file.getStorageId());
FileInfo fileInfo = file.toFileInfo(storage);
fileInfo.setPath(storage.getRecycleBinPath() + fileInfo.getPath());
String newPath = fileInfo.getPath().replace(storage.getRecycleBinPath(), StringConstants.EMPTY);
fileStorageService.move(fileInfo).setPath(newPath).move();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
FileDO file = this.getById(id);
// 删除记录
fileMapper.deleteWithoutRecycleBin(List.of(id), UserContextHolder.getUserId());
// 删除文件
StorageDO storage = storageService.getById(file.getStorageId());
FileInfo fileInfo = file.toFileInfo(storage);
fileInfo.setPath(storage.getRecycleBinPath() + fileInfo.getPath());
fileStorageService.delete(fileInfo);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void clean() {
// 查询文件
List<FileDO> list = fileMapper.selectListInRecycleBin();
// 删除记录
fileMapper.cleanRecycleBin(UserContextHolder.getUserId());
// 删除文件
// 批量获取存储配置
Map<Long, List<FileDO>> fileListGroup = list.stream().collect(Collectors.groupingBy(FileDO::getStorageId));
List<StorageDO> storageList = storageService.listByIds(fileListGroup.keySet());
Map<Long, StorageDO> storageGroup = storageList.stream()
.collect(Collectors.toMap(StorageDO::getId, Function.identity(), (existing, replacement) -> existing));
// 删除文件
for (Map.Entry<Long, List<FileDO>> entry : fileListGroup.entrySet()) {
StorageDO storage = storageGroup.get(entry.getKey());
// 清空回收站
FileInfo fileInfo = new FileInfo();
fileInfo.setPlatform(storage.getCode());
fileInfo.setBasePath(StringConstants.EMPTY);
fileInfo.setPath(storage.getRecycleBinPath());
fileStorageService.delete(fileInfo);
}
}
/**
* 根据 ID 查询
*
* @param id ID
* @return 文件信息
*/
private FileDO getById(Long id) {
FileDO file = fileMapper.selectByIdInRecycleBin(id);
if (file == null) {
throw new BusinessException("ID 为 [%s] 的文件已不存在".formatted(id));
}
return file;
}
}

View File

@@ -30,8 +30,10 @@ import org.dromara.x.file.storage.core.ProgressListener;
import org.dromara.x.file.storage.core.upload.UploadPretreatment;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import top.continew.admin.common.base.service.BaseServiceImpl;
import top.continew.admin.common.context.UserContextHolder;
import top.continew.admin.common.enums.DisEnableStatusEnum;
import top.continew.admin.system.enums.FileTypeEnum;
import top.continew.admin.system.mapper.FileMapper;
@@ -45,6 +47,7 @@ import top.continew.admin.system.service.FileService;
import top.continew.admin.system.service.StorageService;
import top.continew.starter.cache.redisson.util.RedisLockUtils;
import top.continew.starter.core.constant.StringConstants;
import top.continew.starter.core.util.CollUtils;
import top.continew.starter.core.util.StrUtils;
import top.continew.starter.core.util.validation.CheckUtils;
import top.continew.starter.core.util.validation.ValidationUtils;
@@ -52,6 +55,7 @@ import top.continew.starter.core.util.validation.ValidationUtils;
import java.io.File;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
@@ -71,25 +75,32 @@ public class FileServiceImpl extends BaseServiceImpl<FileMapper, FileDO, FileRes
private StorageService storageService;
@Override
public void beforeDelete(List<Long> ids) {
@Transactional(rollbackFor = Exception.class)
public void delete(List<Long> ids) {
List<FileDO> fileList = baseMapper.lambdaQuery().in(FileDO::getId, ids).list();
if (CollUtil.isEmpty(fileList)) {
return;
}
// 批量获取存储配置
Map<Long, List<FileDO>> fileListGroup = fileList.stream().collect(Collectors.groupingBy(FileDO::getStorageId));
List<StorageDO> storageList = storageService.listByIds(fileListGroup.keySet());
Map<Long, StorageDO> storageGroup = storageList.stream()
.collect(Collectors.toMap(StorageDO::getId, Function.identity(), (existing, replacement) -> existing));
// 删除记录
for (Map.Entry<Long, List<FileDO>> entry : fileListGroup.entrySet()) {
StorageDO storage = storageService.getById(entry.getKey());
for (FileDO file : entry.getValue()) {
if (!FileTypeEnum.DIR.equals(file.getType())) {
FileInfo fileInfo = file.toFileInfo(storage);
fileStorageService.delete(fileInfo);
} else {
// 不允许删除非空文件夹
boolean exists = baseMapper.lambdaQuery()
.eq(FileDO::getParentPath, file.getPath())
.eq(FileDO::getStorageId, entry.getKey())
.exists();
CheckUtils.throwIf(exists, "文件夹 [{}] 不为空,请先删除文件夹下的内容", file.getName());
}
StorageDO storage = storageGroup.get(entry.getKey());
List<Long> idList = CollUtils.mapToList(entry.getValue(), FileDO::getId);
if (Boolean.TRUE.equals(storage.getRecycleBinEnabled())) {
baseMapper.deleteByIds(idList);
} else {
baseMapper.deleteWithoutRecycleBin(idList, UserContextHolder.getUserId());
}
}
// 删除实际文件
for (Map.Entry<Long, List<FileDO>> entry : fileListGroup.entrySet()) {
StorageDO storage = storageGroup.get(entry.getKey());
entry.getValue().forEach(file -> this.deleteFile(file, storage));
}
}
@Override
@@ -322,4 +333,32 @@ public class FileServiceImpl extends BaseServiceImpl<FileMapper, FileDO, FileRes
}
}
}
/**
* 删除实际文件
*
* @param file 文件
* @param storage 存储配置
*/
private void deleteFile(FileDO file, StorageDO storage) {
Long storageId = storage.getId();
if (FileTypeEnum.DIR.equals(file.getType())) {
// 不允许删除非空文件夹
boolean exists = baseMapper.lambdaQuery()
.eq(FileDO::getParentPath, file.getPath())
.eq(FileDO::getStorageId, storageId)
.exists();
CheckUtils.throwIf(exists, "文件夹 [{}] 不为空,请先删除文件夹下的内容", file.getName());
return;
}
FileInfo fileInfo = file.toFileInfo(storage);
if (Boolean.TRUE.equals(storage.getRecycleBinEnabled())) {
// 移动到回收站目录
fileInfo.setId(file.getId().toString());
fileStorageService.move(fileInfo).setPath(storage.getRecycleBinPath() + fileInfo.getPath()).move();
} else {
// 删除文件
fileStorageService.delete(fileInfo);
}
}
}

View File

@@ -91,9 +91,11 @@ public class StorageServiceImpl extends BaseServiceImpl<StorageMapper, StorageDO
if (StorageTypeEnum.OSS.equals(req.getType())) {
req.setSecretKey(this.decryptSecretKey(req.getSecretKey(), oldStorage));
}
// 校验存储编码、存储类型、状态
// 校验存储类型、存储编码、回收站配置、状态
CheckUtils.throwIfNotEqual(req.getType(), oldStorage.getType(), "不允许修改存储类型");
CheckUtils.throwIfNotEqual(req.getCode(), oldStorage.getCode(), "不允许修改存储编码");
CheckUtils.throwIfNotEqual(req.getRecycleBinEnabled(), oldStorage.getRecycleBinEnabled(), "不允许修改回收站配置");
CheckUtils.throwIfNotEqual(req.getRecycleBinPath(), oldStorage.getRecycleBinPath(), "不允许修改回收站配置");
DisEnableStatusEnum newStatus = req.getStatus();
CheckUtils.throwIf(Boolean.TRUE.equals(oldStorage.getIsDefault()) && DisEnableStatusEnum.DISABLE
.equals(newStatus), "[{}] 是默认存储,不允许禁用", oldStorage.getName());

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="top.continew.admin.system.mapper.FileMapper">
<update id="deleteWithoutRecycleBin">
UPDATE sys_file
SET deleted = id, update_user = #{userId}, update_time = NOW()
WHERE id IN
<foreach item="item" index="index" collection="ids" separator="," open="(" close=")">
#{item}
</foreach>
</update>
</mapper>