diff --git a/db/init-table-postgres-full.sql b/db/init-table-postgres-full.sql index 15679c1..c595562 100644 --- a/db/init-table-postgres-full.sql +++ b/db/init-table-postgres-full.sql @@ -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 '代理人'; diff --git a/db/init-table.sql b/db/init-table.sql index a53ac74..20023cf 100644 --- a/db/init-table.sql +++ b/db/init-table.sql @@ -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; \ No newline at end of file diff --git a/db/upgrade-20261101-meshsafe-file-security.sql b/db/upgrade-20261101-meshsafe-file-security.sql new file mode 100644 index 0000000..8b0277d --- /dev/null +++ b/db/upgrade-20261101-meshsafe-file-security.sql @@ -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 '代理人'; diff --git a/docker-test/application-local.yml b/docker-test/application-local.yml new file mode 100644 index 0000000..b55be82 --- /dev/null +++ b/docker-test/application-local.yml @@ -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 diff --git a/docker-test/application.yml b/docker-test/application.yml index 9bc6c00..364fdfc 100644 --- a/docker-test/application.yml +++ b/docker-test/application.yml @@ -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 diff --git a/docs/接口文档-文件安全检测.md b/docs/接口文档-文件安全检测.md new file mode 100644 index 0000000..f3bf2f0 --- /dev/null +++ b/docs/接口文档-文件安全检测.md @@ -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`(`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`,无检测记录时 `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`(本次复核生成的最新检测记录) +- **行为**:新增一条检测记录留痕并立即生效;若该文件此前有 MeshSafe 任务 ID,会尽力回写 MeshSafe 人工复核接口(失败仅告警) + +**错误提示**:无检测记录、复核结论不合法、无操作权限、非本机构文件时返回 `success=false` 并附 `msg`。 + +## 3. 分享/传输预检 + +- **URL**:`GET /file_security/share_check` +- **权限**:所有登录用户(校验本人文件) +- **参数**: + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| userFileId | Long | 是 | 用户文件ID(普通文件/文件夹) | + +- **返回**:`AlitaResult` + +**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`,受理成功返回 `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` 会带上具体文件名与原因。 diff --git a/src/main/java/com/senmesh/sfile/client/MeshSafeClient.java b/src/main/java/com/senmesh/sfile/client/MeshSafeClient.java new file mode 100644 index 0000000..000bcf1 --- /dev/null +++ b/src/main/java/com/senmesh/sfile/client/MeshSafeClient.java @@ -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 文件安全检测客户端 + *

+ * 对接 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 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 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 bodyMap = new LinkedHashMap<>(); + bodyMap.put("decision", decision); + bodyMap.put("comment", comment); + byte[] body = JsonUtils.toJsonString(bodyMap).getBytes(StandardCharsets.UTF_8); + + Map 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 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 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 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 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); + } + } +} diff --git a/src/main/java/com/senmesh/sfile/client/MeshSafeScanResult.java b/src/main/java/com/senmesh/sfile/client/MeshSafeScanResult.java new file mode 100644 index 0000000..108a352 --- /dev/null +++ b/src/main/java/com/senmesh/sfile/client/MeshSafeScanResult.java @@ -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 检测结果 + *

