feat: 对接MeshSafe文件安全检测(上传/解包自动送检+轮询回写,不通过禁止分享传输,文件夹确认后仅分享通过内容,人工复核/重新检测,meshsafe.enabled总开关)
This commit is contained in:
@@ -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_time 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 '代理人';
|
||||
|
||||
@@ -1278,4 +1278,25 @@ CREATE TABLE `x_upload_config` (
|
||||
UNIQUE KEY `uk_org_id` (`org_id`)
|
||||
) 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;
|
||||
71
db/upgrade-20261101-meshsafe-file-security.sql
Normal file
71
db/upgrade-20261101-meshsafe-file-security.sql
Normal 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 '代理人';
|
||||
118
docker-test/application-local.yml
Normal file
118
docker-test/application-local.yml
Normal 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
|
||||
@@ -82,6 +82,18 @@ dify:
|
||||
scan:
|
||||
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:
|
||||
custom:
|
||||
topic-exchange-name: s-file-test-topic
|
||||
|
||||
171
docs/接口文档-文件安全检测.md
Normal file
171
docs/接口文档-文件安全检测.md
Normal 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<String> | 检测不通过文件名 |
|
||||
| scanningFileNames | List<String> | 检测中文件名 |
|
||||
| failedFileNames | List<String> | 检测失败文件名 |
|
||||
| excludeFileIds | List<Long> | 确认跳过时需排除的用户文件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` 会带上具体文件名与原因。
|
||||
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