فهرست منبع

fix: 一键群发日志、上传下载安全修复与登录鉴权加固

新增群发点击日志落库与总后台/销售端接口;修复 Redis AutoType、下载需带 Token、上传保留真实文件名;登录相关放行与 Token 解析容错。

Co-authored-by: Cursor <cursoragent@cursor.com>
吴树波 13 ساعت پیش
والد
کامیت
d4bffbe18c
57فایلهای تغییر یافته به همراه1431 افزوده شده و 125 حذف شده
  1. 177 0
      docs/高危漏洞修复功能检查清单.md
  2. 5 1
      fs-ad-api/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  3. 5 1
      fs-ad-new-api/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  4. 46 0
      fs-admin/src/main/java/com/fs/qw/controller/QwMassSendLogController.java
  5. 11 5
      fs-admin/src/main/java/com/fs/web/controller/common/CommonController.java
  6. 7 4
      fs-common-api/src/main/java/com/fs/app/controller/CommonController.java
  7. 5 1
      fs-common-api/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  8. 40 7
      fs-common/src/main/java/com/fs/common/utils/file/FileUtils.java
  9. 5 3
      fs-common/src/main/java/com/fs/common/utils/poi/ExcelUtil.java
  10. 133 20
      fs-common/src/main/java/com/fs/common/utils/security/OssUploadSecurityUtils.java
  11. 10 10
      fs-common/src/main/java/com/fs/common/utils/security/SqlSafeUtils.java
  12. 8 8
      fs-common/src/main/java/com/fs/common/utils/security/UrlSecurityUtils.java
  13. 3 3
      fs-common/src/main/java/com/fs/common/utils/security/XmlSecurityUtils.java
  14. 5 1
      fs-company-app/src/main/java/com/fs/core/config/FastJson2JsonRedisSerializer.java
  15. 20 10
      fs-company/src/main/java/com/fs/company/controller/common/CommonController.java
  16. 63 0
      fs-company/src/main/java/com/fs/company/controller/qw/QwMassSendLogController.java
  17. 5 1
      fs-company/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  18. 3 2
      fs-company/src/main/java/com/fs/framework/config/SecurityConfig.java
  19. 27 13
      fs-company/src/main/java/com/fs/framework/service/TokenService.java
  20. 5 1
      fs-doctor-app/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  21. 5 1
      fs-framework/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  22. 3 2
      fs-framework/src/main/java/com/fs/framework/config/SecurityConfig.java
  23. 5 1
      fs-ipad-task/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  24. 5 1
      fs-live-app/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  25. 5 1
      fs-live-mq/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  26. 5 1
      fs-mp-app/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  27. 5 1
      fs-qw-api-msg/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  28. 5 1
      fs-qw-api/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  29. 5 1
      fs-qw-company-api/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  30. 5 1
      fs-qw-mq/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  31. 5 1
      fs-qw-task/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  32. 5 1
      fs-qw-voice/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  33. 5 1
      fs-qwhook-msg/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  34. 5 1
      fs-qwhook-sop/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  35. 5 1
      fs-qwhook/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  36. 5 1
      fs-redis/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  37. 5 1
      fs-repeat-api/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  38. 76 0
      fs-service/src/main/java/com/fs/sop/domain/QwMassSendLog.java
  39. 18 0
      fs-service/src/main/java/com/fs/sop/mapper/QwMassSendLogMapper.java
  40. 4 2
      fs-service/src/main/java/com/fs/sop/params/SendUserLogsInfoMsgParam.java
  41. 26 0
      fs-service/src/main/java/com/fs/sop/service/IQwMassSendLogService.java
  42. 253 0
      fs-service/src/main/java/com/fs/sop/service/impl/QwMassSendLogServiceImpl.java
  43. 138 4
      fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java
  44. 16 0
      fs-service/src/main/java/com/fs/system/oss/CloudStorageService.java
  45. 3 0
      fs-service/src/main/resources/db/20260805-miniapp-verify-file.sql
  46. 5 0
      fs-service/src/main/resources/db/20260806-qw-mass-send-log-alter.sql
  47. 28 0
      fs-service/src/main/resources/db/20260806-qw-mass-send-log-menu.sql
  48. 24 0
      fs-service/src/main/resources/db/20260806-qw-mass-send-log.sql
  49. 30 0
      fs-service/src/main/resources/db/20260807-company-qw-mass-send-log-menu.sql
  50. 108 0
      fs-service/src/main/resources/mapper/sop/QwMassSendLogMapper.xml
  51. 1 1
      fs-service/src/main/resources/mapper/sop/SopGenerationFailedLogMapper.xml
  52. 5 1
      fs-store/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  53. 10 4
      fs-store/src/main/java/com/fs/store/controller/common/CommonController.java
  54. 5 1
      fs-user-app-ai-chat/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  55. 5 1
      fs-user-app/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  56. 5 1
      fs-watch/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  57. 5 1
      fs-wx-api/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

+ 177 - 0
docs/高危漏洞修复功能检查清单.md

@@ -0,0 +1,177 @@
+# 高危漏洞修复功能检查清单
+
+> **对应批次**:2026-08-06 08:54:02 ~ 08:54:07  
+> **仓库**:`ylrz_his_scrm_java`  
+> **提交数量**:10 个 security/docs fix commit  
+> **作者**:吴树波  
+> **用途**:测试 / 自检勾选;配套报告见同目录 `高危漏洞修复报告.md`  
+> **说明**:本批为高危安全加固(无业务新功能、无本批 SQL 脚本)。`companyUI` / `adminUI` 当日上午无对应 08:54 提交。
+
+---
+
+## 一、本批提交简表
+
+按时间从早到晚:
+
+| # | Hash(短) | 时间 | Message |
+|---|------------|------|---------|
+| 1 | `9d0c68c9` | 08:54:02 | fix(security): 修复SQL注入风险 |
+| 2 | `c9551cfb` | 08:54:03 | fix(security): 修复上传路径穿越与SSRF |
+| 3 | `cbe52d3c` | 08:54:03 | fix(security): 禁用登录后门并收敛匿名放行 |
+| 4 | `49b3cd07` | 08:54:04 | fix(security): 修复会员与名片数据越权 |
+| 5 | `909d908a` | 08:54:04 | fix(security): 修复支付回调与跨租户重置密码 |
+| 6 | `2d0c50a8` | 08:54:06 | fix(security): 修复XXE风险 |
+| 7 | `78ba1b49` | 08:54:06 | fix(security): 关闭Fastjson AutoType并移除硬编码云密钥 |
+| 8 | `1aaf380e` | 08:54:07 | fix(security): 修复命令注入风险 |
+| 9 | `e4e18fbe` | 08:54:07 | fix(security): 禁止html上传并加固剩余上传入口 |
+| 10 | `c1600cf7` | 08:54:07 | docs: 新增高危漏洞修复报告 |
+
+**一句话归纳**:SQL 白名单、上传/SSRF、登录后门与匿名放行收敛、销售会员/名片越权、支付回调与重置密码、XXE、Redis Fastjson AutoType、短信密钥外置、命令注入、禁止 html 上传。
+
+---
+
+## 二、环境前提(测本批前必读)
+
+| # | 前提项 | 说明 |
+|---|--------|------|
+| 1 | 重启后端 | 涉及 `fs-framework` Security、`fs-common` 工具类、多端 Controller;至少重启 admin、user-app、company / company-app、store、qw-api 等实际部署节点 |
+| 2 | 无需本批 SQL | 无表结构变更,不必执行本批 DDL/DML |
+| 3 | 短信密钥 | `tencent.cloud.secret-id` / `secret-key` 须配置(环境变量 `TENCENT_CLOUD_SECRET_ID` / `TENCENT_CLOUD_SECRET_KEY` 或 yml);未配置则发短信报「腾讯云短信密钥未配置」 |
+| 4 | 后门已禁用 | 万能验证码已清空;`/loginByUserId`、`/loginTest` 固定 **403**。请用真实短信,勿再依赖旧后门 |
+| 5 | 清 Token 重登 | 建议清浏览器/App 旧 Token 后重登;匿名已取消的接口未带 Token 会 **401** |
+| 6 | Redis / AutoType | 各端 Fastjson **关闭 AutoType**;若出现反序列化异常,清相关 key 或整库测试 Redis 后重登 |
+| 7 | 销售端鉴权说明 | `fs-company-app` 仍为 `/** permitAll` + `@Login` 拦截(未改为 Spring 强鉴权);销售登录 401 优先查 **拦截器/JWT/网关**,不要只盯 admin 的 `SecurityConfig` |
+
+---
+
+## 三、按模块检查表
+
+检查结果列请填写:`通过` / `失败` / 备注。
+
+### 1. 用户登录 / Token / 匿名放行(`cbe52d3c`)
+
+| 检查目的 | 操作步骤 | 预期结果 | 失败排查 | 检查结果 |
+|----------|----------|----------|----------|----------|
+| 正常短信登录可用 | App/H5:发验证码 → 登录 | 成功拿到 Token,可进首页 | 接口:登录 Controller(如 `/app/...` 短信登录);Redis `sms:code:{手机}`;短信密钥配置;日志是否「验证码错误/过期」 |  |
+| 万能验证码失效 | 用历史固定码(如原 `EdisonX!@#.+-123`)登录 | 失败(验证码错误/过期) | 确认已部署新包;`INTERNAL_LOGIN_CODE` 为空 |  |
+| 验证码一次性 | 同一验证码登录成功后再登一次 | 第二次失败 | Redis 是否仍有 `sms:code:`;成功路径应 `deleteObject` |  |
+| 改密防刷 | 故意错验证码 ≥5 次后改密 | 提示「错误次数过多,请30分钟后再试」 | Redis `sms:fail:{手机}` TTL 30 分钟 |  |
+| 后门禁用 | POST `loginByUserId` / `loginTest` | **403**「该接口已禁用」;日志 warn | 是否打到旧节点;日志关键字 `已禁用的高危接口` |  |
+| 原匿名上传需鉴权 | 无 Token 调 `/common/uploadOSS**`、`uploadWang`、`chat/upload/uploadFile`、`download**`、`unbindQwUserByServerIds` | **401**(需认证) | `SecurityConfig` 是否生效;网关是否绕过 |  |
+| 支付/回调仍可匿名 | 无 Token 访问仍应匿名的支付/短信等路径(配置里保留的) | 能进回调逻辑(签名另验) | 对比 `SecurityConfig` 仍保留的 `.anonymous()` 列表 |  |
+| Swagger/Druid | 生产环境无 Token 访问 swagger/druid | 应 **401**,不可再匿名打开 | 若仍通:可能未重启或另有网关放行 |  |
+
+### 2. 销售端会员 / 名片 / 权限(`49b3cd07`)
+
+| 检查目的 | 操作步骤 | 预期结果 | 失败排查 | 检查结果 |
+|----------|----------|----------|----------|----------|
+| 改自己名下会员 | 销售 A 改归属自己的会员昵称/备注 | 成功 | company-app 日志;`FsUserController`;关系表 `fs_user_company_user` |  |
+| 越权改他人会员 | 销售 A 用 B 的会员 id 调修改/启停 | 「无权操作该会员」 | 是否带正确 Token;`checkUserCompanyUserOwnership` |  |
+| 批量启停 | 批量含他人联系人 id | 整批拒绝 | 同上 |  |
+| 删自己名片 | 删本人 cardId | 成功 | `UserController.delMyCard` |  |
+| 越权删名片 | 删他人 cardId | 「无权删除他人名片」或名片不存在 | 同上 |  |
+| getUserList 需登录 | 无 `@Login` Token 调 `/getUserList` | 按拦截器失败(未登录) | company-app `@Login` 拦截器、Token 头 |  |
+| 改同事状态 | 非管理员 / 跨公司调 `changeUserState` | 「仅管理员」或「无权操作其他公司用户」 | 操作者 `isAdmin`、`companyId` |  |
+
+**销售登录 401 补充**:本批未改 company-app 的 `permitAll`。若登录后业务接口 401:查 JWT 是否写入、拦截器白名单、网关鉴权;对比是否误走了 admin `SecurityConfig` 域名。
+
+### 3. 支付回调与后台重置密码(`909d908a`)
+
+| 检查目的 | 操作步骤 | 预期结果 | 失败排查 | 检查结果 |
+|----------|----------|----------|----------|----------|
+| 微信回调单笔单账 | 各业务前缀订单真实/沙箱支付(inquiry/store/package/course/appvip/integral/product/payment) | 仅对应业务入账一次,无串账 | `WxPayController` 日志;订单状态;未知前缀有 warn |  |
+| 汇付回调验签 | 合法回调 vs 篡改 sign/空 sign | 合法成功;非法返回 `fail`,error 日志「验签失败」 | `/hfPayNotify`;`BasePay.HUIFU_DEFAULT_PUBLIC_KEY`;日志 `汇付回调` |  |
+| 跨租户重置密码 | 无数据权限账号重置他租户用户密码 | 被 `checkUserDataScope` 拦截 | admin `SysUserController.resetPwd`;数据权限配置 |  |
+
+### 4. 上传 / 路径穿越 / 禁 html(`c9551cfb` + `e4e18fbe`)
+
+| 检查目的 | 操作步骤 | 预期结果 | 失败排查 | 检查结果 |
+|----------|----------|----------|----------|----------|
+| 正常图片/文档/音视频上传 | 各端常用上传(jpg/png/pdf/mp4 等) | 成功返回 URL | `OssUploadSecurityUtils`;文件后缀白名单 |  |
+| 拒绝 html/htm/jsp | 上传 `.html`/`.htm` | 失败(不支持类型) | `MimeTypeUtils` 已去掉 html;各 upload 入口 |  |
+| 路径穿越文件名 | 文件名含 `../`、`\` | 拒绝「文件名非法」 | OSS 工具类校验 |  |
+| 达人上传需登录 | 无 Token 调达人 `uploadOSSTalent` | 未登录失败;登录后正常 | `TalentController` `@Login` |  |
+| 知识库文件名 | 上传带特殊字符中文名 | 清洗后可上传,过长截断 | `ChatDatasetFileController` |  |
+
+### 5. SSRF / 声纹 / FastGpt(`c9551cfb`)
+
+| 检查目的 | 操作步骤 | 预期结果 | 失败排查 | 检查结果 |
+|----------|----------|----------|----------|----------|
+| 内网 URL 拒绝 | 声纹/拉 URL 填 `http://127.0.0.1` 或内网 IP | 「禁止访问内网或本地地址」 | `UrlSecurityUtils`;`CompanyUserController` / FastGpt `urlToFile` |  |
+| 合法公网 HTTPS | 正常公网资源 | 仍可用 | DNS/网络;是否误伤内网依赖(若有) |  |
+
+### 6. SQL 注入防护(`9d0c68c9`)
+
+| 检查目的 | 操作步骤 | 预期结果 | 失败排查 | 检查结果 |
+|----------|----------|----------|----------|----------|
+| 订单列表正常排序 | 商城/直播订单列表 asc/desc | 列表正常 | 对应订单列表接口;参数 `sortOrder` |  |
+| 非法 sortOrder | 传 `asc;drop...` 等 | 业务异常「排序方向非法」 | `SqlSafeUtils.validateOrderDirection` |  |
+| 直播趋势 category | 合法:`page_views` 等;非法字符串 | 合法出图;非法拒绝 | `LiveDataServiceImpl`;白名单五列 |  |
+
+### 7. 企微 XML / XXE(`2d0c50a8`)
+
+| 检查目的 | 操作步骤 | 预期结果 | 失败排查 | 检查结果 |
+|----------|----------|----------|----------|----------|
+| 正常企微回调 | 触发正常消息/事件回调 | 解析成功、业务照常 | `QwController`、`XMLParse` |  |
+| (安全)带 DOCTYPE 的恶意 XML | 若可测 | 解析失败,不读外部实体 | 应用日志 Parser 异常 |  |
+
+### 8. Redis Fastjson AutoType / 短信密钥(`78ba1b49`)
+
+| 检查目的 | 操作步骤 | 预期结果 | 失败排查 | 检查结果 |
+|----------|----------|----------|----------|----------|
+| 登录与缓存读写 | 登录、读用户缓存、常见列表 | 无 AutoType 反序列化炸栈 | 异常堆栈含 Fastjson;清 Redis 后重试 |  |
+| 短信发送 | 配置密钥后发验证码 | 成功 | 未配置则明确错误;查 yml/环境变量 |  |
+
+### 9. 音视频 / 直播命令执行(`1aaf380e`)
+
+| 检查目的 | 操作步骤 | 预期结果 | 失败排查 | 检查结果 |
+|----------|----------|----------|----------|----------|
+| 语音转 silk / ffmpeg 相关 | 走原语音消息转换、点播转码、直播推流 | 功能与改前一致 | 服务器是否有 silk/ffmpeg;`ProcessBuilder` 相关 ERROR 日志;路径含空格场景 |  |
+
+---
+
+## 四、相邻批(非本批,勿与 08:54 混验)
+
+若联调覆盖「小程序校验文件 / 飞书 Redis」,请按 **2026-08-05 下午** 单独验证:
+
+| 仓库 | Hash | 时间 | Message |
+|------|------|------|---------|
+| java | `7fd8ce05` | 08-05 **16:24** | 删除不用的配置文件,销售端新增小程序,上传校验文件(含 `uploadMiniappVerifyFile`、`FeiShuService` Redis 索引失效) |
+| companyUI | `2e1aad25` | 08-05 **16:33** | 同上(播放源配置:添加小程序、上传校验文件 UI) |
+
+### 【相邻批】小程序校验文件(08-05,非 08:54)
+
+| 检查目的 | 操作步骤 | 预期结果 | 失败排查 | 检查结果 |
+|----------|----------|----------|----------|----------|
+| 销售后台上传校验文件 | companyUI「播放源配置」→ 上传校验文件 | 原名落盘;列表显示 `verifyFileName` | 后端 `POST /app/common/uploadMiniappVerifyFile`;配置 `fs.miniappVerifyPath`;落盘机磁盘权限;common-api 日志 |  |
+| 微信校验可访问 | 用微信要求的 URL 访问校验文件 | HTTP 200、内容正确 | Nginx/静态目录是否指向同一路径 |  |
+
+### 【相邻批】飞书 Redis 缓存(08-05,非 08:54)
+
+| 检查目的 | 操作步骤 | 预期结果 | 失败排查 | 检查结果 |
+|----------|----------|----------|----------|----------|
+| 创建免授权文档缓存 | 生成飞书直链文档 | Redis 有 `feishu:direct_doc:*`,并登记 `feishu:direct_doc:index:*` | `FeiShuService` 日志「创建并缓存飞书免授权文档」 |  |
+| 一键群发强制刷新 | 补发/强制刷新飞书链接 | 旧缓存删除,`usedIndex=true` 时按索引删;日志「删除完成」 | 日志字段 `usedIndex`/`deleted`;勿再依赖 SCAN;索引缺失时走有界兜底(最多 shard 500) |  |
+
+---
+
+## 五、建议冒烟顺序
+
+1. 配置短信密钥并重启相关后端  
+2. 用户真实短信登录(含后门 403、万能码失效抽检)  
+3. 销售端会员/名片:正常操作 + 越权各一单  
+4. 常用上传(正常类型成功;html 拒绝)  
+5. 一笔支付回调(有环境再测;汇付验签抽检)  
+6. 订单列表排序(正常 asc/desc + 非法 sortOrder)  
+7. (若已发 8/5)小程序校验文件与飞书刷新  
+
+---
+
+## 六、文档与提交说明
+
+| 项 | 说明 |
+|----|------|
+| 报告文档 | `c1600cf7` 新增 `docs/高危漏洞修复报告*.md`,联调时以报告为修复依据 |
+| 本文件 | 功能检查勾选表,可直接打印或复制到测试用例库 |
+
+检查人:__________ 日期:__________ 环境:__________

