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()), (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()), (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()), (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()), (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()), (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` 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 VALUES
(1, '开发环境', 'local_dev', 1, NULL, NULL, NULL, 'C:/continew-admin/data/file/', 'http://localhost:8000/file/', '本地存储', b'1', 1, 1, 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'0', 2, 2, 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` INSERT INTO `sys_client`

View File

@@ -283,24 +283,26 @@ CREATE TABLE IF NOT EXISTS `sys_notice_log` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公告日志表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公告日志表';
CREATE TABLE IF NOT EXISTS `sys_storage` ( CREATE TABLE IF NOT EXISTS `sys_storage` (
`id` bigint(20) AUTO_INCREMENT COMMENT 'ID', `id` bigint(20) AUTO_INCREMENT COMMENT 'ID',
`name` varchar(100) NOT NULL COMMENT '名称', `name` varchar(100) NOT NULL COMMENT '名称',
`code` varchar(30) NOT NULL COMMENT '编码', `code` varchar(30) NOT NULL COMMENT '编码',
`type` tinyint(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '类型1本地存储2对象存储', `type` tinyint(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '类型1本地存储2对象存储',
`access_key` varchar(255) DEFAULT NULL COMMENT 'Access Key', `access_key` varchar(255) DEFAULT NULL COMMENT 'Access Key',
`secret_key` varchar(255) DEFAULT NULL COMMENT 'Secret Key', `secret_key` varchar(255) DEFAULT NULL COMMENT 'Secret Key',
`endpoint` varchar(255) DEFAULT NULL COMMENT 'Endpoint', `endpoint` varchar(255) DEFAULT NULL COMMENT 'Endpoint',
`bucket_name` varchar(255) NOT NULL COMMENT 'Bucket', `bucket_name` varchar(255) NOT NULL COMMENT 'Bucket',
`domain` varchar(255) DEFAULT NULL COMMENT '域名', `domain` varchar(255) DEFAULT NULL COMMENT '域名',
`description` varchar(200) DEFAULT NULL COMMENT '描述', `recycle_bin_enabled` bit(1) NOT NULL DEFAULT b'1' COMMENT '启用回收站',
`is_default` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否为默认存储', `recycle_bin_path` varchar(255) DEFAULT NULL COMMENT '回收站路径',
`sort` int NOT NULL DEFAULT 999 COMMENT '排序', `description` varchar(200) DEFAULT NULL COMMENT '描述',
`status` tinyint(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '状态1启用2禁用', `is_default` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否为默认存储',
`create_user` bigint(20) NOT NULL COMMENT '创建人', `sort` int NOT NULL DEFAULT 999 COMMENT '排序',
`create_time` datetime NOT NULL COMMENT '创建时间', `status` tinyint(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '状态1启用2禁用',
`update_user` bigint(20) DEFAULT NULL COMMENT '修改', `create_user` bigint(20) NOT NULL COMMENT '创建',
`update_time` datetime DEFAULT NULL COMMENT '修改时间', `create_time` datetime NOT NULL COMMENT '创建时间',
`deleted` bigint(20) NOT NULL DEFAULT 0 COMMENT '是否已删除0id', `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`), PRIMARY KEY (`id`),
UNIQUE INDEX `uk_code`(`code`, `deleted`), UNIQUE INDEX `uk_code`(`code`, `deleted`),
INDEX `idx_create_user`(`create_user`), 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()), (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()), (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()), (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()), (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()), (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" 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 VALUES
(1, '开发环境', 'local_dev', 1, NULL, NULL, NULL, 'C:/continew-admin/data/file/', 'http://localhost:8000/file/', '本地存储', true, 1, 1, 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/', '本地存储', false, 2, 2, 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" 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 '公告日志表'; COMMENT ON TABLE "sys_notice_log" IS '公告日志表';
CREATE TABLE IF NOT EXISTS "sys_storage" ( CREATE TABLE IF NOT EXISTS "sys_storage" (
"id" int8 NOT NULL, "id" int8 NOT NULL,
"name" varchar(100) NOT NULL, "name" varchar(100) NOT NULL,
"code" varchar(30) NOT NULL, "code" varchar(30) NOT NULL,
"type" int2 NOT NULL DEFAULT 1, "type" int2 NOT NULL DEFAULT 1,
"access_key" varchar(255) DEFAULT NULL, "access_key" varchar(255) DEFAULT NULL,
"secret_key" varchar(255) DEFAULT NULL, "secret_key" varchar(255) DEFAULT NULL,
"endpoint" varchar(255) DEFAULT NULL, "endpoint" varchar(255) DEFAULT NULL,
"bucket_name" varchar(255) NOT NULL, "bucket_name" varchar(255) NOT NULL,
"domain" varchar(255) DEFAULT NULL, "domain" varchar(255) DEFAULT NULL,
"description" varchar(200) DEFAULT NULL, "recycle_bin_enabled" bool NOT NULL DEFAULT true,
"is_default" bool NOT NULL DEFAULT false, "recycle_bin_path" varchar(255) DEFAULT NULL,
"sort" int4 NOT NULL DEFAULT 999, "description" varchar(200) DEFAULT NULL,
"status" int2 NOT NULL DEFAULT 1, "is_default" bool NOT NULL DEFAULT false,
"create_user" int8 NOT NULL, "sort" int4 NOT NULL DEFAULT 999,
"create_time" timestamp NOT NULL, "status" int2 NOT NULL DEFAULT 1,
"update_user" int8 DEFAULT NULL, "create_user" int8 NOT NULL,
"update_time" timestamp DEFAULT NULL, "create_time" timestamp NOT NULL,
"deleted" int8 NOT NULL DEFAULT 0, "update_user" int8 DEFAULT NULL,
"update_time" timestamp DEFAULT NULL,
"deleted" int8 NOT NULL DEFAULT 0,
PRIMARY KEY ("id") PRIMARY KEY ("id")
); );
CREATE UNIQUE INDEX "uk_storage_code" ON "sys_storage" ("code", "deleted"); 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_create_user" ON "sys_storage" ("create_user");
CREATE INDEX "idx_storage_update_user" ON "sys_storage" ("update_user"); CREATE INDEX "idx_storage_update_user" ON "sys_storage" ("update_user");
CREATE INDEX "idx_storage_deleted" ON "sys_storage" ("deleted"); CREATE INDEX "idx_storage_deleted" ON "sys_storage" ("deleted");
COMMENT ON COLUMN "sys_storage"."id" IS 'ID'; COMMENT ON COLUMN "sys_storage"."id" IS 'ID';
COMMENT ON COLUMN "sys_storage"."name" IS '名称'; COMMENT ON COLUMN "sys_storage"."name" IS '名称';
COMMENT ON COLUMN "sys_storage"."code" IS '编码'; COMMENT ON COLUMN "sys_storage"."code" IS '编码';
COMMENT ON COLUMN "sys_storage"."type" IS '类型1本地存储2对象存储'; COMMENT ON COLUMN "sys_storage"."type" IS '类型1本地存储2对象存储';
COMMENT ON COLUMN "sys_storage"."access_key" IS 'Access Key'; 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"."secret_key" IS 'Secret Key';
COMMENT ON COLUMN "sys_storage"."endpoint" IS 'Endpoint'; COMMENT ON COLUMN "sys_storage"."endpoint" IS 'Endpoint';
COMMENT ON COLUMN "sys_storage"."bucket_name" IS 'Bucket'; COMMENT ON COLUMN "sys_storage"."bucket_name" IS 'Bucket';
COMMENT ON COLUMN "sys_storage"."domain" IS '域名'; COMMENT ON COLUMN "sys_storage"."domain" IS '域名';
COMMENT ON COLUMN "sys_storage"."description" IS '描述'; COMMENT ON COLUMN "sys_storage"."recycle_bin_enabled" IS '启用回收站';
COMMENT ON COLUMN "sys_storage"."is_default" IS '是否为默认存储'; COMMENT ON COLUMN "sys_storage"."recycle_bin_path" IS '回收站路径';
COMMENT ON COLUMN "sys_storage"."sort" IS '排序'; COMMENT ON COLUMN "sys_storage"."description" IS '描述';
COMMENT ON COLUMN "sys_storage"."status" IS '状态1启用2禁用'; COMMENT ON COLUMN "sys_storage"."is_default" IS '是否为默认存储';
COMMENT ON COLUMN "sys_storage"."create_user" IS '创建人'; COMMENT ON COLUMN "sys_storage"."sort" IS '排序';
COMMENT ON COLUMN "sys_storage"."create_time" IS '创建时间'; COMMENT ON COLUMN "sys_storage"."status" IS '状态1启用2禁用';
COMMENT ON COLUMN "sys_storage"."update_user" IS '修改'; COMMENT ON COLUMN "sys_storage"."create_user" IS '创建';
COMMENT ON COLUMN "sys_storage"."update_time" IS '修改时间'; COMMENT ON COLUMN "sys_storage"."create_time" IS '创建时间';
COMMENT ON COLUMN "sys_storage"."deleted" IS '是否已删除0id'; COMMENT ON COLUMN "sys_storage"."update_user" IS '修改人';
COMMENT ON TABLE "sys_storage" 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" ( CREATE TABLE IF NOT EXISTS "sys_file" (
"id" int8 NOT NULL, "id" int8 NOT NULL,

View File

@@ -37,6 +37,7 @@ import top.continew.starter.core.util.URLUtils;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@@ -55,6 +56,9 @@ public class FileRecorderImpl implements FileRecorder {
@Override @Override
public boolean save(FileInfo fileInfo) { public boolean save(FileInfo fileInfo) {
if (fileInfo.getAttr() == null) {
return true;
}
// 保存文件信息 // 保存文件信息
FileDO file = new FileDO(fileInfo); FileDO file = new FileDO(fileInfo);
StorageDO storage = (StorageDO)fileInfo.getAttr().get(ClassUtil.getClassName(StorageDO.class, false)); 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) { public boolean delete(String url) {
FileDO file = this.getFileByUrl(url); FileDO file = this.getFileByUrl(url);
if (file == null) { if (file == null) {
return false; return true;
} }
return fileMapper.lambdaUpdate().eq(FileDO::getId, file.getId()).remove(); 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)); List<StorageDO> storageList = storageMapper.selectByIds(CollUtils.mapToList(list, FileDO::getStorageId));
Map<Long, StorageDO> storageMap = storageList.stream() 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 -> { return list.stream().filter(file -> {
// http://localhost:8000/file/user/avatar/6825e687db4174e7a297a5f8.png => http://localhost:8000/file/user/avatar // http://localhost:8000/file/user/avatar/6825e687db4174e7a297a5f8.png => http://localhost:8000/file/user/avatar
String urlPrefix = StrUtil.subBefore(url, StringConstants.SLASH, true); 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())) { if (StrUtil.isNotBlank(req.getDomain())) {
req.setDomain(StrUtil.appendIfMissing(req.getDomain(), StringConstants.SLASH)); 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; 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.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; 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.entity.FileDO;
import top.continew.admin.system.model.resp.file.FileStatisticsResp; import top.continew.admin.system.model.resp.file.FileStatisticsResp;
import top.continew.starter.data.mapper.BaseMapper; 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") @Select("SELECT type, COUNT(1) number, SUM(size) size FROM sys_file WHERE deleted = 0 AND type != 0 GROUP BY type")
List<FileStatisticsResp> statistics(); 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.date.DateUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil; import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
@@ -114,6 +115,12 @@ public class FileDO extends BaseDO {
*/ */
private Long storageId; private Long storageId;
/**
* 是否已删除01回收站
*/
@TableLogic(value = "0", delval = "1")
private Long deleted;
/** /**
* 基于 {@link FileInfo} 构建文件信息对象 * 基于 {@link FileInfo} 构建文件信息对象
* *
@@ -143,7 +150,7 @@ public class FileDO extends BaseDO {
/** /**
* 转换为 {@link FileInfo} 文件信息对象 * 转换为 {@link FileInfo} 文件信息对象
* *
* @param storage 存储配置信息 * @param storage 存储配置
* @return {@link FileInfo} 文件信息对象 * @return {@link FileInfo} 文件信息对象
*/ */
public FileInfo toFileInfo(StorageDO storage) { public FileInfo toFileInfo(StorageDO storage) {

View File

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

View File

@@ -16,6 +16,8 @@
package top.continew.admin.system.model.req; 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 com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
@@ -38,6 +40,7 @@ import java.io.Serializable;
* @since 2023/12/26 22:09 * @since 2023/12/26 22:09
*/ */
@Data @Data
@SpelValid
@Schema(description = "存储创建或修改请求参数") @Schema(description = "存储创建或修改请求参数")
public class StorageReq implements Serializable { public class StorageReq implements Serializable {
@@ -109,6 +112,20 @@ public class StorageReq implements Serializable {
@NotBlank(message = "访问路径不能为空", groups = ValidationGroup.Storage.Local.class) @NotBlank(message = "访问路径不能为空", groups = ValidationGroup.Storage.Local.class)
private String domain; 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") @Schema(description = "域名", example = "http://localhost:8000/file")
private String domain; 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.dromara.x.file.storage.core.upload.UploadPretreatment;
import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import top.continew.admin.common.base.service.BaseServiceImpl; 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.common.enums.DisEnableStatusEnum;
import top.continew.admin.system.enums.FileTypeEnum; import top.continew.admin.system.enums.FileTypeEnum;
import top.continew.admin.system.mapper.FileMapper; 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.admin.system.service.StorageService;
import top.continew.starter.cache.redisson.util.RedisLockUtils; import top.continew.starter.cache.redisson.util.RedisLockUtils;
import top.continew.starter.core.constant.StringConstants; 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.StrUtils;
import top.continew.starter.core.util.validation.CheckUtils; import top.continew.starter.core.util.validation.CheckUtils;
import top.continew.starter.core.util.validation.ValidationUtils; 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.io.File;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@@ -71,25 +75,32 @@ public class FileServiceImpl extends BaseServiceImpl<FileMapper, FileDO, FileRes
private StorageService storageService; private StorageService storageService;
@Override @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(); 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)); 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()) { for (Map.Entry<Long, List<FileDO>> entry : fileListGroup.entrySet()) {
StorageDO storage = storageService.getById(entry.getKey()); StorageDO storage = storageGroup.get(entry.getKey());
for (FileDO file : entry.getValue()) { List<Long> idList = CollUtils.mapToList(entry.getValue(), FileDO::getId);
if (!FileTypeEnum.DIR.equals(file.getType())) { if (Boolean.TRUE.equals(storage.getRecycleBinEnabled())) {
FileInfo fileInfo = file.toFileInfo(storage); baseMapper.deleteByIds(idList);
fileStorageService.delete(fileInfo); } else {
} else { baseMapper.deleteWithoutRecycleBin(idList, UserContextHolder.getUserId());
// 不允许删除非空文件夹
boolean exists = baseMapper.lambdaQuery()
.eq(FileDO::getParentPath, file.getPath())
.eq(FileDO::getStorageId, entry.getKey())
.exists();
CheckUtils.throwIf(exists, "文件夹 [{}] 不为空,请先删除文件夹下的内容", file.getName());
}
} }
} }
// 删除实际文件
for (Map.Entry<Long, List<FileDO>> entry : fileListGroup.entrySet()) {
StorageDO storage = storageGroup.get(entry.getKey());
entry.getValue().forEach(file -> this.deleteFile(file, storage));
}
} }
@Override @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())) { if (StorageTypeEnum.OSS.equals(req.getType())) {
req.setSecretKey(this.decryptSecretKey(req.getSecretKey(), oldStorage)); req.setSecretKey(this.decryptSecretKey(req.getSecretKey(), oldStorage));
} }
// 校验存储编码、存储类型、状态 // 校验存储类型、存储编码、回收站配置、状态
CheckUtils.throwIfNotEqual(req.getType(), oldStorage.getType(), "不允许修改存储类型"); CheckUtils.throwIfNotEqual(req.getType(), oldStorage.getType(), "不允许修改存储类型");
CheckUtils.throwIfNotEqual(req.getCode(), oldStorage.getCode(), "不允许修改存储编码"); CheckUtils.throwIfNotEqual(req.getCode(), oldStorage.getCode(), "不允许修改存储编码");
CheckUtils.throwIfNotEqual(req.getRecycleBinEnabled(), oldStorage.getRecycleBinEnabled(), "不允许修改回收站配置");
CheckUtils.throwIfNotEqual(req.getRecycleBinPath(), oldStorage.getRecycleBinPath(), "不允许修改回收站配置");
DisEnableStatusEnum newStatus = req.getStatus(); DisEnableStatusEnum newStatus = req.getStatus();
CheckUtils.throwIf(Boolean.TRUE.equals(oldStorage.getIsDefault()) && DisEnableStatusEnum.DISABLE CheckUtils.throwIf(Boolean.TRUE.equals(oldStorage.getIsDefault()) && DisEnableStatusEnum.DISABLE
.equals(newStatus), "[{}] 是默认存储,不允许禁用", oldStorage.getName()); .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>