feat: 对接MeshSafe文件安全检测(上传/解包自动送检+轮询回写,不通过禁止分享传输,文件夹确认后仅分享通过内容,人工复核/重新检测,meshsafe.enabled总开关)
This commit is contained in:
280
src/main/java/com/senmesh/sfile/client/MeshSafeClient.java
Normal file
280
src/main/java/com/senmesh/sfile/client/MeshSafeClient.java
Normal file
@@ -0,0 +1,280 @@
|
||||
package com.senmesh.sfile.client;
|
||||
|
||||
import cn.hutool.core.io.IoUtil;
|
||||
import com.senmesh.common.exception.BusinessException;
|
||||
import com.senmesh.common.util.JsonUtils;
|
||||
import com.senmesh.sfile.config.MeshSafeProperties;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.codec.digest.DigestUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* MeshSafe 文件安全检测客户端
|
||||
* <p>
|
||||
* 对接 MeshSafe /api/v1 接口,认证规范见 MeshSafe《对外接口文档》:
|
||||
* 请求需携带 HMAC-SHA256 签名(密钥经 HKDF-SHA256 派生),响应需验签,
|
||||
* 验签失败的检测结论一律拒绝使用。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class MeshSafeClient {
|
||||
|
||||
private static final String PATH_SCANS = "/api/v1/scans";
|
||||
|
||||
private static final String INFO_REQUEST_SIGNING = "meshsafe/api/v1/request-signing";
|
||||
|
||||
private static final String INFO_RESPONSE_SIGNING = "meshsafe/api/v1/response-signing";
|
||||
|
||||
private static final String HEADER_CLIENT_ID = "X-MS-Client-Id";
|
||||
|
||||
private static final String HEADER_TIMESTAMP = "X-MS-Timestamp";
|
||||
|
||||
private static final String HEADER_NONCE = "X-MS-Nonce";
|
||||
|
||||
private static final String HEADER_BODY_SHA256 = "X-MS-Body-SHA256";
|
||||
|
||||
private static final String HEADER_SIGNATURE = "X-MS-Signature";
|
||||
|
||||
private static final String HEADER_RESPONSE_TIMESTAMP = "X-MS-Response-Timestamp";
|
||||
|
||||
private static final String HEADER_RESPONSE_SIGNATURE = "X-MS-Response-Signature";
|
||||
|
||||
private static final String MULTIPART_BOUNDARY_PREFIX = "----MeshSafeBoundary";
|
||||
|
||||
@Autowired
|
||||
private MeshSafeProperties meshSafeProperties;
|
||||
|
||||
/**
|
||||
* 提交文件检测(同步模式直接返回结论,异步模式返回受理结果需轮询)
|
||||
*
|
||||
* @param fileName 文件名
|
||||
* @param fileBytes 文件字节
|
||||
* @return 检测结果
|
||||
*/
|
||||
public MeshSafeScanResult scan(String fileName, byte[] fileBytes) {
|
||||
String boundary = MULTIPART_BOUNDARY_PREFIX + UUID.randomUUID().toString().replace("-", "");
|
||||
byte[] body = buildMultipartBody(boundary, fileName, fileBytes);
|
||||
String contentType = "multipart/form-data; boundary=" + boundary;
|
||||
|
||||
Map<String, String> headers = signHeaders("POST", PATH_SCANS, body);
|
||||
headers.put("Content-Type", contentType);
|
||||
|
||||
String responseBody = execute("POST", PATH_SCANS, headers, body);
|
||||
return parseScanResult(responseBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询检测任务结果
|
||||
*
|
||||
* @param taskId MeshSafe 任务ID
|
||||
* @return 检测结果
|
||||
*/
|
||||
public MeshSafeScanResult getScan(String taskId) {
|
||||
String path = PATH_SCANS + "/" + taskId;
|
||||
Map<String, String> headers = signHeaders("GET", path, new byte[0]);
|
||||
String responseBody = execute("GET", path, headers, null);
|
||||
return parseScanResult(responseBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* 回写人工复核结论到 MeshSafe
|
||||
*
|
||||
* @param taskId MeshSafe 任务ID
|
||||
* @param decision 复核结论:pass / reject
|
||||
* @param comment 复核说明
|
||||
*/
|
||||
public void review(String taskId, String decision, String comment) {
|
||||
String path = PATH_SCANS + "/" + taskId + "/review";
|
||||
Map<String, Object> bodyMap = new LinkedHashMap<>();
|
||||
bodyMap.put("decision", decision);
|
||||
bodyMap.put("comment", comment);
|
||||
byte[] body = JsonUtils.toJsonString(bodyMap).getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
Map<String, String> headers = signHeaders("POST", path, body);
|
||||
headers.put("Content-Type", "application/json");
|
||||
execute("POST", path, headers, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送请求并验签
|
||||
* 使用JDK原生HttpURLConnection保证multipart字节与签名计算完全一致
|
||||
* (hutool检测到multipart Content-Type时会重建表单体导致body被覆盖)
|
||||
*/
|
||||
private String execute(String method, String path, Map<String, String> headers, byte[] body) {
|
||||
String url = StringUtils.removeEnd(meshSafeProperties.getBaseUrl(), "/") + path;
|
||||
byte[] bodyBytes = body == null ? new byte[0] : body;
|
||||
try {
|
||||
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
|
||||
conn.setRequestMethod(method);
|
||||
conn.setConnectTimeout(meshSafeProperties.getTimeoutSeconds() * 1000);
|
||||
conn.setReadTimeout(meshSafeProperties.getTimeoutSeconds() * 1000);
|
||||
conn.setDoOutput(true);
|
||||
for (Map.Entry<String, String> entry : headers.entrySet()) {
|
||||
conn.setRequestProperty(entry.getKey(), entry.getValue());
|
||||
}
|
||||
try (OutputStream os = conn.getOutputStream()) {
|
||||
os.write(bodyBytes);
|
||||
}
|
||||
|
||||
int status = conn.getResponseCode();
|
||||
byte[] responseBytes;
|
||||
try (InputStream is = status >= 400 ? conn.getErrorStream() : conn.getInputStream()) {
|
||||
responseBytes = IoUtil.readBytes(is);
|
||||
}
|
||||
|
||||
String responseTimestamp = conn.getHeaderField(HEADER_RESPONSE_TIMESTAMP);
|
||||
String responseSignature = conn.getHeaderField(HEADER_RESPONSE_SIGNATURE);
|
||||
verifyResponseSignature(responseTimestamp, responseSignature, responseBytes);
|
||||
|
||||
String responseBody = new String(responseBytes, StandardCharsets.UTF_8);
|
||||
if (status < 200 || status >= 300) {
|
||||
log.error("MeshSafe请求失败, method: {}, path: {}, status: {}, response: {}",
|
||||
method, path, status, responseBody);
|
||||
throw new BusinessException("MeshSafe服务返回异常: HTTP " + status);
|
||||
}
|
||||
return responseBody;
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("MeshSafe请求异常, method: {}, path: {}", method, path, e);
|
||||
throw new BusinessException("MeshSafe服务请求失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验响应签名,验签失败拒绝使用该结论
|
||||
*/
|
||||
private void verifyResponseSignature(String timestamp, String signature, byte[] responseBytes) {
|
||||
if (StringUtils.isBlank(timestamp) || StringUtils.isBlank(signature)) {
|
||||
throw new BusinessException("MeshSafe响应缺少签名头,拒绝使用该检测结论");
|
||||
}
|
||||
byte[] responseKey = deriveKey(INFO_RESPONSE_SIGNING);
|
||||
String payload = DigestUtils.sha256Hex(responseBytes) + timestamp;
|
||||
String expected = hmacHex(responseKey, payload);
|
||||
if (!expected.equalsIgnoreCase(signature)) {
|
||||
throw new BusinessException("MeshSafe响应签名校验失败,拒绝使用该检测结论");
|
||||
}
|
||||
}
|
||||
|
||||
private MeshSafeScanResult parseScanResult(String responseBody) {
|
||||
MeshSafeScanResult result = JsonUtils.toObject(responseBody, MeshSafeScanResult.class);
|
||||
if (result == null || StringUtils.isBlank(result.getTaskId())) {
|
||||
throw new BusinessException("MeshSafe响应解析失败: " + responseBody);
|
||||
}
|
||||
result.setRawJson(responseBody);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造请求签名头
|
||||
* 待签名串: METHOD \n PATH \n CANONICAL_QUERY \n BODY_SHA256 \n TIMESTAMP \n NONCE \n CLIENT_ID
|
||||
*/
|
||||
private Map<String, String> signHeaders(String method, String path, byte[] body) {
|
||||
String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
|
||||
String nonce = UUID.randomUUID().toString().replace("-", "");
|
||||
String bodySha256 = DigestUtils.sha256Hex(body);
|
||||
|
||||
String payload = String.join("\n",
|
||||
method.toUpperCase(), path, "", bodySha256, timestamp, nonce,
|
||||
meshSafeProperties.getClientId());
|
||||
|
||||
String signature = hmacHex(deriveKey(INFO_REQUEST_SIGNING), payload);
|
||||
|
||||
Map<String, String> headers = new LinkedHashMap<>();
|
||||
headers.put(HEADER_CLIENT_ID, meshSafeProperties.getClientId());
|
||||
headers.put(HEADER_TIMESTAMP, timestamp);
|
||||
headers.put(HEADER_NONCE, nonce);
|
||||
headers.put(HEADER_BODY_SHA256, bodySha256);
|
||||
headers.put(HEADER_SIGNATURE, signature);
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* HKDF-SHA256 密钥派生(RFC 5869, extract + expand 两步)
|
||||
*/
|
||||
private byte[] deriveKey(String info) {
|
||||
try {
|
||||
byte[] ikm = meshSafeProperties.getSecret().getBytes(StandardCharsets.UTF_8);
|
||||
byte[] salt = meshSafeProperties.getClientId().getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(salt, "HmacSHA256"));
|
||||
byte[] prk = mac.doFinal(ikm);
|
||||
|
||||
byte[] okm = new byte[32];
|
||||
byte[] block = new byte[0];
|
||||
int offset = 0;
|
||||
byte counter = 1;
|
||||
while (offset < okm.length) {
|
||||
mac.init(new SecretKeySpec(prk, "HmacSHA256"));
|
||||
mac.update(block);
|
||||
mac.update(info.getBytes(StandardCharsets.UTF_8));
|
||||
mac.update(new byte[]{counter});
|
||||
block = mac.doFinal();
|
||||
int n = Math.min(block.length, okm.length - offset);
|
||||
System.arraycopy(block, 0, okm, offset, n);
|
||||
offset += n;
|
||||
counter++;
|
||||
}
|
||||
return okm;
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("MeshSafe签名密钥派生失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String hmacHex(byte[] key, String message) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(key, "HmacSHA256"));
|
||||
return hex(mac.doFinal(message.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("MeshSafe签名计算失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String hex(byte[] bytes) {
|
||||
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 手工构造 multipart 请求体(签名需基于最终字节计算,不能用HTTP库自动构造)
|
||||
*/
|
||||
private byte[] buildMultipartBody(String boundary, String fileName, byte[] fileBytes) {
|
||||
try {
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
String channel = StringUtils.defaultIfBlank(meshSafeProperties.getChannel(), "default");
|
||||
|
||||
bos.write(("--" + boundary + "\r\n"
|
||||
+ "Content-Disposition: form-data; name=\"channel\"\r\n\r\n"
|
||||
+ channel + "\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
String safeName = fileName.replace("\"", "");
|
||||
bos.write(("--" + boundary + "\r\n"
|
||||
+ "Content-Disposition: form-data; name=\"file\"; filename=\"" + safeName + "\"\r\n"
|
||||
+ "Content-Type: application/octet-stream\r\n\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
bos.write(fileBytes);
|
||||
bos.write(("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
return bos.toByteArray();
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("构造MeshSafe检测请求失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.senmesh.sfile.client;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* MeshSafe 检测结果
|
||||
* <p>
|
||||
* 字段说明见 MeshSafe《对外接口文档》§5,action 是调用方唯一决策依据。
|
||||
* MeshSafe 返回 snake_case 字段名。
|
||||
*/
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class MeshSafeScanResult {
|
||||
|
||||
/**
|
||||
* 任务标识,用于后续查询与审计追溯
|
||||
*/
|
||||
@JsonProperty("task_id")
|
||||
private String taskId;
|
||||
|
||||
/**
|
||||
* 处置动作:pass / manual_review / reject / pending
|
||||
*/
|
||||
private String action;
|
||||
|
||||
/**
|
||||
* 人可读的判定理由,可直接展示给用户
|
||||
*/
|
||||
private String reason;
|
||||
|
||||
/**
|
||||
* 任务状态:pending / running / completed / failed
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 提示信息(异步受理时返回)
|
||||
*/
|
||||
private String message;
|
||||
|
||||
/**
|
||||
* 原始响应JSON
|
||||
*/
|
||||
private String rawJson;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.senmesh.sfile.config;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* MeshSafe 文件安全检测对接配置
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "meshsafe")
|
||||
@Component
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
public class MeshSafeProperties {
|
||||
|
||||
/**
|
||||
* 是否开启文件安全检测(关闭时上传/解包不提交检测,分享传输不校验)
|
||||
*/
|
||||
private Boolean enabled = false;
|
||||
|
||||
/**
|
||||
* MeshSafe 服务地址,如 https://meshsafe.internal
|
||||
*/
|
||||
private String baseUrl;
|
||||
|
||||
/**
|
||||
* 调用方标识(MeshSafe 申请的 client_id)
|
||||
*/
|
||||
private String clientId;
|
||||
|
||||
/**
|
||||
* 调用方密钥(MeshSafe 申请的 secret,用于 HMAC 签名)
|
||||
*/
|
||||
private String secret;
|
||||
|
||||
/**
|
||||
* 业务通道标识,仅用于 MeshSafe 日志追溯
|
||||
*/
|
||||
private String channel = "default";
|
||||
|
||||
/**
|
||||
* 请求超时时间(秒)
|
||||
*/
|
||||
private Integer timeoutSeconds = 120;
|
||||
|
||||
/**
|
||||
* 检测服务异常时的降级策略:true-放行(仅标记检测失败) / false-阻断
|
||||
*/
|
||||
private Boolean failOpen = true;
|
||||
|
||||
/**
|
||||
* 检测中的记录超过该分钟数仍未拿到结论则标记为检测失败(防死轮询)
|
||||
*/
|
||||
private Integer pollTimeoutMinutes = 60;
|
||||
|
||||
/**
|
||||
* 人工复核是否尽力回写 MeshSafe(失败仅告警,不影响本地生效)
|
||||
*/
|
||||
private Boolean reviewSyncToMeshSafe = true;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.senmesh.sfile.constant;
|
||||
|
||||
/**
|
||||
* 文件安全检测状态枚举
|
||||
*/
|
||||
public enum SecurityScanStatusEnum {
|
||||
|
||||
/**
|
||||
* 检测中
|
||||
*/
|
||||
SCANNING("1", "检测中"),
|
||||
|
||||
/**
|
||||
* 检测通过
|
||||
*/
|
||||
PASSED("2", "通过"),
|
||||
|
||||
/**
|
||||
* 检测不通过(禁止分享传输)
|
||||
*/
|
||||
REJECTED("3", "不通过"),
|
||||
|
||||
/**
|
||||
* 检测失败(MeshSafe异常/超时)
|
||||
*/
|
||||
FAILED("4", "检测失败");
|
||||
|
||||
private final String status;
|
||||
|
||||
private final String desc;
|
||||
|
||||
SecurityScanStatusEnum(String status, String desc) {
|
||||
this.status = status;
|
||||
this.desc = desc;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public String getDesc() {
|
||||
return desc;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为不通过状态
|
||||
*
|
||||
* @param status 检测状态
|
||||
* @return true-不通过,false-非不通过
|
||||
*/
|
||||
public static boolean isRejected(String status) {
|
||||
return REJECTED.getStatus().equals(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为检测中状态
|
||||
*
|
||||
* @param status 检测状态
|
||||
* @return true-检测中,false-非检测中
|
||||
*/
|
||||
public static boolean isScanning(String status) {
|
||||
return SCANNING.getStatus().equals(status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.senmesh.sfile.controller;
|
||||
|
||||
import com.senmesh.common.AlitaResult;
|
||||
import com.senmesh.sfile.config.MeshSafeProperties;
|
||||
import com.senmesh.sfile.dto.FileSecurityDTO;
|
||||
import com.senmesh.sfile.dto.FileSecurityReviewDTO;
|
||||
import com.senmesh.sfile.dto.FileSecurityShareCheckDTO;
|
||||
import com.senmesh.sfile.service.FileSecurityService;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import io.swagger.annotations.ApiParam;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 文件安全检测Controller
|
||||
*/
|
||||
@Api(tags = "文件安全检测")
|
||||
@RestController
|
||||
@RequestMapping("file_security")
|
||||
public class FileSecurityController {
|
||||
|
||||
@Autowired
|
||||
private FileSecurityService fileSecurityService;
|
||||
|
||||
@Autowired
|
||||
private MeshSafeProperties meshSafeProperties;
|
||||
|
||||
@GetMapping("/enabled")
|
||||
@ApiOperation("查询安全检测开关(meshsafe.enabled,前端据此展示/隐藏检测列)")
|
||||
public AlitaResult<Boolean> enabled() {
|
||||
return AlitaResult.success(Boolean.TRUE.equals(meshSafeProperties.getEnabled()));
|
||||
}
|
||||
|
||||
@GetMapping("/detail")
|
||||
@ApiOperation("查询文件最新检测结果")
|
||||
public AlitaResult<FileSecurityDTO> detail(
|
||||
@ApiParam(value = "文件编码", required = true) @RequestParam String fileCode) {
|
||||
return AlitaResult.success(fileSecurityService.latest(fileCode));
|
||||
}
|
||||
|
||||
@GetMapping("/share_check")
|
||||
@ApiOperation("分享/传输预检:统计文件及子文件的检测拦截情况")
|
||||
public AlitaResult<FileSecurityShareCheckDTO> shareCheck(
|
||||
@ApiParam(value = "用户文件ID", required = true) @RequestParam Long userFileId) {
|
||||
return AlitaResult.success(fileSecurityService.shareCheck(userFileId));
|
||||
}
|
||||
|
||||
@PostMapping("/review")
|
||||
@ApiOperation("人工复核修改检测结果(超管/机构管理员)")
|
||||
public AlitaResult<FileSecurityDTO> review(
|
||||
@Validated @RequestBody FileSecurityReviewDTO reviewDTO) {
|
||||
return AlitaResult.success(fileSecurityService.review(reviewDTO));
|
||||
}
|
||||
|
||||
@PostMapping("/rescan")
|
||||
@ApiOperation("重新提交安全检测(超管/机构管理员)")
|
||||
public AlitaResult<Boolean> rescan(
|
||||
@ApiParam(value = "文件编码", required = true) @RequestParam String fileCode) {
|
||||
fileSecurityService.rescan(fileCode);
|
||||
return AlitaResult.success(true);
|
||||
}
|
||||
}
|
||||
43
src/main/java/com/senmesh/sfile/dao/FileSecurityMapper.java
Normal file
43
src/main/java/com/senmesh/sfile/dao/FileSecurityMapper.java
Normal file
@@ -0,0 +1,43 @@
|
||||
package com.senmesh.sfile.dao;
|
||||
|
||||
import com.senmesh.sfile.dto.FileSecurityDTO;
|
||||
import com.senmesh.sfile.entity.FileSecurity;
|
||||
import com.senmesh.tkmybatis.mybatis.MyMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 文件安全检测Mapper
|
||||
*/
|
||||
@Mapper
|
||||
public interface FileSecurityMapper extends MyMapper<FileSecurity> {
|
||||
|
||||
/**
|
||||
* 查询检测记录列表(按id倒序)
|
||||
*
|
||||
* @param fileCodes 文件编码集合
|
||||
* @param fileCode 文件编码
|
||||
* @return List<FileSecurityDTO>
|
||||
*/
|
||||
List<FileSecurityDTO> list(@Param("fileCodes") List<String> fileCodes,
|
||||
@Param("fileCode") String fileCode);
|
||||
|
||||
/**
|
||||
* 查询最新检测状态为指定集合的记录(每个文件编码取最新一条)
|
||||
*
|
||||
* @param fileCodes 文件编码集合
|
||||
* @param scanStatusIn 检测状态集合
|
||||
* @return List<FileSecurityDTO>
|
||||
*/
|
||||
List<FileSecurityDTO> listLatestByStatusIn(@Param("fileCodes") List<String> fileCodes,
|
||||
@Param("scanStatusIn") List<String> scanStatusIn);
|
||||
|
||||
/**
|
||||
* 查询检测中的记录(定时轮询用)
|
||||
*
|
||||
* @return List<FileSecurityDTO>
|
||||
*/
|
||||
List<FileSecurityDTO> listScanning();
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 扩展用户文件创建DTO
|
||||
* 根据普通文件创建新的文件(复制)
|
||||
@@ -50,5 +52,8 @@ public class ExpandUserFileCreateDTO extends BaseOperateDTO {
|
||||
|
||||
@ApiModelProperty("访问码")
|
||||
private String accessCode;
|
||||
|
||||
@ApiModelProperty("复制时需排除的用户文件ID(安全检测未通过跳过分享场景)")
|
||||
private List<Long> excludeFileIds;
|
||||
}
|
||||
|
||||
|
||||
54
src/main/java/com/senmesh/sfile/dto/FileSecurityDTO.java
Normal file
54
src/main/java/com/senmesh/sfile/dto/FileSecurityDTO.java
Normal file
@@ -0,0 +1,54 @@
|
||||
package com.senmesh.sfile.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.senmesh.common.dto.BaseDTO;
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 文件安全检测记录DTO
|
||||
*/
|
||||
@ApiModel("文件安全检测记录")
|
||||
@Data
|
||||
@SuperBuilder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class FileSecurityDTO extends BaseDTO {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty("文件编码")
|
||||
private String fileCode;
|
||||
|
||||
@ApiModelProperty("MeshSafe检测任务ID")
|
||||
private String taskId;
|
||||
|
||||
@ApiModelProperty("检测状态:1检测中/2通过/3不通过/4检测失败")
|
||||
private String scanStatus;
|
||||
|
||||
@ApiModelProperty("MeshSafe处置动作:pass/manual_review/reject")
|
||||
private String action;
|
||||
|
||||
@ApiModelProperty("检测结论说明")
|
||||
private String reason;
|
||||
|
||||
@ApiModelProperty("检测完整结果JSON")
|
||||
private String resultJson;
|
||||
|
||||
@ApiModelProperty("人工复核说明(人工改判时有值)")
|
||||
private String reviewComment;
|
||||
|
||||
@ApiModelProperty("检测时间")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date createTime;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.senmesh.sfile.dto;
|
||||
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 文件安全检测人工复核DTO
|
||||
*/
|
||||
@ApiModel("文件安全检测人工复核")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class FileSecurityReviewDTO {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty("文件编码")
|
||||
@NotBlank(message = "文件编码不能为空")
|
||||
private String fileCode;
|
||||
|
||||
@ApiModelProperty("复核结论:pass通过/reject不通过")
|
||||
@NotBlank(message = "复核结论不能为空")
|
||||
private String decision;
|
||||
|
||||
@ApiModelProperty("复核说明(审计要求,必填)")
|
||||
@NotBlank(message = "复核说明不能为空")
|
||||
private String comment;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.senmesh.sfile.dto;
|
||||
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 文件安全检测分享预检DTO
|
||||
*/
|
||||
@ApiModel("文件安全检测分享预检")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class FileSecurityShareCheckDTO {
|
||||
|
||||
@ApiModelProperty("用户文件ID")
|
||||
private Long userFileId;
|
||||
|
||||
@ApiModelProperty("文件大类:1文件/2文件夹")
|
||||
private String fileType;
|
||||
|
||||
@ApiModelProperty("目录内文件总数(不含目录)")
|
||||
private Integer totalFileCount;
|
||||
|
||||
@ApiModelProperty("检测不通过数量")
|
||||
private Integer blockedCount;
|
||||
|
||||
@ApiModelProperty("检测中数量")
|
||||
private Integer scanningCount;
|
||||
|
||||
@ApiModelProperty("检测失败数量")
|
||||
private Integer failedCount;
|
||||
|
||||
@ApiModelProperty("检测不通过文件名列表")
|
||||
private List<String> blockedFileNames;
|
||||
|
||||
@ApiModelProperty("检测中文件名列表")
|
||||
private List<String> scanningFileNames;
|
||||
|
||||
@ApiModelProperty("检测失败文件名列表")
|
||||
private List<String> failedFileNames;
|
||||
|
||||
@ApiModelProperty("确认跳过时需排除的用户文件ID列表(不通过+检测中,fail-closed时含检测失败)")
|
||||
private List<Long> excludeFileIds;
|
||||
}
|
||||
@@ -32,5 +32,8 @@ public class ShareUserFileCreateDTO extends BaseOperateDTO {
|
||||
@NotNull(message = "有效天数不能为空")
|
||||
private Integer validDays;
|
||||
|
||||
@ApiModelProperty("文件夹内存在检测未通过文件时,确认后仅复制检测通过的内容")
|
||||
private Boolean onlyPassed;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -28,5 +28,8 @@ public class TransferUserFileCreateDTO extends BaseOperateDTO {
|
||||
@NotNull(message = "用户文件ID不能为空")
|
||||
private Long userFileId;
|
||||
|
||||
@ApiModelProperty("文件夹内存在检测未通过文件时,确认后仅传输检测通过的内容")
|
||||
private Boolean onlyPassed;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -69,5 +69,22 @@ public class UserFileDTO extends BaseDTO {
|
||||
|
||||
@ApiModelProperty("风险详情JSON")
|
||||
private String riskJson;
|
||||
|
||||
@ApiModelProperty("安全检测状态:1检测中/2通过/3不通过/4检测失败(空为未检测)")
|
||||
private String securityStatus;
|
||||
|
||||
@ApiModelProperty("安全检测处置动作:pass/manual_review/reject")
|
||||
private String securityAction;
|
||||
|
||||
@ApiModelProperty("安全检测结论说明")
|
||||
private String securityReason;
|
||||
|
||||
@ApiModelProperty("安全检测人工复核说明")
|
||||
private String securityReviewComment;
|
||||
|
||||
@ApiModelProperty("安全检测时间")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date securityTime;
|
||||
}
|
||||
|
||||
|
||||
60
src/main/java/com/senmesh/sfile/entity/FileSecurity.java
Normal file
60
src/main/java/com/senmesh/sfile/entity/FileSecurity.java
Normal file
@@ -0,0 +1,60 @@
|
||||
package com.senmesh.sfile.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.senmesh.common.entity.BaseEntity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
|
||||
/**
|
||||
* 文件安全检测记录
|
||||
* <p>
|
||||
* 同一文件允许多条检测记录(人工复核/重新检测均新增),业务取最新一条生效。
|
||||
*/
|
||||
@TableName("x_file_security")
|
||||
@Data
|
||||
@SuperBuilder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class FileSecurity extends BaseEntity {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 文件编码
|
||||
*/
|
||||
private String fileCode;
|
||||
|
||||
/**
|
||||
* MeshSafe检测任务ID
|
||||
*/
|
||||
private String taskId;
|
||||
|
||||
/**
|
||||
* 检测状态:1检测中/2通过/3不通过/4检测失败
|
||||
*/
|
||||
private String scanStatus;
|
||||
|
||||
/**
|
||||
* MeshSafe处置动作:pass/manual_review/reject
|
||||
*/
|
||||
private String action;
|
||||
|
||||
/**
|
||||
* 检测结论说明
|
||||
*/
|
||||
private String reason;
|
||||
|
||||
/**
|
||||
* 检测完整结果JSON
|
||||
*/
|
||||
private String resultJson;
|
||||
|
||||
/**
|
||||
* 人工复核说明(人工改判时有值)
|
||||
*/
|
||||
private String reviewComment;
|
||||
}
|
||||
39
src/main/java/com/senmesh/sfile/job/MeshSafeScanJob.java
Normal file
39
src/main/java/com/senmesh/sfile/job/MeshSafeScanJob.java
Normal file
@@ -0,0 +1,39 @@
|
||||
package com.senmesh.sfile.job;
|
||||
|
||||
import com.senmesh.common.annotation.AlitaStopWatch;
|
||||
import com.senmesh.common.util.MockUtils;
|
||||
import com.senmesh.sfile.service.FileSecurityService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* MeshSafe文件安全检测轮询定时任务
|
||||
* <p>
|
||||
* 轮询检测中的任务直到拿到结论(MeshSafe异步模式/网络异常重试),
|
||||
* 检测中的记录超过配置时长仍无结论则标记检测失败。
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class MeshSafeScanJob {
|
||||
|
||||
@Autowired
|
||||
private FileSecurityService fileSecurityService;
|
||||
|
||||
/**
|
||||
* 定时轮询未完成的检测任务,默认每30秒执行一次
|
||||
*/
|
||||
@Scheduled(cron = "${meshsafe.poll.cron:0 */30 * * * ?}")
|
||||
@AlitaStopWatch
|
||||
public void execute() {
|
||||
try {
|
||||
MockUtils.mockPlatUser();
|
||||
fileSecurityService.pollPendingScans();
|
||||
} catch (Exception e) {
|
||||
log.error("MeshSafe检测轮询任务执行失败", e);
|
||||
} finally {
|
||||
MockUtils.clearUser();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.senmesh.sfile.dao.FileSecurityMapper">
|
||||
|
||||
<sql id="queryWhereSql">
|
||||
WHERE t.deleted = FALSE
|
||||
<if test='fileCode != null and fileCode.trim() != ""'>
|
||||
AND t.file_code = #{fileCode}
|
||||
</if>
|
||||
<if test='fileCodes != null and !fileCodes.isEmpty()'>
|
||||
AND t.file_code IN
|
||||
<foreach collection="fileCodes" item="item" open="(" separator="," close=")">
|
||||
#{item}
|
||||
</foreach>
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<select id="list" resultType="com.senmesh.sfile.dto.FileSecurityDTO">
|
||||
SELECT t.*
|
||||
FROM x_file_security t
|
||||
<include refid="queryWhereSql"></include>
|
||||
ORDER BY t.id DESC
|
||||
</select>
|
||||
|
||||
<select id="listLatestByStatusIn" resultType="com.senmesh.sfile.dto.FileSecurityDTO">
|
||||
SELECT t.*
|
||||
FROM x_file_security t
|
||||
WHERE t.deleted = FALSE
|
||||
AND t.scan_status IN
|
||||
<foreach collection="scanStatusIn" item="item" open="(" separator="," close=")">
|
||||
#{item}
|
||||
</foreach>
|
||||
AND t.id = (
|
||||
SELECT MAX(t2.id) FROM x_file_security t2
|
||||
WHERE t2.file_code = t.file_code AND t2.deleted = FALSE
|
||||
)
|
||||
<if test='fileCodes != null and !fileCodes.isEmpty()'>
|
||||
AND t.file_code IN
|
||||
<foreach collection="fileCodes" item="item" open="(" separator="," close=")">
|
||||
#{item}
|
||||
</foreach>
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="listScanning" resultType="com.senmesh.sfile.dto.FileSecurityDTO">
|
||||
SELECT t.*
|
||||
FROM x_file_security t
|
||||
WHERE t.deleted = FALSE
|
||||
AND t.scan_status = '1'
|
||||
ORDER BY t.id ASC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -4,7 +4,10 @@
|
||||
<mapper namespace="com.senmesh.sfile.dao.UserFileMapper">
|
||||
|
||||
<select id="list" resultType="com.senmesh.sfile.dto.UserFileDTO">
|
||||
SELECT t.*, fr.file_size, fc.file_category, fk.max_risk_level, fk.risk_json
|
||||
SELECT t.*, fr.file_size, fc.file_category, fk.max_risk_level, fk.risk_json,
|
||||
fs.scan_status AS security_status, fs.action AS security_action,
|
||||
fs.reason AS security_reason, fs.review_comment AS security_review_comment,
|
||||
fs.create_time AS security_time
|
||||
<include refid="queryFromSql"></include>
|
||||
ORDER BY t.id DESC
|
||||
</select>
|
||||
@@ -13,6 +16,9 @@
|
||||
SELECT
|
||||
t.*,
|
||||
fr.file_size,
|
||||
fs.scan_status AS security_status, fs.action AS security_action,
|
||||
fs.reason AS security_reason, fs.review_comment AS security_review_comment,
|
||||
fs.create_time AS security_time,
|
||||
u.phone,
|
||||
u.username,
|
||||
u.real_name,
|
||||
@@ -20,6 +26,9 @@
|
||||
FROM x_user_file t
|
||||
INNER JOIN uc_user u ON t.user_id = u.id AND u.deleted = FALSE
|
||||
LEFT JOIN xs_file_record fr ON t.file_code = fr.file_code AND fr.deleted = FALSE
|
||||
LEFT JOIN x_file_security fs ON t.file_code = fs.file_code AND fs.deleted = FALSE
|
||||
AND fs.id = (SELECT MAX(fs2.id) FROM x_file_security fs2
|
||||
WHERE fs2.file_code = t.file_code AND fs2.deleted = FALSE)
|
||||
WHERE t.deleted = FALSE
|
||||
AND u.phone = #{param.phone}
|
||||
<if test='param.rootTypeIn != null and !param.rootTypeIn.isEmpty()'>
|
||||
@@ -72,6 +81,9 @@
|
||||
LEFT JOIN xs_file_record fr ON t.file_code = fr.file_code AND fr.deleted = FALSE
|
||||
LEFT JOIN x_file_category fc ON fr.file_suffix = fc.file_suffix AND fc.deleted = FALSE
|
||||
LEFT JOIN x_file_risk fk ON t.file_code = fk.file_code AND fk.deleted = FALSE
|
||||
LEFT JOIN x_file_security fs ON t.file_code = fs.file_code AND fs.deleted = FALSE
|
||||
AND fs.id = (SELECT MAX(fs2.id) FROM x_file_security fs2
|
||||
WHERE fs2.file_code = t.file_code AND fs2.deleted = FALSE)
|
||||
<if test='param.parentNotInvalid != null and param.parentNotInvalid == true'>
|
||||
LEFT JOIN x_user_file parent ON t.parent_id = parent.id AND parent.deleted = FALSE
|
||||
</if>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.senmesh.sfile.service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.senmesh.sfile.dto.FileSecurityDTO;
|
||||
import com.senmesh.sfile.dto.FileSecurityShareCheckDTO;
|
||||
import com.senmesh.sfile.dto.FileSecurityReviewDTO;
|
||||
|
||||
/**
|
||||
* 文件安全检测Service
|
||||
*/
|
||||
public interface FileSecurityService {
|
||||
|
||||
/**
|
||||
* 异步提交MeshSafe检测(上传/解包落库后调用,失败仅标记不影响主流程)
|
||||
* 已有检测中/通过/不通过记录时跳过
|
||||
*
|
||||
* @param fileCode 文件编码
|
||||
* @param fileName 文件名
|
||||
*/
|
||||
void submitScanAsync(String fileCode, String fileName);
|
||||
|
||||
/**
|
||||
* 异步重新提交MeshSafe检测(重新检测场景)
|
||||
* 仅已有检测中记录时跳过
|
||||
*
|
||||
* @param fileCode 文件编码
|
||||
* @param fileName 文件名
|
||||
*/
|
||||
void resubmitScanAsync(String fileCode, String fileName);
|
||||
|
||||
/**
|
||||
* 查询文件最新检测记录
|
||||
*
|
||||
* @param fileCode 文件编码
|
||||
* @return FileSecurityDTO 无记录返回null
|
||||
*/
|
||||
FileSecurityDTO latest(String fileCode);
|
||||
|
||||
/**
|
||||
* 校验文件(含文件夹子文件)是否允许分享/传输
|
||||
* 检测不通过或检测中则抛出业务异常
|
||||
*
|
||||
* @param userFileId 用户文件id(普通文件)
|
||||
*/
|
||||
void checkShareable(Long userFileId);
|
||||
|
||||
/**
|
||||
* 分享/传输预检:统计文件(含文件夹子文件)的安全检测拦截情况,供前端弹窗提示
|
||||
*
|
||||
* @param userFileId 用户文件id(普通文件)
|
||||
* @return FileSecurityShareCheckDTO 检测关闭时返回全零结果
|
||||
*/
|
||||
FileSecurityShareCheckDTO shareCheck(Long userFileId);
|
||||
|
||||
/**
|
||||
* 分享/传输前置处理:文件夹内存在检测未通过文件且用户确认跳过(onlyPassed)时,
|
||||
* 返回需排除的用户文件ID列表;单文件被拦截或未确认跳过时抛业务异常
|
||||
*
|
||||
* @param userFileId 用户文件id(普通文件)
|
||||
* @param onlyPassed 用户是否确认仅分享/传输通过的内容
|
||||
* @param actionName 动作名(分享/传输,用于提示文案)
|
||||
* @return List<Long> 需排除的用户文件ID,空列表表示无需排除
|
||||
*/
|
||||
List<Long> resolveShareExclusions(Long userFileId, boolean onlyPassed, String actionName);
|
||||
|
||||
/**
|
||||
* 人工复核修改检测结果(超管/机构管理员)
|
||||
* 新增一条检测记录留痕,并尽力回写MeshSafe
|
||||
*
|
||||
* @param reviewDTO 复核参数
|
||||
* @return FileSecurityDTO 新的检测结果
|
||||
*/
|
||||
FileSecurityDTO review(FileSecurityReviewDTO reviewDTO);
|
||||
|
||||
/**
|
||||
* 重新提交检测(超管/机构管理员)
|
||||
*
|
||||
* @param fileCode 文件编码
|
||||
*/
|
||||
void rescan(String fileCode);
|
||||
|
||||
/**
|
||||
* 轮询未完成的检测任务(定时任务调用):
|
||||
* 无taskId的记录补提交,有taskId的轮询结论,超时标记检测失败
|
||||
*/
|
||||
void pollPendingScans();
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
package com.senmesh.sfile.service.impl;
|
||||
|
||||
import com.senmesh.common.exception.BusinessException;
|
||||
import com.senmesh.common.exception.util.Assert;
|
||||
import com.senmesh.common.util.ListUtil;
|
||||
import com.senmesh.common.util.UserContextHolder;
|
||||
import com.senmesh.component.file.entity.FileRecord;
|
||||
import com.senmesh.component.file.service.FileRecordService;
|
||||
import com.senmesh.component.file.util.FileStorageUtils;
|
||||
import com.senmesh.frame.redis.dto.LockDTO;
|
||||
import com.senmesh.frame.redis.redisson.RedissonUtils;
|
||||
import com.senmesh.sfile.client.MeshSafeClient;
|
||||
import com.senmesh.sfile.client.MeshSafeScanResult;
|
||||
import com.senmesh.sfile.config.MeshSafeProperties;
|
||||
import com.senmesh.sfile.constant.FileTypeEnum;
|
||||
import com.senmesh.sfile.constant.RootTypeEnum;
|
||||
import com.senmesh.sfile.constant.SecurityScanStatusEnum;
|
||||
import com.senmesh.sfile.dao.FileSecurityMapper;
|
||||
import com.senmesh.sfile.dto.FileSecurityDTO;
|
||||
import com.senmesh.sfile.dto.FileSecurityShareCheckDTO;
|
||||
import com.senmesh.sfile.dto.FileSecurityReviewDTO;
|
||||
import com.senmesh.sfile.dto.UserFileDTO;
|
||||
import com.senmesh.sfile.dto.UserFileQueryDTO;
|
||||
import com.senmesh.sfile.entity.FileSecurity;
|
||||
import com.senmesh.sfile.service.FileSecurityService;
|
||||
import com.senmesh.sfile.service.UserFileService;
|
||||
import com.senmesh.usercenter.user.service.OrgUserService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.aop.framework.AopContext;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 文件安全检测ServiceImpl
|
||||
* <p>
|
||||
* 对接 MeshSafe 文件安全检测服务:上传/传输接收解包后自动送检,
|
||||
* 检测不通过的文件禁止分享与跨网传输(下载预览不受限),
|
||||
* 管理员可人工复核修改检测结果(留痕)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class FileSecurityServiceImpl implements FileSecurityService {
|
||||
|
||||
private static final String MESHSAFE_ACTION_PASS = "pass";
|
||||
|
||||
private static final String MESHSAFE_ACTION_MANUAL_REVIEW = "manual_review";
|
||||
|
||||
private static final String MESHSAFE_ACTION_REJECT = "reject";
|
||||
|
||||
private static final String MESHSAFE_STATUS_COMPLETED = "completed";
|
||||
|
||||
private static final String MESHSAFE_STATUS_FAILED = "failed";
|
||||
|
||||
@Autowired
|
||||
private MeshSafeProperties meshSafeProperties;
|
||||
|
||||
@Autowired
|
||||
private MeshSafeClient meshSafeClient;
|
||||
|
||||
@Autowired
|
||||
private FileSecurityMapper fileSecurityMapper;
|
||||
|
||||
@Autowired
|
||||
private FileRecordService fileRecordService;
|
||||
|
||||
@Autowired
|
||||
private UserFileService userFileService;
|
||||
|
||||
@Autowired
|
||||
private OrgUserService orgUserService;
|
||||
|
||||
@Override
|
||||
@Async("asyncExecutor")
|
||||
public void submitScanAsync(String fileCode, String fileName) {
|
||||
submitScanInternal(fileCode, fileName, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Async("asyncExecutor")
|
||||
public void resubmitScanAsync(String fileCode, String fileName) {
|
||||
submitScanInternal(fileCode, fileName, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileSecurityDTO latest(String fileCode) {
|
||||
Assert.notBlank(fileCode, "文件编码不能为空");
|
||||
List<FileSecurityDTO> list = fileSecurityMapper.list(null, fileCode);
|
||||
return ListUtil.first(list);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkShareable(Long userFileId) {
|
||||
FileSecurityShareCheckDTO check = shareCheck(userFileId);
|
||||
if (!check.getBlockedFileNames().isEmpty()) {
|
||||
throw new BusinessException(
|
||||
buildBlockedMessage(check.getBlockedFileNames(), "安全检测未通过,禁止分享与传输"));
|
||||
}
|
||||
if (!check.getScanningFileNames().isEmpty()) {
|
||||
throw new BusinessException(
|
||||
buildBlockedMessage(check.getScanningFileNames(), "正在安全检测中,请稍后再试"));
|
||||
}
|
||||
// 检测失败:降级策略为fail-closed时同样禁止
|
||||
if (!Boolean.TRUE.equals(meshSafeProperties.getFailOpen())
|
||||
&& !check.getFailedFileNames().isEmpty()) {
|
||||
throw new BusinessException(
|
||||
buildBlockedMessage(check.getFailedFileNames(), "安全检测失败,请重新检测后再试"));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileSecurityShareCheckDTO shareCheck(Long userFileId) {
|
||||
FileSecurityShareCheckDTO result = FileSecurityShareCheckDTO.builder()
|
||||
.userFileId(userFileId)
|
||||
.totalFileCount(0)
|
||||
.blockedCount(0)
|
||||
.scanningCount(0)
|
||||
.failedCount(0)
|
||||
.blockedFileNames(new ArrayList<>())
|
||||
.scanningFileNames(new ArrayList<>())
|
||||
.failedFileNames(new ArrayList<>())
|
||||
.excludeFileIds(new ArrayList<>())
|
||||
.build();
|
||||
if (!isEnabled()) {
|
||||
return result;
|
||||
}
|
||||
Assert.notNull(userFileId, "文件ID不能为空");
|
||||
UserFileDTO userFile = userFileService.findOne(UserFileQueryDTO.builder()
|
||||
.id(userFileId)
|
||||
.rootType(RootTypeEnum.NORMAL.getType())
|
||||
.build());
|
||||
result.setFileType(userFile.getFileType());
|
||||
|
||||
// 收集该文件及子文件(文件夹展开)的记录,pathLike为包含匹配,精确过滤自身与子孙节点
|
||||
UserFileQueryDTO queryDTO = UserFileQueryDTO.builder()
|
||||
.userId(userFile.getUserId())
|
||||
.rootType(RootTypeEnum.NORMAL.getType())
|
||||
.fileType(FileTypeEnum.FILE.getType())
|
||||
.pathLike(userFile.getPath())
|
||||
.build();
|
||||
queryDTO.disablePage();
|
||||
List<UserFileDTO> files = userFileService.list(queryDTO);
|
||||
String pathPrefix = userFile.getPath() + ",";
|
||||
Map<String, List<UserFileDTO>> filesByCode = files.stream()
|
||||
.filter(f -> StringUtils.isNotBlank(f.getFileCode()))
|
||||
.filter(f -> f.getPath().equals(userFile.getPath()) || f.getPath().startsWith(pathPrefix))
|
||||
.collect(Collectors.groupingBy(UserFileDTO::getFileCode));
|
||||
result.setTotalFileCount(filesByCode.values().stream().mapToInt(List::size).sum());
|
||||
if (filesByCode.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
|
||||
collectStatusFiles(result, filesByCode, SecurityScanStatusEnum.REJECTED.getStatus(), true);
|
||||
collectStatusFiles(result, filesByCode, SecurityScanStatusEnum.SCANNING.getStatus(), true);
|
||||
boolean excludeFailed = !Boolean.TRUE.equals(meshSafeProperties.getFailOpen());
|
||||
collectStatusFiles(result, filesByCode, SecurityScanStatusEnum.FAILED.getStatus(), excludeFailed);
|
||||
|
||||
result.setBlockedCount(result.getBlockedFileNames().size());
|
||||
result.setScanningCount(result.getScanningFileNames().size());
|
||||
result.setFailedCount(result.getFailedFileNames().size());
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Long> resolveShareExclusions(Long userFileId, boolean onlyPassed, String actionName) {
|
||||
FileSecurityShareCheckDTO check = shareCheck(userFileId);
|
||||
boolean hasBlocked = !check.getBlockedFileNames().isEmpty();
|
||||
boolean hasScanning = !check.getScanningFileNames().isEmpty();
|
||||
boolean hasFailed = !Boolean.TRUE.equals(meshSafeProperties.getFailOpen())
|
||||
&& !check.getFailedFileNames().isEmpty();
|
||||
if (!hasBlocked && !hasScanning && !hasFailed) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
boolean isFolder = FileTypeEnum.FOLDER.getType().equals(check.getFileType());
|
||||
if (!isFolder || !onlyPassed) {
|
||||
// 单文件被拦截,或文件夹未确认跳过:维持整体拦截
|
||||
this.checkShareable(userFileId);
|
||||
}
|
||||
if (check.getTotalFileCount() <= check.getExcludeFileIds().size()) {
|
||||
throw new BusinessException("文件夹内没有检测通过的文件,无法" + actionName);
|
||||
}
|
||||
return check.getExcludeFileIds();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按检测状态收集命中的文件名与需排除的文件ID
|
||||
*/
|
||||
private void collectStatusFiles(FileSecurityShareCheckDTO result,
|
||||
Map<String, List<UserFileDTO>> filesByCode, String status, boolean exclude) {
|
||||
List<FileSecurityDTO> records = fileSecurityMapper.listLatestByStatusIn(
|
||||
new ArrayList<>(filesByCode.keySet()), Collections.singletonList(status));
|
||||
for (FileSecurityDTO record : records) {
|
||||
List<UserFileDTO> matched = filesByCode.get(record.getFileCode());
|
||||
if (matched == null) {
|
||||
continue;
|
||||
}
|
||||
for (UserFileDTO file : matched) {
|
||||
if (SecurityScanStatusEnum.REJECTED.getStatus().equals(status)) {
|
||||
result.getBlockedFileNames().add(file.getFileName());
|
||||
} else if (SecurityScanStatusEnum.SCANNING.getStatus().equals(status)) {
|
||||
result.getScanningFileNames().add(file.getFileName());
|
||||
} else {
|
||||
result.getFailedFileNames().add(file.getFileName());
|
||||
}
|
||||
if (exclude) {
|
||||
result.getExcludeFileIds().add(file.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileSecurityDTO review(FileSecurityReviewDTO reviewDTO) {
|
||||
Long operateUserId = UserContextHolder.getExistLoginUserId();
|
||||
Assert.isTrue(isEnabled(), "文件安全检测未开启");
|
||||
Assert.isTrue(UserContextHolder.isSuperior() || UserContextHolder.isAdministrator(),
|
||||
"当前用户无操作权限");
|
||||
|
||||
String fileCode = reviewDTO.getFileCode();
|
||||
String decision = reviewDTO.getDecision();
|
||||
Assert.isTrue(MESHSAFE_ACTION_PASS.equals(decision) || MESHSAFE_ACTION_REJECT.equals(decision),
|
||||
"复核结论不合法,仅支持pass/reject");
|
||||
|
||||
FileSecurityDTO latest = latest(fileCode);
|
||||
Assert.notNull(latest, "该文件无安全检测记录");
|
||||
this.checkOrgScope(fileCode);
|
||||
|
||||
FileSecurity record = FileSecurity.builder()
|
||||
.fileCode(fileCode)
|
||||
.taskId(latest.getTaskId())
|
||||
.scanStatus(MESHSAFE_ACTION_PASS.equals(decision)
|
||||
? SecurityScanStatusEnum.PASSED.getStatus()
|
||||
: SecurityScanStatusEnum.REJECTED.getStatus())
|
||||
.action(MESHSAFE_ACTION_MANUAL_REVIEW)
|
||||
.reason(reviewDTO.getComment())
|
||||
.reviewComment(reviewDTO.getComment())
|
||||
.build();
|
||||
fileSecurityMapper.saveEntity(record);
|
||||
|
||||
// 尽力回写MeshSafe人工复核结论,失败仅告警不影响本地生效
|
||||
if (Boolean.TRUE.equals(meshSafeProperties.getReviewSyncToMeshSafe())
|
||||
&& StringUtils.isNotBlank(latest.getTaskId())) {
|
||||
try {
|
||||
meshSafeClient.review(latest.getTaskId(), decision, reviewDTO.getComment());
|
||||
} catch (Exception e) {
|
||||
log.warn("回写MeshSafe人工复核结论失败, fileCode: {}, taskId: {}",
|
||||
fileCode, latest.getTaskId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("文件安全检测人工复核完成, fileCode: {}, decision: {}, operateUserId: {}",
|
||||
fileCode, decision, operateUserId);
|
||||
|
||||
FileSecurityDTO result = new FileSecurityDTO();
|
||||
result.setFileCode(record.getFileCode());
|
||||
result.setTaskId(record.getTaskId());
|
||||
result.setScanStatus(record.getScanStatus());
|
||||
result.setAction(record.getAction());
|
||||
result.setReason(record.getReason());
|
||||
result.setReviewComment(record.getReviewComment());
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void rescan(String fileCode) {
|
||||
Assert.isTrue(isEnabled(), "文件安全检测未开启");
|
||||
Assert.isTrue(UserContextHolder.isSuperior() || UserContextHolder.isAdministrator(),
|
||||
"当前用户无操作权限");
|
||||
Assert.notBlank(fileCode, "文件编码不能为空");
|
||||
|
||||
FileSecurityDTO latest = latest(fileCode);
|
||||
Assert.isTrue(latest == null
|
||||
|| !SecurityScanStatusEnum.isScanning(latest.getScanStatus()),
|
||||
"文件正在安全检测中,请勿重复操作");
|
||||
this.checkOrgScope(fileCode);
|
||||
|
||||
FileRecord fileRecord = fileRecordService.find(
|
||||
FileRecord.builder().fileCode(fileCode).build());
|
||||
Assert.notNull(fileRecord, "文件不存在");
|
||||
|
||||
// 事务使本方法成为advised方法,AopContext.currentProxy()才能取到本Service代理
|
||||
((FileSecurityService) AopContext.currentProxy())
|
||||
.resubmitScanAsync(fileCode, fileRecord.getFileName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void pollPendingScans() {
|
||||
if (!isEnabled()) {
|
||||
return;
|
||||
}
|
||||
List<FileSecurityDTO> scanningList = fileSecurityMapper.listScanning();
|
||||
if (scanningList.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
log.info("开始轮询未完成的文件安全检测任务, 数量: {}", scanningList.size());
|
||||
for (FileSecurityDTO scanning : scanningList) {
|
||||
try {
|
||||
if (isPollTimeout(scanning)) {
|
||||
markFailed(scanning.getId(), "安全检测超时");
|
||||
continue;
|
||||
}
|
||||
if (StringUtils.isBlank(scanning.getTaskId())) {
|
||||
// 定时兜底补提交(异步提交失败未落taskId的记录)
|
||||
this.submitScanningRecord(scanning.getId());
|
||||
} else {
|
||||
MeshSafeScanResult result = meshSafeClient.getScan(scanning.getTaskId());
|
||||
applyScanResult(scanning.getId(), result);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("轮询文件安全检测任务失败, id: {}, fileCode: {}, taskId: {}",
|
||||
scanning.getId(), scanning.getFileCode(), scanning.getTaskId(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 补提交指定检测中记录:仍为检测中且无taskId时才提交
|
||||
*/
|
||||
private void submitScanningRecord(Long recordId) {
|
||||
FileSecurity record = fileSecurityMapper.getById(recordId);
|
||||
if (record == null
|
||||
|| !SecurityScanStatusEnum.isScanning(record.getScanStatus())
|
||||
|| StringUtils.isNotBlank(record.getTaskId())) {
|
||||
return;
|
||||
}
|
||||
if (!RedissonUtils.tryLock(getLockDTO(record.getFileCode()))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 双重检查:拿到锁后记录可能已被其他实例处理
|
||||
record = fileSecurityMapper.getById(recordId);
|
||||
if (record == null
|
||||
|| !SecurityScanStatusEnum.isScanning(record.getScanStatus())
|
||||
|| StringUtils.isNotBlank(record.getTaskId())) {
|
||||
return;
|
||||
}
|
||||
byte[] fileBytes = readFileBytes(record.getFileCode());
|
||||
MeshSafeScanResult result = meshSafeClient.scan(resolveFileName(record.getFileCode()), fileBytes);
|
||||
applyScanResult(recordId, result);
|
||||
} catch (Exception e) {
|
||||
log.error("补提交文件安全检测失败, recordId: {}, fileCode: {}", recordId, record.getFileCode(), e);
|
||||
} finally {
|
||||
RedissonUtils.unlock(getLockDTO(record.getFileCode()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交检测(可在异步线程或轮询任务中执行)
|
||||
*
|
||||
* @param force false-已有检测中/通过/不通过记录时跳过; true-仅已有检测中记录时跳过(用于重新检测)
|
||||
*/
|
||||
private void submitScanInternal(String fileCode, String fileName, boolean force) {
|
||||
try {
|
||||
if (!isEnabled() || StringUtils.isBlank(fileCode)) {
|
||||
return;
|
||||
}
|
||||
if (!RedissonUtils.tryLock(getLockDTO(fileCode))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
FileSecurityDTO latest = latest(fileCode);
|
||||
if (latest != null
|
||||
&& !SecurityScanStatusEnum.FAILED.getStatus().equals(latest.getScanStatus())) {
|
||||
if (!force || SecurityScanStatusEnum.isScanning(latest.getScanStatus())) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
FileSecurity record = FileSecurity.builder()
|
||||
.fileCode(fileCode)
|
||||
.scanStatus(SecurityScanStatusEnum.SCANNING.getStatus())
|
||||
.build();
|
||||
fileSecurityMapper.saveEntity(record);
|
||||
|
||||
byte[] fileBytes = readFileBytes(fileCode);
|
||||
MeshSafeScanResult result = meshSafeClient.scan(
|
||||
StringUtils.defaultIfBlank(fileName, resolveFileName(fileCode)), fileBytes);
|
||||
applyScanResult(record.getId(), result);
|
||||
} finally {
|
||||
RedissonUtils.unlock(getLockDTO(fileCode));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("文件安全检测提交失败, fileCode: {}", fileCode, e);
|
||||
markFailedQuietly(fileCode, "安全检测提交失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按MeshSafe结论回写检测记录
|
||||
* completed时manual_review与reject均判为不通过(保守不放行,由人工复核放行)
|
||||
*/
|
||||
private void applyScanResult(Long recordId, MeshSafeScanResult result) {
|
||||
FileSecurity update = FileSecurity.builder().id(recordId).build();
|
||||
String status = result.getStatus();
|
||||
if (MESHSAFE_STATUS_COMPLETED.equals(status)) {
|
||||
update.setTaskId(result.getTaskId());
|
||||
update.setAction(result.getAction());
|
||||
update.setReason(result.getReason());
|
||||
update.setResultJson(result.getRawJson());
|
||||
if (MESHSAFE_ACTION_PASS.equals(result.getAction())) {
|
||||
update.setScanStatus(SecurityScanStatusEnum.PASSED.getStatus());
|
||||
} else {
|
||||
update.setScanStatus(SecurityScanStatusEnum.REJECTED.getStatus());
|
||||
}
|
||||
} else if (MESHSAFE_STATUS_FAILED.equals(status)) {
|
||||
update.setTaskId(result.getTaskId());
|
||||
update.setScanStatus(SecurityScanStatusEnum.FAILED.getStatus());
|
||||
update.setReason(StringUtils.defaultIfBlank(result.getReason(),
|
||||
StringUtils.defaultIfBlank(result.getMessage(), "安全检测失败")));
|
||||
update.setResultJson(result.getRawJson());
|
||||
} else {
|
||||
// pending/running: 记录taskId等待轮询
|
||||
update.setTaskId(result.getTaskId());
|
||||
}
|
||||
fileSecurityMapper.updateEntitySelective(update);
|
||||
}
|
||||
|
||||
private void markFailedQuietly(String fileCode, String reason) {
|
||||
try {
|
||||
FileSecurityDTO latest = latest(fileCode);
|
||||
if (latest != null && SecurityScanStatusEnum.isScanning(latest.getScanStatus())) {
|
||||
markFailed(latest.getId(), reason);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("标记检测失败状态异常, fileCode: {}", fileCode, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void markFailed(Long recordId, String reason) {
|
||||
FileSecurity update = FileSecurity.builder()
|
||||
.id(recordId)
|
||||
.scanStatus(SecurityScanStatusEnum.FAILED.getStatus())
|
||||
.reason(reason)
|
||||
.build();
|
||||
fileSecurityMapper.updateEntitySelective(update);
|
||||
}
|
||||
|
||||
private boolean isPollTimeout(FileSecurityDTO scanning) {
|
||||
Integer timeoutMinutes = meshSafeProperties.getPollTimeoutMinutes();
|
||||
if (timeoutMinutes == null || timeoutMinutes <= 0) {
|
||||
return false;
|
||||
}
|
||||
Date baseline = scanning.getUpdateTime() != null
|
||||
? scanning.getUpdateTime() : scanning.getCreateTime();
|
||||
if (baseline == null) {
|
||||
return false;
|
||||
}
|
||||
return System.currentTimeMillis() - baseline.getTime() > timeoutMinutes * 60L * 1000L;
|
||||
}
|
||||
|
||||
private String resolveFileName(String fileCode) {
|
||||
FileRecord fileRecord = fileRecordService.find(
|
||||
FileRecord.builder().fileCode(fileCode).build());
|
||||
return fileRecord == null ? fileCode : fileRecord.getFileName();
|
||||
}
|
||||
|
||||
private byte[] readFileBytes(String fileCode) {
|
||||
FileRecord fileRecord = fileRecordService.find(
|
||||
FileRecord.builder().fileCode(fileCode).build());
|
||||
Assert.notNull(fileRecord, "未找到对应文件, fileCode: " + fileCode);
|
||||
try (InputStream inputStream = FileStorageUtils.get().download(fileRecord.getFilePath())) {
|
||||
Assert.notNull(inputStream, "获取文件流失败, fileCode: " + fileCode);
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[8192];
|
||||
int len;
|
||||
while ((len = inputStream.read(buffer)) > 0) {
|
||||
bos.write(buffer, 0, len);
|
||||
}
|
||||
return bos.toByteArray();
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("读取待检测文件失败, fileCode: " + fileCode, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 非平台超管(机构管理员)操作文件时,校验文件所属用户属于本机构
|
||||
*/
|
||||
private void checkOrgScope(String fileCode) {
|
||||
if (UserContextHolder.isSuperior()) {
|
||||
return;
|
||||
}
|
||||
UserFileDTO ownerFile = userFileService.find(UserFileQueryDTO.builder()
|
||||
.fileCode(fileCode)
|
||||
.rootType(RootTypeEnum.NORMAL.getType())
|
||||
.build());
|
||||
Assert.notNull(ownerFile, "文件不存在");
|
||||
Assert.isTrue(
|
||||
orgUserService.existOrgUser(UserContextHolder.getExistLoginOrgId(), ownerFile.getUserId()),
|
||||
"只能操作本机构的文件");
|
||||
}
|
||||
|
||||
private String buildBlockedMessage(List<String> fileNames, String reasonSuffix) {
|
||||
List<String> distinct = fileNames.stream().distinct().limit(3).collect(Collectors.toList());
|
||||
String detail = String.join("、", distinct)
|
||||
+ (fileNames.size() > 3 ? " 等" : "");
|
||||
return "文件「" + detail + "」" + reasonSuffix;
|
||||
}
|
||||
|
||||
private boolean isEnabled() {
|
||||
return Boolean.TRUE.equals(meshSafeProperties.getEnabled())
|
||||
&& StringUtils.isNotBlank(meshSafeProperties.getBaseUrl())
|
||||
&& StringUtils.isNotBlank(meshSafeProperties.getClientId())
|
||||
&& StringUtils.isNotBlank(meshSafeProperties.getSecret());
|
||||
}
|
||||
|
||||
public static LockDTO getLockDTO(String fileCode) {
|
||||
Assert.notBlank(fileCode, "文件编码不能为空");
|
||||
return LockDTO.builder()
|
||||
.methodName("filesecurity")
|
||||
.tryLockWaitTime(0L)
|
||||
.lockTimeout(60 * 5L)
|
||||
.arg(fileCode)
|
||||
.failMsg("该文件安全检测正在被操作")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import com.senmesh.sfile.constant.FileTypeEnum;
|
||||
import com.senmesh.sfile.constant.RootTypeEnum;
|
||||
import com.senmesh.sfile.dto.UserFileCreateDTO;
|
||||
import com.senmesh.sfile.dto.UserFileDTO;
|
||||
import com.senmesh.sfile.service.FileSecurityService;
|
||||
import com.senmesh.sfile.service.NormalUserFileService;
|
||||
import com.senmesh.sfile.service.UploadConfigService;
|
||||
import com.senmesh.sfile.service.UserFileService;
|
||||
@@ -31,13 +32,15 @@ public class NormalUserFileServiceImpl implements NormalUserFileService {
|
||||
@Autowired
|
||||
private FileRecordService fileRecordService;
|
||||
|
||||
@Autowired
|
||||
private FileSecurityService fileSecurityService;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public UserFileDTO create(UserFileCreateDTO createDTO) {
|
||||
createDTO.setUserId(UserContextHolder.getExistLoginUserId());
|
||||
// 设置根类型为普通
|
||||
createDTO.setRootType(RootTypeEnum.NORMAL.getType());
|
||||
// TODO 扫描文件审核
|
||||
createDTO.setFileStatus(FileStatusEnum.NORMAL.getStatus());
|
||||
// 机构上传配置校验(仅校验文件,文件夹无后缀/大小概念)
|
||||
if (FileTypeEnum.isFile(createDTO.getFileType())) {
|
||||
@@ -50,7 +53,13 @@ public class NormalUserFileServiceImpl implements NormalUserFileService {
|
||||
uploadConfigService.check(createDTO.getFileName(), fileSize);
|
||||
}
|
||||
// 创建用户文件
|
||||
return userFileService.create(createDTO);
|
||||
UserFileDTO userFileDTO = userFileService.create(createDTO);
|
||||
// MeshSafe文件安全检测(异步送检,不影响主流程)
|
||||
if (FileTypeEnum.isFile(createDTO.getFileType())
|
||||
&& StringUtils.isNotBlank(createDTO.getFileCode())) {
|
||||
fileSecurityService.submitScanAsync(createDTO.getFileCode(), createDTO.getFileName());
|
||||
}
|
||||
return userFileDTO;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.senmesh.sfile.dto.ExpandUserFileCreateDTO;
|
||||
import com.senmesh.sfile.dto.ShareUserFileCreateDTO;
|
||||
import com.senmesh.sfile.dto.UserFileDTO;
|
||||
import com.senmesh.sfile.dto.UserFileQueryDTO;
|
||||
import com.senmesh.sfile.service.FileSecurityService;
|
||||
import com.senmesh.sfile.service.ShareUserFileService;
|
||||
import com.senmesh.sfile.service.UserFileService;
|
||||
import java.util.List;
|
||||
@@ -28,6 +29,9 @@ public class ShareUserFileServiceImpl implements ShareUserFileService {
|
||||
@Autowired
|
||||
private UserFileService userFileService;
|
||||
|
||||
@Autowired
|
||||
private FileSecurityService fileSecurityService;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Long create(ShareUserFileCreateDTO createDTO) {
|
||||
@@ -35,6 +39,10 @@ public class ShareUserFileServiceImpl implements ShareUserFileService {
|
||||
Long userId = UserContextHolder.getExistLoginUserId();
|
||||
Integer validDays = createDTO.getValidDays();
|
||||
|
||||
// MeshSafe安全检测校验:单文件不通过直接拦截;文件夹确认跳过时仅分享通过的内容
|
||||
List<Long> excludeIds = fileSecurityService.resolveShareExclusions(
|
||||
userFileId, Boolean.TRUE.equals(createDTO.getOnlyPassed()), "分享");
|
||||
|
||||
// 根据有效天数计算过期时间
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
calendar.setTime(new Date());
|
||||
@@ -52,6 +60,7 @@ public class ShareUserFileServiceImpl implements ShareUserFileService {
|
||||
.fileStatus(FileStatusEnum.NORMAL.getStatus())
|
||||
.accessCode(accessCode)
|
||||
.endTime(endTime)
|
||||
.excludeFileIds(excludeIds)
|
||||
.build();
|
||||
return userFileService.create(expandCreateDTO).getFolder().getId();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import com.senmesh.sfile.dto.TransferMetadataDTO;
|
||||
import com.senmesh.sfile.dto.UserFileDTO;
|
||||
import com.senmesh.sfile.dto.UserFileModifyStatusDTO;
|
||||
import com.senmesh.sfile.service.FileRiskService;
|
||||
import com.senmesh.sfile.service.FileSecurityService;
|
||||
import com.senmesh.sfile.service.TransferFileMonitorService;
|
||||
import com.senmesh.sfile.service.UserFileService;
|
||||
import com.senmesh.usercenter.user.dto.UserDTO;
|
||||
@@ -64,6 +65,9 @@ public class TransferFileMonitorServiceImpl implements TransferFileMonitorServic
|
||||
@Autowired
|
||||
private FileRiskService fileRiskService;
|
||||
|
||||
@Autowired
|
||||
private FileSecurityService fileSecurityService;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@@ -150,10 +154,13 @@ public class TransferFileMonitorServiceImpl implements TransferFileMonitorServic
|
||||
// 2. 解包并创建用户文件(传入已解析的元数据,直接创建为正常状态)
|
||||
ExpandUserFileDTO result = unpackTransferFileInternal(zipFile, metadata);
|
||||
|
||||
// 3. 创建 .finish 文件到发送目录,告诉发送方处理完成
|
||||
// 3. MeshSafe文件安全检测(异步送检,不影响解包流程)
|
||||
submitSecurityScan(result);
|
||||
|
||||
// 4. 创建 .finish 文件到发送目录,告诉发送方处理完成
|
||||
createFinishFile(fileName);
|
||||
|
||||
// 4. 删除已处理的 ZIP 文件
|
||||
// 5. 删除已处理的 ZIP 文件
|
||||
Files.delete(zipFile);
|
||||
log.info("处理完成,已删除 ZIP 文件: {}", fileName);
|
||||
|
||||
@@ -162,6 +169,20 @@ public class TransferFileMonitorServiceImpl implements TransferFileMonitorServic
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MeshSafe文件安全检测:解包产生的每个文件异步送检,失败不影响解包流程
|
||||
*/
|
||||
private void submitSecurityScan(ExpandUserFileDTO expandUserFileDTO) {
|
||||
if (expandUserFileDTO == null || expandUserFileDTO.getFiles() == null) {
|
||||
return;
|
||||
}
|
||||
for (UserFileDTO file : expandUserFileDTO.getFiles()) {
|
||||
if (StringUtils.isNotBlank(file.getFileCode())) {
|
||||
fileSecurityService.submitScanAsync(file.getFileCode(), file.getFileName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据源端用户手机号和用户类型映射目标端用户。映射失败时改名为 .error,避免定时任务重复解包。
|
||||
*/
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.senmesh.sfile.dto.ExpandUserFileDTO;
|
||||
import com.senmesh.sfile.dto.TransferMetadataDTO;
|
||||
import com.senmesh.sfile.dto.TransferUserFileCreateDTO;
|
||||
import com.senmesh.sfile.dto.UserFileDTO;
|
||||
import com.senmesh.sfile.service.FileSecurityService;
|
||||
import com.senmesh.sfile.service.TransferUserFileService;
|
||||
import com.senmesh.sfile.service.UserFileService;
|
||||
import com.senmesh.usercenter.user.dto.UserDTO;
|
||||
@@ -51,11 +52,18 @@ public class TransferUserFileServiceImpl implements TransferUserFileService {
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Autowired
|
||||
private FileSecurityService fileSecurityService;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Long create(TransferUserFileCreateDTO createDTO) {
|
||||
Long userId = UserContextHolder.getExistLoginUserId();
|
||||
|
||||
// MeshSafe安全检测校验:单文件不通过直接拦截;文件夹确认跳过时仅传输通过的内容
|
||||
List<Long> excludeIds = fileSecurityService.resolveShareExclusions(
|
||||
createDTO.getUserFileId(), Boolean.TRUE.equals(createDTO.getOnlyPassed()), "传输");
|
||||
|
||||
// 根据 FileProperties#location 的类型决定是内穿外还是外传内
|
||||
String location = fileProperties.getTransfer().getLocation();
|
||||
String rootType;
|
||||
@@ -73,6 +81,7 @@ public class TransferUserFileServiceImpl implements TransferUserFileService {
|
||||
.userId(userId)
|
||||
.rootType(rootType)
|
||||
.fileStatus(FileStatusEnum.TRANSFERRING.getStatus())
|
||||
.excludeFileIds(excludeIds)
|
||||
.build();
|
||||
ExpandUserFileDTO expandUserFileDTO = userFileService.create(expandCreateDTO);
|
||||
|
||||
|
||||
@@ -149,10 +149,15 @@ public class UserFileServiceImpl implements UserFileService {
|
||||
.build();
|
||||
queryDTO.disablePage();
|
||||
List<UserFileDTO> filesToCopy = this.list(queryDTO);
|
||||
List<Long> excludeFileIds = expandCreateDTO.getExcludeFileIds();
|
||||
|
||||
// 4. 在文件夹下创建文件对象
|
||||
// 4. 在文件夹下创建文件对象(跳过需排除的文件,如安全检测未通过确认跳过分享)
|
||||
List<UserFileDTO> createdFiles = new java.util.ArrayList<>();
|
||||
for (UserFileDTO fileDTO : filesToCopy) {
|
||||
if (excludeFileIds != null && excludeFileIds.contains(fileDTO.getId())) {
|
||||
log.info("复制用户文件时跳过排除文件: id={}, fileName={}", fileDTO.getId(), fileDTO.getFileName());
|
||||
continue;
|
||||
}
|
||||
UserFileCreateDTO fileCreateDTO = UserFileCreateDTO.builder()
|
||||
.userId(userId)
|
||||
.rootType(rootType)
|
||||
|
||||
Reference in New Issue
Block a user