+ 5 - 1
fs-ad-api/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -28,7 +28,11 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 
     static
     {
-        ParserConfig.getGlobalInstance().setAutoTypeSupport(false); // 安全修复:关闭 AutoType,防止反序列化 RCE
+        // 关闭全局 AutoType,仅白名单放行本系统/必要 JDK 类,保证 Redis LoginUser 会话可读,避免 getInfo 401
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
+        ParserConfig.getGlobalInstance().addAccept("java.util.");
+        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)

+ 5 - 1
fs-ad-new-api/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -28,7 +28,11 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 
     static
     {
-        ParserConfig.getGlobalInstance().setAutoTypeSupport(false); // 安全修复:关闭 AutoType,防止反序列化 RCE
+        // 关闭全局 AutoType,仅白名单放行本系统/必要 JDK 类,保证 Redis LoginUser 会话可读,避免 getInfo 401
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
+        ParserConfig.getGlobalInstance().addAccept("java.util.");
+        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)

+ 46 - 0
fs-admin/src/main/java/com/fs/qw/controller/QwMassSendLogController.java

@@ -0,0 +1,46 @@
+package com.fs.qw.controller;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.sop.domain.QwMassSendLog;
+import com.fs.sop.service.IQwMassSendLogService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 一键群发销售点击行为日志(总后台查看)
+ */
+@RestController
+@RequestMapping("/qw/massSendLog")
+public class QwMassSendLogController extends BaseController {
+
+    @Autowired
+    private IQwMassSendLogService qwMassSendLogService;
+
+    /**
+     * 分页列表
+     */
+    @PreAuthorize("@ss.hasPermi('qw:massSendLog:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(QwMassSendLog query) {
+        startPage();
+        List<QwMassSendLog> list = qwMassSendLogService.selectQwMassSendLogList(query);
+        return getDataTable(list);
+    }
+
+    /**
+     * 详情
+     */
+    @PreAuthorize("@ss.hasPermi('qw:massSendLog:query')")
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id) {
+        return AjaxResult.success(qwMassSendLogService.selectQwMassSendLogById(id));
+    }
+}

+ 11 - 5
fs-admin/src/main/java/com/fs/web/controller/common/CommonController.java

@@ -85,7 +85,7 @@ public class CommonController
     /**
      * 通用下载请求
      *
-     * @param fileName 文件名称
+     * @param fileName 文件名称(纯文件名,禁止路径)
      * @param delete 是否删除
      */
     @GetMapping("common/download")