+ * 字段说明见 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; +} diff --git a/src/main/java/com/senmesh/sfile/config/MeshSafeProperties.java b/src/main/java/com/senmesh/sfile/config/MeshSafeProperties.java new file mode 100644 index 0000000..a9cf8fc --- /dev/null +++ b/src/main/java/com/senmesh/sfile/config/MeshSafeProperties.java @@ -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; +} diff --git a/src/main/java/com/senmesh/sfile/constant/SecurityScanStatusEnum.java b/src/main/java/com/senmesh/sfile/constant/SecurityScanStatusEnum.java new file mode 100644 index 0000000..be0ae7c --- /dev/null +++ b/src/main/java/com/senmesh/sfile/constant/SecurityScanStatusEnum.java @@ -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); + } +} diff --git a/src/main/java/com/senmesh/sfile/controller/FileSecurityController.java b/src/main/java/com/senmesh/sfile/controller/FileSecurityController.java new file mode 100644 index 0000000..0c5c76b --- /dev/null +++ b/src/main/java/com/senmesh/sfile/controller/FileSecurityController.java @@ -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 enabled() { + return AlitaResult.success(Boolean.TRUE.equals(meshSafeProperties.getEnabled())); + } + + @GetMapping("/detail") + @ApiOperation("查询文件最新检测结果") + public AlitaResult detail( + @ApiParam(value = "文件编码", required = true) @RequestParam String fileCode) { + return AlitaResult.success(fileSecurityService.latest(fileCode)); + } + + @GetMapping("/share_check") + @ApiOperation("分享/传输预检:统计文件及子文件的检测拦截情况") + public AlitaResult shareCheck( + @ApiParam(value = "用户文件ID", required = true) @RequestParam Long userFileId) { + return AlitaResult.success(fileSecurityService.shareCheck(userFileId)); + } + + @PostMapping("/review") + @ApiOperation("人工复核修改检测结果(超管/机构管理员)") + public AlitaResult review( + @Validated @RequestBody FileSecurityReviewDTO reviewDTO) { + return AlitaResult.success(fileSecurityService.review(reviewDTO)); + } + + @PostMapping("/rescan") + @ApiOperation("重新提交安全检测(超管/机构管理员)") + public AlitaResult rescan( + @ApiParam(value = "文件编码", required = true) @RequestParam String fileCode) { + fileSecurityService.rescan(fileCode); + return AlitaResult.success(true); + } +} diff --git a/src/main/java/com/senmesh/sfile/dao/FileSecurityMapper.java b/src/main/java/com/senmesh/sfile/dao/FileSecurityMapper.java new file mode 100644 index 0000000..c9de138 --- /dev/null +++ b/src/main/java/com/senmesh/sfile/dao/FileSecurityMapper.java @@ -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 { + + /** + * 查询检测记录列表(按id倒序) + * + * @param fileCodes 文件编码集合 + * @param fileCode 文件编码 + * @return List + */ + List list(@Param("fileCodes") List fileCodes, + @Param("fileCode") String fileCode); + + /** + * 查询最新检测状态为指定集合的记录(每个文件编码取最新一条) + * + * @param fileCodes 文件编码集合 + * @param scanStatusIn 检测状态集合 + * @return List + */ + List listLatestByStatusIn(@Param("fileCodes") List fileCodes, + @Param("scanStatusIn") List scanStatusIn); + + /** + * 查询检测中的记录(定时轮询用) + * + * @return List + */ + List listScanning(); +} diff --git a/src/main/java/com/senmesh/sfile/dto/ExpandUserFileCreateDTO.java b/src/main/java/com/senmesh/sfile/dto/ExpandUserFileCreateDTO.java index adc021e..d1ff9df 100644 --- a/src/main/java/com/senmesh/sfile/dto/ExpandUserFileCreateDTO.java +++ b/src/main/java/com/senmesh/sfile/dto/ExpandUserFileCreateDTO.java @@ -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 excludeFileIds; } diff --git a/src/main/java/com/senmesh/sfile/dto/FileSecurityDTO.java b/src/main/java/com/senmesh/sfile/dto/FileSecurityDTO.java new file mode 100644 index 0000000..93253c0 --- /dev/null +++ b/src/main/java/com/senmesh/sfile/dto/FileSecurityDTO.java @@ -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; +} diff --git a/src/main/java/com/senmesh/sfile/dto/FileSecurityReviewDTO.java b/src/main/java/com/senmesh/sfile/dto/FileSecurityReviewDTO.java new file mode 100644 index 0000000..be9df65 --- /dev/null +++ b/src/main/java/com/senmesh/sfile/dto/FileSecurityReviewDTO.java @@ -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; +} diff --git a/src/main/java/com/senmesh/sfile/dto/FileSecurityShareCheckDTO.java b/src/main/java/com/senmesh/sfile/dto/FileSecurityShareCheckDTO.java new file mode 100644 index 0000000..65c5bf7 --- /dev/null +++ b/src/main/java/com/senmesh/sfile/dto/FileSecurityShareCheckDTO.java @@ -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 blockedFileNames; + + @ApiModelProperty("检测中文件名列表") + private List scanningFileNames; + + @ApiModelProperty("检测失败文件名列表") + private List failedFileNames; + + @ApiModelProperty("确认跳过时需排除的用户文件ID列表(不通过+检测中,fail-closed时含检测失败)") + private List excludeFileIds; +} diff --git a/src/main/java/com/senmesh/sfile/dto/ShareUserFileCreateDTO.java b/src/main/java/com/senmesh/sfile/dto/ShareUserFileCreateDTO.java index d124d1f..3a0e857 100644 --- a/src/main/java/com/senmesh/sfile/dto/ShareUserFileCreateDTO.java +++ b/src/main/java/com/senmesh/sfile/dto/ShareUserFileCreateDTO.java @@ -32,5 +32,8 @@ public class ShareUserFileCreateDTO extends BaseOperateDTO { @NotNull(message = "有效天数不能为空") private Integer validDays; + @ApiModelProperty("文件夹内存在检测未通过文件时,确认后仅复制检测通过的内容") + private Boolean onlyPassed; + } diff --git a/src/main/java/com/senmesh/sfile/dto/TransferUserFileCreateDTO.java b/src/main/java/com/senmesh/sfile/dto/TransferUserFileCreateDTO.java index 04f44f5..07ae0ec 100644 --- a/src/main/java/com/senmesh/sfile/dto/TransferUserFileCreateDTO.java +++ b/src/main/java/com/senmesh/sfile/dto/TransferUserFileCreateDTO.java @@ -28,5 +28,8 @@ public class TransferUserFileCreateDTO extends BaseOperateDTO { @NotNull(message = "用户文件ID不能为空") private Long userFileId; + @ApiModelProperty("文件夹内存在检测未通过文件时,确认后仅传输检测通过的内容") + private Boolean onlyPassed; + } diff --git a/src/main/java/com/senmesh/sfile/dto/UserFileDTO.java b/src/main/java/com/senmesh/sfile/dto/UserFileDTO.java index 12da04a..80ab283 100644 --- a/src/main/java/com/senmesh/sfile/dto/UserFileDTO.java +++ b/src/main/java/com/senmesh/sfile/dto/UserFileDTO.java @@ -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; } diff --git a/src/main/java/com/senmesh/sfile/entity/FileSecurity.java b/src/main/java/com/senmesh/sfile/entity/FileSecurity.java new file mode 100644 index 0000000..4b87e50 --- /dev/null +++ b/src/main/java/com/senmesh/sfile/entity/FileSecurity.java @@ -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; + +/** + * 文件安全检测记录 + *

+ * 同一文件允许多条检测记录(人工复核/重新检测均新增),业务取最新一条生效。 + */ +@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; +} diff --git a/src/main/java/com/senmesh/sfile/job/MeshSafeScanJob.java b/src/main/java/com/senmesh/sfile/job/MeshSafeScanJob.java new file mode 100644 index 0000000..cacd347 --- /dev/null +++ b/src/main/java/com/senmesh/sfile/job/MeshSafeScanJob.java @@ -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文件安全检测轮询定时任务 + *

