feat: 对接MeshSafe文件安全检测(上传/解包自动送检+轮询回写,不通过禁止分享传输,文件夹确认后仅分享通过内容,人工复核/重新检测,meshsafe.enabled总开关)

This commit is contained in:
2026-11-01 20:29:31 +08:00
parent c5a262b3dd
commit 4c7dd207d0
30 changed files with 2000 additions and 6 deletions

View File

@@ -2046,3 +2046,37 @@ COMMENT ON COLUMN x_upload_config.create_time IS '创建时间';
COMMENT ON COLUMN x_upload_config.update_by IS '修改人'; COMMENT ON COLUMN x_upload_config.update_by IS '修改人';
COMMENT ON COLUMN x_upload_config.update_time IS '修改时间'; COMMENT ON COLUMN x_upload_config.update_time IS '修改时间';
COMMENT ON COLUMN x_upload_config.proxy_by IS '代理人'; COMMENT ON COLUMN x_upload_config.proxy_by IS '代理人';
CREATE TABLE x_file_security (
id BIGSERIAL PRIMARY KEY,
file_code VARCHAR(32) DEFAULT NULL,
task_id VARCHAR(64) DEFAULT NULL,
scan_status VARCHAR(2) DEFAULT NULL,
action VARCHAR(32) DEFAULT NULL,
reason VARCHAR(1024) DEFAULT NULL,
result_json TEXT DEFAULT NULL,
review_comment VARCHAR(512) DEFAULT NULL,
deleted BOOLEAN DEFAULT FALSE,
create_by BIGINT DEFAULT NULL,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
update_by BIGINT DEFAULT NULL,
update_time TIMESTAMP DEFAULT NULL,
proxy_by BIGINT DEFAULT NULL
);
CREATE INDEX idx_file_security_file_code ON x_file_security(file_code);
CREATE INDEX idx_file_security_scan_status ON x_file_security(scan_status);
COMMENT ON TABLE x_file_security IS '文件安全检测记录';
COMMENT ON COLUMN x_file_security.id IS '主键ID';
COMMENT ON COLUMN x_file_security.file_code IS '文件编码';
COMMENT ON COLUMN x_file_security.task_id IS 'MeshSafe检测任务ID';
COMMENT ON COLUMN x_file_security.scan_status IS '检测状态:1检测中/2通过/3不通过/4检测失败';
COMMENT ON COLUMN x_file_security.action IS 'MeshSafe处置动作:pass/manual_review/reject';
COMMENT ON COLUMN x_file_security.reason IS '检测结论说明';
COMMENT ON COLUMN x_file_security.result_json IS '检测完整结果JSON';
COMMENT ON COLUMN x_file_security.review_comment IS '人工复核说明(人工改判时有值)';
COMMENT ON COLUMN x_file_security.deleted IS '是否删除';
COMMENT ON COLUMN x_file_security.create_by IS '创建人';
COMMENT ON COLUMN x_file_security.create_time IS '创建时间';
COMMENT ON COLUMN x_file_security.update_by IS '修改人';
COMMENT ON COLUMN x_file_security.update_time IS '修改时间';
COMMENT ON COLUMN x_file_security.proxy_by IS '代理人';

View File