@@ -93,17 +93,23 @@ public class CommonController
     {
         try
         {
-            if (!FileUtils.checkAllowDownload(fileName))
+            if (!FileUtils.checkAllowDownloadFileName(fileName))
             {
                 throw new Exception(StringUtils.format("文件名称({})非法,不允许下载。 ", fileName));
             }
-            String realFileName = System.currentTimeMillis() + fileName.substring(fileName.indexOf("_") + 1);
-            String filePath = FSConfig.getDownloadPath() + fileName;
+            // 防御性取 basename,避免异常拼接跳出下载目录
+            String safeFileName = StringUtils.substringAfterLast(fileName.replace('\\', '/'), "/");
+            if (StringUtils.isEmpty(safeFileName))
+            {
+                safeFileName = fileName;
+            }
+            String realFileName = System.currentTimeMillis() + safeFileName.substring(safeFileName.indexOf("_") + 1);
+            String filePath = FSConfig.getDownloadPath() + safeFileName;
 
             response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
             FileUtils.setAttachmentResponseHeader(response, realFileName);
             FileUtils.writeBytes(filePath, response.getOutputStream());
-            if (delete)
+            if (Boolean.TRUE.equals(delete))
             {
                 FileUtils.deleteFile(filePath);
             }

+ 7 - 4
fs-common-api/src/main/java/com/fs/app/controller/CommonController.java

@@ -2,6 +2,7 @@ package com.fs.app.controller;
 
 import com.fs.common.config.FSConfig;
 import com.fs.common.core.domain.R;
+import com.fs.common.utils.security.OssUploadSecurityUtils;
 import com.fs.core.utils.OrderCodeUtils;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
@@ -57,10 +58,12 @@ public class CommonController {
         if (StringUtils.isBlank(originalName)) {
             return R.error("文件名不能为空");
         }
-        // 只取文件名,防止路径穿越
-        String safeName = Paths.get(originalName).getFileName().toString().trim();
-        if (StringUtils.isBlank(safeName) || safeName.contains("..")) {
-            return R.error("非法文件名");
+        String safeName;
+        try {
+            // 清洗为安全 basename,保留真实文件名(小程序校验要求精确原名落盘,同名覆盖)
+            safeName = OssUploadSecurityUtils.sanitizeSafeFilename(originalName);
+        } catch (Exception e) {
+            return R.error(e.getMessage() == null ? "非法文件名" : e.getMessage());
         }
 
         try {

+ 5 - 1
fs-common-api/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -28,7 +28,11 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 
     static
     {
-        ParserConfig.getGlobalInstance().setAutoTypeSupport(false); // 安全修复:关闭 AutoType,防止反序列化 RCE
+        // 关闭全局 AutoType,仅白名单放行本系统/必要 JDK 类,保证 Redis LoginUser 会话可读,避免 getInfo 401
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
+        ParserConfig.getGlobalInstance().addAccept("java.util.");
+        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)

+ 40 - 7
fs-common/src/main/java/com/fs/common/utils/file/FileUtils.java

@@ -25,7 +25,10 @@ import com.fs.common.utils.uuid.IdUtils;
  */
 public class FileUtils
 {
-    public static String FILENAME_PATTERN = "[a-zA-Z0-9_\\-\\|\\.\\u4e00-\\u9fa5]+";
+    /**
+     * 安全业务文件名:中英文、数字、空格、._-|(与导出清洗规则对齐,允许中文可读名)
+     */
+    public static String FILENAME_PATTERN = "[a-zA-Z0-9_\\-\\|\\.\\u4e00-\\u9fa5\\s]+";
 
     /**
      * 输出指定文件的byte数组
@@ -133,20 +136,25 @@ public class FileUtils
     }
 
     /**
-     * 检查文件是否可下载
-     * 
-     * @param resource 需要下载的文件
+     * 检查资源是否可下载(兼容 /profile/... 资源路径)。
+     * 禁止路径穿越;扩展名须在白名单内。
+     *
+     * @param resource 需要下载的资源路径或文件名
      * @return true 正常 false 非法
      */
     public static boolean checkAllowDownload(String resource)
     {
-        // 禁止目录上跳级别
-        if (StringUtils.contains(resource, ".."))
+        if (StringUtils.isEmpty(resource))
+        {
+            return false;
+        }
+        // 禁止目录上跳与空字节
+        if (StringUtils.contains(resource, "..") || StringUtils.contains(resource, "\0"))
         {
             return false;
         }
 
-        // 检查允许下载的文件规则
+        // 检查允许下载的文件扩展名白名单
         if (ArrayUtils.contains(MimeTypeUtils.DEFAULT_ALLOWED_EXTENSION, FileTypeUtils.getFileType(resource)))
         {
             return true;
@@ -156,6 +164,31 @@ public class FileUtils
         return false;
     }
 
+    /**
+     * 校验导出类纯文件名是否可下载(含中文业务名)。
+     * 禁止路径分隔与穿越;允许中英文/数字/空格/._-| + 白名单扩展名。
+     *
+     * @param fileName 纯文件名,如 uuid_业务名.xlsx
+     * @return true 正常 false 非法
+     */
+    public static boolean checkAllowDownloadFileName(String fileName)
+    {
+        if (StringUtils.isEmpty(fileName))
+        {
+            return false;
+        }
+        // 纯文件名不得含路径分隔
+        if (StringUtils.contains(fileName, "/") || StringUtils.contains(fileName, "\\"))
+        {
+            return false;
+        }
+        if (!isValidFilename(fileName))
+        {
+            return false;
+        }
+        return checkAllowDownload(fileName);
+    }
+
     /**
      * 下载文件名重新编码
      * 

+ 5 - 3
fs-common/src/main/java/com/fs/common/utils/poi/ExcelUtil.java

@@ -57,6 +57,7 @@ import com.fs.common.utils.file.FileTypeUtils;
 import com.fs.common.utils.file.FileUtils;
 import com.fs.common.utils.file.ImageUtils;
 import com.fs.common.utils.reflect.ReflectUtils;
+import com.fs.common.utils.security.OssUploadSecurityUtils;
 
 /**
  * Excel相关处理
@@ -989,12 +990,13 @@ public class ExcelUtil<T>
     }
 
     /**
-     * 编码文件名
+     * 编码文件名:UUID 前缀 + 清洗后的业务可读名(保留中文),统一 .xlsx
      */
     public String encodingFilename(String filename)
     {
-        filename = UUID.randomUUID().toString() + "_" + filename + ".xlsx";
-        return filename;
+        // 先拼扩展名再清洗,保证后缀合法且去除路径/非法字符
+        String safeName = OssUploadSecurityUtils.sanitizeSafeFilename(filename + ".xlsx");
+        return UUID.randomUUID().toString() + "_" + safeName;
     }
 
     /**

+ 133 - 20
fs-common/src/main/java/com/fs/common/utils/security/OssUploadSecurityUtils.java

@@ -4,55 +4,168 @@ import com.fs.common.exception.file.InvalidExtensionException;
 import com.fs.common.exception.file.OssException;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.file.FileUploadUtils;
+import com.fs.common.utils.uuid.IdUtils;
 import org.springframework.web.multipart.MultipartFile;
 
+import java.io.File;
+
 /**
- * OSS 上传安全校验:后缀白名单,禁止可执行/脚本类型
+ * OSS 涓婁紶瀹夊叏鏍¢獙锛氬悗缂€鐧藉悕鍗曪紝娓呮礂骞朵繚鐣欏彲璇荤湡瀹炴枃浠跺悕锛岄槻璺�緞绌胯秺
  */
 public final class OssUploadSecurityUtils {
 
     /**
-     * 业务允许的安全后缀(不含 html/htm/jsp 等危险类型)
+     * 涓氬姟鍏佽�鐨勫畨鍏ㄥ悗缂€锛堜笉鍚� html/htm/jsp 绛夊嵄闄╃被鍨嬶級
      */
     public static final String[] SAFE_ALLOWED_EXTENSION = {
-            // 图片
+            // 鍥剧墖
             "bmp", "gif", "jpg", "jpeg", "png", "webp",
-            // 文档
+            // 鏂囨。
             "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "pdf",
-            // 压缩
+            // 鍘嬬缉
             "rar", "zip", "gz", "bz2",
-            // 音视频
+            // 闊宠�棰�
             "mp3", "wav", "mp4", "avi", "rmvb"
     };
 
+    /** 瀹夊叏鏂囦欢鍚嶆渶澶ч暱搴︼紙鍚�墿灞曞悕锛� */
+    private static final int MAX_SAFE_FILENAME_LENGTH = 100;
+
     private OssUploadSecurityUtils() {
     }
 
     /**
-     * 校验文件并返回带点后缀,如 .jpg
+     * 鏍¢獙鏂囦欢骞惰繑鍥炲甫鐐瑰悗缂€锛屽� .jpg锛堜笉璐熻矗淇濈暀鍘熸枃浠跺悕锛�
      */
     public static String validateAndGetDotSuffix(MultipartFile file) {
-        if (file == null || file.isEmpty()) {
-            throw new OssException("上传文件不能为空");
+        validateFileBasics(file);
+        // 鍏堟竻娲� basename锛岄伩鍏� Windows 鍏ㄨ矾寰勪腑鐨� \\ 琚��鍒や负闈炴硶
+        sanitizeSafeFilename(file.getOriginalFilename());
+        assertExtensionAllowed(file);
+        String extension = FileUploadUtils.getExtension(file);
+        if (StringUtils.isEmpty(extension)) {
+            throw new OssException("鏃犳硶璇嗗埆鏂囦欢鍚庣紑");
         }
-        String originalFilename = file.getOriginalFilename();
+        return "." + extension.toLowerCase();
+    }
+
+    /**
+     * 鏍¢獙鏂囦欢骞惰繑鍥炴竻娲楀悗鐨勫畨鍏ㄧ湡瀹炴枃浠跺悕锛堜繚鐣� basename + 鎵╁睍鍚嶏紝濡� xxx.docx锛�
+     */
+    public static String validateAndGetSafeFilename(MultipartFile file) {
+        validateFileBasics(file);
+        String safeFilename = sanitizeSafeFilename(file.getOriginalFilename());
+        assertExtensionAllowed(file);
+        String extension = FileUploadUtils.getExtension(file);
+        if (StringUtils.isEmpty(extension)) {
+            throw new OssException("鏃犳硶璇嗗埆鏂囦欢鍚庣紑");
+        }
+        String lowerExt = extension.toLowerCase();
+        // 纭�繚杩斿洖鍚嶄笌鐧藉悕鍗曟墿灞曞悕涓€鑷达紙缁熶竴灏忓啓锛�
+        int dot = safeFilename.lastIndexOf('.');
+        String base = dot > 0 ? safeFilename.substring(0, dot) : safeFilename;
+        if (StringUtils.isEmpty(base)) {
+            base = "file";
+        }
+        return base + "." + lowerExt;
+    }
+
+    /**
+     * 娓呮礂鍘熷�鏂囦欢鍚嶏細浠呬繚鐣� basename锛屽幓闄よ矾寰勫垎闅�/鎺у埗瀛楃�/闈炴硶瀛楃�
+     */
+    public static String sanitizeSafeFilename(String originalFilename) {
         if (StringUtils.isEmpty(originalFilename)) {
-            throw new OssException("文件名不能为空");
+            throw new OssException("鏂囦欢鍚嶄笉鑳戒负绌�");
+        }
+        String name = originalFilename.replace('\\', '/');
+        int slash = name.lastIndexOf('/');
+        if (slash >= 0) {
+            name = name.substring(slash + 1);
+        }
+        name = name.trim();
+        // 鍘婚櫎鎺у埗瀛楃�
+        StringBuilder sb = new StringBuilder(name.length());
+        for (int i = 0; i < name.length(); i++) {
+            char c = name.charAt(i);
+            if (c >= 0x20 && c != 0x7F) {
+                sb.append(c);
+            }
+        }
+        name = sb.toString().trim();
+        if (StringUtils.isEmpty(name) || ".".equals(name) || "..".equals(name) || name.contains("..")) {
+            throw new OssException("鏂囦欢鍚嶉潪娉�");
+        }
+        // 浠呬繚鐣欙細涓�嫳鏂囥€佹暟瀛椼€�.銆乢銆�-銆佺┖鏍�
+        name = name.replaceAll("[^a-zA-Z0-9._\\-\\u4e00-\\u9fa5\\s]", "_");
+        name = name.replaceAll("_+", "_").replaceAll("\\s+", " ").trim();
+        while (name.startsWith(".")) {
+            name = name.substring(1);
+        }
+        if (StringUtils.isEmpty(name)) {
+            throw new OssException("鏂囦欢鍚嶉潪娉�");
         }
-        if (originalFilename.contains("..") || originalFilename.contains("/") || originalFilename.contains("\\")) {
-            throw new OssException("文件名非法");
+        if (name.length() > MAX_SAFE_FILENAME_LENGTH) {
+            int dot = name.lastIndexOf('.');
+            if (dot > 0 && dot < name.length() - 1) {
+                String ext = name.substring(dot);
+                int keep = MAX_SAFE_FILENAME_LENGTH - ext.length();
+                if (keep < 1) {
+                    keep = 1;
+                }
+                name = name.substring(0, keep) + ext;
+            } else {
+                name = name.substring(0, MAX_SAFE_FILENAME_LENGTH);
+            }
         }
+        return name;
+    }
+
+    /**
+     * 浼樺厛浣跨敤瀹夊叏鍘熷悕锛涜嫢鐩�爣鐩�綍宸插瓨鍦ㄥ悓鍚嶅垯鍔犵煭 UUID 鍓嶇紑锛坲uid_鍘熷悕锛�
+     */
+    public static String resolveUniqueFilename(File targetDir, String safeFilename) {
+        if (targetDir == null) {
+            return safeFilename;
+        }
+        File candidate = new File(targetDir, safeFilename);
+        if (!candidate.exists()) {
+            return safeFilename;
+        }
+        String shortId = IdUtils.fastSimpleUUID();
+        if (shortId.length() > 8) {
+            shortId = shortId.substring(0, 8);
+        }
+        return shortId + "_" + safeFilename;
+    }
+
+    /**
+     * 鏋勯€犲�璞″瓨鍌ㄧ敤鍙��鍞�竴鍚嶏細鐭璘UID_瀹夊叏鍘熷悕锛堜粛鍚�湡瀹炴墿灞曞悕锛�
+     * 鐢ㄤ簬鏃犳硶鍏堟煡鍐茬獊鐨� OSS 鍦烘櫙锛岄伩鍏嶅悓鍚嶈�鐩�
+     */
+    public static String buildOssObjectFilename(String safeFilename) {
+        String shortId = IdUtils.fastSimpleUUID();
+        if (shortId.length() > 8) {
+            shortId = shortId.substring(0, 8);
+        }
+        return shortId + "_" + safeFilename;
+    }
+
+    private static void validateFileBasics(MultipartFile file) {
+        if (file == null || file.isEmpty()) {
+            throw new OssException("涓婁紶鏂囦欢涓嶈兘涓虹┖");
+        }
+        if (StringUtils.isEmpty(file.getOriginalFilename())) {
+            throw new OssException("鏂囦欢鍚嶄笉鑳戒负绌�");
+        }
+    }
+
+    private static void assertExtensionAllowed(MultipartFile file) {
         try {
             FileUploadUtils.assertAllowed(file, SAFE_ALLOWED_EXTENSION);
         } catch (InvalidExtensionException e) {
-            throw new OssException("不支持的文件类型: " + FileUploadUtils.getExtension(file));
+            throw new OssException("涓嶆敮鎸佺殑鏂囦欢绫诲瀷: " + FileUploadUtils.getExtension(file));
         } catch (Exception e) {
-            throw new OssException("文件校验失败: " + e.getMessage());
+            throw new OssException("鏂囦欢鏍¢獙澶辫触: " + e.getMessage());
         }
-        String extension = FileUploadUtils.getExtension(file);
-        if (StringUtils.isEmpty(extension)) {
-            throw new OssException("无法识别文件后缀");
-        }
-        return "." + extension.toLowerCase();
     }
 }

+ 10 - 10
fs-common/src/main/java/com/fs/common/utils/security/SqlSafeUtils.java

@@ -9,13 +9,13 @@ import java.util.Locale;
 import java.util.Set;
 
 /**
- * SQL 动态片段安全校验工具(排序方向、列名白名单)
+ * SQL 鍔ㄦ€佺墖娈靛畨鍏ㄦ牎楠屽伐鍏凤紙鎺掑簭鏂瑰悜銆佸垪鍚嶇櫧鍚嶅崟锛�
  */
 public final class SqlSafeUtils {
 
     private static final Set<String> ORDER_DIRECTIONS = new HashSet<String>(Arrays.asList("asc", "desc"));
 
-    /** 直播趋势统计允许的 category 列名 */
+    /** 鐩存挱瓒嬪娍缁熻�鍏佽�鐨� category 鍒楀悕 */
     private static final Set<String> LIVE_CATEGORY_COLUMNS = new HashSet<String>(Arrays.asList(
             "page_views", "unique_visitors", "total_views", "unique_viewers", "streams"
     ));
@@ -24,7 +24,7 @@ public final class SqlSafeUtils {
     }
 
     /**
-     * 校验排序方向,仅允许 asc/desc;空值直接返回
+     * 鏍¢獙鎺掑簭鏂瑰悜锛屼粎鍏佽� asc/desc锛涚┖鍊肩洿鎺ヨ繑鍥�
      */
     public static String validateOrderDirection(String order) {
         if (StringUtils.isEmpty(order)) {
@@ -32,35 +32,35 @@ public final class SqlSafeUtils {
         }
         String lower = order.trim().toLowerCase(Locale.ROOT);
         if (!ORDER_DIRECTIONS.contains(lower)) {
-            throw new ServiceException("排序方向非法,仅支持 asc/desc");
+            throw new ServiceException("鎺掑簭鏂瑰悜闈炴硶锛屼粎鏀�寔 asc/desc");
         }
         return lower;
     }
 
     /**
-     * 校验直播趋势 category,防止 ${category} SQL 注入
+     * 鏍¢獙鐩存挱瓒嬪娍 category锛岄槻姝� ${category} SQL 娉ㄥ叆
      */
     public static String validateLiveCategory(String category) {
         if (StringUtils.isEmpty(category)) {
-            throw new ServiceException("category 不能为空");
+            throw new ServiceException("category 涓嶈兘涓虹┖");
         }
         String value = category.trim();
         if (!LIVE_CATEGORY_COLUMNS.contains(value)) {
-            throw new ServiceException("非法的 category 参数");
+            throw new ServiceException("闈炴硶鐨� category 鍙傛暟");
         }
         return value;
     }
 
     /**
-     * 校验排序字段名:仅允许字母数字下划线,且长度受限
+     * 鏍¢獙鎺掑簭瀛楁�鍚嶏細浠呭厑璁稿瓧姣嶆暟瀛椾笅鍒掔嚎锛屼笖闀垮害鍙楅檺
      */
     public static String validateColumnName(String column) {
         if (StringUtils.isEmpty(column)) {
-            throw new ServiceException("排序字段不能为空");
+            throw new ServiceException("鎺掑簭瀛楁�涓嶈兘涓虹┖");
         }
         String value = column.trim();
         if (value.length() > 64 || !value.matches("^[a-zA-Z0-9_]+$")) {
-            throw new ServiceException("排序字段名非法: " + column);
+            throw new ServiceException("鎺掑簭瀛楁�鍚嶉潪娉�: " + column);
         }
         return value;
     }

+ 8 - 8
fs-common/src/main/java/com/fs/common/utils/security/UrlSecurityUtils.java

@@ -11,7 +11,7 @@ import java.util.Locale;
 import java.util.Set;
 
 /**
- * 对外 URL 访问安全校验,防止 SSRF
+ * 瀵瑰� URL 璁块棶瀹夊叏鏍¢獙锛岄槻姝� SSRF
  */
 public final class UrlSecurityUtils {
 
@@ -21,21 +21,21 @@ public final class UrlSecurityUtils {
     }
 
     /**
-     * 校验 URL:协议白名单 + 拒绝内网/回环/链路本地地址
+     * 鏍¢獙 URL锛氬崗璁�櫧鍚嶅崟 + 鎷掔粷鍐呯綉/鍥炵幆/閾捐矾鏈�湴鍦板潃
      */
     public static void validatePublicHttpUrl(String urlStr) {
         if (StringUtils.isEmpty(urlStr)) {
-            throw new ServiceException("URL 不能为空");
+            throw new ServiceException("URL 涓嶈兘涓虹┖");
         }
         try {
             URL url = new URL(urlStr);
             String protocol = url.getProtocol() == null ? "" : url.getProtocol().toLowerCase(Locale.ROOT);
             if (!ALLOWED_PROTOCOLS.contains(protocol)) {
-                throw new ServiceException("仅允许 HTTP/HTTPS 协议");
+                throw new ServiceException("浠呭厑璁� HTTP/HTTPS 鍗忚�");
             }
             String host = url.getHost();
             if (StringUtils.isEmpty(host)) {
-                throw new ServiceException("URL 主机非法");
+                throw new ServiceException("URL 涓绘満闈炴硶");
             }
             InetAddress address = InetAddress.getByName(host);
             if (address.isAnyLocalAddress()
@@ -43,16 +43,16 @@ public final class UrlSecurityUtils {
                     || address.isLinkLocalAddress()
                     || address.isSiteLocalAddress()
                     || address.isMulticastAddress()) {
-                throw new ServiceException("禁止访问内网或本地地址");
+                throw new ServiceException("绂佹�璁块棶鍐呯綉鎴栨湰鍦板湴鍧€");
             }
             String ip = address.getHostAddress();
             if (ip.startsWith("169.254.") || "0.0.0.0".equals(ip) || "::1".equals(ip)) {
-                throw new ServiceException("禁止访问内网或本地地址");
+                throw new ServiceException("绂佹�璁块棶鍐呯綉鎴栨湰鍦板湴鍧€");
             }
         } catch (ServiceException e) {
             throw e;
         } catch (Exception e) {
-            throw new ServiceException("非法 URL: " + e.getMessage());
+            throw new ServiceException("闈炴硶 URL: " + e.getMessage());
         }
     }
 }

+ 3 - 3
fs-common/src/main/java/com/fs/common/utils/security/XmlSecurityUtils.java

@@ -5,7 +5,7 @@ import javax.xml.parsers.DocumentBuilderFactory;
 import javax.xml.parsers.ParserConfigurationException;
 
 /**
- * XML 解析安全工厂:禁用 DTD/外部实体,防止 XXE
+ * XML 瑙f瀽瀹夊叏宸ュ巶锛氱�鐢� DTD/澶栭儴瀹炰綋锛岄槻姝� XXE
  */
 public final class XmlSecurityUtils {
 
@@ -13,11 +13,11 @@ public final class XmlSecurityUtils {
     }
 
     /**
-     * 创建已禁用外部实体的 DocumentBuilder
+     * 鍒涘缓宸茬�鐢ㄥ�閮ㄥ疄浣撶殑 DocumentBuilder
      */
     public static DocumentBuilder newSafeDocumentBuilder() throws ParserConfigurationException {
         DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
-        // 禁止 DOCTYPE,阻断绝大多数 XXE
+        // 绂佹� DOCTYPE锛岄樆鏂�粷澶у�鏁� XXE
         dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
         dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
         dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);

+ 5 - 1
fs-company-app/src/main/java/com/fs/core/config/FastJson2JsonRedisSerializer.java

@@ -28,7 +28,11 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 
     static
     {
-        ParserConfig.getGlobalInstance().setAutoTypeSupport(false); // 安全修复:关闭 AutoType,防止反序列化 RCE
+        // 关闭全局 AutoType,仅白名单放行本系统/必要 JDK 类,保证 Redis LoginUser 会话可读,避免 getInfo 401
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
+        ParserConfig.getGlobalInstance().addAccept("java.util.");
+        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)

+ 20 - 10
fs-company/src/main/java/com/fs/company/controller/common/CommonController.java

@@ -143,17 +143,23 @@ public class CommonController
     {
         try
         {
-            if (!FileUtils.checkAllowDownload(fileName))
+            if (!FileUtils.checkAllowDownloadFileName(fileName))
             {
                 throw new Exception(StringUtils.format("文件名称({})非法,不允许下载。 ", fileName));
             }
-            String realFileName = System.currentTimeMillis() + fileName.substring(fileName.indexOf("_") + 1);
-            String filePath = FSConfig.getDownloadPath() + fileName;
+            // 防御性取 basename,避免异常拼接跳出下载目录
+            String safeFileName = StringUtils.substringAfterLast(fileName.replace('\\', '/'), "/");
+            if (StringUtils.isEmpty(safeFileName))
+            {
+                safeFileName = fileName;
+            }
+            String realFileName = System.currentTimeMillis() + safeFileName.substring(safeFileName.indexOf("_") + 1);
+            String filePath = FSConfig.getDownloadPath() + safeFileName;
 
             response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
             FileUtils.setAttachmentResponseHeader(response, realFileName);
             FileUtils.writeBytes(filePath, response.getOutputStream());
-            if (delete)
+            if (Boolean.TRUE.equals(delete))
             {
                 FileUtils.deleteFile(filePath);
             }
@@ -243,13 +249,17 @@ public class CommonController
         {
             throw new OssException("上传文件不能为空");
         }
-        // 上传文件
-        String suffix = OssUploadSecurityUtils.validateAndGetDotSuffix(file);
-        // 禁止使用原始文件名构造 OSS 路径,避免路径穿越
+        // 校验后缀白名单,并保留清洗后的真实文件名(如 xxx.docx)
+        String safeFileName = OssUploadSecurityUtils.validateAndGetSafeFilename(file);
         CloudStorageService storage = OSSFactory.build();
-        String url = storage.uploadSuffix(file.getBytes(), suffix);
-        String fileName = file.getOriginalFilename();
-        return R.ok().put("url",url).put("fileName",fileName);
+        // 对象键:日期目录 + basename_时间戳.扩展名(防同日覆盖,仍可读且含真实扩展名)
+        int dot = safeFileName.lastIndexOf('.');
+        String baseName = dot > 0 ? safeFileName.substring(0, dot) : safeFileName;
+        String ext = dot > 0 ? safeFileName.substring(dot) : "";
+        String objectFileName = baseName + "_" + System.currentTimeMillis() + ext;
+        String objectKey = storage.getPathWithSafeFilename(null, objectFileName);
+        String url = storage.upload(file.getBytes(), objectKey);
+        return R.ok().put("url", url).put("fileName", safeFileName);
     }
 
     @PostMapping("common/uploadOSSByHOOKImage")

+ 63 - 0
fs-company/src/main/java/com/fs/company/controller/qw/QwMassSendLogController.java

@@ -0,0 +1,63 @@
+package com.fs.company.controller.qw;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.utils.ServletUtils;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import com.fs.sop.domain.QwMassSendLog;
+import com.fs.sop.service.IQwMassSendLogService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 一键群发销售点击行为日志(销售公司端)
+ */
+@RestController
+@RequestMapping("/qw/massSendLog")
+public class QwMassSendLogController extends BaseController {
+
+    @Autowired
+    private IQwMassSendLogService qwMassSendLogService;
+
+    @Autowired
+    private TokenService tokenService;
+
+    /**
+     * 分页列表:强制按当前登录公司过滤,忽略前端传入的 companyId
+     */
+    @PreAuthorize("@ss.hasPermi('qw:massSendLog:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(QwMassSendLog query) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getCompany().getCompanyId();
+        // 忽略前端传入的 companyId,防越权
+        query.setCompanyId(companyId);
+        startPage();
+        List<QwMassSendLog> list = qwMassSendLogService.selectQwMassSendLogList(query);
+        return getDataTable(list);
+    }
+
+    /**
+     * 详情:仅允许查看本公司数据
+     */
+    @PreAuthorize("@ss.hasPermi('qw:massSendLog:query')")
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getCompany().getCompanyId();
+        QwMassSendLog row = qwMassSendLogService.selectQwMassSendLogById(id);
+        if (row == null || !Objects.equals(row.getCompanyId(), companyId)) {
+            return AjaxResult.error("无权查看或记录不存在");
+        }
+        return AjaxResult.success(row);
+    }
+}

+ 5 - 1
fs-company/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -28,7 +28,11 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 
     static
     {
-        ParserConfig.getGlobalInstance().setAutoTypeSupport(false); // 安全修复:关闭 AutoType,防止反序列化 RCE
+        // 关闭全局 AutoType,仅白名单放行本系统/必要 JDK 类,保证 Redis LoginUser 会话可读,避免 getInfo 401
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
+        ParserConfig.getGlobalInstance().addAccept("java.util.");
+        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)

+ 3 - 2
fs-company/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -99,8 +99,9 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                 .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                 // 过滤请求
                 .authorizeRequests()
-                // 对于登录login 注册register 验证码captchaImage 允许匿名访问
-                .antMatchers("/chat/upload/**","/login", "/register", "/captchaImage","/checkIsNeedCheck","/getWechatQrCode","/checkWechatScan","/callback").anonymous()
+                // 登录相关接口用 permitAll:带旧 Token 时 anonymous 会拒访;/error 放行避免异常转发后二次 401
+                .antMatchers("/chat/upload/**", "/login", "/register", "/captchaImage", "/checkIsNeedCheck", "/getWechatQrCode", "/checkWechatScan", "/callback").permitAll()
+                .antMatchers("/error").permitAll()
                 .antMatchers(
                         HttpMethod.GET,
                         "/",

+ 27 - 13
fs-company/src/main/java/com/fs/framework/service/TokenService.java

@@ -61,22 +61,36 @@ public class TokenService
         String token = getToken(request);
         if (StringUtils.isNotEmpty(token))
         {
-            Claims claims = parseToken(token);
-            // 解析对应的权限以及用户信息
-            String uuid = (String) claims.get(Constants.COMPANY_LOGIN_USER_KEY);
-            String userKey = getTokenKey(uuid);
-            LoginUser user = redisCache.getCacheObject(userKey);
-            return user;
+            try
+            {
+                Claims claims = parseToken(token);
+                // 解析对应的权限以及用户信息
+                String uuid = (String) claims.get(Constants.COMPANY_LOGIN_USER_KEY);
+                String userKey = getTokenKey(uuid);
+                LoginUser user = redisCache.getCacheObject(userKey);
+                return user;
+            }
+            catch (Exception e)
+            {
+                // 令牌无效/过期时不抛异常,避免过滤器异常转发 /error 被二次 401 掩盖真实原因
+            }
         }
-        token=getUrlToken(request);
+        token = getUrlToken(request);
         if (StringUtils.isNotEmpty(token))
         {
-            Claims claims = parseToken(token);
-            // 解析对应的权限以及用户信息
-            String uuid = (String) claims.get(Constants.COMPANY_LOGIN_USER_KEY);
-            String userKey = getTokenKey(uuid);
-            LoginUser user = redisCache.getCacheObject(userKey);
-            return user;
+            try
+            {
+                Claims claims = parseToken(token);
+                // 解析对应的权限以及用户信息
+                String uuid = (String) claims.get(Constants.COMPANY_LOGIN_USER_KEY);
+                String userKey = getTokenKey(uuid);
+                LoginUser user = redisCache.getCacheObject(userKey);
+                return user;
+            }
+            catch (Exception e)
+            {
+                // 同上:URL 令牌解析失败时按未登录处理
+            }
         }
 
         return null;

+ 5 - 1
fs-doctor-app/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -28,7 +28,11 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 
     static
     {
-        ParserConfig.getGlobalInstance().setAutoTypeSupport(false); // 安全修复:关闭 AutoType,防止反序列化 RCE
+        // 关闭全局 AutoType,仅白名单放行本系统/必要 JDK 类,保证 Redis LoginUser 会话可读,避免 getInfo 401
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
+        ParserConfig.getGlobalInstance().addAccept("java.util.");
+        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)

+ 5 - 1
fs-framework/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -27,7 +27,11 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 
     static
     {
-        ParserConfig.getGlobalInstance().setAutoTypeSupport(false); // 安全修复:关闭 AutoType,防止反序列化 RCE
+        // 关闭全局 AutoType,仅白名单放行本系统/必要 JDK 类,保证 Redis LoginUser 会话可读,避免 getInfo 401
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
+        ParserConfig.getGlobalInstance().addAccept("java.util.");
+        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)

+ 3 - 2
fs-framework/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -96,8 +96,9 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                 .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                 // 过滤请求
                 .authorizeRequests()
-                // 对于登录login 注册register 验证码captchaImage 允许匿名访问
-                .antMatchers("/login", "/register", "/captchaImage","/getWechatQrCode","/checkWechatScan","/callback","/checkIsNeedCheck").anonymous()
+                // 登录相关用 permitAll:带旧 Token 时 anonymous 会拒访;/error 放行避免异常转发后二次 401
+                .antMatchers("/login", "/register", "/captchaImage", "/getWechatQrCode", "/checkWechatScan", "/callback", "/checkIsNeedCheck").permitAll()
+                .antMatchers("/error").permitAll()
                 .antMatchers("/app/common/test").anonymous()
                 .antMatchers("/ad/adDyApi/authorized").anonymous()
                 .antMatchers(

+ 5 - 1
fs-ipad-task/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -28,7 +28,11 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 
     static
     {
-        ParserConfig.getGlobalInstance().setAutoTypeSupport(false); // 安全修复:关闭 AutoType,防止反序列化 RCE
+        // 关闭全局 AutoType,仅白名单放行本系统/必要 JDK 类,保证 Redis LoginUser 会话可读,避免 getInfo 401
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
+        ParserConfig.getGlobalInstance().addAccept("java.util.");
+        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)

+ 5 - 1
fs-live-app/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -28,7 +28,11 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 
     static
     {
-        ParserConfig.getGlobalInstance().setAutoTypeSupport(false); // 安全修复:关闭 AutoType,防止反序列化 RCE
+        // 关闭全局 AutoType,仅白名单放行本系统/必要 JDK 类,保证 Redis LoginUser 会话可读,避免 getInfo 401
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
+        ParserConfig.getGlobalInstance().addAccept("java.util.");
+        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)

+ 5 - 1
fs-live-mq/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -28,7 +28,11 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 
     static
     {
-        ParserConfig.getGlobalInstance().setAutoTypeSupport(false); // 安全修复:关闭 AutoType,防止反序列化 RCE
+        // 关闭全局 AutoType,仅白名单放行本系统/必要 JDK 类,保证 Redis LoginUser 会话可读,避免 getInfo 401
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
+        ParserConfig.getGlobalInstance().addAccept("java.util.");
+        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)

+ 5 - 1
fs-mp-app/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -28,7 +28,11 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 
     static
     {
-        ParserConfig.getGlobalInstance().setAutoTypeSupport(false); // 安全修复:关闭 AutoType,防止反序列化 RCE
+        // 关闭全局 AutoType,仅白名单放行本系统/必要 JDK 类,保证 Redis LoginUser 会话可读,避免 getInfo 401
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
+        ParserConfig.getGlobalInstance().addAccept("java.util.");
+        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)

+ 5 - 1
fs-qw-api-msg/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -28,7 +28,11 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 
     static
     {
-        ParserConfig.getGlobalInstance().setAutoTypeSupport(false); // 安全修复:关闭 AutoType,防止反序列化 RCE
+        // 关闭全局 AutoType,仅白名单放行本系统/必要 JDK 类,保证 Redis LoginUser 会话可读,避免 getInfo 401
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
+        ParserConfig.getGlobalInstance().addAccept("java.util.");
+        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)

+ 5 - 1
fs-qw-api/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -28,7 +28,11 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 
     static
     {
-        ParserConfig.getGlobalInstance().setAutoTypeSupport(false); // 安全修复:关闭 AutoType,防止反序列化 RCE
+        // 关闭全局 AutoType,仅白名单放行本系统/必要 JDK 类,保证 Redis LoginUser 会话可读,避免 getInfo 401
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
+        ParserConfig.getGlobalInstance().addAccept("java.util.");
+        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)

+ 5 - 1
fs-qw-company-api/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -28,7 +28,11 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 
     static
     {
-        ParserConfig.getGlobalInstance().setAutoTypeSupport(false); // 安全修复:关闭 AutoType,防止反序列化 RCE
+        // 关闭全局 AutoType,仅白名单放行本系统/必要 JDK 类,保证 Redis LoginUser 会话可读,避免 getInfo 401
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
+        ParserConfig.getGlobalInstance().addAccept("java.util.");
+        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)

+ 5 - 1
fs-qw-mq/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -28,7 +28,11 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 
     static
     {
-        ParserConfig.getGlobalInstance().setAutoTypeSupport(false); // 安全修复:关闭 AutoType,防止反序列化 RCE
+        // 关闭全局 AutoType,仅白名单放行本系统/必要 JDK 类,保证 Redis LoginUser 会话可读,避免 getInfo 401
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
+        ParserConfig.getGlobalInstance().addAccept("java.util.");
+        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)

+ 5 - 1
fs-qw-task/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -28,7 +28,11 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 
     static
     {
-        ParserConfig.getGlobalInstance().setAutoTypeSupport(false); // 安全修复:关闭 AutoType,防止反序列化 RCE
+        // 关闭全局 AutoType,仅白名单放行本系统/必要 JDK 类,保证 Redis LoginUser 会话可读,避免 getInfo 401
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
+        ParserConfig.getGlobalInstance().addAccept("java.util.");
+        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)

+ 5 - 1
fs-qw-voice/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -28,7 +28,11 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 
     static
     {
-        ParserConfig.getGlobalInstance().setAutoTypeSupport(false); // 安全修复:关闭 AutoType,防止反序列化 RCE
+        // 关闭全局 AutoType,仅白名单放行本系统/必要 JDK 类,保证 Redis LoginUser 会话可读,避免 getInfo 401
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
+        ParserConfig.getGlobalInstance().addAccept("java.util.");
+        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)

+ 5 - 1
fs-qwhook-msg/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -28,7 +28,11 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 
     static
     {
-        ParserConfig.getGlobalInstance().setAutoTypeSupport(false); // 安全修复:关闭 AutoType,防止反序列化 RCE
+        // 关闭全局 AutoType,仅白名单放行本系统/必要 JDK 类,保证 Redis LoginUser 会话可读,避免 getInfo 401
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
+        ParserConfig.getGlobalInstance().addAccept("java.util.");
+        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)

+ 5 - 1
fs-qwhook-sop/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -28,7 +28,11 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 
     static
     {
-        ParserConfig.getGlobalInstance().setAutoTypeSupport(false); // 安全修复:关闭 AutoType,防止反序列化 RCE
+        // 关闭全局 AutoType,仅白名单放行本系统/必要 JDK 类,保证 Redis LoginUser 会话可读,避免 getInfo 401
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
+        ParserConfig.getGlobalInstance().addAccept("java.util.");
+        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)

+ 5 - 1
fs-qwhook/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -28,7 +28,11 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 
     static
     {
-        ParserConfig.getGlobalInstance().setAutoTypeSupport(false); // 安全修复:关闭 AutoType,防止反序列化 RCE
+        // 关闭全局 AutoType,仅白名单放行本系统/必要 JDK 类,保证 Redis LoginUser 会话可读,避免 getInfo 401
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
+        ParserConfig.getGlobalInstance().addAccept("java.util.");
+        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)

+ 5 - 1
fs-redis/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -28,7 +28,11 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 
     static
     {
-        ParserConfig.getGlobalInstance().setAutoTypeSupport(false); // 安全修复:关闭 AutoType,防止反序列化 RCE
+        // 关闭全局 AutoType,仅白名单放行本系统/必要 JDK 类,保证 Redis LoginUser 会话可读,避免 getInfo 401
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
+        ParserConfig.getGlobalInstance().addAccept("java.util.");
+        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)

+ 5 - 1
fs-repeat-api/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -28,7 +28,11 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 
     static
     {
-        ParserConfig.getGlobalInstance().setAutoTypeSupport(false); // 安全修复:关闭 AutoType,防止反序列化 RCE
+        // 关闭全局 AutoType,仅白名单放行本系统/必要 JDK 类,保证 Redis LoginUser 会话可读,避免 getInfo 401
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
+        ParserConfig.getGlobalInstance().addAccept("java.util.");
+        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)

+ 76 - 0
fs-service/src/main/java/com/fs/sop/domain/QwMassSendLog.java

@@ -0,0 +1,76 @@
+package com.fs.sop.domain;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 一键群发销售点击行为日志 qw_mass_send_log(主库 fs_his)
+ */
+@Data
+public class QwMassSendLog implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 主键自增 */
+    private Long id;
+
+    /** 公司ID */
+    private Long companyId;
+
+    /** 公司名称 */
+    private String companyName;
+
+    /** 销售用户ID */
+    private Long companyUserId;
+
+    /** 销售昵称 */
+    private String companyUserName;
+
+    /** 发送客户人数 */
+    private Integer customerCount;
+
+    /** 发送内容 JSON */
+    private String contentJson;
+
+    /** 课程ID */
+    private Long courseId;
+
+    /** 课程名称 */
+    private String courseName;
+
+    /** 课节ID */
+    private Long videoId;
+
+    /** 课节/小结名称 */
+    private String videoName;
+
+    /** 企微企业ID */
+    private String corpId;
+
+    /** SOP主键 */
+    private String sopId;
+
+    /** SOP名称 */
+    private String sopName;
+
+    /**
+     * 来源:1=sendUserLogsInfoMsg,2=sendUserLogsInfoMsgType,3=sendUserLogsInfoMsgSop
+     */
+    private Integer sendSource;
+
+    /** 创建人 */
+    private String createBy;
+
+    /** 创建时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    /** 查询:开始时间 */
+    private String sTime;
+
+    /** 查询:结束时间 */
+    private String eTime;
+}

+ 18 - 0
fs-service/src/main/java/com/fs/sop/mapper/QwMassSendLogMapper.java

@@ -0,0 +1,18 @@
+package com.fs.sop.mapper;
+
+import com.fs.sop.domain.QwMassSendLog;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 一键群发销售点击行为日志 Mapper(默认主库)
+ */
+public interface QwMassSendLogMapper {
+
+    int insertQwMassSendLog(QwMassSendLog log);
+
+    QwMassSendLog selectQwMassSendLogById(@Param("id") Long id);
+
+    List<QwMassSendLog> selectQwMassSendLogList(QwMassSendLog query);
+}

+ 4 - 2
fs-service/src/main/java/com/fs/sop/params/SendUserLogsInfoMsgParam.java

@@ -5,8 +5,8 @@ import lombok.Data;
 import java.util.List;
 
 /**
-* 一键群发
-*/
+ * 一键群发
+ */
 @Data
 public class SendUserLogsInfoMsgParam {
 
@@ -19,6 +19,8 @@ public class SendUserLogsInfoMsgParam {
     private Integer sendType;
     private String setting;
     private String sopId;
+    /** SOP名称(可由前端传入,未传则后端按 sopId 查 SOP 库) */
+    private String sopName;
     private String userIdParam;
     private String startTime;
     private String corpId;

+ 26 - 0
fs-service/src/main/java/com/fs/sop/service/IQwMassSendLogService.java

@@ -0,0 +1,26 @@
+package com.fs.sop.service;
+
+import com.fs.sop.domain.QwMassSendLog;
+import com.fs.sop.params.SendUserLogsInfoMsgParam;
+
+import java.util.List;
+
+/**
+ * 一键群发销售点击行为日志
+ */
+public interface IQwMassSendLogService {
+
+    /**
+     * 记录一次一键群发点击行为(失败不影响主流程,由实现类吞掉异常)
+     *
+     * @param param         群发参数
+     * @param customerCount 本次客户人数
+     * @param sendSource    1=msg 2=msgType 3=msgSop
+     * @param companyIdHint 公司ID提示(可为空)
+     */
+    void saveMassSendClickLog(SendUserLogsInfoMsgParam param, int customerCount, int sendSource, Long companyIdHint);
+
+    QwMassSendLog selectQwMassSendLogById(Long id);
+
+    List<QwMassSendLog> selectQwMassSendLogList(QwMassSendLog query);
+}

+ 253 - 0
fs-service/src/main/java/com/fs/sop/service/impl/QwMassSendLogServiceImpl.java

@@ -0,0 +1,253 @@
+package com.fs.sop.service.impl;
+
+import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.Company;
+import com.fs.company.mapper.CompanyMapper;
+import com.fs.course.domain.FsUserCourse;
+import com.fs.course.domain.FsUserCourseVideo;
+import com.fs.course.mapper.FsUserCourseMapper;
+import com.fs.course.mapper.FsUserCourseVideoMapper;
+import com.fs.qw.cache.QwSopCacheService;
+import com.fs.sop.domain.QwMassSendLog;
+import com.fs.sop.domain.QwSop;
+import com.fs.sop.mapper.QwMassSendLogMapper;
+import com.fs.sop.mapper.QwSopMapper;
+import com.fs.sop.params.SendUserLogsInfoMsgParam;
+import com.fs.sop.service.IQwMassSendLogService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Service;
+
+import java.lang.reflect.Method;
+import java.util.List;
+
+/**
+ * 一键群发销售点击行为日志(默认主库 fs_his,无需切 SOP)
+ */
+@Service
+public class QwMassSendLogServiceImpl implements IQwMassSendLogService {
+
+    private static final Logger log = LoggerFactory.getLogger(QwMassSendLogServiceImpl.class);
+
+    @Autowired
+    private QwMassSendLogMapper qwMassSendLogMapper;
+
+    @Autowired
+    private CompanyMapper companyMapper;
+
+    @Autowired
+    private FsUserCourseMapper fsUserCourseMapper;
+
+    @Autowired
+    private FsUserCourseVideoMapper fsUserCourseVideoMapper;
+
+    @Autowired
+    private QwSopMapper qwSopMapper;
+
+    /**
+     * 与主库统计同口径:内部调用 QwSopMapper.selectQwSopNameBySopId(@DataSource(SOP))
+     */
+    @Autowired
+    private QwSopCacheService qwSopCacheService;
+
+    @Override
+    public void saveMassSendClickLog(SendUserLogsInfoMsgParam param, int customerCount, int sendSource, Long companyIdHint) {
+        try {
+            QwMassSendLog row = new QwMassSendLog();
+            row.setCustomerCount(customerCount < 0 ? 0 : customerCount);
+            row.setSendSource(sendSource);
+            if (param != null) {
+                row.setContentJson(param.getSetting());
+                row.setCorpId(param.getCorpId());
+                row.setSopId(resolveSopId(param));
+                if (param.getSopName() != null && !param.getSopName().isEmpty()) {
+                    row.setSopName(param.getSopName());
+                }
+                if (param.getCourseId() != null) {
+                    row.setCourseId(param.getCourseId().longValue());
+                }
+                if (param.getVideoId() != null) {
+                    row.setVideoId(param.getVideoId().longValue());
+                }
+            }
+            fillOperator(row);
+            fillCompany(row, companyIdHint);
+            fillCourseName(row);
+            fillVideoName(row);
+            fillSopName(row);
+            qwMassSendLogMapper.insertQwMassSendLog(row);
+        } catch (Exception e) {
+            log.error("记录一键群发点击日志失败,不影响群发主流程", e);
+        }
+    }
+
+    @Override
+    public QwMassSendLog selectQwMassSendLogById(Long id) {
+        return qwMassSendLogMapper.selectQwMassSendLogById(id);
+    }
+
+    @Override
+    public List<QwMassSendLog> selectQwMassSendLogList(QwMassSendLog query) {
+        return qwMassSendLogMapper.selectQwMassSendLogList(query);
+    }
+
+    /**
+     * 解析 sopId:优先 param.sopId,否则取 sopIds 首个
+     */
+    private static String resolveSopId(SendUserLogsInfoMsgParam param) {
+        if (param == null) {
+            return null;
+        }
+        if (param.getSopId() != null && !param.getSopId().isEmpty()) {
+            return param.getSopId();
+        }
+        if (param.getSopIds() != null && param.getSopIds().length > 0
+                && param.getSopIds()[0] != null && !param.getSopIds()[0].isEmpty()) {
+            return param.getSopIds()[0];
+        }
+        return null;
+    }
+
+    /**
+     * 兼容公司端 LoginUser(CompanyUser)与总后台 LoginUser(SysUser)
+     */
+    private void fillOperator(QwMassSendLog row) {
+        try {
+            Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+            if (auth == null || auth.getPrincipal() == null) {
+                return;
+            }
+            Object principal = auth.getPrincipal();
+            Object user = invokeNoArg(principal, "getUser");
+            if (user != null) {
+                Object userId = invokeNoArg(user, "getUserId");
+                if (userId instanceof Number) {
+                    row.setCompanyUserId(((Number) userId).longValue());
+                }
+                Object nickName = invokeNoArg(user, "getNickName");
+                if (nickName != null) {
+                    row.setCompanyUserName(String.valueOf(nickName));
+                } else {
+                    Object userName = invokeNoArg(user, "getUserName");
+                    if (userName != null) {
+                        row.setCompanyUserName(String.valueOf(userName));
+                    }
+                }
+                row.setCreateBy(row.getCompanyUserName());
+            }
+            Object company = invokeNoArg(principal, "getCompany");
+            if (company != null) {
+                Object companyId = invokeNoArg(company, "getCompanyId");
+                if (companyId instanceof Number) {
+                    row.setCompanyId(((Number) companyId).longValue());
+                }
+                Object companyName = invokeNoArg(company, "getCompanyName");
+                if (companyName != null) {
+                    row.setCompanyName(String.valueOf(companyName));
+                }
+            }
+        } catch (Exception e) {
+            log.warn("解析一键群发操作人信息失败: {}", e.getMessage());
+        }
+    }
+
+    private void fillCompany(QwMassSendLog row, Long companyIdHint) {
+        if (row.getCompanyId() == null && companyIdHint != null) {
+            row.setCompanyId(companyIdHint);
+        }
+        if (row.getCompanyId() != null && (row.getCompanyName() == null || row.getCompanyName().isEmpty())) {
+            try {
+                Company company = companyMapper.selectCompanyById(row.getCompanyId());
+                if (company != null) {
+                    row.setCompanyName(company.getCompanyName());
+                }
+            } catch (Exception e) {
+                log.warn("查询公司名称失败 companyId={}", row.getCompanyId());
+            }
+        }
+    }
+
+    private void fillCourseName(QwMassSendLog row) {
+        if (row.getCourseId() == null) {
+            return;
+        }
+        try {
+            FsUserCourse course = fsUserCourseMapper.selectFsUserCourseByCourseId(row.getCourseId());
+            if (course != null) {
+                row.setCourseName(course.getCourseName());
+            }
+        } catch (Exception e) {
+            log.warn("查询课程名称失败 courseId={}", row.getCourseId());
+        }
+    }
+
+    /**
+     * 按 videoId 查课节/小结标题
+     */
+    private void fillVideoName(QwMassSendLog row) {
+        if (row.getVideoId() == null) {
+            return;
+        }
+        try {
+            FsUserCourseVideo video = fsUserCourseVideoMapper.selectFsUserCourseVideoByVideoId(row.getVideoId());
+            if (video != null) {
+                row.setVideoName(video.getTitle());
+            }
+        } catch (Exception e) {
+            log.warn("查询课节名称失败 videoId={}", row.getVideoId());
+        }
+    }
+
+    /**
+     * 填充 SOP 名称:已有前端/调用方传入则不覆盖;否则按 sopId 查 SOP 库
+     * 1) QwSopCacheService(内部走 Mapper,与主库统计同口径)
+     * 2) 回退 qwSopMapper.selectQwSopNameBySopId / selectQwSopById(Mapper 上已有 @DataSource(SOP) AOP)
+     */
+    private void fillSopName(QwMassSendLog row) {
+        if (row.getSopName() != null && !row.getSopName().isEmpty()) {
+            return;
+        }
+        if (row.getSopId() == null || row.getSopId().isEmpty()) {
+            return;
+        }
+        try {
+            String cached = qwSopCacheService.getQwSopNameBySopId(row.getSopId());
+            if (StringUtils.isNotBlank(cached) && !"-".equals(cached)) {
+                row.setSopName(cached);
+                return;
+            }
+        } catch (Exception e) {
+            log.warn("QwSopCacheService查SOP名称失败 sopId={} err={}", row.getSopId(), e.getMessage());
+        }
+        try {
+            String name = qwSopMapper.selectQwSopNameBySopId(row.getSopId());
+            if (StringUtils.isNotBlank(name)) {
+                row.setSopName(name);
+                return;
+            }
+            QwSop sop = qwSopMapper.selectQwSopById(row.getSopId());
+            if (sop != null && StringUtils.isNotBlank(sop.getName())) {
+                row.setSopName(sop.getName());
+            } else {
+                log.warn("查询SOP名称为空 sopId={}", row.getSopId());
+            }
+        } catch (Exception e) {
+            log.warn("查询SOP名称失败 sopId={} err={}", row.getSopId(), e.getMessage());
+        }
+    }
+
+    private static Object invokeNoArg(Object target, String methodName) {
+        if (target == null) {
+            return null;
+        }
+        try {
+            Method method = target.getClass().getMethod(methodName);
+            return method.invoke(target);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+}

+ 138 - 4
fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java

@@ -40,6 +40,7 @@ import com.fs.live.domain.LiveWatchLog;
 import com.fs.live.mapper.LiveWatchLogMapper;
 import com.fs.qw.domain.*;
 import com.fs.qw.mapper.*;
+import com.fs.qw.cache.QwSopCacheService;
 import com.fs.qw.param.QwExtCourseSopWatchLog;
 import com.fs.qw.param.QwExternalContactVOTime;
 import com.fs.qw.param.QwTagSearchParam;
@@ -59,6 +60,7 @@ import com.fs.sop.params.BatchSopUserLogsInfoParam;
 import com.fs.sop.params.BatchSopUserLogsInfoParamU;
 import com.fs.sop.params.SendUserLogsInfoMsgParam;
 import com.fs.sop.params.SopUserLogsParamByDate;
+import com.fs.sop.service.IQwMassSendLogService;
 import com.fs.sop.service.IQwSopLogsService;
 import com.fs.sop.service.IQwSopService;
 import com.fs.sop.service.IQwSopTempVoiceService;
@@ -142,6 +144,10 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
     @Autowired
     private QwSopMapper qwSopMapper;
 
+    /** SOP 名称缓存(内部走 QwSopMapper @DataSource(SOP)) */
+    @Autowired
+    private QwSopCacheService qwSopCacheService;
+
     @Autowired
     private QwSopLogsMapper qwSopLogsMapper;
 
@@ -185,6 +191,9 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
     @Autowired
     private CompanyMapper companyMapper;
 
+    @Autowired
+    private IQwMassSendLogService qwMassSendLogService;
+
     @Autowired
     private CompanyDeptMapper companyDeptMapper;
 
@@ -559,6 +568,8 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
             return  R.error().put("msg","企业不存在,请联系管理员");
         }
         List<QwSopLogs> sopLogsList;
+        // 群聊路径客户人数(外部成员数);个人路径为 -1 表示不使用
+        int groupPathCustomerCount = -1;
         if(param.getFilterMode() != null && param.getFilterMode() == 2 && param.getChatIds() != null && param.getChatIds().length > 0){
             List<QwGroupChat> groupList = qwGroupChatMapper.selectQwGroupChatByChatIds(param.getChatIds());
 
@@ -577,6 +588,9 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                 });
             }
 
+            // 与个人路径「本次覆盖客户数」语义对齐:统计未退群的外部联系人(type=2),而非选中群数量
+            groupPathCustomerCount = countGroupMassSendCustomerCount(groupUserList, groupList);
+
             //没有传值课程和课节 是直播的数据
             if(null != param.getCourseId() && null !=param.getVideoId()){
                 try {
@@ -1390,13 +1404,109 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
         }
 
         //批量插入 发送记录
-        if (!sopLogsList.isEmpty()) {
+        int customerCount = 0;
+        if (sopLogsList != null && !sopLogsList.isEmpty()) {
             processAndInsertQwSopLogsBySendMsg(sopLogsList);
+            if (groupPathCustomerCount >= 0) {
+                // 群路径:sendType=2按人发用实际发送条数;发群消息时一条日志对应一个群,客户人数改用外部成员数
+                if (param.getSendType() != null && param.getSendType() == 2) {
+                    customerCount = sopLogsList.size();
+                } else {
+                    customerCount = groupPathCustomerCount > 0 ? groupPathCustomerCount : sopLogsList.size();
+                }
+            } else {
+                // 个人路径:一条发送记录对应一个客户
+                customerCount = sopLogsList.size();
+            }
+        }
+        // 记录销售点击一键群发行为(主库),不影响主流程
+        Long companyIdHint = qwSop != null ? qwSop.getCompanyId() : null;
+        // 本方法开头已从 SOP 库查出 QwSop,直接带上名称;若开头未查到则再补一次
+        if (qwSop != null && (param.getSopName() == null || param.getSopName().isEmpty())
+                && qwSop.getName() != null && !qwSop.getName().isEmpty()) {
+            param.setSopName(qwSop.getName());
+        } else {
+            fillParamSopNameFromSopDb(param);
         }
+        qwMassSendLogService.saveMassSendClickLog(param, customerCount, 1, companyIdHint);
 
         return R.ok();
     }
 
+    /**
+     * 群发参数补齐 SOP 名称:已有则不覆盖;否则按 sopId 查 SOP 库 qw_sop.name
+     * 优先 QwSopCacheService,回退 Mapper(类/方法上已有 @DataSource(SOP) AOP,无需手动切库)
+     */
+    private void fillParamSopNameFromSopDb(SendUserLogsInfoMsgParam param) {
+        if (param == null) {
+            return;
+        }
+        if (!StringUtil.strIsNullOrEmpty(param.getSopName())) {
+            return;
+        }
+        String sopId = param.getSopId();
+        if (StringUtil.strIsNullOrEmpty(sopId) && param.getSopIds() != null && param.getSopIds().length > 0) {
+            sopId = param.getSopIds()[0];
+            if (!StringUtil.strIsNullOrEmpty(sopId)) {
+                param.setSopId(sopId);
+            }
+        }
+        if (StringUtil.strIsNullOrEmpty(sopId)) {
+            return;
+        }
+        try {
+            String cached = qwSopCacheService.getQwSopNameBySopId(sopId);
+            if (!StringUtil.strIsNullOrEmpty(cached) && !"-".equals(cached)) {
+                param.setSopName(cached);
+                return;
+            }
+        } catch (Exception e) {
+            log.warn("QwSopCacheService补全群发SOP名称失败 sopId={}", sopId, e);
+        }
+        try {
+            String name = qwSopMapper.selectQwSopNameBySopId(sopId);
+            if (!StringUtil.strIsNullOrEmpty(name)) {
+                param.setSopName(name);
+                return;
+            }
+            QwSop sop = qwSopMapper.selectQwSopById(sopId);
+            if (sop != null && !StringUtil.strIsNullOrEmpty(sop.getName())) {
+                param.setSopName(sop.getName());
+            }
+        } catch (Exception e) {
+            log.warn("补全群发SOP名称失败 sopId={}", sopId, e);
+        }
+    }
+
+    /**
+     * 群聊一键群发客户人数:优先统计未退群外部联系人(type=2)数量,
+     * 与个人路径「本次覆盖客户数」语义对齐;成员明细缺失时回退 groupSize 之和,再回退选中群数。
+     */
+    private static int countGroupMassSendCustomerCount(List<QwGroupChatUser> groupUserList, List<QwGroupChat> groupList) {
+        if (groupUserList != null && !groupUserList.isEmpty()) {
+            int externalCount = (int) groupUserList.stream()
+                    .filter(Objects::nonNull)
+                    .filter(u -> "2".equals(String.valueOf(u.getType())))
+                    .filter(u -> u.getIsOut() == null || u.getIsOut() == 0L)
+                    .count();
+            if (externalCount > 0) {
+                return externalCount;
+            }
+        }
+        if (groupList != null && !groupList.isEmpty()) {
+            int sizeSum = groupList.stream()
+                    .filter(Objects::nonNull)
+                    .filter(g -> g.getGroupSize() != null && g.getGroupSize() > 0)
+                    .mapToInt(g -> g.getGroupSize().intValue())
+                    .sum();
+            if (sizeSum > 0) {
+                return sizeSum;
+            }
+            return groupList.size();
+        }
+        return 0;
+    }
+
     private void updateQwUserKey(QwSopLogs sopLogs, String qwUserId, String sopId,String chatId) {
         // 设置实际发送人
         SopUserLogs qwSopLogs = sopUserLogsMapper.queryUserBySopId(qwUserId,sopId,chatId);
@@ -1430,7 +1540,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
             case 1:
                 break;
             case 2:
-                processQwSopLogsBySendMsg(param,param.getDraftStrategy());
+                processQwSopLogsBySendMsg(param, param.getDraftStrategy(), 2);
                 log.info("一键群发操作日志2:{}", JSON.toJSONString(param));
                 break;
         }
@@ -1447,7 +1557,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
         String[] ids = sopUserLogsIds.toArray(new String[0]);
         param.setIds(ids);
         log.info("一键群发操作日志1:{}", JSON.toJSONString(param));
-        processQwSopLogsBySendMsg(param,param.getDraftStrategy());
+        processQwSopLogsBySendMsg(param, param.getDraftStrategy(), 3);
         return null;
     }
 
@@ -1473,7 +1583,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
         return watchLogVOList;
     }
 
-    private R processQwSopLogsBySendMsg(SendUserLogsInfoMsgParam param,Integer draftStrategy) {
+    private R processQwSopLogsBySendMsg(SendUserLogsInfoMsgParam param, Integer draftStrategy, int sendSource) {
 
 
         String json = configService.selectConfigByKey("course.config");
@@ -1498,6 +1608,19 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
 
         List<SopUserLogsInfo> sopUserLogs = sopUserLogsMapper.selectSopUserLogsByIds(param.getIds());
 
+        // 补全 sopId:MsgType/MsgSop 入口常只传 ids 或 sopIds,未传 sopId,导致群发日志 sop_name 为空
+        if (StringUtil.strIsNullOrEmpty(param.getSopId())) {
+            if (param.getSopIds() != null && param.getSopIds().length > 0
+                    && !StringUtil.strIsNullOrEmpty(param.getSopIds()[0])) {
+                param.setSopId(param.getSopIds()[0]);
+            } else if (sopUserLogs != null && !sopUserLogs.isEmpty()
+                    && !StringUtil.strIsNullOrEmpty(sopUserLogs.get(0).getSopId())) {
+                param.setSopId(sopUserLogs.get(0).getSopId());
+            }
+        }
+        // 与 sendUserLogsInfoMsg 同口径:落库前按 sopId 从 SOP 库补齐名称(前端未传 sopName 时)
+        fillParamSopNameFromSopDb(param);
+
         // 查询公司关联小程序数据
         List<CompanyMiniapp> miniList = companyMiniappService.list(new QueryWrapper<CompanyMiniapp>().orderByAsc("sort_num"));
         Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap = miniList.stream().collect(Collectors.groupingBy(CompanyMiniapp::getCompanyId, Collectors.groupingBy(CompanyMiniapp::getType)));
@@ -1540,6 +1663,9 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
             sendType = 20;
         }
         int finalSendType = sendType;
+        // 累计本次点击对应的客户发送条数
+        final int[] customerCountHolder = new int[]{0};
+        final Long[] companyIdHolder = new Long[]{null};
         groupedLogs.forEach((key, logs) -> {
 
             QwUser qwUser = qwUserMapper.selectQwUserByCorpIdAndUserId(key.getValue(), key.getKey());
@@ -1549,12 +1675,16 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
             }else {
 
                 if (qwUser.getCompanyUserId()!=null && qwUser.getCompanyId()!=null){
+                    if (companyIdHolder[0] == null) {
+                        companyIdHolder[0] = qwUser.getCompanyId();
+                    }
                     List<QwSopLogs> sopLogsList = processInsertSopUserLogsInfo(logs, qwUser, param, words, config, qwCompany, finalSort,
                             finalSendType,miniMap,companies, deptMiniAppMap, companyUserDeptMap, feishuShardAllocator);
 
                     //批量插入 发送记录
                     if (!sopLogsList.isEmpty()) {
                         processAndInsertQwSopLogsBySendMsg(sopLogsList);
+                        customerCountHolder[0] += sopLogsList.size();
                     }
 
                 }else {
@@ -1566,6 +1696,10 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
 
         });
 
+        // 落库前再补一次 SOP 名称(防止前文查询失败或前端未传)
+        fillParamSopNameFromSopDb(param);
+        // 记录销售点击一键群发行为(主库),不影响主流程
+        qwMassSendLogService.saveMassSendClickLog(param, customerCountHolder[0], sendSource, companyIdHolder[0]);
 
         return  R.ok();
     }

+ 16 - 0
fs-service/src/main/java/com/fs/system/oss/CloudStorageService.java

@@ -38,6 +38,22 @@ public abstract class CloudStorageService
         return path + suffix;
     }
 
+    /**
+     * 使用清洗后的真实文件名构造对象路径(prefix/yyyyMMddHHmmss/安全文件名)
+     * @param prefix 前缀
+     * @param safeFilename 已清洗的 basename(含扩展名)
+     * @return 对象存储路径
+     */
+    public String getPathWithSafeFilename(String prefix, String safeFilename)
+    {
+        String path = DateUtils.dateTime() + "/" + safeFilename;
+        if (StringUtils.isNotBlank(prefix))
+        {
+            path = prefix + "/" + path;
+        }
+        return path;
+    }
+
     /**
      * 文件上传
      * @param data    文件字节数组

+ 3 - 0
fs-service/src/main/resources/db/20260805-miniapp-verify-file.sql

@@ -0,0 +1,3 @@
+-- 小程序域名校验文件名
+ALTER TABLE `fs_course_play_source_config`
+    ADD COLUMN `verify_file_name` varchar(255) NULL COMMENT '小程序域名校验文件名' AFTER `user_course_auth_domain`;

+ 5 - 0
fs-service/src/main/resources/db/20260806-qw-mass-send-log-alter.sql

@@ -0,0 +1,5 @@
+-- 一键群发日志表增强:课节名、SOP名称(主库 fs_his)
+-- 已建表环境执行;新建请直接用 20260806-qw-mass-send-log.sql
+ALTER TABLE `qw_mass_send_log`
+  ADD COLUMN `video_name` varchar(255) DEFAULT NULL COMMENT '课节/小结名称' AFTER `video_id`,
+  ADD COLUMN `sop_name` varchar(255) DEFAULT NULL COMMENT 'SOP名称' AFTER `sop_id`;

+ 28 - 0
fs-service/src/main/resources/db/20260806-qw-mass-send-log-menu.sql

@@ -0,0 +1,28 @@
+-- 一键群发点击日志菜单(总后台 sys_menu)
+-- 权限字:qw:massSendLog:list / qw:massSendLog:query
+-- 组件路径:qw/massSendLog/index
+-- 请自行修改下面 parent_id 为实际父菜单ID后执行
+
+SET @parent_id = 0;  -- TODO: 改为你的父菜单ID
+
+INSERT INTO sys_menu (
+  menu_name, parent_id, order_num, path, component,
+  is_frame, is_cache, menu_type, visible, status,
+  perms, icon, create_by, create_time, remark
+) VALUES (
+  '一键群发日志', @parent_id, 90, 'massSendLog', 'qw/massSendLog/index',
+  1, 0, 'C', '0', '0',
+  'qw:massSendLog:list', 'log', 'admin', sysdate(), '销售一键群发点击行为日志'
+);
+
+SET @menu_id = LAST_INSERT_ID();
+
+INSERT INTO sys_menu (
+  menu_name, parent_id, order_num, path, component,
+  is_frame, is_cache, menu_type, visible, status,
+  perms, icon, create_by, create_time, remark
+) VALUES (
+  '一键群发日志查询', @menu_id, 1, '#', '',
+  1, 0, 'F', '0', '0',
+  'qw:massSendLog:query', '#', 'admin', sysdate(), ''
+);

+ 24 - 0
fs-service/src/main/resources/db/20260806-qw-mass-send-log.sql

@@ -0,0 +1,24 @@
+-- 一键群发销售点击行为日志(主库 fs_his)
+CREATE TABLE IF NOT EXISTS `qw_mass_send_log` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键自增',
+  `company_id` bigint(20) DEFAULT NULL COMMENT '公司ID',
+  `company_name` varchar(128) DEFAULT NULL COMMENT '公司名称',
+  `company_user_id` bigint(20) DEFAULT NULL COMMENT '销售用户ID',
+  `company_user_name` varchar(64) DEFAULT NULL COMMENT '销售昵称',
+  `customer_count` int(11) NOT NULL DEFAULT 0 COMMENT '发送客户人数',
+  `content_json` mediumtext COMMENT '发送内容JSON(setting)',
+  `course_id` bigint(20) DEFAULT NULL COMMENT '课程ID',
+  `course_name` varchar(255) DEFAULT NULL COMMENT '课程名称',
+  `video_id` bigint(20) DEFAULT NULL COMMENT '课节ID',
+  `video_name` varchar(255) DEFAULT NULL COMMENT '课节/小结名称',
+  `live_id` bigint(20) DEFAULT NULL COMMENT '直播间ID(兼容保留,不再写入)',
+  `corp_id` varchar(64) DEFAULT NULL COMMENT '企微企业ID',
+  `sop_id` varchar(64) DEFAULT NULL COMMENT 'SOP主键',
+  `sop_name` varchar(255) DEFAULT NULL COMMENT 'SOP名称',
+  `send_source` tinyint(4) NOT NULL DEFAULT 1 COMMENT '来源:1=msg 2=msgType 3=msgSop',
+  `create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
+  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_company_create` (`company_id`, `create_time`),
+  KEY `idx_user_create` (`company_user_id`, `create_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='一键群发销售点击行为日志';

+ 30 - 0
fs-service/src/main/resources/db/20260807-company-qw-mass-send-log-menu.sql

@@ -0,0 +1,30 @@
+-- 一键群发点击日志菜单(销售端 company_menu,主库)
+-- 权限字:qw:massSendLog:list / qw:massSendLog:query
+-- 组件路径:qw/massSendLog/index
+-- 动态路由按菜单 path/component 加载,请先查出父菜单ID后修改 @parent_id
+-- 示例:SELECT menu_id, menu_name, path FROM company_menu WHERE menu_name LIKE '%SOP%' OR path LIKE '%qw%';
+-- 执行后需在角色管理中勾选该菜单(或写入 company_role_menu)
+
+SET @parent_id = 0;  -- TODO: 改为实际父菜单ID
+
+INSERT INTO company_menu (
+  menu_name, parent_id, order_num, path, component,
+  is_frame, is_cache, menu_type, visible, status,
+  perms, icon, create_by, create_time, remark
+) VALUES (
+  '一键群发日志', @parent_id, 90, 'massSendLog', 'qw/massSendLog/index',
+  1, 0, 'C', '0', '0',
+  'qw:massSendLog:list', 'log', 'admin', sysdate(), '销售一键群发点击行为日志'
+);
+
+SET @menu_id = LAST_INSERT_ID();
+
+INSERT INTO company_menu (
+  menu_name, parent_id, order_num, path, component,
+  is_frame, is_cache, menu_type, visible, status,
+  perms, icon, create_by, create_time, remark
+) VALUES (
+  '一键群发日志查询', @menu_id, 1, '#', '',
+  1, 0, 'F', '0', '0',
+  'qw:massSendLog:query', '#', 'admin', sysdate(), ''
+);

+ 108 - 0
fs-service/src/main/resources/mapper/sop/QwMassSendLogMapper.xml

@@ -0,0 +1,108 @@
+<?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.fs.sop.mapper.QwMassSendLogMapper">
+
+    <resultMap type="com.fs.sop.domain.QwMassSendLog" id="QwMassSendLogResult">
+        <result property="id" column="id"/>
+        <result property="companyId" column="company_id"/>
+        <result property="companyName" column="company_name"/>
+        <result property="companyUserId" column="company_user_id"/>
+        <result property="companyUserName" column="company_user_name"/>
+        <result property="customerCount" column="customer_count"/>
+        <result property="contentJson" column="content_json"/>
+        <result property="courseId" column="course_id"/>
+        <result property="courseName" column="course_name"/>
+        <result property="videoId" column="video_id"/>
+        <result property="videoName" column="video_name"/>
+        <result property="corpId" column="corp_id"/>
+        <result property="sopId" column="sop_id"/>
+        <result property="sopName" column="sop_name"/>
+        <result property="sendSource" column="send_source"/>
+        <result property="createBy" column="create_by"/>
+        <result property="createTime" column="create_time"/>
+    </resultMap>
+
+    <sql id="selectColumns">
+        select id, company_id, company_name, company_user_id, company_user_name,
+               customer_count, content_json, course_id, course_name, video_id, video_name,
+               corp_id, sop_id, sop_name, send_source, create_by, create_time
+        from qw_mass_send_log
+    </sql>
+
+    <insert id="insertQwMassSendLog" parameterType="com.fs.sop.domain.QwMassSendLog" useGeneratedKeys="true" keyProperty="id">
+        insert into qw_mass_send_log
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="companyId != null">company_id,</if>
+            <if test="companyName != null">company_name,</if>
+            <if test="companyUserId != null">company_user_id,</if>
+            <if test="companyUserName != null">company_user_name,</if>
+            <if test="customerCount != null">customer_count,</if>
+            <if test="contentJson != null">content_json,</if>
+            <if test="courseId != null">course_id,</if>
+            <if test="courseName != null">course_name,</if>
+            <if test="videoId != null">video_id,</if>
+            <if test="videoName != null">video_name,</if>
+            <if test="corpId != null">corp_id,</if>
+            <if test="sopId != null">sop_id,</if>
+            <if test="sopName != null">sop_name,</if>
+            <if test="sendSource != null">send_source,</if>
+            <if test="createBy != null">create_by,</if>
+            create_time,
+        </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="companyId != null">#{companyId},</if>
+            <if test="companyName != null">#{companyName},</if>
+            <if test="companyUserId != null">#{companyUserId},</if>
+            <if test="companyUserName != null">#{companyUserName},</if>
+            <if test="customerCount != null">#{customerCount},</if>
+            <if test="contentJson != null">#{contentJson},</if>
+            <if test="courseId != null">#{courseId},</if>
+            <if test="courseName != null">#{courseName},</if>
+            <if test="videoId != null">#{videoId},</if>
+            <if test="videoName != null">#{videoName},</if>
+            <if test="corpId != null">#{corpId},</if>
+            <if test="sopId != null">#{sopId},</if>
+            <if test="sopName != null">#{sopName},</if>
+            <if test="sendSource != null">#{sendSource},</if>
+            <if test="createBy != null">#{createBy},</if>
+            sysdate(),
+        </trim>
+    </insert>
+
+    <select id="selectQwMassSendLogById" parameterType="Long" resultMap="QwMassSendLogResult">
+        <include refid="selectColumns"/>
+        where id = #{id}
+    </select>
+
+    <select id="selectQwMassSendLogList" parameterType="com.fs.sop.domain.QwMassSendLog" resultMap="QwMassSendLogResult">
+        <include refid="selectColumns"/>
+        <where>
+            <if test="companyId != null">and company_id = #{companyId}</if>
+            <if test="companyName != null and companyName != ''">
+                and company_name like concat('%', #{companyName}, '%')
+            </if>
+            <if test="companyUserId != null">and company_user_id = #{companyUserId}</if>
+            <if test="companyUserName != null and companyUserName != ''">
+                and company_user_name like concat('%', #{companyUserName}, '%')
+            </if>
+            <if test="courseId != null">and course_id = #{courseId}</if>
+            <if test="courseName != null and courseName != ''">
+                and course_name like concat('%', #{courseName}, '%')
+            </if>
+            <if test="videoName != null and videoName != ''">
+                and video_name like concat('%', #{videoName}, '%')
+            </if>
+            <if test="corpId != null and corpId != ''">and corp_id = #{corpId}</if>
+            <if test="sopId != null and sopId != ''">and sop_id = #{sopId}</if>
+            <if test="sopName != null and sopName != ''">
+                and sop_name like concat('%', #{sopName}, '%')
+            </if>
+            <if test="sendSource != null">and send_source = #{sendSource}</if>
+            <if test="sTime != null and sTime != ''">and DATE(create_time) &gt;= DATE(#{sTime})</if>
+            <if test="eTime != null and eTime != ''">and DATE(create_time) &lt;= DATE(#{eTime})</if>
+        </where>
+        order by id desc
+    </select>
+</mapper>

+ 1 - 1
fs-service/src/main/resources/mapper/sop/SopGenerationFailedLogMapper.xml

@@ -125,7 +125,7 @@
             l.qw_user_id, l.qw_user_name, l.company_user_id, l.company_id, l.corp_id,
             l.fail_type, l.fail_reason, l.is_retry, l.retry_time, l.retry_by, 
             l.day_num, l.send_time, l.element_time, l.create_time, l.update_time,
-            s.sop_name as sop_name,
+            s.name as sop_name,
             ul.start_time as start_time,
             ec.name as external_user_name,
             ec.external_userid as external_user_id

+ 5 - 1
fs-store/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -28,7 +28,11 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 
     static
     {
-        ParserConfig.getGlobalInstance().setAutoTypeSupport(false); // 安全修复:关闭 AutoType,防止反序列化 RCE
+        // 关闭全局 AutoType,仅白名单放行本系统/必要 JDK 类,保证 Redis LoginUser 会话可读,避免 getInfo 401
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
+        ParserConfig.getGlobalInstance().addAccept("java.util.");
+        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)

+ 10 - 4
fs-store/src/main/java/com/fs/store/controller/common/CommonController.java

@@ -54,17 +54,23 @@ public class CommonController
     {
         try
         {
-            if (!FileUtils.checkAllowDownload(fileName))
+            if (!FileUtils.checkAllowDownloadFileName(fileName))
             {
                 throw new Exception(StringUtils.format("文件名称({})非法,不允许下载。 ", fileName));
             }
-            String realFileName = System.currentTimeMillis() + fileName.substring(fileName.indexOf("_") + 1);
-            String filePath = FSConfig.getDownloadPath() + fileName;
+            // 防御性取 basename,避免异常拼接跳出下载目录
+            String safeFileName = StringUtils.substringAfterLast(fileName.replace('\\', '/'), "/");
+            if (StringUtils.isEmpty(safeFileName))
+            {
+                safeFileName = fileName;
+            }
+            String realFileName = System.currentTimeMillis() + safeFileName.substring(safeFileName.indexOf("_") + 1);
+            String filePath = FSConfig.getDownloadPath() + safeFileName;
 
             response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
             FileUtils.setAttachmentResponseHeader(response, realFileName);
             FileUtils.writeBytes(filePath, response.getOutputStream());
-            if (delete)
+            if (Boolean.TRUE.equals(delete))
             {
                 FileUtils.deleteFile(filePath);
             }

+ 5 - 1
fs-user-app-ai-chat/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -28,7 +28,11 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 
     static
     {
-        ParserConfig.getGlobalInstance().setAutoTypeSupport(false); // 安全修复:关闭 AutoType,防止反序列化 RCE
+        // 关闭全局 AutoType,仅白名单放行本系统/必要 JDK 类,保证 Redis LoginUser 会话可读,避免 getInfo 401
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
+        ParserConfig.getGlobalInstance().addAccept("java.util.");
+        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)

+ 5 - 1
fs-user-app/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -28,7 +28,11 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 
     static
     {
-        ParserConfig.getGlobalInstance().setAutoTypeSupport(false); // 安全修复:关闭 AutoType,防止反序列化 RCE
+        // 关闭全局 AutoType,仅白名单放行本系统/必要 JDK 类,保证 Redis LoginUser 会话可读,避免 getInfo 401
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
+        ParserConfig.getGlobalInstance().addAccept("java.util.");
+        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)

+ 5 - 1
fs-watch/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -28,7 +28,11 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 
     static
     {
-        ParserConfig.getGlobalInstance().setAutoTypeSupport(false); // 安全修复:关闭 AutoType,防止反序列化 RCE
+        // 关闭全局 AutoType,仅白名单放行本系统/必要 JDK 类,保证 Redis LoginUser 会话可读,避免 getInfo 401
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
+        ParserConfig.getGlobalInstance().addAccept("java.util.");
+        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)

+ 5 - 1
fs-wx-api/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -28,7 +28,11 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 
     static
     {
-        ParserConfig.getGlobalInstance().setAutoTypeSupport(false); // 安全修复:关闭 AutoType,防止反序列化 RCE
+        // 关闭全局 AutoType,仅白名单放行本系统/必要 JDK 类,保证 Redis LoginUser 会话可读,避免 getInfo 401
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
+        ParserConfig.getGlobalInstance().addAccept("java.util.");
+        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)