+ * 轮询检测中的任务直到拿到结论(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(); + } + } +} diff --git a/src/main/java/com/senmesh/sfile/mapper/FileSecurityMapper.xml b/src/main/java/com/senmesh/sfile/mapper/FileSecurityMapper.xml new file mode 100644 index 0000000..7ec9f5c --- /dev/null +++ b/src/main/java/com/senmesh/sfile/mapper/FileSecurityMapper.xml @@ -0,0 +1,53 @@ + + + + + + WHERE t.deleted = FALSE + + AND t.file_code = #{fileCode} + + + AND t.file_code IN + + #{item} + + + + + + + + + + + diff --git a/src/main/java/com/senmesh/sfile/mapper/UserFileMapper.xml b/src/main/java/com/senmesh/sfile/mapper/UserFileMapper.xml index c07139b..5b5ce50 100644 --- a/src/main/java/com/senmesh/sfile/mapper/UserFileMapper.xml +++ b/src/main/java/com/senmesh/sfile/mapper/UserFileMapper.xml @@ -4,7 +4,10 @@ @@ -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} @@ -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) LEFT JOIN x_user_file parent ON t.parent_id = parent.id AND parent.deleted = FALSE diff --git a/src/main/java/com/senmesh/sfile/service/FileSecurityService.java b/src/main/java/com/senmesh/sfile/service/FileSecurityService.java new file mode 100644 index 0000000..11f9465 --- /dev/null +++ b/src/main/java/com/senmesh/sfile/service/FileSecurityService.java @@ -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 需排除的用户文件ID,空列表表示无需排除 + */ + List 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(); +} diff --git a/src/main/java/com/senmesh/sfile/service/impl/FileSecurityServiceImpl.java b/src/main/java/com/senmesh/sfile/service/impl/FileSecurityServiceImpl.java new file mode 100644 index 0000000..f43271f --- /dev/null +++ b/src/main/java/com/senmesh/sfile/service/impl/FileSecurityServiceImpl.java @@ -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 + *

