feat(file-upload): 集成新疆油田OSS文件上传功能并优化设备管理

- 集成XjOssUtil实现新疆油田平台OSS文件上传下载功能
- 添加XjOssConfig配置类从Nacos读取OSS相关配置信息
- 实现OAuth2 client_credentials鉴权机制并缓存token到Redis
- 支持.tar文件强制使用MyUpload上传,其他文件根据oss_flag配置选择上传方式
- 在WatchDeviceController中禁用移动端解绑手表功能并添加删除设备时的绑定检查
- 添加/sys/upload/down/**和/sys/upload/show/**匿名访问权限支持文件下载预览
- 实现文件下载预览接口支持IOSP平台文件服务并添加安全校验机制
- 在CareUserDetailsParam中添加年份字段默认为当前年份
This commit is contained in:
2026-04-21 17:11:48 +08:00
parent 15dfd6a9c7
commit 42c7d223c9
8 changed files with 933 additions and 166 deletions
@@ -1,5 +1,6 @@
package com.renkang.anyan.model.dto;
import cn.hutool.core.date.DateUtil;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -34,4 +35,6 @@ public class CareUserDetailsParam {
@Schema(title = "pageType")
private String pageType = "plus";
private int year = DateUtil.thisYear();
}
@@ -207,7 +207,9 @@ public class WatchDeviceController extends JeecgController<WatchDevice, IWatchDe
@Operation(summary = "移动端解绑设备(手表)", description = "移动端解绑设备(手表)")
@GetMapping("/app/unbundleDevice")
public Result unbundleDevice(@RequestParam(name = "watchNo") String watchNo) {
return watchDeviceService.unbundleDevice(watchNo);
//移动端暂不允许解绑手表
return Result.error("请联系健康管理员解绑");
// return watchDeviceService.unbundleDevice(watchNo);
}
@@ -356,6 +358,10 @@ public class WatchDeviceController extends JeecgController<WatchDevice, IWatchDe
@DeleteMapping(value = "/delete")
public Result<String> 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<WatchDeviceSwitch> queryWrapper = new LambdaQueryWrapper<>();
@@ -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,6 +52,8 @@ public class MyUploadUtil {
private static String muFileListUploadPath;
private static String muDownPath;
private static String fileTypeWhiteList;
/** 全局配置管理器,用于读取 Redis 中的动态配置 */
private static GlobalManager globalManager;
// private static final int timeout = 10000;
//有些大文件可能需要比较长的时间,这里改成5分钟
private static final int socketTimeout = 10*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")) {
log.info("上传文件为tar文件,使用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;
@@ -0,0 +1,543 @@
package org.jeecg.common.util;
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.mime.HttpMultipartMode;
import org.apache.http.entity.mime.MultipartEntityBuilder;
import org.apache.http.entity.ContentType;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;
import org.jeecg.util.RedisClientUtil;
import org.jeecg.util.RedisKeyPrefix;
import org.springframework.web.multipart.MultipartFile;
import cn.hutool.core.io.file.FileNameUtil;
import cn.hutool.core.util.StrUtil;
import org.jeecg.common.exception.ExceptionAssertsUtil;
import org.jeecg.common.util.filter.StrAttackFilter;
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 文件工具类
* <p>
* 功能:
* 1. OAuth2 client_credentials 鉴权,token 缓存到 Redis401 时自动重新鉴权
* 2. 文件上传(支持 InputStream 和 MultipartFile
* 3. 文件下载(通过 fileKey 获取文件流,处理 302 重定向)
* </p>
* <p>
* 配置项(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 中的相对路径)
* </p>
*/
@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
* <p>
* 接口:POST {gatewayUrl}/oauth/oauth/token
* 参数:grant_type=client_credentials, client_id, client_secret
* </p>
*
* @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);
// 存入 RedisTTL = 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
* <p>
* 优先从 Redis 读取缓存,不存在时自动重新鉴权
* </p>
*
* @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 重试一次
* <p>fileName 为已处理好的完整路径,不再做二次处理</p>
*/
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);
}
}
// ===================== 文件下载 =====================
/**
* 下载文件,返回文件输入流
* <p>
* 入参为短 fileKey,如 808fec572eda4428a84c8b4c4f07f511@aa.png
* 内部自动拼接完整路径:{directory}0/{fileKey}
* </p>
*
* @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
* <p>
* 入参为短 fileKey,如 808fec572eda4428a84c8b4c4f07f511@aa.png
* 内部自动拼接完整路径:{directory}0/{fileKey}
* 返回的 URL 可直接用于浏览器预览或前端展示
* </p>
*
* @param fileKey 短文件 key,如 808fec572eda4428a84c8b4c4f07f511@aa.png
* @return 预览 URL,失败返回 null
*/
public static String show(String fileKey) {
return getLocationUrl(buildFullFileKey(fileKey));
}
/**
* 将短 fileKey 拼接为下载接口所需的完整路径
* <p>规则:{directory}0/{fileKey},示例:scyx/scfz/aygc/jkgl/0/808fec...@aa.png</p>
*/
private static String buildFullFileKey(String fileKey) {
String dir = directory.endsWith("/") ? directory : directory + "/";
return dir + "0/" + fileKey;
}
/**
* 调用 download-by-key 接口,获取 302 重定向的 Location URL
* <p>401 时自动刷新 token 重试一次</p>
*
* @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);
}
}
// ===================== 私有工具方法 =====================
/**
* 文件合法性校验(文件类型白名单)
* <p>
* 白名单为空时跳过校验;文件名无扩展名或扩展名不在白名单中时抛出异常。
* </p>
*
* @param fileName 文件名
*/
private static void checkFileLegitimacy(String fileName) {
if (StrUtil.isNotEmpty(fileTypeWhiteList)) {
String extName = FileNameUtil.extName(fileName);
if (StrUtil.isBlank(extName)) {
ExceptionAssertsUtil.fail("文件上传失败:不能识别的文件类型!");
}
List<String> fileTypeList = Arrays.asList(fileTypeWhiteList.split(","));
// 统一转小写比对,忽略大小写差异
if (!fileTypeList.contains(extName.toLowerCase())) {
ExceptionAssertsUtil.fail("文件上传失败:不支持的文件类型!");
}
}
}
/**
* 从上传接口返回的完整 URL 中截取 fileKey
* <p>
* 返回示例:https://wbwj.http://11.71.10.44:8060/scyx/.../0/808fec572eda4428a84c8b4c4f07f511@aa.png
* 截取结果:808fec572eda4428a84c8b4c4f07f511@aa.png
* 规则:取 @ 符号前最后一个 / 之后的所有内容
* </p>
*
* @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);
}
}
}
}
@@ -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);
}
}
@@ -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 配置类
* <p>
* 从 Nacos 读取配置并注入到 XjOssUtil 静态字段中。
* 对应 Nacos 配置前缀:xj.oss
* </p>
*/
@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);
}
}
@@ -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<String, Filter> filterMap = new HashMap<String, Filter>(1);
@@ -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
* <p>截取 marker 之后的路径部分,并做 URL 解码</p>
*/
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 是否存在路径穿透风险
* <p>
* 拦截 ../、..\ 及其 URL 编码变体(%2e%2e%2f 等),防止跳出授权目录
* </p>
*/
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("/..");
}
}