From 1988e031e5e86ea226714c2d5cf571ca3231f1d2 Mon Sep 17 00:00:00 2001 From: lianlonggang Date: Tue, 21 Apr 2026 17:22:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(file-upload):=20=E9=9B=86=E6=88=90?= =?UTF-8?q?=E6=96=B0=E7=96=86OSS=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=B9=B6=E4=BC=98=E5=8C=96=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现新疆油田平台OSS文件上传工具类XjOssUtil,支持OAuth2鉴权和文件操作 - 在MyUploadUtil中集成OSS上传逻辑,通过oss_flag配置动态选择上传方式 - 为.tar和.apk文件强制使用MyUploadUtil上传,其他文件根据oss_flag配置决定上传方式 - 在手表设备删除接口中增加绑定用户检查,已绑定用户的手表不允许删除 - 添加GlobalManager依赖注入以支持动态配置读取 - 实现文件上传流程的日志记录和错误处理机制 --- .../controller/WatchDeviceController.java | 4 + .../org/jeecg/common/util/MyUploadUtil.java | 355 ++++++------ .../java/org/jeecg/common/util/XjOssUtil.java | 542 ++++++++++++++++++ .../org/jeecg/config/oss/MyUploadConfig.java | 6 + .../org/jeecg/config/oss/XjOssConfig.java | 84 +++ .../org/jeecg/config/shiro/ShiroConfig.java | 2 + .../controller/SysUploadController.java | 100 ++++ 7 files changed, 927 insertions(+), 166 deletions(-) create mode 100644 jeecg-boot-base-core/src/main/java/org/jeecg/common/util/XjOssUtil.java create mode 100644 jeecg-boot-base-core/src/main/java/org/jeecg/config/oss/XjOssConfig.java diff --git a/health-watch/health-watch-biz/src/main/java/com/renkang/watch/controller/WatchDeviceController.java b/health-watch/health-watch-biz/src/main/java/com/renkang/watch/controller/WatchDeviceController.java index eb173c6..5ada030 100644 --- a/health-watch/health-watch-biz/src/main/java/com/renkang/watch/controller/WatchDeviceController.java +++ b/health-watch/health-watch-biz/src/main/java/com/renkang/watch/controller/WatchDeviceController.java @@ -357,6 +357,10 @@ public class WatchDeviceController extends JeecgController delete(@RequestParam(name = "id", required = true) String id) { WatchDevice watchDevice = watchDeviceService.getById(id); + // 已绑定用户的手表不允许删除 + if (StringUtils.hasLength(watchDevice.getBindUserId())) { + return Result.error("该手表已绑定用户,请先解绑后再删除!"); + } watchDeviceService.removeById(id); // 删除开关 LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); diff --git a/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/MyUploadUtil.java b/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/MyUploadUtil.java index 10dbdbd..3901275 100644 --- a/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/MyUploadUtil.java +++ b/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/MyUploadUtil.java @@ -22,6 +22,7 @@ import org.apache.http.impl.client.HttpClients; import org.jeecg.common.constant.CommonConstant; import org.jeecg.common.exception.ExceptionAssertsUtil; import org.jeecg.common.util.filter.StrAttackFilter; +import org.jeecg.util.GlobalManager; import org.springframework.web.multipart.MultipartFile; import javax.imageio.ImageIO; @@ -51,7 +52,9 @@ public class MyUploadUtil { private static String muFileListUploadPath; private static String muDownPath; private static String fileTypeWhiteList; -// private static final int timeout = 10000; + /** 全局配置管理器,用于读取 Redis 中的动态配置 */ + private static GlobalManager globalManager; + // private static final int timeout = 10000; //有些大文件可能需要比较长的时间,这里改成5分钟 private static final int socketTimeout = 10*60*1000; private static final int connectTimeout = 60*1000; @@ -108,6 +111,10 @@ public class MyUploadUtil { MyUploadUtil.fileTypeWhiteList = fileTypeWhiteList; } + public static void setGlobalManager(GlobalManager globalManager) { + MyUploadUtil.globalManager = globalManager; + } + /** * 上传文件 * @@ -116,43 +123,52 @@ public class MyUploadUtil { */ public static String upload(MultipartFile file, String bizPath) throws Exception { - String fileUrl = ""; String fileName = file.getOriginalFilename();// 获取文件名 checkFileLegitimacy(fileName); - //???先屏蔽了 -// if (StringUtils.hasText(file.getOriginalFilename())) { -// String originalFilename = file.getOriginalFilename(); -// String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); -// fileName = fileName + ext; -// } bizPath = StrAttackFilter.filter(bizPath); + // .tar 文件固定走 MyUpload + if (fileName != null && (fileName.endsWith(".tar") || fileName.endsWith(".apk"))) { + log.info("上传文件为tar/apk文件,使用MyUploadUtil上传"); + try { + return upload(file.getInputStream(), bizPath, fileName); + } catch (Exception e) { + log.error("upload error {}", e.getMessage(), e); + return null; + } + } + // 其他文件:oss_flag=1 走 XjOssUtil,否则走 MyUpload + String ossFlag = globalManager != null ? globalManager.getConfigValueByKey("oss_flag") : ""; + if ("1".equals(ossFlag)) { + log.info("开启新疆OSS上传"); + return XjOssUtil.upload(file, bizPath); + } + log.info("使用MyUploadUtil上传"); try { - fileUrl = upload(file.getInputStream(), bizPath, fileName); + return upload(file.getInputStream(), bizPath, fileName); } catch (Exception e) { - fileUrl = null; - log.error("upload error", e); + log.error("upload error {}", e.getMessage(), e); + return null; } - return fileUrl; } - /** - * 上传文件 带缩略图 - * - * @param file - * @return - */ - public static String uploadImg(MultipartFile file, String bizPath, Boolean needThumbnailFlag) throws Exception { - String fileUrl = ""; - String fileName = file.getOriginalFilename();// 获取文件名 - checkFileLegitimacy(fileName); - bizPath = StrAttackFilter.filter(bizPath); - try { - fileUrl = uploadImg(file.getInputStream(), bizPath, fileName, needThumbnailFlag); - } catch (Exception e) { - log.error("upload error", e); - } - return fileUrl; - } +// /** +// * 上传文件 带缩略图 +// * +// * @param file +// * @return +// */ +// public static String uploadImg(MultipartFile file, String bizPath, Boolean needThumbnailFlag) throws Exception { +// String fileUrl = ""; +// String fileName = file.getOriginalFilename();// 获取文件名 +// checkFileLegitimacy(fileName); +// bizPath = StrAttackFilter.filter(bizPath); +// try { +// fileUrl = uploadImg(file.getInputStream(), bizPath, fileName, needThumbnailFlag); +// } catch (Exception e) { +// log.error("upload error", e); +// } +// return fileUrl; +// } public static String upload(InputStream stream, String bizPath, String fileName, boolean check) throws Exception { if (check) { @@ -220,145 +236,152 @@ public class MyUploadUtil { * @return */ public static String upload(InputStream stream, String bizPath, String fileName) throws Exception { + // 其他文件:oss_flag=1 走 XjOssUtil,否则走 MyUpload + String ossFlag = globalManager != null ? globalManager.getConfigValueByKey("oss_flag") : ""; + if ("1".equals(ossFlag)) { + log.info("开启新疆OSS上传"); + return XjOssUtil.upload(stream, bizPath,fileName); + } + log.info("使用MyUploadUtil上传"); return upload(stream, bizPath, fileName, true); } - /** - * 上传文件到myupload - * - * @param stream - * @return - */ - public static String uploadImg(InputStream stream, String bizPath, String fileName, Boolean needThumbnailFlag) throws Exception { - //避免重复文件覆盖,这里处理一下 - if (fileName.indexOf(".") > -1) { - //String fileNameLeft = fileName.substring(0, fileName.lastIndexOf(".")); - String fileNameLeft = UUID.randomUUID().toString(); - fileName = fileNameLeft + "_" + System.currentTimeMillis() + fileName.substring(fileName.lastIndexOf(".")); - } else { - fileName += "_" + System.currentTimeMillis(); - } - String nowday = new SimpleDateFormat("yyyyMMdd").format(new Date()); - String path = bizPath + File.separator + nowday; - String url = muUrl + "/" + muFileUploadPath; - String fileUrl = ""; - //创建post方法连接实例,在post方法中传入待连接地址 - CloseableHttpClient httpClient = HttpClients.createDefault(); - CloseableHttpResponse httpResponse = null; +// /** +// * 上传文件到myupload +// * +// * @param stream +// * @return +// */ +// public static String uploadImg(InputStream stream, String bizPath, String fileName, Boolean needThumbnailFlag) throws Exception { +// //避免重复文件覆盖,这里处理一下 +// if (fileName.indexOf(".") > -1) { +// //String fileNameLeft = fileName.substring(0, fileName.lastIndexOf(".")); +// String fileNameLeft = UUID.randomUUID().toString(); +// fileName = fileNameLeft + "_" + System.currentTimeMillis() + fileName.substring(fileName.lastIndexOf(".")); +// } else { +// fileName += "_" + System.currentTimeMillis(); +// } +// String nowday = new SimpleDateFormat("yyyyMMdd").format(new Date()); +// String path = bizPath + File.separator + nowday; +// String url = muUrl + "/" + muFileUploadPath; +// String fileUrl = ""; +// //创建post方法连接实例,在post方法中传入待连接地址 +// CloseableHttpClient httpClient = HttpClients.createDefault(); +// CloseableHttpResponse httpResponse = null; +// +// byte[] bytes = IoUtil.readBytes(stream); +// +// try { +// URI uri = new URIBuilder(url) +// .addParameter("fileName", fileName) +// .addParameter("path", path) +// .build(); +// HttpPost httppost = new HttpPost(uri); +// RequestConfig requestConfig = RequestConfig.custom().setSocketTimeout(socketTimeout).setConnectTimeout(connectTimeout).build(); +// httppost.setConfig(requestConfig); +// httppost.addHeader("token", muToken); +// HttpEntity reqEntity = MultipartEntityBuilder.create() +// //设置浏览器兼容模式 +// .setMode(HttpMultipartMode.BROWSER_COMPATIBLE) +// //设置请求的编码格式 +// .setCharset(StandardCharsets.UTF_8) +// .addBinaryBody("file", stream, ContentType.APPLICATION_OCTET_STREAM, fileName) +// .build(); +// httppost.setEntity(reqEntity); +// httppost.setEntity(reqEntity); +// +// httpResponse = httpClient.execute(httppost); +// +// +// int backCode = httpResponse.getStatusLine().getStatusCode(); +// if (backCode != HttpStatus.SC_OK) { +// log.error("上传文件失败" + backCode); +// } else { +// fileUrl = path + File.separator + fileName; +// fileUrl = relaceSlash(fileUrl); +// } +// } catch (IOException e) { +// log.error("文件服务器连接失败", e); +// } finally { +// //释放资源 +// try { +// httpClient.close(); +// if (httpResponse != null) { +// httpResponse.close(); +// } +// } catch (IOException e) { + //// e.printStackTrace(); +// } +// } +// +// if (needThumbnailFlag) { +// uploadThumbnail(url, bytes, fileName, path); +// } +// +// return fileUrl; +// } +// +// /** +// * 上传缩略图到myupload +// * +// * @return +// */ +// private static void uploadThumbnail(String url, byte[] bytes, String fileName, String path) { +// //创建post方法连接实例,在post方法中传入待连接地址 +// CloseableHttpClient httpClient = HttpClients.createDefault(); +// CloseableHttpResponse httpResponse = null; +// try { +// String thumbnailName = CommonConstant.THUMBNAIL_PREFIX + fileName; +// +// BufferedImage bufferedImage = Thumbnails.of(new ByteArrayInputStream(bytes)) +// .scale(CommonConstant.THUMBNAIL_SCALE) +// .asBufferedImage(); +// +// ByteArrayOutputStream os = new ByteArrayOutputStream(); +// ImageIO.write(bufferedImage, "png", os); +// InputStream is = new ByteArrayInputStream(os.toByteArray()); +// URI uri = new URIBuilder(url) +// .addParameter("fileName", thumbnailName) +// .addParameter("path", path) +// .build(); +// HttpPost httppost = new HttpPost(uri); +// RequestConfig requestConfig = RequestConfig.custom().setSocketTimeout(socketTimeout).setConnectTimeout(connectTimeout).build(); +// httppost.setConfig(requestConfig); +// httppost.addHeader("token", muToken); +// HttpEntity reqEntity = MultipartEntityBuilder.create() +// //设置浏览器兼容模式 +// .setMode(HttpMultipartMode.BROWSER_COMPATIBLE) +// //设置请求的编码格式 +// .setCharset(Consts.UTF_8) +// +// .addBinaryBody("file", is, ContentType.APPLICATION_OCTET_STREAM, thumbnailName) +// .build(); +// httppost.setEntity(reqEntity); +// +// httpResponse = httpClient.execute(httppost); +// +// int backCode = httpResponse.getStatusLine().getStatusCode(); +// if (backCode != HttpStatus.SC_OK) { +// log.error("上传缩略图失败" + backCode); +// } +// } catch (IOException e) { +// log.error("文件服务器连接失败", e); +// } catch (URISyntaxException e) { +// throw new RuntimeException(e); +// } finally { +// //释放资源 +// try { +// httpClient.close(); +// if (httpResponse != null) { +// httpResponse.close(); +// } +// } catch (IOException e) { +// } +// } +// } - byte[] bytes = IoUtil.readBytes(stream); - - try { - URI uri = new URIBuilder(url) - .addParameter("fileName", fileName) - .addParameter("path", path) - .build(); - HttpPost httppost = new HttpPost(uri); - RequestConfig requestConfig = RequestConfig.custom().setSocketTimeout(socketTimeout).setConnectTimeout(connectTimeout).build(); - httppost.setConfig(requestConfig); - httppost.addHeader("token", muToken); - HttpEntity reqEntity = MultipartEntityBuilder.create() - //设置浏览器兼容模式 - .setMode(HttpMultipartMode.BROWSER_COMPATIBLE) - //设置请求的编码格式 - .setCharset(StandardCharsets.UTF_8) - .addBinaryBody("file", stream, ContentType.APPLICATION_OCTET_STREAM, fileName) - .build(); - httppost.setEntity(reqEntity); - httppost.setEntity(reqEntity); - - httpResponse = httpClient.execute(httppost); - - - int backCode = httpResponse.getStatusLine().getStatusCode(); - if (backCode != HttpStatus.SC_OK) { - log.error("上传文件失败" + backCode); - } else { - fileUrl = path + File.separator + fileName; - fileUrl = relaceSlash(fileUrl); - } - } catch (IOException e) { - log.error("文件服务器连接失败", e); - } finally { - //释放资源 - try { - httpClient.close(); - if (httpResponse != null) { - httpResponse.close(); - } - } catch (IOException e) { -// e.printStackTrace(); - } - } - - if (needThumbnailFlag) { - uploadThumbnail(url, bytes, fileName, path); - } - - return fileUrl; - } - - /** - * 上传缩略图到myupload - * - * @return - */ - private static void uploadThumbnail(String url, byte[] bytes, String fileName, String path) { - //创建post方法连接实例,在post方法中传入待连接地址 - CloseableHttpClient httpClient = HttpClients.createDefault(); - CloseableHttpResponse httpResponse = null; - try { - String thumbnailName = CommonConstant.THUMBNAIL_PREFIX + fileName; - - BufferedImage bufferedImage = Thumbnails.of(new ByteArrayInputStream(bytes)) - .scale(CommonConstant.THUMBNAIL_SCALE) - .asBufferedImage(); - - ByteArrayOutputStream os = new ByteArrayOutputStream(); - ImageIO.write(bufferedImage, "png", os); - InputStream is = new ByteArrayInputStream(os.toByteArray()); - URI uri = new URIBuilder(url) - .addParameter("fileName", thumbnailName) - .addParameter("path", path) - .build(); - HttpPost httppost = new HttpPost(uri); - RequestConfig requestConfig = RequestConfig.custom().setSocketTimeout(socketTimeout).setConnectTimeout(connectTimeout).build(); - httppost.setConfig(requestConfig); - httppost.addHeader("token", muToken); - HttpEntity reqEntity = MultipartEntityBuilder.create() - //设置浏览器兼容模式 - .setMode(HttpMultipartMode.BROWSER_COMPATIBLE) - //设置请求的编码格式 - .setCharset(Consts.UTF_8) - - .addBinaryBody("file", is, ContentType.APPLICATION_OCTET_STREAM, thumbnailName) - .build(); - httppost.setEntity(reqEntity); - - httpResponse = httpClient.execute(httppost); - - int backCode = httpResponse.getStatusLine().getStatusCode(); - if (backCode != HttpStatus.SC_OK) { - log.error("上传缩略图失败" + backCode); - } - } catch (IOException e) { - log.error("文件服务器连接失败", e); - } catch (URISyntaxException e) { - throw new RuntimeException(e); - } finally { - //释放资源 - try { - httpClient.close(); - if (httpResponse != null) { - httpResponse.close(); - } - } catch (IOException e) { - } - } - } - - public String getMuFileExistPath() { - return muFileExistPath; - } +// public String getMuFileExistPath() { +// return muFileExistPath; +// } public static void setMuFileExistPath(String muFileExistPath) { MyUploadUtil.muFileExistPath = muFileExistPath; diff --git a/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/XjOssUtil.java b/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/XjOssUtil.java new file mode 100644 index 0000000..0344dea --- /dev/null +++ b/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/XjOssUtil.java @@ -0,0 +1,542 @@ +package org.jeecg.common.util; + +import cn.hutool.core.io.file.FileNameUtil; +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import lombok.extern.slf4j.Slf4j; +import org.apache.http.Header; +import org.apache.http.HttpStatus; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.utils.URIBuilder; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.mime.HttpMultipartMode; +import org.apache.http.entity.mime.MultipartEntityBuilder; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; +import org.jeecg.common.exception.ExceptionAssertsUtil; +import org.jeecg.common.util.filter.StrAttackFilter; +import org.jeecg.util.RedisClientUtil; +import org.jeecg.util.RedisKeyPrefix; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Date; +import java.util.List; + +/** + * 新疆油田平台(IOSP)OSS 文件工具类 + *

+ * 功能: + * 1. OAuth2 client_credentials 鉴权,token 缓存到 Redis,401 时自动重新鉴权 + * 2. 文件上传(支持 InputStream 和 MultipartFile) + * 3. 文件下载(通过 fileKey 获取文件流,处理 302 重定向) + *

+ *

+ * 配置项(Nacos 中配置,前缀 xj.oss): + * - gateway-url 网关地址,如 http://api.iosp.ydpt.tech + * - client-id OAuth2 客户端ID + * - client-secret OAuth2 客户端密钥 + * - organization-id 组织ID(上传/下载路径参数) + * - config-code 配置Code(上传/下载路径参数) + * - base-url-prefix 文件基础路径前缀(可选,用于截取 Location 中的相对路径) + *

+ */ +@Slf4j +public class XjOssUtil { + + // ===================== 静态配置字段(由 XjOssConfig 注入) ===================== + + /** 网关地址,如 http://api.iosp.ydpt.tech */ + private static String gatewayUrl; + /** OAuth2 客户端ID */ + private static String clientId; + /** OAuth2 客户端密钥 */ + private static String clientSecret; + /** 组织ID */ + private static String organizationId; + /** 配置Code */ + private static String configCode; + /** 文件基础路径前缀,如 http://11.71.10.44:8060/wbwj/scyx/scfz/aygc/jkgl/0 */ + private static String baseUrlPrefix; + /** 允许上传的文件类型白名单,逗号分隔,如 jpg,png,pdf */ + private static String fileTypeWhiteList; + /** 授权上传目录,如 scyx/scfz/aygc/jkgl/ */ + private static String directory; + + // ===================== Redis 工具(由 XjOssConfig 注入) ===================== + + private static RedisClientUtil redisClientUtil; + + // ===================== 常量 ===================== + + /** Redis key 前缀(不含过期时间,仅用于 delete/get 操作) */ + private static final RedisKeyPrefix REDIS_PREFIX_NO_TTL = new RedisKeyPrefix("xj:oss"); + /** Redis 中存储 token 的 key */ + private static final String TOKEN_KEY = "access_token"; + /** token 过期时间预留缓冲(秒),提前刷新避免临界过期 */ + private static final int TOKEN_EXPIRE_BUFFER = 60; + /** HTTP 连接超时(毫秒) */ + private static final int CONNECT_TIMEOUT = 30_000; + /** HTTP Socket 超时(毫秒),大文件上传需要较长时间 */ + private static final int SOCKET_TIMEOUT = 10 * 60_000; + private static final String DIR = "/jkgl/"; + + // ===================== Setter(供 XjOssConfig 注入) ===================== + + public static void setGatewayUrl(String gatewayUrl) { + XjOssUtil.gatewayUrl = gatewayUrl; + } + + public static void setClientId(String clientId) { + XjOssUtil.clientId = clientId; + } + + public static void setClientSecret(String clientSecret) { + XjOssUtil.clientSecret = clientSecret; + } + + public static void setOrganizationId(String organizationId) { + XjOssUtil.organizationId = organizationId; + } + + public static void setConfigCode(String configCode) { + XjOssUtil.configCode = configCode; + } + + public static void setBaseUrlPrefix(String baseUrlPrefix) { + XjOssUtil.baseUrlPrefix = baseUrlPrefix; + } + + public static void setFileTypeWhiteList(String fileTypeWhiteList) { + XjOssUtil.fileTypeWhiteList = fileTypeWhiteList; + } + + public static void setDirectory(String directory) { + XjOssUtil.directory = directory; + } + + public static void setRedisClientUtil(RedisClientUtil redisClientUtil) { + XjOssUtil.redisClientUtil = redisClientUtil; + } + + // ===================== 鉴权 ===================== + + /** + * 向 IOSP 网关发起 OAuth2 鉴权,获取 access_token 并存入 Redis + *

+ * 接口:POST {gatewayUrl}/oauth/oauth/token + * 参数:grant_type=client_credentials, client_id, client_secret + *

+ * + * @return 获取到的 access_token,失败返回 null + */ + public static String authenticate() { + String url = gatewayUrl + "/oauth/oauth/token"; + CloseableHttpClient httpClient = HttpClients.createDefault(); + CloseableHttpResponse response = null; + try { + HttpPost post = new HttpPost(url); + post.setConfig(buildRequestConfig()); + // 使用 multipart/form-data 提交鉴权参数 + post.setEntity(MultipartEntityBuilder.create() + .setMode(HttpMultipartMode.BROWSER_COMPATIBLE) + .setCharset(StandardCharsets.UTF_8) + .addTextBody("grant_type", "client_credentials") + .addTextBody("client_id", clientId) + .addTextBody("client_secret", clientSecret) + .build()); + + response = httpClient.execute(post); + int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode != HttpStatus.SC_OK) { + log.error("[XjOssUtil] 鉴权失败,HTTP状态码: {}", statusCode); + return null; + } + + String body = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); + JSONObject json = JSON.parseObject(body); + String token = json.getString("access_token"); + // expires_in 单位为秒,减去缓冲时间后存入 Redis + int expiresIn = json.getIntValue("expires_in"); + int ttl = Math.max(expiresIn - TOKEN_EXPIRE_BUFFER, 30); + + // 存入 Redis,TTL = expires_in - 缓冲;动态创建带 TTL 的 RedisKeyPrefix + RedisKeyPrefix prefixWithTtl = new RedisKeyPrefix(ttl, "xj:oss"); + redisClientUtil.set(prefixWithTtl, TOKEN_KEY, token); + log.info("[XjOssUtil] 鉴权成功,token 有效期 {}s(含缓冲)", ttl); + return token; + } catch (Exception e) { + log.error("[XjOssUtil] 鉴权异常", e); + return null; + } finally { + closeQuietly(httpClient, response); + } + } + + /** + * 获取有效的 access_token + *

+ * 优先从 Redis 读取缓存,不存在时自动重新鉴权 + *

+ * + * @return 有效的 access_token,失败返回 null + */ + public static String getToken() { + String token = redisClientUtil.get(REDIS_PREFIX_NO_TTL, TOKEN_KEY, String.class); + if (token == null) { + log.info("[XjOssUtil] Redis 中 token 不存在或已过期,重新鉴权"); + token = authenticate(); + } + return token; + } + + // ===================== 文件上传 ===================== + + /** + * 上传文件(MultipartFile 方式) + * + * @param file 前端上传的文件 + * @param bizPath 业务子路径,如 import、temp,拼接在授权目录之后 + * @return 上传成功后的文件 URL,失败返回 null + */ + public static String upload(MultipartFile file, String bizPath) { + try { + return upload(file.getInputStream(), bizPath, file.getOriginalFilename()); + } catch (Exception e) { + log.error("[XjOssUtil] MultipartFile 上传失败", e); + return null; + } + } + + /** + * 上传文件(InputStream 方式) + * + * @param stream 文件输入流 + * @param bizPath 业务子路径,如 import、temp,拼接在授权目录之后 + * @param fileName 文件名,如 test.png + * @return 上传成功后的文件 URL,失败返回 null + */ + public static String upload(InputStream stream, String bizPath, String fileName) { + checkFileLegitimacy(fileName); + // 过滤上传文件夹名特殊字符,防止路径攻击 + bizPath = StrAttackFilter.filter(bizPath); + // 构造上传 URL:/hfle/v2/{organizationId}/files/{configCode}/multipart + String url = gatewayUrl + "/hfle/v2/" + organizationId + "/files/" + configCode + "/multipart"; + + // 避免重复文件覆盖,追加时间戳 + if (fileName.contains(".")) { + String fileNameLeft = fileName.substring(0, fileName.lastIndexOf(".")); + fileName = fileNameLeft + "_" + System.currentTimeMillis() + fileName.substring(fileName.lastIndexOf(".")); + } else { + fileName += "_" + System.currentTimeMillis(); + } + String nowday = new SimpleDateFormat("yyyyMMdd").format(new Date()); + // 最终 fileName = bizPath/yyyyMMdd/originalName_timestamp.ext + final String finalFileName = DIR + bizPath + "/" + nowday + "/" + fileName; + + return doUpload(stream, url, finalFileName); + } + + /** + * 执行实际上传请求,401 时刷新 token 重试一次 + *

fileName 为已处理好的完整路径,不再做二次处理

+ */ + private static String doUpload(InputStream stream, String url, String fileName) { + CloseableHttpClient httpClient = HttpClients.createDefault(); + CloseableHttpResponse response = null; + try { + URI uri = new URIBuilder(url) + .addParameter("directory", directory) + .addParameter("fileName", fileName) + .addParameter("docType", "0") + .build(); + + HttpPost post = new HttpPost(uri); + post.setConfig(buildRequestConfig()); + post.addHeader("Authorization", "Bearer " + getToken()); + post.addHeader("accept", "application/json;charset=utf-8"); + post.setEntity(MultipartEntityBuilder.create() + .setMode(HttpMultipartMode.BROWSER_COMPATIBLE) + .setCharset(StandardCharsets.UTF_8) + .addBinaryBody("file", stream, ContentType.APPLICATION_OCTET_STREAM, fileName) + .build()); + + response = httpClient.execute(post); + int statusCode = response.getStatusLine().getStatusCode(); + + // 401 表示 token 过期,清除缓存后重试一次(fileName 已处理,不会重复拼接) + if (statusCode == HttpStatus.SC_UNAUTHORIZED) { + log.warn("[XjOssUtil] 上传时 token 过期,重新鉴权后重试"); + closeQuietly(httpClient, response); + redisClientUtil.delete(REDIS_PREFIX_NO_TTL, TOKEN_KEY); + return doUpload(stream, url, fileName); + } + + if (statusCode != HttpStatus.SC_OK) { + log.error("[XjOssUtil] 文件上传失败,HTTP状态码: {}", statusCode); + return null; + } + + String fileUrl = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); + // 接口可能返回 HTTP 200 但 body 为业务错误 JSON,需单独判断 + JSONObject resultJson = null; + try { + resultJson = JSON.parseObject(fileUrl); + } catch (Exception ignored) { + } + if (resultJson != null && Boolean.TRUE.equals(resultJson.getBoolean("failed"))) { + log.error("[XjOssUtil] 文件上传业务失败: {}", fileUrl); + return null; + } + String fileKey = extractFileKey(fileUrl); + log.info("[XjOssUtil] 文件上传成功,fileKey: {}", fileKey); + return fileKey; + } catch (Exception e) { + log.error("[XjOssUtil] 文件上传异常", e); + return null; + } finally { + closeQuietly(httpClient, response); + } + } + + // ===================== 文件下载 ===================== + + /** + * 下载文件,返回文件输入流 + *

+ * 入参为短 fileKey,如 808fec572eda4428a84c8b4c4f07f511@aa.png + * 内部自动拼接完整路径:{directory}0/{fileKey} + *

+ * + * @param fileKey 短文件 key,如 808fec572eda4428a84c8b4c4f07f511@aa.png + * @return 文件输入流,调用方负责关闭;失败返回 null + */ + public static InputStream down(String fileKey) { + String locationUrl = getLocationUrl(buildFullFileKey(fileKey)); + if (locationUrl == null) { + return null; + } + CloseableHttpClient downloadClient = HttpClients.createDefault(); + try { + HttpGet downloadGet = new HttpGet(locationUrl); + downloadGet.setConfig(buildRequestConfig()); + CloseableHttpResponse downloadResponse = downloadClient.execute(downloadGet); + int downloadStatus = downloadResponse.getStatusLine().getStatusCode(); + if (downloadStatus != HttpStatus.SC_OK) { + log.error("[XjOssUtil] 获取文件流失败,HTTP状态码: {}", downloadStatus); + closeQuietly(downloadClient, downloadResponse); + return null; + } + // 返回包装流,关闭时自动释放 HTTP 资源 + return new HttpEntityInputStream(downloadResponse.getEntity().getContent(), downloadClient, downloadResponse); + } catch (Exception e) { + log.error("[XjOssUtil] 文件下载异常", e); + closeQuietly(downloadClient, null); + return null; + } + } + + /** + * 获取文件预览地址(Location 重定向 URL) + *

+ * 入参为短 fileKey,如 808fec572eda4428a84c8b4c4f07f511@aa.png + * 内部自动拼接完整路径:{directory}0/{fileKey} + * 返回的 URL 可直接用于浏览器预览或前端展示 + *

+ * + * @param fileKey 短文件 key,如 808fec572eda4428a84c8b4c4f07f511@aa.png + * @return 预览 URL,失败返回 null + */ + public static String show(String fileKey) { + return getLocationUrl(buildFullFileKey(fileKey)); + } + + /** + * 将短 fileKey 拼接为下载接口所需的完整路径 + *

规则:{directory}0/{fileKey},示例:scyx/scfz/aygc/jkgl/0/808fec...@aa.png

+ */ + private static String buildFullFileKey(String fileKey) { + String dir = directory.endsWith("/") ? directory : directory + "/"; + return dir + "0/" + fileKey; + } + + /** + * 调用 download-by-key 接口,获取 302 重定向的 Location URL + *

401 时自动刷新 token 重试一次

+ * + * @param fullFileKey 完整 fileKey,如 scyx/scfz/aygc/jkgl/0/808fec...@aa.png + * @return Location URL,失败返回 null + */ + private static String getLocationUrl(String fullFileKey) { + String url = gatewayUrl + "/hfle/v2/" + organizationId + "/files/" + configCode + "/download-by-key"; + CloseableHttpClient noRedirectClient = org.apache.http.impl.client.HttpClientBuilder.create() + .disableRedirectHandling() + .build(); + CloseableHttpResponse response = null; + try { + URI uri = new URIBuilder(url) + .addParameter("fileKey", fullFileKey) + .build(); + HttpGet get = new HttpGet(uri); + get.setConfig(buildRequestConfig()); + String token = getToken(); + get.addHeader("Authorization", "Bearer " + token); + + // 打印等效 curl 命令,便于调试 + log.info("[XjOssUtil] curl --request GET \\\n --url '{}' \\\n --header 'Authorization: Bearer {}'", + uri.toString(), token); + + response = noRedirectClient.execute(get); + int statusCode = response.getStatusLine().getStatusCode(); + + // 401 表示 token 过期,重新鉴权后重试一次 + if (statusCode == HttpStatus.SC_UNAUTHORIZED) { + log.warn("[XjOssUtil] 获取 Location 时 token 过期,重新鉴权后重试"); + closeQuietly(noRedirectClient, response); + redisClientUtil.delete(REDIS_PREFIX_NO_TTL, TOKEN_KEY); + return getLocationUrl(fullFileKey); + } + + if (statusCode != HttpStatus.SC_MOVED_TEMPORARILY && statusCode != HttpStatus.SC_SEE_OTHER) { + log.error("[XjOssUtil] download-by-key 返回非预期状态码: {}", statusCode); + return null; + } + + Header locationHeader = response.getFirstHeader("Location"); + if (locationHeader == null) { + log.error("[XjOssUtil] download-by-key 未返回 Location 响应头"); + return null; + } + String locationUrl = locationHeader.getValue(); + log.info("[XjOssUtil] 获取到 Location: {}", locationUrl); + return locationUrl; + } catch (Exception e) { + log.error("[XjOssUtil] 获取 Location 异常", e); + return null; + } finally { + closeQuietly(noRedirectClient, response); + } + } + + // ===================== 私有工具方法 ===================== + + /** + * 文件合法性校验(文件类型白名单) + *

+ * 白名单为空时跳过校验;文件名无扩展名或扩展名不在白名单中时抛出异常。 + *

+ * + * @param fileName 文件名 + */ + private static void checkFileLegitimacy(String fileName) { + if (StrUtil.isNotEmpty(fileTypeWhiteList)) { + String extName = FileNameUtil.extName(fileName); + if (StrUtil.isBlank(extName)) { + ExceptionAssertsUtil.fail("文件上传失败:不能识别的文件类型!"); + } + List fileTypeList = Arrays.asList(fileTypeWhiteList.split(",")); + // 统一转小写比对,忽略大小写差异 + if (!fileTypeList.contains(extName.toLowerCase())) { + ExceptionAssertsUtil.fail("文件上传失败:不支持的文件类型!"); + } + } + } + + /** + * 从上传接口返回的完整 URL 中截取 fileKey + *

+ * 返回示例:https://wbwj.http://11.71.10.44:8060/scyx/.../0/808fec572eda4428a84c8b4c4f07f511@aa.png + * 截取结果:808fec572eda4428a84c8b4c4f07f511@aa.png + * 规则:取 @ 符号前最后一个 / 之后的所有内容 + *

+ * + * @param fileUrl 接口返回的完整 URL + * @return fileKey,解析失败时返回原始 URL + */ + private static String extractFileKey(String fileUrl) { + int atIndex = fileUrl.indexOf("@"); + if (atIndex < 0) { + return fileUrl; + } + int slashIndex = fileUrl.lastIndexOf("/", atIndex); + if (slashIndex < 0) { + return fileUrl; + } + return fileUrl.substring(slashIndex + 1); + } + + /** + * 构建统一的 HTTP 请求配置 + */ + private static RequestConfig buildRequestConfig() { + return RequestConfig.custom() + .setConnectTimeout(CONNECT_TIMEOUT) + .setSocketTimeout(SOCKET_TIMEOUT) + .build(); + } + + /** + * 静默关闭 HTTP 资源,忽略异常 + */ + private static void closeQuietly(CloseableHttpClient client, CloseableHttpResponse response) { + try { + if (response != null) { + response.close(); + } + } catch (IOException ignored) { + } + try { + if (client != null) { + client.close(); + } + } catch (IOException ignored) { + } + } + + // ===================== 内部类:包装 InputStream,关闭时释放 HTTP 资源 ===================== + + /** + * 包装 HTTP 响应的 InputStream,关闭时自动释放 HTTP 连接资源 + */ + private static class HttpEntityInputStream extends InputStream { + + private final InputStream delegate; + private final CloseableHttpClient httpClient; + private final CloseableHttpResponse httpResponse; + + HttpEntityInputStream(InputStream delegate, CloseableHttpClient httpClient, CloseableHttpResponse httpResponse) { + this.delegate = delegate; + this.httpClient = httpClient; + this.httpResponse = httpResponse; + } + + @Override + public int read() throws IOException { + return delegate.read(); + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + return delegate.read(b, off, len); + } + + @Override + public void close() throws IOException { + try { + delegate.close(); + } finally { + closeQuietly(httpClient, httpResponse); + } + } + } +} diff --git a/jeecg-boot-base-core/src/main/java/org/jeecg/config/oss/MyUploadConfig.java b/jeecg-boot-base-core/src/main/java/org/jeecg/config/oss/MyUploadConfig.java index 6d2c409..db491b2 100644 --- a/jeecg-boot-base-core/src/main/java/org/jeecg/config/oss/MyUploadConfig.java +++ b/jeecg-boot-base-core/src/main/java/org/jeecg/config/oss/MyUploadConfig.java @@ -2,6 +2,8 @@ package org.jeecg.config.oss; import lombok.extern.slf4j.Slf4j; import org.jeecg.common.util.MyUploadUtil; +import org.jeecg.util.GlobalManager; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -29,6 +31,9 @@ public class MyUploadConfig { @Value(value = "${jeecg.myupload.mu_file_type_white_list}") private String fileTypeWhiteList; + @Autowired + private GlobalManager globalManager; + @Bean public void initMu() { MyUploadUtil.setMuUrl(muUrl); @@ -39,6 +44,7 @@ public class MyUploadConfig { MyUploadUtil.setMuFileListUploadPath(muFileListUploadPath); MyUploadUtil.setMuDownPath(muDownPath); MyUploadUtil.setFileTypeWhiteList(fileTypeWhiteList); + MyUploadUtil.setGlobalManager(globalManager); } } diff --git a/jeecg-boot-base-core/src/main/java/org/jeecg/config/oss/XjOssConfig.java b/jeecg-boot-base-core/src/main/java/org/jeecg/config/oss/XjOssConfig.java new file mode 100644 index 0000000..6f406a4 --- /dev/null +++ b/jeecg-boot-base-core/src/main/java/org/jeecg/config/oss/XjOssConfig.java @@ -0,0 +1,84 @@ +package org.jeecg.config.oss; + +import lombok.extern.slf4j.Slf4j; +import org.jeecg.common.util.XjOssUtil; +import org.jeecg.util.RedisClientUtil; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 新疆油田平台(IOSP)OSS 配置类 + *

+ * 从 Nacos 读取配置并注入到 XjOssUtil 静态字段中。 + * 对应 Nacos 配置前缀:xj.oss + *

+ */ +@Slf4j +@Configuration +public class XjOssConfig { + + /** 网关地址,如 http://api.iosp.ydpt.tech */ + @Value(value = "${variable.anyan.host}") + private String gatewayUrl; + + /** OAuth2 客户端ID */ + @Value(value = "${variable.anyan.client-id}") + private String clientId; + + /** OAuth2 客户端密钥 */ + @Value(value = "${variable.anyan.client-secret}") + private String clientSecret; + + /** 组织ID(上传/下载路径参数) */ + @Value(value = "${variable.anyan.organization-id}") + private String organizationId; + + /** 配置Code(上传/下载路径参数) */ + @Value(value = "${variable.anyan.config-code}") + private String configCode; + + /** + * 文件基础路径前缀(可选) + * 示例:http://11.71.10.44:8060/wbwj/scyx/scfz/aygc/jkgl/0 + * 用于从完整 URL 中截取相对 fileKey + */ + @Value(value = "${variable.anyan.base-url-prefix:}") + private String baseUrlPrefix; + + /** + * 允许上传的文件类型白名单,逗号分隔(可选) + * 示例:jpg,jpeg,png,pdf,doc,docx + * 为空时不做类型限制 + */ + @Value(value = "${variable.anyan.file-type-white-list:}") + private String fileTypeWhiteList; + + /** + * 授权上传目录,需与 IOSP 平台授权的目录一致 + * 示例:scyx/scfz/aygc/jkgl/ + */ + @Value(value = "${variable.anyan.directory}") + private String directory; + + @Autowired + private RedisClientUtil redisClientUtil; + + /** + * 初始化 XjOssUtil 静态配置 + */ + @Bean + public void initXjOss() { + XjOssUtil.setGatewayUrl(gatewayUrl); + XjOssUtil.setClientId(clientId); + XjOssUtil.setClientSecret(clientSecret); + XjOssUtil.setOrganizationId(organizationId); + XjOssUtil.setConfigCode(configCode); + XjOssUtil.setBaseUrlPrefix(baseUrlPrefix); + XjOssUtil.setFileTypeWhiteList(fileTypeWhiteList); + XjOssUtil.setDirectory(directory); + XjOssUtil.setRedisClientUtil(redisClientUtil); + log.info("[XjOssConfig] IOSP OSS 配置初始化完成,网关地址: {}", gatewayUrl); + } +} diff --git a/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroConfig.java b/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroConfig.java index 1aed9e2..a49bdf3 100644 --- a/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroConfig.java +++ b/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroConfig.java @@ -185,6 +185,8 @@ public class ShiroConfig { filterChainDefinitionMap.put("/version/getSysVersionDetailByPackageName", "anon"); filterChainDefinitionMap.put("/sys/api/notice/selectIosUpdate", "anon"); + filterChainDefinitionMap.put("/sys/upload/down/**", "anon"); + filterChainDefinitionMap.put("/sys/upload/show/**", "anon"); // 添加自己的过滤器并且取名为jwt Map filterMap = new HashMap(1); diff --git a/jeecg-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysUploadController.java b/jeecg-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysUploadController.java index 015100c..2d4bc8f 100644 --- a/jeecg-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysUploadController.java +++ b/jeecg-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysUploadController.java @@ -1,10 +1,12 @@ package org.jeecg.modules.system.controller; +import cn.hutool.core.util.StrUtil; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import lombok.extern.slf4j.Slf4j; import org.jeecg.common.api.vo.Result; import org.jeecg.common.util.MyUploadUtil; +import org.jeecg.common.util.XjOssUtil; import org.jeecg.common.util.oConvertUtils; import org.jeecg.modules.oss.service.IOssFileService; import org.jeecg.modules.system.bean.request.UploadFilsDTO; @@ -15,6 +17,11 @@ import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URLEncoder; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -89,6 +96,9 @@ public class SysUploadController { } catch (Exception e) { return Result.error(e.getMessage()); } + if (StrUtil.isBlank(fileUrl)){ + return Result.error("上传失败,请检查配置信息是否正确!"); + } Map map = new HashMap(); map.put("url", fileUrl); return Result.OK(map); @@ -115,4 +125,94 @@ public class SysUploadController { return Result.OK(urlList); } + @Operation(summary = "IOSP文件下载", description = "根据 fileKey 下载文件,如 sys/upload/down/808fec...@aa.png") + @RequestMapping(value = "/down/**", method = RequestMethod.GET) + public void fileDown(HttpServletRequest request, HttpServletResponse response) throws Exception { + String fileKey = extractPathParam(request, "/down/"); + if (isIllegalFileKey(fileKey)) { + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "fileKey 格式非法"); + return; + } + try (InputStream in = XjOssUtil.down(fileKey)) { + if (in == null) { + response.sendError(HttpServletResponse.SC_NOT_FOUND, "文件不存在或下载失败"); + return; + } + // 从 fileKey 中取文件名(@ 后面的部分) + String fileName = fileKey.contains("@") ? fileKey.substring(fileKey.indexOf("@") + 1) : fileKey; + response.setContentType("application/octet-stream"); + response.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(fileName, "UTF-8")); + try (OutputStream out = response.getOutputStream()) { + byte[] buf = new byte[4096]; + int len; + while ((len = in.read(buf)) != -1) { + out.write(buf, 0, len); + } + out.flush(); + } + } catch (Exception e) { + log.error("[SysUploadController] IOSP文件下载失败, fileKey={}", fileKey, e); + } + } + + @Operation(summary = "IOSP文件预览", description = "根据 fileKey 获取预览地址,如 sys/upload/show/808fec...@aa.png") + @RequestMapping(value = "/show/**", method = RequestMethod.GET) + public void fileShow(HttpServletRequest request, HttpServletResponse response) throws Exception { + String fileKey = extractPathParam(request, "/show/"); + if (isIllegalFileKey(fileKey)) { + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "fileKey 格式非法"); + return; + } + try { + String url = XjOssUtil.show(fileKey); + if (url == null) { + response.sendError(HttpServletResponse.SC_NOT_FOUND, "文件不存在或获取预览地址失败"); + return; + } + // 重定向到真实预览地址 + response.sendRedirect(url); + } catch (Exception e) { + log.error("[SysUploadController] IOSP文件预览失败, fileKey={}", fileKey, e); + } + } + + /** + * 从请求路径中提取 fileKey + *

截取 marker 之后的路径部分,并做 URL 解码

+ */ + private String extractPathParam(HttpServletRequest request, String marker) { + String uri = request.getRequestURI(); + int idx = uri.indexOf(marker); + if (idx < 0) { + return ""; + } + String raw = uri.substring(idx + marker.length()); + try { + return java.net.URLDecoder.decode(raw, "UTF-8"); + } catch (Exception e) { + return raw; + } + } + + /** + * 校验 fileKey 是否存在路径穿透风险 + *

+ * 拦截 ../、..\ 及其 URL 编码变体(%2e%2e%2f 等),防止跳出授权目录 + *

+ */ + private boolean isIllegalFileKey(String fileKey) { + if (oConvertUtils.isEmpty(fileKey)) { + return true; + } + // 先做 URL 解码,防止编码绕过 + String decoded; + try { + decoded = java.net.URLDecoder.decode(fileKey, "UTF-8"); + } catch (Exception e) { + return true; + } + return decoded.contains("../") || decoded.contains("..\\") + || decoded.startsWith("..") || decoded.contains("/.."); + } + }