+ * 对接 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 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 files = userFileService.list(queryDTO); + String pathPrefix = userFile.getPath() + ","; + Map> 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 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> filesByCode, String status, boolean exclude) { + List records = fileSecurityMapper.listLatestByStatusIn( + new ArrayList<>(filesByCode.keySet()), Collections.singletonList(status)); + for (FileSecurityDTO record : records) { + List 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 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 fileNames, String reasonSuffix) { + List 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(); + } +} diff --git a/src/main/java/com/senmesh/sfile/service/impl/NormalUserFileServiceImpl.java b/src/main/java/com/senmesh/sfile/service/impl/NormalUserFileServiceImpl.java index 209976b..0553098 100644 --- a/src/main/java/com/senmesh/sfile/service/impl/NormalUserFileServiceImpl.java +++ b/src/main/java/com/senmesh/sfile/service/impl/NormalUserFileServiceImpl.java @@ -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; } } diff --git a/src/main/java/com/senmesh/sfile/service/impl/ShareUserFileServiceImpl.java b/src/main/java/com/senmesh/sfile/service/impl/ShareUserFileServiceImpl.java index 8e535a8..59c5de5 100644 --- a/src/main/java/com/senmesh/sfile/service/impl/ShareUserFileServiceImpl.java +++ b/src/main/java/com/senmesh/sfile/service/impl/ShareUserFileServiceImpl.java @@ -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 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(); } diff --git a/src/main/java/com/senmesh/sfile/service/impl/TransferFileMonitorServiceImpl.java b/src/main/java/com/senmesh/sfile/service/impl/TransferFileMonitorServiceImpl.java index bc5cc91..57b8174 100644 --- a/src/main/java/com/senmesh/sfile/service/impl/TransferFileMonitorServiceImpl.java +++ b/src/main/java/com/senmesh/sfile/service/impl/TransferFileMonitorServiceImpl.java @@ -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,避免定时任务重复解包。 */ diff --git a/src/main/java/com/senmesh/sfile/service/impl/TransferUserFileServiceImpl.java b/src/main/java/com/senmesh/sfile/service/impl/TransferUserFileServiceImpl.java index beaa9c4..5a0c925 100644 --- a/src/main/java/com/senmesh/sfile/service/impl/TransferUserFileServiceImpl.java +++ b/src/main/java/com/senmesh/sfile/service/impl/TransferUserFileServiceImpl.java @@ -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 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); diff --git a/src/main/java/com/senmesh/sfile/service/impl/UserFileServiceImpl.java b/src/main/java/com/senmesh/sfile/service/impl/UserFileServiceImpl.java index 76d0cb4..1505632 100644 --- a/src/main/java/com/senmesh/sfile/service/impl/UserFileServiceImpl.java +++ b/src/main/java/com/senmesh/sfile/service/impl/UserFileServiceImpl.java @@ -149,10 +149,15 @@ public class UserFileServiceImpl implements UserFileService { .build(); queryDTO.disablePage(); List filesToCopy = this.list(queryDTO); + List excludeFileIds = expandCreateDTO.getExcludeFileIds(); - // 4. 在文件夹下创建文件对象 + // 4. 在文件夹下创建文件对象(跳过需排除的文件,如安全检测未通过确认跳过分享) List 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)