@@ -1278,4 +1278,25 @@ CREATE TABLE `x_upload_config` (
UNIQUE KEY `uk_org_id` (`org_id`) UNIQUE KEY `uk_org_id` (`org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='机构上传配置'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='机构上传配置';
CREATE TABLE `x_file_security` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`file_code` varchar(32) DEFAULT NULL COMMENT '文件编码',
`task_id` varchar(64) DEFAULT NULL COMMENT 'MeshSafe检测任务ID',
`scan_status` varchar(2) DEFAULT NULL COMMENT '检测状态:1检测中/2通过/3不通过/4检测失败',
`action` varchar(32) DEFAULT NULL COMMENT 'MeshSafe处置动作:pass/manual_review/reject',
`reason` varchar(1024) DEFAULT NULL COMMENT '检测结论说明',
`result_json` text DEFAULT NULL COMMENT '检测完整结果JSON',
`review_comment` varchar(512) DEFAULT NULL COMMENT '人工复核说明(人工改判时有值)',
`deleted` bit(1) DEFAULT b'0' COMMENT '是否删除',
`create_by` bigint(20) DEFAULT NULL COMMENT '创建人',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` bigint(20) DEFAULT NULL COMMENT '修改人',
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
`proxy_by` bigint(20) DEFAULT NULL COMMENT '代理人',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_file_security_file_code` (`file_code`) USING BTREE,
KEY `idx_file_security_scan_status` (`scan_status`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件安全检测记录';
SET FOREIGN_KEY_CHECKS = 1; SET FOREIGN_KEY_CHECKS = 1;

View File

@@ -0,0 +1,71 @@
-- =============================================================
-- 升级脚本:MeshSafe 文件安全检测对接
-- 日期:2026-11-01
-- 说明:新增 x_file_security 表(按文件编码维度记录安全检测结果)。
-- 文件上传/传输接收解包后自动提交 MeshSafe 检测,
-- 检测结论(通过/不通过)回写本表;同一文件允许多条检测
-- 记录(人工复核、重新检测均新增记录),业务取最新一条生效。
-- 检测不通过的文件禁止分享与跨网传输(下载预览不受限)。
-- 执行方式:MySQL/达梦(DM8 MySQL兼容模式)执行第 1 节;
-- PostgreSQL 执行第 2 节。
-- =============================================================
-- -------------------------------------------------------------
-- 1. MySQL / 达梦 DM8
-- -------------------------------------------------------------
CREATE TABLE `x_file_security` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`file_code` varchar(32) DEFAULT NULL COMMENT '文件编码',
`task_id` varchar(64) DEFAULT NULL COMMENT 'MeshSafe检测任务ID',
`scan_status` varchar(2) DEFAULT NULL COMMENT '检测状态:1检测中/2通过/3不通过/4检测失败',
`action` varchar(32) DEFAULT NULL COMMENT 'MeshSafe处置动作:pass/manual_review/reject',
`reason` varchar(1024) DEFAULT NULL COMMENT '检测结论说明',
`result_json` text DEFAULT NULL COMMENT '检测完整结果JSON',
`review_comment` varchar(512) DEFAULT NULL COMMENT '人工复核说明(人工改判时有值)',
`deleted` bit(1) DEFAULT b'0' COMMENT '是否删除',
`create_by` bigint(20) DEFAULT NULL COMMENT '创建人',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` bigint(20) DEFAULT NULL COMMENT '修改人',
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
`proxy_by` bigint(20) DEFAULT NULL COMMENT '代理人',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_file_security_file_code` (`file_code`) USING BTREE,
KEY `idx_file_security_scan_status` (`scan_status`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件安全检测记录';
-- -------------------------------------------------------------
-- 2. PostgreSQL
-- -------------------------------------------------------------
CREATE TABLE x_file_security (
id BIGSERIAL PRIMARY KEY,
file_code VARCHAR(32) DEFAULT NULL,
task_id VARCHAR(64) DEFAULT NULL,
scan_status VARCHAR(2) DEFAULT NULL,
action VARCHAR(32) DEFAULT NULL,
reason VARCHAR(1024) DEFAULT NULL,
result_json TEXT DEFAULT NULL,
review_comment VARCHAR(512) DEFAULT NULL,
deleted BOOLEAN DEFAULT FALSE,
create_by BIGINT DEFAULT NULL,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
update_by BIGINT DEFAULT NULL,
update_time TIMESTAMP DEFAULT NULL,
proxy_by BIGINT DEFAULT NULL
);
CREATE INDEX idx_file_security_file_code ON x_file_security(file_code);
CREATE INDEX idx_file_security_scan_status ON x_file_security(scan_status);
COMMENT ON TABLE x_file_security IS '文件安全检测记录';
COMMENT ON COLUMN x_file_security.id IS '主键ID';
COMMENT ON COLUMN x_file_security.file_code IS '文件编码';
COMMENT ON COLUMN x_file_security.task_id IS 'MeshSafe检测任务ID';
COMMENT ON COLUMN x_file_security.scan_status IS '检测状态:1检测中/2通过/3不通过/4检测失败';
COMMENT ON COLUMN x_file_security.action IS 'MeshSafe处置动作:pass/manual_review/reject';
COMMENT ON COLUMN x_file_security.reason IS '检测结论说明';
COMMENT ON COLUMN x_file_security.result_json IS '检测完整结果JSON';
COMMENT ON COLUMN x_file_security.review_comment IS '人工复核说明(人工改判时有值)';
COMMENT ON COLUMN x_file_security.deleted IS '是否删除';
COMMENT ON COLUMN x_file_security.create_by IS '创建人';
COMMENT ON COLUMN x_file_security.create_time IS '创建时间';
COMMENT ON COLUMN x_file_security.update_by IS '修改人';
COMMENT ON COLUMN x_file_security.update_time IS '修改时间';
COMMENT ON COLUMN x_file_security.proxy_by IS '代理人';

View File

@@ -0,0 +1,118 @@
server:
port: 8081
spring:
application:
name: s-file
datasource:
url: jdbc:postgresql://127.0.0.1:54330/s_file
driver-class-name: org.postgresql.Driver
username: sfile
password: sfile123
druid:
initial-size: 2
min-idle: 2
max-active: 10
validation-query: SELECT 1
redis:
host: 127.0.0.1
port: 63800
timeout: 5000
lettuce:
pool:
max-active: 8
max-idle: 4
rabbitmq:
host: 127.0.0.1
port: 56730
username: sfile
password: sfile123
servlet:
multipart:
max-file-size: 500MB
max-request-size: 500MB
main:
allow-bean-definition-overriding: true
mybatis-plus:
mapper-locations: classpath*:com/senmesh/**/mapper/*.xml
# 平台编码(init-data 中 uc_platform.platform_code=1)
system:
code: 1
# 发号器雪花 workerId(键格式: code.snowflake.workid.<本机IP>,方括号与裸键双写兼容)
"[code.snowflake.workid.192.168.71.85]": 1
"[code.snowflake.workid.172.27.160.1]": 1
"[code.snowflake.workid.172.28.16.1]": 1
"[code.snowflake.workid.127.0.0.1]": 1
code.snowflake.workid.192.168.71.85: 1
code.snowflake.workid.172.27.160.1: 1
code.snowflake.workid.172.28.16.1: 1
code.snowflake.workid.127.0.0.1: 1
security:
jwt:
enable: true
secret: sfile-local-test-secret-key-please-change-0123456789abcdef
expiration: 86400000
header: Authorization
ignoring: /error,/favicon.ico
login-page: /login
superior-only: false
use-local: true
allow-if-all-abstain-decisions: true
oauth2:
enable: false
not-logs: ""
# 新用户初始密码
user:
default:
password: Qaz123567!@#
file:
upload-dir: D:/zcodedata/sfile/upload
storage:
type: local
transfer:
# 内网侧
location: "1"
send-dir: D:/zcodedata/sfile/transfer/send
receive-dir: D:/zcodedata/sfile/transfer/receive
unpack-risk-check: false
dify:
risk:
rule:
api-key: test-rule-key
scan:
api-key: test-scan-key
# MeshSafe 文件安全检测(对接本地 MeshSafe venv 实例,端口 48443)
# enabled=false 完全关闭检测:不送检/不拦截/预检返回空/前端隐藏检测列
meshsafe:
enabled: true
base-url: http://127.0.0.1:48443
client-id: test-client
secret: test-secret
channel: sfile-local
timeout-seconds: 120
fail-open: true
poll-timeout-minutes: 60
review-sync-to-meshsafe: true
poll:
cron: "0 */10 * * * ?"
rabbitmq:
custom:
topic-exchange-name: s-file-local-topic
delayed-exchange-name: s-file-local-delayed
broadcast-id: s-file-local-broadcast
swagger:
enable: true
logging:
level:
com.senmesh: info

View File

@@ -82,6 +82,18 @@ dify:
scan: scan:
api-key: test-scan-key api-key: test-scan-key
# MeshSafe 文件安全检测(上传/接收解包后自动送检,不通过禁止分享传输)
meshsafe:
enabled: false
base-url: https://meshsafe.internal
client-id: s-file
secret: change-me
channel: default
timeout-seconds: 120
fail-open: true
poll-timeout-minutes: 60
review-sync-to-meshsafe: true
rabbitmq: rabbitmq:
custom: custom:
topic-exchange-name: s-file-test-topic topic-exchange-name: s-file-test-topic

View File

@@ -0,0 +1,171 @@
# 前端接口文档 —— 文件安全检测(MeshSafe 对接)
> 版本:v1.0(2026-11-01)
> 适用后端:s-file(alita-bjy-uygur)
> Swagger 分组:「文件安全检测」
## 功能说明
对接独立文件安全检测服务 **MeshSafe**:文件上传(网盘上传)与跨网传输接收解包后,自动提交 MeshSafe 检测,检测结论回写到本系统并标记在文件上。
核心规则:
- **检测不通过(误报可复核)**:文件**只能查看/下载/预览**,**禁止分享与跨网传输**;管理员可人工复核修改检测结果(需填写复核说明),复核通过后即可正常分享/传输;
- **文件夹部分文件不通过**:分享/传输弹窗内提示未通过的文件清单,用户确认后**仅分享/传输检测通过的内容**(请求带 `onlyPassed=true`,后端排除不通过/检测中的文件);不确认则整体拦截;
- **检测中**:禁止分享与传输(检测通常秒级完成,稍后重试即可);
- **检测失败**:默认不阻断业务(仅标记"检测失败",可重新检测);`meshsafe.fail-open=false` 时检测失败同样禁止分享/传输;
- **未检测**:不阻断(历史文件/开关未开启期间上传的文件);
- 同一文件允许多条检测记录(重新检测、人工复核均新增记录),**列表与校验均取最新一条**;
- 人工复核会尽力回写 MeshSafe 的人工复核接口(失败仅告警,不影响本地生效)。
检测状态(`securityStatus`)取值:
| 值 | 含义 | 能否分享/传输 |
|---|---|---|
| 空 | 未检测 | 能 |
| 1 | 检测中 | 否 |
| 2 | 通过 | 能 |
| 3 | 不通过 | 否 |
| 4 | 检测失败 | 默认能(fail-open=false 时否) |
## 通用约定
与既有接口一致:登录态调用;统一返回 `AlitaResult<T>`(`code="200"` 且 `success=true` 成功;业务失败 `success=false`,`msg` 为提示文案)。
**总开关**:`meshsafe.enabled=false` 时检测功能整体关闭——上传/解包不送检、分享/传输不拦截、预检返回空、复核与重新检测接口报"文件安全检测未开启";前端通过 `GET /file_security/enabled` 获取开关状态,关闭时列表隐藏"安全检测"列。
## 文件列表新增字段
`GET /user_file/page`、`GET /user_file/list`、`GET /user_file/record/page_by_phone` 等返回的文件记录新增以下字段(前端据此展示与禁用操作):
| 字段 | 类型 | 说明 |
|---|---|---|
| securityStatus | String | 安全检测状态:1检测中/2通过/3不通过/4检测失败,null 为未检测 |
| securityAction | String | MeshSafe 处置动作:pass/manual_review/reject |
| securityReason | String | 检测结论说明(可直接展示给用户,人工复核时为复核说明) |
| securityReviewComment | String | 人工复核说明(人工改判时有值) |
---
## 1. 查询文件最新检测结果
- **URL**:`GET /file_security/detail`
- **权限**:所有登录用户
- **参数**:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| fileCode | String | 是 | 文件编码 |
- **返回**:`AlitaResult<FileSecurityDTO>`,无检测记录时 `data` 为 null
**FileSecurityDTO 字段**
| 字段 | 类型 | 说明 |
|---|---|---|
| fileCode | String | 文件编码 |
| taskId | String | MeshSafe 检测任务 ID |
| scanStatus | String | 检测状态:1检测中/2通过/3不通过/4检测失败 |
| action | String | MeshSafe 处置动作:pass/manual_review/reject |
| reason | String | 检测结论说明 |
| resultJson | String | 检测完整结果 JSON(原始响应) |
| reviewComment | String | 人工复核说明(人工改判时有值) |
| createTime | String | 记录时间 yyyy-MM-dd HH:mm:ss |
## 2. 人工复核修改检测结果
- **URL**:`POST /file_security/review`
- **权限**:平台超管(全部)/ 机构管理员(仅本机构用户的文件)
- **Content-Type**:`application/json`
- **参数**:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| fileCode | String | 是 | 文件编码 |
| decision | String | 是 | 复核结论:`pass` 通过 / `reject` 不通过 |
| comment | String | 是 | 复核说明(审计要求,必填),例如"经人工查验为误报" |
- **返回**:`AlitaResult<FileSecurityDTO>`(本次复核生成的最新检测记录)
- **行为**:新增一条检测记录留痕并立即生效;若该文件此前有 MeshSafe 任务 ID,会尽力回写 MeshSafe 人工复核接口(失败仅告警)
**错误提示**:无检测记录、复核结论不合法、无操作权限、非本机构文件时返回 `success=false` 并附 `msg`。
## 3. 分享/传输预检
- **URL**:`GET /file_security/share_check`
- **权限**:所有登录用户(校验本人文件)
- **参数**:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| userFileId | Long | 是 | 用户文件ID(普通文件/文件夹) |
- **返回**:`AlitaResult<FileSecurityShareCheckDTO>`
**FileSecurityShareCheckDTO 字段**
| 字段 | 类型 | 说明 |
|---|---|---|
| fileType | String | 1文件/2文件夹 |
| totalFileCount | Integer | 目录内文件总数(不含目录) |
| blockedCount | Integer | 检测不通过数量 |
| scanningCount | Integer | 检测中数量 |
| failedCount | Integer | 检测失败数量 |
| blockedFileNames | List&lt;String&gt; | 检测不通过文件名 |
| scanningFileNames | List&lt;String&gt; | 检测中文件名 |
| failedFileNames | List&lt;String&gt; | 检测失败文件名 |
| excludeFileIds | List&lt;Long&gt; | 确认跳过时需排除的用户文件ID |
**前端用法**:文件夹分享/传输前调用,`excludeFileIds` 非空时弹窗展示文件清单,用户确认后分享/传输请求带 `onlyPassed=true`。
## 4. 分享/传输的 onlyPassed 参数
`POST /share_user_file/create`、`POST /transfer_user_file/create` 请求体新增可选字段:
| 参数 | 类型 | 说明 |
|---|---|---|
| onlyPassed | Boolean | 文件夹内存在检测未通过文件且用户已确认跳过时传 `true`,仅复制/传输检测通过的内容;缺省或 `false` 时维持整体拦截 |
规则:单文件检测不通过 → 无论该参数一律拦截;文件夹 + `onlyPassed=true` → 排除不通过/检测中文件;文件夹内全部被拦截 → 报错「文件夹内没有检测通过的文件,无法分享/传输」。
## 5. 重新提交安全检测
- **URL**:`POST /file_security/rescan`
- **权限**:平台超管(全部)/ 机构管理员(仅本机构用户的文件)
- **参数**(query):
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| fileCode | String | 是 | 文件编码 |
- **返回**:`AlitaResult<Boolean>`,受理成功返回 `true`(异步执行,结果通过列表/详情查询获取)
- **行为**:新增一条"检测中"记录并异步提交 MeshSafe;该文件正在检测中时拒绝重复提交
---
## 后端对接配置(运维)
```yaml
meshsafe:
enabled: true # 总开关,false 时完全不送检、不校验
base-url: https://meshsafe.internal
client-id: s-file # MeshSafe 分配的调用方标识
secret: "***" # MeshSafe 分配的密钥(请勿泄露)
channel: default # 业务通道标识(仅用于 MeshSafe 日志追溯)
timeout-seconds: 120 # 单次请求超时
fail-open: true # 检测服务异常时是否放行(true仅标记检测失败)
poll-timeout-minutes: 60 # 检测中记录超时时长,超时标记检测失败
review-sync-to-meshsafe: true # 人工复核是否回写 MeshSafe
poll:
cron: "0 */30 * * * ?" # 轮询未完成检测任务的定时任务(默认30秒)
```
数据表:`x_file_security`(见 `db/upgrade-20261101-meshsafe-file-security.sql`)。
送检触发点:
1. **网盘上传**:`POST /normal_user_file/create` 落库后异步送检;
2. **跨网传输接收**:传输解包定时任务创建文件记录后逐个异步送检(保护"外传内"方向);
3. **手动重新检测**:上述 `/file_security/rescan`。
分享/传输入口强校验:`POST /share_user_file/create`、`POST /transfer_user_file/create` 均校验文件及子文件的最新检测状态,不通过/检测中直接拒绝,`msg` 会带上具体文件名与原因。

View 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);
}
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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();
}

View File

@@ -15,6 +15,8 @@ import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull; import javax.validation.constraints.NotNull;
import org.springframework.format.annotation.DateTimeFormat; import org.springframework.format.annotation.DateTimeFormat;
import java.util.List;
/** /**
* 扩展用户文件创建DTO * 扩展用户文件创建DTO
* 根据普通文件创建新的文件(复制) * 根据普通文件创建新的文件(复制)
@@ -50,5 +52,8 @@ public class ExpandUserFileCreateDTO extends BaseOperateDTO {
@ApiModelProperty("访问码") @ApiModelProperty("访问码")
private String accessCode; private String accessCode;
@ApiModelProperty("复制时需排除的用户文件ID(安全检测未通过跳过分享场景)")
private List<Long> excludeFileIds;
} }

View 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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -32,5 +32,8 @@ public class ShareUserFileCreateDTO extends BaseOperateDTO {
@NotNull(message = "有效天数不能为空") @NotNull(message = "有效天数不能为空")
private Integer validDays; private Integer validDays;
@ApiModelProperty("文件夹内存在检测未通过文件时,确认后仅复制检测通过的内容")
private Boolean onlyPassed;
} }

View File

@@ -28,5 +28,8 @@ public class TransferUserFileCreateDTO extends BaseOperateDTO {
@NotNull(message = "用户文件ID不能为空") @NotNull(message = "用户文件ID不能为空")
private Long userFileId; private Long userFileId;
@ApiModelProperty("文件夹内存在检测未通过文件时,确认后仅传输检测通过的内容")
private Boolean onlyPassed;
} }

View File

@@ -69,5 +69,22 @@ public class UserFileDTO extends BaseDTO {
@ApiModelProperty("风险详情JSON") @ApiModelProperty("风险详情JSON")
private String riskJson; 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;
} }

View 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;
}

View 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();
}
}
}

View File

@@ -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>

View File

@@ -4,7 +4,10 @@
<mapper namespace="com.senmesh.sfile.dao.UserFileMapper"> <mapper namespace="com.senmesh.sfile.dao.UserFileMapper">
<select id="list" resultType="com.senmesh.sfile.dto.UserFileDTO"> <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> <include refid="queryFromSql"></include>
ORDER BY t.id DESC ORDER BY t.id DESC
</select> </select>
@@ -13,6 +16,9 @@
SELECT SELECT
t.*, t.*,
fr.file_size, 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.phone,
u.username, u.username,
u.real_name, u.real_name,
@@ -20,6 +26,9 @@
FROM x_user_file t FROM x_user_file t
INNER JOIN uc_user u ON t.user_id = u.id AND u.deleted = FALSE 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 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 WHERE t.deleted = FALSE
AND u.phone = #{param.phone} AND u.phone = #{param.phone}
<if test='param.rootTypeIn != null and !param.rootTypeIn.isEmpty()'> <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 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_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_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'> <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 LEFT JOIN x_user_file parent ON t.parent_id = parent.id AND parent.deleted = FALSE
</if> </if>

View File

@@ -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();
}

View File

@@ -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();
}
}

View File

@@ -8,6 +8,7 @@ import com.senmesh.sfile.constant.FileTypeEnum;
import com.senmesh.sfile.constant.RootTypeEnum; import com.senmesh.sfile.constant.RootTypeEnum;
import com.senmesh.sfile.dto.UserFileCreateDTO; import com.senmesh.sfile.dto.UserFileCreateDTO;
import com.senmesh.sfile.dto.UserFileDTO; import com.senmesh.sfile.dto.UserFileDTO;
import com.senmesh.sfile.service.FileSecurityService;
import com.senmesh.sfile.service.NormalUserFileService; import com.senmesh.sfile.service.NormalUserFileService;
import com.senmesh.sfile.service.UploadConfigService; import com.senmesh.sfile.service.UploadConfigService;
import com.senmesh.sfile.service.UserFileService; import com.senmesh.sfile.service.UserFileService;
@@ -31,13 +32,15 @@ public class NormalUserFileServiceImpl implements NormalUserFileService {
@Autowired @Autowired
private FileRecordService fileRecordService; private FileRecordService fileRecordService;
@Autowired
private FileSecurityService fileSecurityService;
@Override @Override
@Transactional @Transactional
public UserFileDTO create(UserFileCreateDTO createDTO) { public UserFileDTO create(UserFileCreateDTO createDTO) {
createDTO.setUserId(UserContextHolder.getExistLoginUserId()); createDTO.setUserId(UserContextHolder.getExistLoginUserId());
// 设置根类型为普通 // 设置根类型为普通
createDTO.setRootType(RootTypeEnum.NORMAL.getType()); createDTO.setRootType(RootTypeEnum.NORMAL.getType());
// TODO 扫描文件审核
createDTO.setFileStatus(FileStatusEnum.NORMAL.getStatus()); createDTO.setFileStatus(FileStatusEnum.NORMAL.getStatus());
// 机构上传配置校验(仅校验文件,文件夹无后缀/大小概念) // 机构上传配置校验(仅校验文件,文件夹无后缀/大小概念)
if (FileTypeEnum.isFile(createDTO.getFileType())) { if (FileTypeEnum.isFile(createDTO.getFileType())) {
@@ -50,7 +53,13 @@ public class NormalUserFileServiceImpl implements NormalUserFileService {
uploadConfigService.check(createDTO.getFileName(), fileSize); 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;
} }
} }

View File

@@ -9,6 +9,7 @@ import com.senmesh.sfile.dto.ExpandUserFileCreateDTO;
import com.senmesh.sfile.dto.ShareUserFileCreateDTO; import com.senmesh.sfile.dto.ShareUserFileCreateDTO;
import com.senmesh.sfile.dto.UserFileDTO; import com.senmesh.sfile.dto.UserFileDTO;
import com.senmesh.sfile.dto.UserFileQueryDTO; import com.senmesh.sfile.dto.UserFileQueryDTO;
import com.senmesh.sfile.service.FileSecurityService;
import com.senmesh.sfile.service.ShareUserFileService; import com.senmesh.sfile.service.ShareUserFileService;
import com.senmesh.sfile.service.UserFileService; import com.senmesh.sfile.service.UserFileService;
import java.util.List; import java.util.List;
@@ -28,6 +29,9 @@ public class ShareUserFileServiceImpl implements ShareUserFileService {
@Autowired @Autowired
private UserFileService userFileService; private UserFileService userFileService;
@Autowired
private FileSecurityService fileSecurityService;
@Override @Override
@Transactional @Transactional
public Long create(ShareUserFileCreateDTO createDTO) { public Long create(ShareUserFileCreateDTO createDTO) {
@@ -35,6 +39,10 @@ public class ShareUserFileServiceImpl implements ShareUserFileService {
Long userId = UserContextHolder.getExistLoginUserId(); Long userId = UserContextHolder.getExistLoginUserId();
Integer validDays = createDTO.getValidDays(); Integer validDays = createDTO.getValidDays();
// MeshSafe安全检测校验:单文件不通过直接拦截;文件夹确认跳过时仅分享通过的内容
List<Long> excludeIds = fileSecurityService.resolveShareExclusions(
userFileId, Boolean.TRUE.equals(createDTO.getOnlyPassed()), "分享");
// 根据有效天数计算过期时间 // 根据有效天数计算过期时间
Calendar calendar = Calendar.getInstance(); Calendar calendar = Calendar.getInstance();
calendar.setTime(new Date()); calendar.setTime(new Date());
@@ -52,6 +60,7 @@ public class ShareUserFileServiceImpl implements ShareUserFileService {
.fileStatus(FileStatusEnum.NORMAL.getStatus()) .fileStatus(FileStatusEnum.NORMAL.getStatus())
.accessCode(accessCode) .accessCode(accessCode)
.endTime(endTime) .endTime(endTime)
.excludeFileIds(excludeIds)
.build(); .build();
return userFileService.create(expandCreateDTO).getFolder().getId(); return userFileService.create(expandCreateDTO).getFolder().getId();
} }

View File

@@ -14,6 +14,7 @@ import com.senmesh.sfile.dto.TransferMetadataDTO;
import com.senmesh.sfile.dto.UserFileDTO; import com.senmesh.sfile.dto.UserFileDTO;
import com.senmesh.sfile.dto.UserFileModifyStatusDTO; import com.senmesh.sfile.dto.UserFileModifyStatusDTO;
import com.senmesh.sfile.service.FileRiskService; import com.senmesh.sfile.service.FileRiskService;
import com.senmesh.sfile.service.FileSecurityService;
import com.senmesh.sfile.service.TransferFileMonitorService; import com.senmesh.sfile.service.TransferFileMonitorService;
import com.senmesh.sfile.service.UserFileService; import com.senmesh.sfile.service.UserFileService;
import com.senmesh.usercenter.user.dto.UserDTO; import com.senmesh.usercenter.user.dto.UserDTO;
@@ -64,6 +65,9 @@ public class TransferFileMonitorServiceImpl implements TransferFileMonitorServic
@Autowired @Autowired
private FileRiskService fileRiskService; private FileRiskService fileRiskService;
@Autowired
private FileSecurityService fileSecurityService;
@Autowired @Autowired
private UserService userService; private UserService userService;
@@ -150,10 +154,13 @@ public class TransferFileMonitorServiceImpl implements TransferFileMonitorServic
// 2. 解包并创建用户文件(传入已解析的元数据,直接创建为正常状态) // 2. 解包并创建用户文件(传入已解析的元数据,直接创建为正常状态)
ExpandUserFileDTO result = unpackTransferFileInternal(zipFile, metadata); ExpandUserFileDTO result = unpackTransferFileInternal(zipFile, metadata);
// 3. 创建 .finish 文件到发送目录,告诉发送方处理完成 // 3. MeshSafe文件安全检测(异步送检,不影响解包流程)
submitSecurityScan(result);
// 4. 创建 .finish 文件到发送目录,告诉发送方处理完成
createFinishFile(fileName); createFinishFile(fileName);
// 4. 删除已处理的 ZIP 文件 // 5. 删除已处理的 ZIP 文件
Files.delete(zipFile); Files.delete(zipFile);
log.info("处理完成,已删除 ZIP 文件: {}", fileName); 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,避免定时任务重复解包。 * 根据源端用户手机号和用户类型映射目标端用户。映射失败时改名为 .error,避免定时任务重复解包。
*/ */

View File

@@ -13,6 +13,7 @@ import com.senmesh.sfile.dto.ExpandUserFileDTO;
import com.senmesh.sfile.dto.TransferMetadataDTO; import com.senmesh.sfile.dto.TransferMetadataDTO;
import com.senmesh.sfile.dto.TransferUserFileCreateDTO; import com.senmesh.sfile.dto.TransferUserFileCreateDTO;
import com.senmesh.sfile.dto.UserFileDTO; import com.senmesh.sfile.dto.UserFileDTO;
import com.senmesh.sfile.service.FileSecurityService;
import com.senmesh.sfile.service.TransferUserFileService; import com.senmesh.sfile.service.TransferUserFileService;
import com.senmesh.sfile.service.UserFileService; import com.senmesh.sfile.service.UserFileService;
import com.senmesh.usercenter.user.dto.UserDTO; import com.senmesh.usercenter.user.dto.UserDTO;
@@ -51,11 +52,18 @@ public class TransferUserFileServiceImpl implements TransferUserFileService {
@Autowired @Autowired
private UserService userService; private UserService userService;
@Autowired
private FileSecurityService fileSecurityService;
@Override @Override
@Transactional @Transactional
public Long create(TransferUserFileCreateDTO createDTO) { public Long create(TransferUserFileCreateDTO createDTO) {
Long userId = UserContextHolder.getExistLoginUserId(); Long userId = UserContextHolder.getExistLoginUserId();
// MeshSafe安全检测校验:单文件不通过直接拦截;文件夹确认跳过时仅传输通过的内容
List<Long> excludeIds = fileSecurityService.resolveShareExclusions(
createDTO.getUserFileId(), Boolean.TRUE.equals(createDTO.getOnlyPassed()), "传输");
// 根据 FileProperties#location 的类型决定是内穿外还是外传内 // 根据 FileProperties#location 的类型决定是内穿外还是外传内
String location = fileProperties.getTransfer().getLocation(); String location = fileProperties.getTransfer().getLocation();
String rootType; String rootType;
@@ -73,6 +81,7 @@ public class TransferUserFileServiceImpl implements TransferUserFileService {
.userId(userId) .userId(userId)
.rootType(rootType) .rootType(rootType)
.fileStatus(FileStatusEnum.TRANSFERRING.getStatus()) .fileStatus(FileStatusEnum.TRANSFERRING.getStatus())
.excludeFileIds(excludeIds)
.build(); .build();
ExpandUserFileDTO expandUserFileDTO = userFileService.create(expandCreateDTO); ExpandUserFileDTO expandUserFileDTO = userFileService.create(expandCreateDTO);

View File

@@ -149,10 +149,15 @@ public class UserFileServiceImpl implements UserFileService {
.build(); .build();
queryDTO.disablePage(); queryDTO.disablePage();
List<UserFileDTO> filesToCopy = this.list(queryDTO); List<UserFileDTO> filesToCopy = this.list(queryDTO);
List<Long> excludeFileIds = expandCreateDTO.getExcludeFileIds();
// 4. 在文件夹下创建文件对象 // 4. 在文件夹下创建文件对象(跳过需排除的文件,如安全检测未通过确认跳过分享)
List<UserFileDTO> createdFiles = new java.util.ArrayList<>(); List<UserFileDTO> createdFiles = new java.util.ArrayList<>();
for (UserFileDTO fileDTO : filesToCopy) { for (UserFileDTO fileDTO : filesToCopy) {
if (excludeFileIds != null && excludeFileIds.contains(fileDTO.getId())) {
log.info("复制用户文件时跳过排除文件: id={}, fileName={}", fileDTO.getId(), fileDTO.getFileName());
continue;
}
UserFileCreateDTO fileCreateDTO = UserFileCreateDTO.builder() UserFileCreateDTO fileCreateDTO = UserFileCreateDTO.builder()
.userId(userId) .userId(userId)
.rootType(rootType) .rootType(rootType)