diff --git a/sql/dm/ruoyi-vue-pro-dm8.sql b/sql/dm/ruoyi-vue-pro-dm8.sql index 83fc4b8f..f9a6db25 100644 --- a/sql/dm/ruoyi-vue-pro-dm8.sql +++ b/sql/dm/ruoyi-vue-pro-dm8.sql @@ -340,7 +340,8 @@ CREATE TABLE infra_file ( updater varchar(64) DEFAULT '' NULL, update_time datetime DEFAULT CURRENT_TIMESTAMP NOT NULL, deleted bit DEFAULT '0' NOT NULL, - DOWNLOAD_COUNT INT DEFAULT 0 NOT NULL + DOWNLOAD_COUNT INT DEFAULT 0 NOT NULL, + DOWNLOADABLE SMALLINT DEFAULT 1 NOT NULL ); COMMENT ON COLUMN infra_file.id IS '文件编号'; @@ -358,6 +359,7 @@ COMMENT ON COLUMN infra_file.updater IS '更新者'; COMMENT ON COLUMN infra_file.update_time IS '更新时间'; COMMENT ON COLUMN infra_file.deleted IS '是否删除'; COMMENT ON COLUMN INFRA_FILE.DOWNLOAD_COUNT IS '下载次数'; +COMMENT ON COLUMN INFRA_FILE.DOWNLOADABLE IS '是否可下载(1是,0否)'; COMMENT ON TABLE infra_file IS '文件表'; CREATE INDEX idx_infra_file_hash ON infra_file(hash); diff --git a/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/enums/VerifyCodeSendType.java b/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/enums/VerifyCodeSendType.java new file mode 100644 index 00000000..10e1ef6f --- /dev/null +++ b/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/enums/VerifyCodeSendType.java @@ -0,0 +1,9 @@ +package com.zt.plat.framework.common.enums; + +/** + * 验证码发送方式 + */ +public enum VerifyCodeSendType { + SMS, // 短信验证码 + E_OFFICE // e办消息推送 +} diff --git a/zt-module-infra/zt-module-infra-api/src/main/java/com/zt/plat/module/infra/api/file/dto/FileCreateReqDTO.java b/zt-module-infra/zt-module-infra-api/src/main/java/com/zt/plat/module/infra/api/file/dto/FileCreateReqDTO.java index c4290020..0b07387c 100644 --- a/zt-module-infra/zt-module-infra-api/src/main/java/com/zt/plat/module/infra/api/file/dto/FileCreateReqDTO.java +++ b/zt-module-infra/zt-module-infra-api/src/main/java/com/zt/plat/module/infra/api/file/dto/FileCreateReqDTO.java @@ -22,4 +22,6 @@ public class FileCreateReqDTO { @NotEmpty(message = "文件内容不能为空") private byte[] content; + @Schema(description = "是否可下载(true是,false否)", example = "true") + private Boolean downloadable; } diff --git a/zt-module-infra/zt-module-infra-api/src/main/java/com/zt/plat/module/infra/api/file/dto/FileRespDTO.java b/zt-module-infra/zt-module-infra-api/src/main/java/com/zt/plat/module/infra/api/file/dto/FileRespDTO.java index 58af4c9a..3b318d0c 100644 --- a/zt-module-infra/zt-module-infra-api/src/main/java/com/zt/plat/module/infra/api/file/dto/FileRespDTO.java +++ b/zt-module-infra/zt-module-infra-api/src/main/java/com/zt/plat/module/infra/api/file/dto/FileRespDTO.java @@ -37,4 +37,7 @@ public class FileRespDTO { @Schema(description = "文件下载次数") private Integer downloadCount; + @Schema(description = "是否可下载(true是,false否)") + private Boolean downloadable; + } \ No newline at end of file diff --git a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/controller/admin/file/FileController.java b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/controller/admin/file/FileController.java index 1c5d9b6a..3e812f1b 100644 --- a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/controller/admin/file/FileController.java +++ b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/controller/admin/file/FileController.java @@ -3,6 +3,7 @@ package com.zt.plat.module.infra.controller.admin.file; import cn.hutool.core.io.IoUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.URLUtil; +import com.zt.plat.framework.common.enums.VerifyCodeSendType; import com.zt.plat.framework.common.exception.ServiceException; import com.zt.plat.framework.common.pojo.CommonResult; import com.zt.plat.framework.common.pojo.PageResult; @@ -21,6 +22,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; @@ -43,12 +45,17 @@ import static com.zt.plat.module.infra.framework.file.core.utils.FileTypeUtils.w @Slf4j public class FileController { + @Value("${zt.file.preview-base-url:}") + private String previewBaseUrl; + @Resource private FileService fileService; @GetMapping("/get") @Operation(summary = "获取文件预览地址", description = "根据 fileId 返回文件预览 url(kkfile)") - public CommonResult getPreviewUrl(@RequestParam("fileId") Long fileId) { + public CommonResult getPreviewUrl(@RequestParam("fileId") Long fileId, + @RequestParam(value = "code", required = false) String code, + HttpServletRequest request) throws Exception { FileDO fileDO = fileService.getActiveFileById(fileId); if (fileDO == null) { return CommonResult.error(HttpStatus.NOT_FOUND.value(), "文件不存在"); @@ -59,6 +66,27 @@ public class FileController { // FileDO 转换为 FileRespVO FileRespVO fileRespVO = BeanUtils.toBean(fileDO, FileRespVO.class); + + // 加密文件:塞入“临时解密预览 URL” + if (Boolean.TRUE.equals(fileRespVO.getIsEncrypted())) { // FileDO 通过 aesIv 判断加密 + + if (cn.hutool.core.util.StrUtil.isBlank(code)) { + return CommonResult.error(HttpStatus.BAD_REQUEST.value(), "加密文件预览需要验证码 code"); + } + // 验证通过:发放给 kkfile 用的短期 token(kkfile 不带登录态) + Long userId = getLoginUserId(); + boolean flag = fileService.verifyCode(fileId, userId, code); + if(!flag){ + return CommonResult.customize(null, HttpStatus.INTERNAL_SERVER_ERROR.value(), "验证码错误"); + } + + String token = fileService.generatePreviewToken(fileId, userId); + + String baseUrl = buildPublicBaseUrl(request); // 见下方函数 + String decryptUrl = baseUrl + "/admin-api/infra/file/preview-decrypt?fileId=" + fileId + "&token=" + token; + fileRespVO.setUrl(decryptUrl); + } + return success(fileRespVO); } @@ -166,15 +194,32 @@ public class FileController { } @GetMapping("/generate-download-code") @Operation(summary = "获取下载验证码") - public CommonResult preDownloadEncrypt(@RequestParam("fileId") Long fileId) { + public CommonResult preDownloadEncrypt(@RequestParam("fileId") Long fileId, + @RequestParam(value = "sendType", required = false) String sendType // 可选:SMS / E_OFFICE + ) { Long userId = getLoginUserId(); + + // 解析 sendType(允许为空) + VerifyCodeSendType sendTypeEnum = null; + if (sendType != null && !sendType.trim().isEmpty()) { + try { + sendTypeEnum = VerifyCodeSendType.valueOf(sendType.trim().toUpperCase()); + } catch (IllegalArgumentException ex) { + return CommonResult.error(HttpStatus.BAD_REQUEST.value(), + "sendType 参数不合法,可选:SMS / E_OFFICE"); + } + } + FileDO activeFileById = fileService.getActiveFileById(fileId); if (activeFileById == null) { return CommonResult.error(HttpStatus.NOT_FOUND.value(), "文件不存在"); } + FileRespVO fileRespVO = BeanUtils.toBean(activeFileById, FileRespVO.class); try { - fileService.generateFileVerificationCode(fileId, userId); + String code = fileService.generateFileVerificationCode(fileId, userId); + if(sendTypeEnum != null) + fileService.sendVerifyCode(code, sendTypeEnum); // 发送验证码 return CommonResult.customize(fileRespVO, HttpStatus.OK.value(), "验证码已生成,请使用验证码下载文件"); } catch (ServiceException e) { return CommonResult.customize(fileRespVO, HttpStatus.OK.value(), e.getMessage()); @@ -191,4 +236,53 @@ public class FileController { } return CommonResult.customize(null, HttpStatus.OK.value(), "验证码校验通过"); } + + @GetMapping("/preview-decrypt") + @PermitAll + @TenantIgnore + @Operation(summary = "加密文件预览解密流(供 kkfile 拉取)") + public void previewDecrypt(@RequestParam("fileId") Long fileId, + @RequestParam("token") String token, + HttpServletResponse response) throws Exception { + + boolean ok = fileService.verifyPreviewToken(fileId, token); + if (!ok) { + response.setStatus(HttpStatus.FORBIDDEN.value()); + return; + } + + FileDO fileDO = fileService.getActiveFileById(fileId); + if (fileDO == null) { + response.setStatus(HttpStatus.NOT_FOUND.value()); + return; + } + + // byte[] content = fileService.getDecryptedBytes(fileId); + response.setHeader("Cache-Control", "no-store"); + response.setContentType(fileDO.getType()); + + String filename = java.net.URLEncoder.encode(fileDO.getName(), java.nio.charset.StandardCharsets.UTF_8); + response.setHeader("Content-Disposition", "inline; filename*=UTF-8''" + filename); + + // cn.hutool.core.io.IoUtil.write(response.getOutputStream(), true, content); + fileService.writeDecryptedToStream(fileId, response.getOutputStream()); + } + + private String buildPublicBaseUrl(HttpServletRequest request) { + if (previewBaseUrl != null && !previewBaseUrl.isBlank()) { + return previewBaseUrl.endsWith("/") + ? previewBaseUrl.substring(0, previewBaseUrl.length() - 1) + : previewBaseUrl; + } + // 兜底:从请求推断 + String scheme = request.getHeader("X-Forwarded-Proto"); + if (scheme == null) scheme = request.getScheme(); + + String host = request.getHeader("X-Forwarded-Host"); + if (host == null) host = request.getHeader("Host"); + if (host == null) host = request.getServerName() + ":" + request.getServerPort(); + + return scheme + "://" + host; + } + } diff --git a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/controller/admin/file/vo/file/FileRespVO.java b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/controller/admin/file/vo/file/FileRespVO.java index 6d4d12f4..77d3d443 100644 --- a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/controller/admin/file/vo/file/FileRespVO.java +++ b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/controller/admin/file/vo/file/FileRespVO.java @@ -23,8 +23,8 @@ import java.util.Date; @Accessors(chain = true) public class FileRespVO { public String getUrl() { - // 加密附件不返回 url - if (Boolean.TRUE.equals(this.isEncrypted)) { + // 不可下载 或 加密附件不返回 url + if (Boolean.FALSE.equals(this.downloadable) || Boolean.TRUE.equals(this.isEncrypted)) { return null; } // 如果 url 已经是临时下载地址(如预签名 URL),直接返回 @@ -62,8 +62,8 @@ public class FileRespVO { private String previewUrl; public String getPreviewUrl() { - // 加密附件不返回 previewUrl - if (Boolean.TRUE.equals(this.isEncrypted)) { + // 不可下载不返回 previewUrl + if (Boolean.FALSE.equals(this.downloadable) ) { return null; } // 仅当 url 不为空时生成 @@ -75,7 +75,15 @@ public class FileRespVO { if (onlinePreview == null || onlinePreview.isEmpty()) { return null; } - String presignedUrl = this.getUrl(); + // 添加加密文件预览逻辑 + String presignedUrl = null; + if (Boolean.TRUE.equals(this.isEncrypted)) { + if (url != null && (url.startsWith("http://") || url.startsWith("https://"))) { + presignedUrl = url; + } + }else{ + presignedUrl = this.getUrl(); + } if (presignedUrl == null || presignedUrl.isEmpty()) { return null; } @@ -102,4 +110,6 @@ public class FileRespVO { @Schema(description = "下载次数") private Integer downloadCount; + @Schema(description = "是否可下载(true是,false否)") + private Boolean downloadable; } diff --git a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/dal/dataobject/file/FileDO.java b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/dal/dataobject/file/FileDO.java index c82c0e3c..d1be1f72 100644 --- a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/dal/dataobject/file/FileDO.java +++ b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/dal/dataobject/file/FileDO.java @@ -70,6 +70,11 @@ public class FileDO extends BaseDO { */ private Integer downloadCount; + /** + * 是否可下载(true是,false否) + */ + private Boolean downloadable; + /** * 是否加密 *

diff --git a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/dal/redis/RedisKeyConstants.java b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/dal/redis/RedisKeyConstants.java index 8ffde8c1..dcaf93ff 100644 --- a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/dal/redis/RedisKeyConstants.java +++ b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/dal/redis/RedisKeyConstants.java @@ -6,4 +6,7 @@ package com.zt.plat.module.infra.dal.redis; public class RedisKeyConstants { public static final String FILE_VERIFICATION_CODE = "infra:file:verification_code:%d:%d"; public static final String FILE_VERIFICATION_CODE_USER_SET = "infra:file:verification_code:user:%d"; + + // 加密文件预览token + public static final String FILE_PREVIEW_TOKEN = "infra:file:preview-token:%s"; } diff --git a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/framework/rpc/config/RpcConfiguration.java b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/framework/rpc/config/RpcConfiguration.java index 352e5313..b978f66b 100644 --- a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/framework/rpc/config/RpcConfiguration.java +++ b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/framework/rpc/config/RpcConfiguration.java @@ -2,11 +2,12 @@ package com.zt.plat.module.infra.framework.rpc.config; import com.zt.plat.module.system.api.permission.PermissionApi; import com.zt.plat.module.system.api.permission.RoleApi; +import com.zt.plat.module.system.api.sms.SmsSendApi; import com.zt.plat.module.system.api.user.AdminUserApi; import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.context.annotation.Configuration; @Configuration(value = "infraRpcConfiguration", proxyBeanMethods = false) -@EnableFeignClients(clients = {PermissionApi.class, RoleApi.class, AdminUserApi.class}) +@EnableFeignClients(clients = {PermissionApi.class, RoleApi.class, AdminUserApi.class, SmsSendApi.class }) public class RpcConfiguration { } diff --git a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/service/file/FileService.java b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/service/file/FileService.java index 71e1fdc9..52d959b1 100644 --- a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/service/file/FileService.java +++ b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/service/file/FileService.java @@ -1,5 +1,6 @@ package com.zt.plat.module.infra.service.file; +import com.zt.plat.framework.common.enums.VerifyCodeSendType; import com.zt.plat.framework.common.pojo.PageResult; import com.zt.plat.module.infra.controller.admin.file.vo.file.FileCreateReqVO; import com.zt.plat.module.infra.controller.admin.file.vo.file.FilePageReqVO; @@ -10,6 +11,8 @@ import com.zt.plat.module.infra.dal.dataobject.file.FileDO; import jakarta.validation.constraints.NotEmpty; import lombok.SneakyThrows; +import java.io.OutputStream; + /** * 文件 Service 接口 * @@ -72,6 +75,14 @@ public interface FileService { */ String generateFileVerificationCode(Long fileId, Long userId); + + /** + * 发送验证码 + * @param code 验证码 + * @param verifyCodeSendType 发送类型 + */ + void sendVerifyCode(String code, VerifyCodeSendType verifyCodeSendType); + /** * 校验验证码并返回解密后的文件内容 */ @@ -125,4 +136,25 @@ public interface FileService { * @param fileId */ void incDownloadCount(Long fileId); + + /** + * 临时生成文件预览token + * @param fileId 文件ID + * @param userId 用户ID + * @return 临时token + */ + String generatePreviewToken(Long fileId, Long userId); + + /** + * 验证文件预览token + * @param fileId 文件ID + * @param token 用户ID + * @return 临时token + */ + boolean verifyPreviewToken(Long fileId, String token); + + /** + * 校验预览 token 后,将文件内容解密并写入输出流(用于预览) + */ + void writeDecryptedToStream(Long fileId, OutputStream outputStream) throws Exception; } diff --git a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/service/file/FileServiceImpl.java b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/service/file/FileServiceImpl.java index 66346820..6a7b4801 100644 --- a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/service/file/FileServiceImpl.java +++ b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/service/file/FileServiceImpl.java @@ -2,13 +2,17 @@ package com.zt.plat.module.infra.service.file; import cn.hutool.core.date.LocalDateTimeUtil; import cn.hutool.core.io.FileUtil; +import cn.hutool.core.io.IoUtil; import cn.hutool.core.lang.Assert; import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.digest.DigestUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.google.common.annotations.VisibleForTesting; +import com.zt.plat.framework.common.enums.VerifyCodeSendType; import com.zt.plat.framework.common.pojo.PageResult; import com.zt.plat.framework.common.util.object.BeanUtils; +import com.zt.plat.framework.security.core.LoginUser; +import com.zt.plat.framework.security.core.util.SecurityFrameworkUtils; import com.zt.plat.module.infra.controller.admin.file.vo.file.FileCreateReqVO; import com.zt.plat.module.infra.controller.admin.file.vo.file.FilePageReqVO; import com.zt.plat.module.infra.controller.admin.file.vo.file.FilePresignedUrlRespVO; @@ -21,6 +25,9 @@ import com.zt.plat.module.infra.framework.file.core.client.FileClient; import com.zt.plat.module.infra.framework.file.core.client.s3.FilePresignedUrlRespDTO; import com.zt.plat.module.infra.framework.file.core.utils.FileTypeUtils; import com.zt.plat.module.infra.util.VerificationCodeUtil; +import com.zt.plat.module.system.api.permission.RoleApi; +import com.zt.plat.module.system.api.sms.SmsSendApi; +import com.zt.plat.module.system.api.sms.dto.send.SmsSendSingleToUserReqDTO; import jakarta.annotation.Resource; import lombok.SneakyThrows; import org.apache.commons.lang3.StringUtils; @@ -31,10 +38,9 @@ import org.springframework.stereotype.Service; import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; +import java.io.OutputStream; import java.security.SecureRandom; -import java.util.Base64; -import java.util.Collections; -import java.util.List; +import java.util.*; import static cn.hutool.core.date.DatePattern.PURE_DATE_PATTERN; import static com.zt.plat.framework.common.exception.util.ServiceExceptionUtil.exception; @@ -51,8 +57,17 @@ public class FileServiceImpl implements FileService { @Value("${zt.AES.key}") private String aesKey; - @Value("${zt.verify-code:}") + @Value("${zt.verify-code:666666}") private String fixedVerifyCode; + // 加密文件预览token过期时间 + @Value("${zt.file.preview-expire-seconds:300}") + private Integer previewExpireSeconds; + + @Resource + private RoleApi roleApi; + + @Resource + private SmsSendApi smsSendApi; @Resource private StringRedisTemplate stringRedisTemplate; @@ -65,6 +80,31 @@ public class FileServiceImpl implements FileService { return VerificationCodeUtil.generateCode(codeKey, userSetKey, stringRedisTemplate); } + @Override + public void sendVerifyCode(String code, VerifyCodeSendType verifyCodeSendType) { + if(verifyCodeSendType == null) return; + LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); + Assert.notNull(loginUser,"用户未登录或权限不足!"); + if(loginUser == null) return; + if (VerifyCodeSendType.SMS.equals(verifyCodeSendType)) { + Map templateParams = new HashMap<>(); + templateParams.put("code",code); + SmsSendSingleToUserReqDTO smsSendReqDTO = new SmsSendSingleToUserReqDTO(); + if(loginUser.getInfo().get(LoginUser.INFO_KEY_PHONE)!=null) + smsSendReqDTO.setMobile(loginUser.getInfo().get(LoginUser.INFO_KEY_PHONE)); + smsSendReqDTO.setUserId(loginUser.getId()); + smsSendReqDTO.setTemplateCode("test_02"); + smsSendReqDTO.setTemplateParams(templateParams); + smsSendApi.sendSingleSmsToAdmin(smsSendReqDTO); + return; + } + + if (VerifyCodeSendType.E_OFFICE.equals(verifyCodeSendType)) { + // TODO 预留实现接口 + return; + } + } + @Override public byte[] verifyCodeAndGetFile(Long fileId, Long userId, String code) throws Exception { // 开发模式下,验证码直接获取配置进行比对 @@ -349,4 +389,36 @@ public class FileServiceImpl implements FileService { fileMapper.incDownloadCount(fileId); } + @Override + public String generatePreviewToken(Long fileId, Long userId) { + // 你也可以加:validateFileExists(fileId) + String token = UUID.randomUUID().toString().replace("-", ""); + String key = String.format(RedisKeyConstants.FILE_PREVIEW_TOKEN, token); + stringRedisTemplate.opsForValue().set(key, String.valueOf(fileId), + java.time.Duration.ofSeconds(previewExpireSeconds)); + return token; + } + + @Override + public boolean verifyPreviewToken(Long fileId, String token) { + String key = String.format(RedisKeyConstants.FILE_PREVIEW_TOKEN, token); + String val = stringRedisTemplate.opsForValue().get(key); + if (val == null || !val.equals(String.valueOf(fileId))) { + return false; + } + // 可选:单次使用更安全 + // stringRedisTemplate.delete(key); + return true; + } + + @Override + public void writeDecryptedToStream(Long fileId, OutputStream os) throws Exception { + FileDO fileDO = getActiveFileById(fileId); + if (fileDO == null) { + throw exception(FILE_NOT_EXISTS); + } + + byte[] decrypted = getDecryptedBytes(fileId); + IoUtil.write(os, true, decrypted); + } }