Explorar o código

服务号口令看课/ 防sql注入/上传校验/ 等安全问题

wangxy hai 1 día
pai
achega
8d16e4c521
Modificáronse 100 ficheiros con 2300 adicións e 478 borrados
  1. 5 0
      .gitignore
  2. 73 0
      env.secrets.example
  3. 6 4
      fs-ad-api/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  4. 4 3
      fs-ad-api/src/main/java/com/fs/framework/config/SecurityConfig.java
  5. 146 0
      fs-admin/src/main/java/com/fs/company/controller/CompanyController.java
  6. 30 0
      fs-admin/src/main/java/com/fs/course/controller/FsCourseMpMaterialController.java
  7. 4 1
      fs-admin/src/main/java/com/fs/course/controller/FsCoursePlaySourceConfigController.java
  8. 7 0
      fs-admin/src/main/java/com/fs/course/controller/FsUserCoursePeriodController.java
  9. 4 3
      fs-admin/src/main/java/com/fs/course/task/VideoTask.java
  10. 159 0
      fs-admin/src/main/java/com/fs/his/controller/FsCompanyController.java
  11. 18 0
      fs-admin/src/main/java/com/fs/his/param/CompanyWatchPasswordState.java
  12. 41 23
      fs-admin/src/main/java/com/fs/his/task/Task.java
  13. 9 6
      fs-admin/src/main/java/com/fs/hisStore/task/ErpTask.java
  14. 2 14
      fs-admin/src/main/java/com/fs/web/controller/common/CommonController.java
  15. 1 1
      fs-admin/src/main/resources/application.yml
  16. 6 4
      fs-ai-call-task/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  17. 1 7
      fs-ai-call-task/src/main/java/com/fs/framework/config/SecurityConfig.java
  18. 6 4
      fs-cid-workflow/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  19. 1 7
      fs-cid-workflow/src/main/java/com/fs/framework/config/SecurityConfig.java
  20. 6 4
      fs-common-api/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  21. 4 3
      fs-common-api/src/main/java/com/fs/framework/config/SecurityConfig.java
  22. 186 0
      fs-common/src/main/java/com/fs/common/utils/file/OssUploadUtils.java
  23. 16 0
      fs-common/src/main/java/com/fs/common/utils/sql/SqlUtil.java
  24. 36 3
      fs-company-app/src/main/java/com/fs/app/controller/AppBaseController.java
  25. 2 8
      fs-company-app/src/main/java/com/fs/app/controller/CommonController.java
  26. 105 1
      fs-company-app/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java
  27. 2 1
      fs-company-app/src/main/java/com/fs/app/interceptor/AuthorizationInterceptor.java
  28. 6 4
      fs-company-app/src/main/java/com/fs/core/config/FastJson2JsonRedisSerializer.java
  29. 5 18
      fs-company-app/src/main/java/com/fs/core/config/SecurityConfig.java
  30. 1 2
      fs-company/src/main/java/com/fs/chat/controller/ChatUploadController.java
  31. 7 26
      fs-company/src/main/java/com/fs/company/controller/common/CommonController.java
  32. 46 25
      fs-company/src/main/java/com/fs/company/controller/company/CompanyUserController.java
  33. 43 18
      fs-company/src/main/java/com/fs/company/utils/AudioUtils.java
  34. 6 4
      fs-company/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  35. 1 7
      fs-company/src/main/java/com/fs/framework/config/SecurityConfig.java
  36. 2 8
      fs-doctor-app/src/main/java/com/fs/app/controller/CommonController.java
  37. 6 4
      fs-doctor-app/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  38. 4 3
      fs-doctor-app/src/main/java/com/fs/framework/config/SecurityConfig.java
  39. 8 5
      fs-framework/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  40. 0 7
      fs-framework/src/main/java/com/fs/framework/config/SecurityConfig.java
  41. 6 4
      fs-ipad-task/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  42. 1 7
      fs-ipad-task/src/main/java/com/fs/framework/config/SecurityConfig.java
  43. 6 4
      fs-live-app/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  44. 4 3
      fs-live-app/src/main/java/com/fs/framework/config/SecurityConfig.java
  45. 6 4
      fs-qw-api-msg/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  46. 1 8
      fs-qw-api-msg/src/main/java/com/fs/framework/config/SecurityConfig.java
  47. 1 8
      fs-qw-api/src/main/java/com/fs/app/controller/CommonController.java
  48. 6 4
      fs-qw-api/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  49. 1 8
      fs-qw-api/src/main/java/com/fs/framework/config/SecurityConfig.java
  50. 6 4
      fs-qw-mq/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  51. 4 3
      fs-qw-mq/src/main/java/com/fs/framework/config/SecurityConfig.java
  52. 6 4
      fs-qw-task/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  53. 1 7
      fs-qw-task/src/main/java/com/fs/framework/config/SecurityConfig.java
  54. 6 4
      fs-qw-voice/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  55. 4 3
      fs-qw-voice/src/main/java/com/fs/framework/config/SecurityConfig.java
  56. 43 18
      fs-qwhook-msg/src/main/java/com/fs/app/utils/AudioUtils.java
  57. 6 4
      fs-qwhook-msg/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  58. 4 3
      fs-qwhook-msg/src/main/java/com/fs/framework/config/SecurityConfig.java
  59. 43 18
      fs-qwhook-sop/src/main/java/com/fs/app/utils/AudioUtils.java
  60. 6 4
      fs-qwhook-sop/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  61. 4 3
      fs-qwhook-sop/src/main/java/com/fs/framework/config/SecurityConfig.java
  62. 43 18
      fs-qwhook/src/main/java/com/fs/app/utils/AudioUtils.java
  63. 6 4
      fs-qwhook/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  64. 4 3
      fs-qwhook/src/main/java/com/fs/framework/config/SecurityConfig.java
  65. 6 4
      fs-redis/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  66. 4 3
      fs-redis/src/main/java/com/fs/framework/config/SecurityConfig.java
  67. 6 4
      fs-repeat-api/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  68. 4 3
      fs-repeat-api/src/main/java/com/fs/framework/config/SecurityConfig.java
  69. 22 12
      fs-service/src/main/java/com/fs/ai/service/impl/BaiduAIServiceImpl.java
  70. 27 0
      fs-service/src/main/java/com/fs/common/config/EnvSecrets.java
  71. 29 0
      fs-service/src/main/java/com/fs/common/config/ExternalSecretConfig.java
  72. 4 0
      fs-service/src/main/java/com/fs/company/domain/Company.java
  73. 2 2
      fs-service/src/main/java/com/fs/company/mapper/CompanyDeptMapper.java
  74. 1 1
      fs-service/src/main/java/com/fs/company/mapper/CompanySmsLogsMapper.java
  75. 4 4
      fs-service/src/main/java/com/fs/company/mapper/CompanyTcmReportMapper.java
  76. 2 2
      fs-service/src/main/java/com/fs/company/mapper/CompanyUserMapper.java
  77. 3 3
      fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceCallerMapper.java
  78. 6 6
      fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceLogsMapper.java
  79. 1 1
      fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceMobileMapper.java
  80. 5 0
      fs-service/src/main/java/com/fs/company/service/ICompanyService.java
  81. 20 0
      fs-service/src/main/java/com/fs/company/service/impl/CompanyServiceImpl.java
  82. 1 7
      fs-service/src/main/java/com/fs/company/service/impl/CompanyUserServiceImpl.java
  83. 10 0
      fs-service/src/main/java/com/fs/company/vo/CompanyVO.java
  84. 3 30
      fs-service/src/main/java/com/fs/core/config/WxMpConfiguration.java
  85. 187 0
      fs-service/src/main/java/com/fs/core/config/WxMpDynamicConfigService.java
  86. 9 11
      fs-service/src/main/java/com/fs/course/GetObjectDemo.java
  87. 36 0
      fs-service/src/main/java/com/fs/course/config/CourseConfig.java
  88. 5 0
      fs-service/src/main/java/com/fs/course/config/CourseMaConfig.java
  89. 43 0
      fs-service/src/main/java/com/fs/course/domain/FsCourseWatchPassword.java
  90. 7 3
      fs-service/src/main/java/com/fs/course/domain/FsUserCoursePeriodDays.java
  91. 21 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseWatchPasswordMapper.java
  92. 5 0
      fs-service/src/main/java/com/fs/course/mapper/FsSalesUserPeriodRelationMapper.java
  93. 1 1
      fs-service/src/main/java/com/fs/course/mapper/FsUserCoursePeriodMapper.java
  94. 1 0
      fs-service/src/main/java/com/fs/course/param/FsCourseWatchLogListParam.java
  95. 46 0
      fs-service/src/main/java/com/fs/course/param/newfs/FsCourseWatchShareParam.java
  96. 26 0
      fs-service/src/main/java/com/fs/course/service/IFsCourseMpFollowPosterService.java
  97. 15 0
      fs-service/src/main/java/com/fs/course/service/IFsCourseMpMaterialService.java
  98. 40 0
      fs-service/src/main/java/com/fs/course/service/IFsCourseWatchPasswordService.java
  99. 8 3
      fs-service/src/main/java/com/fs/course/service/IFsUserCoursePeriodDaysService.java
  100. 457 0
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseMpFollowPosterServiceImpl.java

+ 5 - 0
.gitignore

@@ -54,3 +54,8 @@ hs_err_pid*
 
 #1.配置语法: 以斜杠“/”开头表示目录: 以星号“*”通配多个字符: 以问号“?”通配单个字符 以方括号“[]”包含单个字符的匹配列表: 以叹号“!”表示不忽略(跟踪)匹配到的文件或目录:
 #此外,git 对于 .ignore 配置文件是按行从上到下进行规则匹配的,意味着如果前面的规则匹配的范围更大,则后面的规则将不会生效
+
+# C-03 secrets
+env.secrets
+.env
+

+ 73 - 0
env.secrets.example

@@ -0,0 +1,73 @@
+# fs-user-app / backend C-03 secrets env file
+# Copy to env.secrets (do not commit). Load before start.
+# IDEA: Run Configuration -> Environment variables
+# PowerShell: . .\scripts\load-env-secrets.ps1
+
+# Huawei cloud OBS/VOD/CDN
+HUAWEI_CLOUD_AK=K2UTJGIN7UTZJR2XMXYG
+HUAWEI_CLOUD_SK=sbyeNJLbcYmH6copxeFP9pAoksM4NIT9Zw4x0SRX
+
+# Tencent COS
+TENCENT_COS_SECRET_ID=AKIDiMq9lDf2EOM9lIfqqfKo7FNgM5meD0sT
+TENCENT_COS_SECRET_KEY=u5SuS80342xzx8FRBukza9lVNHKNMSaB
+
+# Tencent SMS
+SMS_TENCENT_SECRET_ID=AKIDdAdvzoQpzuOo11mq2TntjKI5eqrhNp22
+SMS_TENCENT_SECRET_KEY=zmLgpzzcwAFNJ4dx2iDTVHgw3ELCrIVg
+
+# Baidu AI
+BAIDU_AI_CLIENT_ID=LUP3P6NA0q9CEAmUhjnWXt4Q
+BAIDU_AI_CLIENT_SECRET=Esxtj5QVM2klCZV1HfIRYutW5mJ2AP5G
+BAIDU_AI_SECRET_KEY=bce-v3/ALTAK-P03N0q1ET2QKrbq3RGdgB/456be350fac6b3fe41ced46cada2cc287e04f5fc
+BAIDU_AI_PLUGINS=4v4ki9a5t1hxdv92
+
+# Feishu token AES (16 bytes)
+FEISHU_TOKEN_AES_KEY=9f3a7c21b8e4d6a5c2f1097e3b4a6d8f
+
+# Tulin sync
+TULIN_SYNC_SECRET_KEY=xysync_dLsSaheCzK7RU9gd
+TULIN_SYNC_PHARMACY_ID=26ee29ce-35aa-46c8-9650-3fe46014690c
+TULIN_SYNC_CORP_ID=PNBnO6MajM0tAd8t
+
+# Volcengine HSY
+HSY_ACCESS_KEY=AKLTZTc4YTE4ZjI2OWViNDNjZGI2NjhiYTI5Njc5ZjA1Mzk
+HSY_SECRET_KEY=WXpjelpUYzFOakF5TUdObE5EZGtNR0ZsWXpKaU1tTmtZakk1WXpObE4yRQ==
+HSY_ROLE_ACCESS_KEY=AKLTNmMwNjJkNDFhYTVjNDIzYzhhNzEyZmZmZTlmYzBhNGM
+HSY_ROLE_SECRET_KEY=T0RaaFl6UmhZV1V4WXpKbU5EWTBNMkZpT0RNNU9UY3daak0wTjJFd09XUQ==
+
+# Admin JWT token.secret optional
+TOKEN_SECRET=abcdefghijklmnopqrstuvwxyz
+
+# Druid console (H-02: console disabled in hdt yml; keep strong creds if ever re-enabled)
+DRUID_LOGIN_USERNAME=druid_admin
+DRUID_LOGIN_PASSWORD=ChangeMe_Druid_Strong!
+
+# ===== H-01 红德堂(正式服务器用,部署时整文件覆盖即可)=====
+HDT_REDIS_HOST=192.168.0.226
+HDT_REDIS_PASSWORD=Ylrztek250218!3@.
+HDT_MYSQL_PASSWORD=Ylrztek250218!3@.
+HDT_SOP_MYSQL_PASSWORD=Ylrztek250218!3@.
+HDT_EASYCALL_MYSQL_PASSWORD=easycallcenter365
+HDT_ROCKETMQ_SECRET_KEY=aZS4z!88dNndKTfhITzTpTxRrVUShtH3
+HDT_OPENIM_SECRET=openIM123
+HDT_WX_MINIAPP_SECRET_1=58910ae743005c396012b029c7def579
+HDT_WX_MINIAPP_SECRET_2=928d2961c81610d8f64b019597212fcd
+HDT_WX_MINIAPP_TOKEN=Ncbnd7lJvkripVOpyTFAna6NAWCxCrvC
+HDT_WX_MINIAPP_AES_KEY=HlEiBB55eaWUaeBVAQO3cWKWPYv1vOVQSq7nFNICw4E
+HDT_WX_CP_SECRET=ec7okROXJqkNafq66aKNv0asTQIG0CYrj3vyBbo
+HDT_WX_CP_TOKEN=PPKOdAoMO
+HDT_WX_CP_AES_KEY=PKvaxtpSvNGpfTDmVUHIK8Wok2ESyYX24qpXJAdMP
+HDT_WX_PAY_MCH_KEY=8cab128997a347c10898b877f38
+HDT_WX_MP_SECRET=c6e5726c7039092a907c3242c8d3e406
+HDT_WX_MP_TOKEN=PPKOdAlCoMO
+HDT_WX_MP_AES_KEY=8WCur56h3f44rJ5Dq7Ab1PKy6nbOwnUHTAnvwWNFeQT
+HDT_WX_OPEN_SECRET=602e2a9ea639b6cd18eb80e715c4b22e
+HDT_AIFABU_APP_KEY=7b471be905ab17ef358c610d117601d008
+HDT_WATCH_PASSWORD1=cp-t5or_6xw7$mt
+HDT_WATCH_PASSWORD2=mdf-m2h_6yw2$hq
+HDT_WATCH_PASSWORD3=v9xsKuqn_$d2y
+HDT_APP_JWT_SECRET=hdt-zxqzbl
+HDT_NUONUO_SECRET=A2EB20764D304D16
+HDT_TENCENT_COS_SECRET_ID=AKIDLl1tguyrZ6QddTCi2BLJ4e3OXVIuJVVK
+HDT_TENCENT_COS_SECRET_KEY=g9R6kLrMp8EDzXszylLispiQxHRN6cw5
+HDT_FEISHU_APP_SECRET=3OhIlldgTAnk8yBEwHtJpdnNY6BujFvc

+ 6 - 4
fs-ad-api/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -1,6 +1,7 @@
 package com.fs.framework.config;
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.parser.Feature;
 import com.alibaba.fastjson.parser.ParserConfig;
 import com.alibaba.fastjson.serializer.SerializerFeature;
 import com.fasterxml.jackson.databind.JavaType;
@@ -14,8 +15,9 @@ import java.nio.charset.Charset;
 
 /**
  * Redis使用FastJson序列化
- * 
-
+ *
+ * C-02 修正:保留 WriteClassName,使 RedisTemplate 能还原 LoginUser 等类型;
+ * AutoType 仅白名单 com.fs. 包,降低任意类反序列化 RCE 风险。
  */
 public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 {
@@ -29,6 +31,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
     static
     {
         ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)
@@ -55,8 +58,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
             return null;
         }
         String str = new String(bytes, DEFAULT_CHARSET);
-
-        return JSON.parseObject(str, clazz);
+        return JSON.parseObject(str, clazz, Feature.SupportAutoType);
     }
 
     public void setObjectMapper(ObjectMapper objectMapper)

+ 4 - 3
fs-ad-api/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -8,9 +8,10 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
 
 /**
- * spring security配置
- * 
-
+ * spring security配置(App/Webhook 端)
+ * <p>
+ * 业务鉴权由 {@code @Login} + AuthorizationInterceptor(或回调签名)完成。
+ * Spring Security 放行 HTTP,敏感接口必须打 {@code @Login} 或做签名校验。
  */
 @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
 public class SecurityConfig extends WebSecurityConfigurerAdapter

+ 146 - 0
fs-admin/src/main/java/com/fs/company/controller/CompanyController.java

@@ -22,18 +22,32 @@ import com.fs.company.service.*;
 import com.fs.company.vo.CompanyCrmVO;
 import com.fs.company.vo.CompanyVO;
 import com.fs.company.vo.CompanyVoiceCallerListVO;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.fs.course.domain.FsCoursePlaySourceConfig;
+import com.fs.course.service.IFsCoursePlaySourceConfigService;
+import com.fs.core.config.WxMpProperties;
 import com.fs.core.utils.OrderCodeUtils;
 import com.fs.course.config.CourseConfig;
+import com.fs.course.config.CourseMaConfig;
 import com.fs.framework.web.service.TokenService;
 import com.fs.his.vo.OptionsVO;
 import com.fs.system.service.ISysConfigService;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.context.properties.bind.BindResult;
+import org.springframework.boot.context.properties.bind.Binder;
+import org.springframework.core.env.Environment;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.web.bind.annotation.*;
 
 import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
 
 /**
  * 企业Controller
@@ -59,6 +73,10 @@ public class CompanyController extends BaseController
     private ICompanyVoiceCallerService callerService;
     @Autowired
     private ISysConfigService configService;
+    @Autowired
+    private Environment environment;
+    @Autowired
+    private IFsCoursePlaySourceConfigService playSourceConfigService;
 
     /**
      * 查询企业列表
@@ -138,6 +156,10 @@ public class CompanyController extends BaseController
     @PostMapping
     public R add(@RequestBody Company company)
     {
+        String validateMsg = validateCourseMaAppId(company.getCourseMaAppId());
+        if (validateMsg != null) {
+            return R.error(validateMsg);
+        }
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         if(company.getDeptId() != null){
             company.setDeptId(loginUser.getDeptId());
@@ -157,6 +179,10 @@ public class CompanyController extends BaseController
     @PutMapping
     public AjaxResult edit(@RequestBody Company company)
     {
+        String validateMsg = validateCourseMaAppId(company.getCourseMaAppId());
+        if (validateMsg != null) {
+            return AjaxResult.error(validateMsg);
+        }
         CompanyUser companyUser = new CompanyUser();
         if (company.getStatus()==0){
             companyUser.setStatus("1");
@@ -180,6 +206,126 @@ public class CompanyController extends BaseController
         return toAjax(companyService.updateCompany(company));
     }
 
+    /**
+     * 服务号点播下拉:只返回有中文名的服务号(courseMa.config type=2 + 点播表 type=2)。
+     * 不把 yml wx.mp 里仅有 appId、无名称的项塞进下拉。
+     */
+    @GetMapping("/courseMaAppOptions")
+    public R courseMaAppOptions() {
+        Map<String, Map<String, Object>> merged = new LinkedHashMap<>();
+        mergeCourseMaConfigType2(merged);
+        QueryWrapper<FsCoursePlaySourceConfig> qw = new QueryWrapper<FsCoursePlaySourceConfig>()
+                .eq("is_del", 0)
+                .eq("type", 2)
+                .and(w -> w.eq("status", 0).or().isNull("status"));
+        List<FsCoursePlaySourceConfig> playSources = playSourceConfigService.list(qw);
+        if (playSources != null) {
+            for (FsCoursePlaySourceConfig ps : playSources) {
+                if (StringUtils.isEmpty(ps.getAppid()) || StringUtils.isEmpty(ps.getName())) {
+                    continue;
+                }
+                Map<String, Object> item = merged.get(ps.getAppid());
+                if (item == null) {
+                    item = new HashMap<>();
+                    item.put("appId", ps.getAppid());
+                    item.put("appName", ps.getName());
+                    merged.put(ps.getAppid(), item);
+                } else {
+                    item.put("appName", ps.getName());
+                }
+                item.put("source", "playSource");
+            }
+        }
+        List<Map<String, Object>> data = new ArrayList<>();
+        for (Map<String, Object> item : merged.values()) {
+            Object appId = item.get("appId");
+            Object appName = item.get("appName");
+            if (appId == null || appName == null) {
+                continue;
+            }
+            if (String.valueOf(appId).equals(String.valueOf(appName))) {
+                continue;
+            }
+            data.add(item);
+        }
+        BindResult<WxMpProperties> bindResult = Binder.get(environment).bind("wx.mp", WxMpProperties.class);
+        boolean wxMpConfigured = bindResult.isBound() && bindResult.get().getConfigs() != null
+                && !bindResult.get().getConfigs().isEmpty();
+        return R.ok().put("data", data).put("wxMpConfigured", wxMpConfigured);
+    }
+
+    private void mergeCourseMaConfigType2(Map<String, Map<String, Object>> merged) {
+        String json = configService.selectConfigByKey("courseMa.config");
+        if (StringUtils.isEmpty(json)) {
+            return;
+        }
+        try {
+            List<CourseMaConfig> list = com.alibaba.fastjson.JSON.parseArray(json, CourseMaConfig.class);
+            if (list == null) {
+                return;
+            }
+            for (CourseMaConfig cfg : list) {
+                if (cfg == null || StringUtils.isEmpty(cfg.getAppid()) || StringUtils.isEmpty(cfg.getName())) {
+                    continue;
+                }
+                if (!"2".equals(String.valueOf(cfg.getType()))) {
+                    continue;
+                }
+                Map<String, Object> item = new HashMap<>();
+                item.put("appId", cfg.getAppid());
+                item.put("appName", cfg.getName());
+                item.put("source", "courseMaConfig");
+                merged.put(cfg.getAppid(), item);
+            }
+        } catch (Exception ignored) {
+        }
+    }
+
+    private boolean existsInCourseMaConfigType2(String courseMaAppId) {
+        String json = configService.selectConfigByKey("courseMa.config");
+        if (StringUtils.isEmpty(json)) {
+            return false;
+        }
+        try {
+            List<CourseMaConfig> list = com.alibaba.fastjson.JSON.parseArray(json, CourseMaConfig.class);
+            if (list == null) {
+                return false;
+            }
+            for (CourseMaConfig cfg : list) {
+                if (cfg != null && courseMaAppId.equals(cfg.getAppid()) && "2".equals(String.valueOf(cfg.getType()))) {
+                    return true;
+                }
+            }
+        } catch (Exception ignored) {
+        }
+        return false;
+    }
+
+    /**
+     * 校验:courseMa.config type=2 / 点播表 type=2 / wx.mp 任一命中即可。
+     */
+    private String validateCourseMaAppId(String courseMaAppId) {
+        if (StringUtils.isEmpty(courseMaAppId)) {
+            return null;
+        }
+        if (existsInCourseMaConfigType2(courseMaAppId)) {
+            return null;
+        }
+        BindResult<WxMpProperties> bindResult = Binder.get(environment).bind("wx.mp", WxMpProperties.class);
+        List<WxMpProperties.MpConfig> configs = bindResult.isBound() && bindResult.get().getConfigs() != null
+                ? bindResult.get().getConfigs()
+                : Collections.emptyList();
+        boolean inWxMp = configs.stream().anyMatch(c -> courseMaAppId.equals(c.getAppId()));
+        long playSourceCount = playSourceConfigService.count(new QueryWrapper<FsCoursePlaySourceConfig>()
+                .eq("is_del", 0)
+                .eq("type", 2)
+                .eq("appid", courseMaAppId)
+                .and(w -> w.eq("status", 0).or().isNull("status")));
+        if (inWxMp || playSourceCount > 0) {
+            return null;
+        }
+        return "所选服务号无效:请在系统参数 courseMa.config 中新增类型=服务号(type=2),或接入 wx.mp.configs";
+    }
 
 
     /**

+ 30 - 0
fs-admin/src/main/java/com/fs/course/controller/FsCourseMpMaterialController.java

@@ -0,0 +1,30 @@
+package com.fs.course.controller;
+
+import com.fs.common.core.domain.R;
+import com.fs.course.service.IFsCourseMpMaterialService;
+import lombok.AllArgsConstructor;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * 服务号口令卡片封面:上传永久图片素材并返回 media_id。
+ */
+@RestController
+@RequestMapping("/course/mpMaterial")
+@AllArgsConstructor
+public class FsCourseMpMaterialController {
+
+    private final IFsCourseMpMaterialService mpMaterialService;
+
+    /**
+     * 上传图片到指定服务号永久素材库。
+     */
+    @PostMapping("/uploadImage")
+    public R uploadImage(@RequestParam("appId") String appId,
+                         @RequestParam("file") MultipartFile file) {
+        return mpMaterialService.uploadImage(appId, file);
+    }
+}

+ 4 - 1
fs-admin/src/main/java/com/fs/course/controller/FsCoursePlaySourceConfigController.java

@@ -50,7 +50,9 @@ public class FsCoursePlaySourceConfigController extends BaseController {
                               @RequestParam(required = false) Integer isMall,
                               @RequestParam(required = false, defaultValue = "1") Integer pageNum,
                               @RequestParam(required = false, defaultValue = "10") Integer pageSize,
-                              @RequestParam(required = false) Long companyId, @RequestParam(required = false) Integer status
+                              @RequestParam(required = false) Long companyId,
+                              @RequestParam(required = false) Integer status,
+                              @RequestParam(required = false) Integer type
     ) {
         Map<String, Object> params = new HashMap<>();
         params.put("name", name);
@@ -71,6 +73,7 @@ public class FsCoursePlaySourceConfigController extends BaseController {
         params.put("userId", userId);
         params.put("deptId", deptId);
         params.put("status",status);
+        params.put("type", type);
         PageHelper.startPage(pageNum, pageSize);
         List<FsCoursePlaySourceConfigVO> list = fsCoursePlaySourceConfigService.selectCoursePlaySourceConfigVOListByMap(params);
         return getDataTable(list);

+ 7 - 0
fs-admin/src/main/java/com/fs/course/controller/FsUserCoursePeriodController.java

@@ -159,6 +159,13 @@ public class FsUserCoursePeriodController extends BaseController {
         return fsUserCoursePeriodDaysService.insertCourse(entity);
     }
 
+    @PreAuthorize("@ss.hasPermi('course:period:addCourse')")
+    @Log(title = "批量转注册课", businessType = BusinessType.UPDATE)
+    @PostMapping("/batchToRegisterCourse")
+    public R batchToRegisterCourse(@RequestBody Long[] ids) {
+        return fsUserCoursePeriodDaysService.batchToRegisterCourse(ids);
+    }
+
     @PreAuthorize("@ss.hasPermi('course:period:dayRemove')")
     @Log(title = "会员营期课程删除并重排日期", businessType = BusinessType.DELETE)
     @DeleteMapping("/day/reorder")

+ 4 - 3
fs-admin/src/main/java/com/fs/course/task/VideoTask.java

@@ -29,8 +29,7 @@ import java.util.*;
 @Component("videoTask")
 public class VideoTask {
     private final Logger logger = LoggerFactory.getLogger(this.getClass());
-    private static final String YL_SECRET_ID = "AKIDiMq9lDf2EOM9lIfqqfKo7FNgM5meD0sT";
-    private static final String YL_SECRET_KEY = "u5SuS80342xzx8FRBukza9lVNHKNMSaB";
+    // COS 密钥从环境变量读取:TENCENT_COS_SECRET_ID / TENCENT_COS_SECRET_KEY
     private static final String LIKE_KEY_PREFIX = "like:video:";
     private static final String UNLIKE_KEY_PREFIX = "unlike:video:";
     private static final String FAVORITE_KEY_PREFIX = "favorite:video:";
@@ -222,7 +221,9 @@ public class VideoTask {
         List<FsUserCourseVideo> videos  = courseVideoMapper.selectVideoIsPrivate();
         for (FsUserCourseVideo video : videos){
             // 1 初始化用户身份信息(secretId, secretKey)
-            COSCredentials cred = new BasicCOSCredentials(YL_SECRET_ID, YL_SECRET_KEY);
+            COSCredentials cred = new BasicCOSCredentials(
+                    com.fs.common.config.EnvSecrets.get("TENCENT_COS_SECRET_ID"),
+                    com.fs.common.config.EnvSecrets.get("TENCENT_COS_SECRET_KEY"));
             // 2 设置bucket的区域,
             ClientConfig clientConfig = new ClientConfig(new Region("ap-chongqing"));
             // 3 生成cos客户端

+ 159 - 0
fs-admin/src/main/java/com/fs/his/controller/FsCompanyController.java

@@ -1,9 +1,15 @@
 package com.fs.his.controller;
 
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 
 import cn.hutool.json.JSONUtil;
 import com.alibaba.fastjson.JSON;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.fs.common.annotation.RepeatSubmit;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.domain.model.LoginUser;
@@ -19,13 +25,21 @@ import com.fs.company.param.CompanyDivConfigUpdateParam;
 import com.fs.company.param.CompanyRechargeParam;
 import com.fs.company.service.*;
 import com.fs.company.vo.CompanyVO;
+import com.fs.core.config.WxMpProperties;
 import com.fs.core.utils.OrderCodeUtils;
 import com.fs.course.config.CourseConfig;
+import com.fs.course.config.CourseMaConfig;
+import com.fs.course.domain.FsCoursePlaySourceConfig;
+import com.fs.course.service.IFsCoursePlaySourceConfigService;
 import com.fs.framework.web.service.TokenService;
 import com.fs.his.mapper.FsDoctorMapper;
 import com.fs.his.param.CompanyRedPacketState;
+import com.fs.his.param.CompanyWatchPasswordState;
 import com.fs.his.vo.OptionsVO;
 import com.fs.system.service.ISysConfigService;
+import org.springframework.boot.context.properties.bind.BindResult;
+import org.springframework.boot.context.properties.bind.Binder;
+import org.springframework.core.env.Environment;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.transaction.annotation.Transactional;
@@ -71,6 +85,11 @@ public class FsCompanyController extends BaseController {
     private ISysConfigService configService;
     @Autowired
     private ICompanyDivConfigService companyDivConfigService;
+    @Autowired
+    private Environment environment;
+    @Autowired
+    private IFsCoursePlaySourceConfigService playSourceConfigService;
+
     /**
      * 查询诊所管理列表
      */
@@ -150,6 +169,10 @@ public class FsCompanyController extends BaseController {
     @PostMapping
     public R add(@RequestBody Company company)
     {
+        String validateMsg = validateCourseMaAppId(company.getCourseMaAppId());
+        if (validateMsg != null) {
+            return R.error(validateMsg);
+        }
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         String json = configService.selectConfigByKey("course.config");
         CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
@@ -167,11 +190,135 @@ public class FsCompanyController extends BaseController {
     @PutMapping
     public AjaxResult edit(@RequestBody Company company)
     {
+        String validateMsg = validateCourseMaAppId(company.getCourseMaAppId());
+        if (validateMsg != null) {
+            return AjaxResult.error(validateMsg);
+        }
         company.setMoney(null);
         company.setUpdateMiniApp(true);
         return toAjax(companyService.updateCompany(company));
     }
 
+    /**
+     * 服务号点播下拉:只返回有中文名的服务号(courseMa.config type=2 + 点播表 type=2)。
+     * 不把 yml wx.mp 里仅有 appId、无名称的项塞进下拉。
+     */
+    @GetMapping("/courseMaAppOptions")
+    public R courseMaAppOptions() {
+        Map<String, Map<String, Object>> merged = new LinkedHashMap<>();
+        mergeCourseMaConfigType2(merged);
+        QueryWrapper<FsCoursePlaySourceConfig> qw = new QueryWrapper<FsCoursePlaySourceConfig>()
+                .eq("is_del", 0)
+                .eq("type", 2)
+                .and(w -> w.eq("status", 0).or().isNull("status"));
+        List<FsCoursePlaySourceConfig> playSources = playSourceConfigService.list(qw);
+        if (playSources != null) {
+            for (FsCoursePlaySourceConfig ps : playSources) {
+                if (StringUtils.isEmpty(ps.getAppid()) || StringUtils.isEmpty(ps.getName())) {
+                    continue;
+                }
+                Map<String, Object> item = merged.get(ps.getAppid());
+                if (item == null) {
+                    item = new HashMap<>();
+                    item.put("appId", ps.getAppid());
+                    item.put("appName", ps.getName());
+                    merged.put(ps.getAppid(), item);
+                } else {
+                    item.put("appName", ps.getName());
+                }
+                item.put("source", "playSource");
+            }
+        }
+        // 仅保留有中文名称的项(过滤掉 appName 等于 appId 的裸 appId)
+        List<Map<String, Object>> data = new ArrayList<>();
+        for (Map<String, Object> item : merged.values()) {
+            Object appId = item.get("appId");
+            Object appName = item.get("appName");
+            if (appId == null || appName == null) {
+                continue;
+            }
+            if (String.valueOf(appId).equals(String.valueOf(appName))) {
+                continue;
+            }
+            data.add(item);
+        }
+        BindResult<WxMpProperties> bindResult = Binder.get(environment).bind("wx.mp", WxMpProperties.class);
+        boolean wxMpConfigured = bindResult.isBound() && bindResult.get().getConfigs() != null
+                && !bindResult.get().getConfigs().isEmpty();
+        return R.ok().put("data", data).put("wxMpConfigured", wxMpConfigured);
+    }
+
+    private void mergeCourseMaConfigType2(Map<String, Map<String, Object>> merged) {
+        String json = configService.selectConfigByKey("courseMa.config");
+        if (StringUtils.isEmpty(json)) {
+            return;
+        }
+        try {
+            List<CourseMaConfig> list = JSON.parseArray(json, CourseMaConfig.class);
+            if (list == null) {
+                return;
+            }
+            for (CourseMaConfig cfg : list) {
+                if (cfg == null || StringUtils.isEmpty(cfg.getAppid()) || StringUtils.isEmpty(cfg.getName())) {
+                    continue;
+                }
+                if (!"2".equals(String.valueOf(cfg.getType()))) {
+                    continue;
+                }
+                Map<String, Object> item = new HashMap<>();
+                item.put("appId", cfg.getAppid());
+                item.put("appName", cfg.getName());
+                item.put("source", "courseMaConfig");
+                merged.put(cfg.getAppid(), item);
+            }
+        } catch (Exception ignored) {
+            // 配置 JSON 异常时忽略,避免拖垮公司编辑页
+        }
+    }
+
+    private boolean existsInCourseMaConfigType2(String courseMaAppId) {
+        String json = configService.selectConfigByKey("courseMa.config");
+        if (StringUtils.isEmpty(json)) {
+            return false;
+        }
+        try {
+            List<CourseMaConfig> list = JSON.parseArray(json, CourseMaConfig.class);
+            if (list == null) {
+                return false;
+            }
+            for (CourseMaConfig cfg : list) {
+                if (cfg != null && courseMaAppId.equals(cfg.getAppid()) && "2".equals(String.valueOf(cfg.getType()))) {
+                    return true;
+                }
+            }
+        } catch (Exception ignored) {
+        }
+        return false;
+    }
+
+    private String validateCourseMaAppId(String courseMaAppId) {
+        if (StringUtils.isEmpty(courseMaAppId)) {
+            return null;
+        }
+        if (existsInCourseMaConfigType2(courseMaAppId)) {
+            return null;
+        }
+        BindResult<WxMpProperties> bindResult = Binder.get(environment).bind("wx.mp", WxMpProperties.class);
+        List<WxMpProperties.MpConfig> configs = bindResult.isBound() && bindResult.get().getConfigs() != null
+                ? bindResult.get().getConfigs()
+                : Collections.emptyList();
+        boolean inWxMp = configs.stream().anyMatch(c -> courseMaAppId.equals(c.getAppId()));
+        long playSourceCount = playSourceConfigService.count(new QueryWrapper<FsCoursePlaySourceConfig>()
+                .eq("is_del", 0)
+                .eq("type", 2)
+                .eq("appid", courseMaAppId)
+                .and(w -> w.eq("status", 0).or().isNull("status")));
+        if (inWxMp || playSourceCount > 0) {
+            return null;
+        }
+        return "所选服务号无效:请在系统参数「点播播放源配置」(courseMa.config) 中新增类型=服务号(type=2),或接入 wx.mp.configs";
+    }
+
     /**
      * 删除诊所管理
      */
@@ -310,6 +457,18 @@ public class FsCompanyController extends BaseController {
         return R.ok("修改成功");
     }
 
+    @PreAuthorize("@ss.hasPermi('his:company:edit')")
+    @PostMapping(value = "/changeWatchPasswordEnabled")
+    @Log(title = "销售公司口令看课开关", businessType = BusinessType.UPDATE)
+    public R changeWatchPasswordEnabled(@RequestBody CompanyWatchPasswordState params) {
+        try {
+            companyService.changeWatchPasswordEnabled(params.getCompanyId(), params.getState());
+            return R.ok("修改成功");
+        } catch (Exception e) {
+            return R.error(e.getMessage() == null ? "修改失败" : e.getMessage());
+        }
+    }
+
     /**
      * 获取公司分账配置详细信息
      */

+ 18 - 0
fs-admin/src/main/java/com/fs/his/param/CompanyWatchPasswordState.java

@@ -0,0 +1,18 @@
+package com.fs.his.param;
+
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+
+@Data
+public class CompanyWatchPasswordState {
+
+    @NotNull(message = "公司ID不能为空")
+    @ApiModelProperty("销售公司ID")
+    private Long companyId;
+
+    @NotNull(message = "状态不能为空")
+    @ApiModelProperty("口令看课开关 0关闭 1开启")
+    private Integer state;
+}

+ 41 - 23
fs-admin/src/main/java/com/fs/his/task/Task.java

@@ -813,39 +813,57 @@ public class Task {
     private void jstErpIntegralOrderDelivery(List<Long> o_ids) throws InterruptedException {
         if (CollectionUtils.isEmpty(o_ids)) return;
 
+        // 对齐商城:按订单所属店铺 shop_code 查询(推单已按店铺,不能再用全局 jst.shop_code)
         List<List<Long>> batches = Lists.partition(o_ids, 100);
         for (List<Long> batch : batches) {
-            int pageIndex = 1;
-            boolean has_next = false;
-            do {
+            List<FsIntegralOrder> integralOrders = fsIntegralOrderMapper.selectFsIntegralOrderByExtendOrderId(batch);
+            if (CollectionUtils.isEmpty(integralOrders)) {
+                continue;
+            }
+            for (FsIntegralOrder order : integralOrders) {
+                if (order == null || StringUtils.isEmpty(order.getExtendOrderId())) {
+                    continue;
+                }
+                String shopId = fsIntegralOrderService.resolveJstShopCode(order);
+                if (StringUtils.isEmpty(shopId)) {
+                    shopId = shopCode;
+                }
+                if (StringUtils.isEmpty(shopId)) {
+                    log.warn("积分订单发货同步缺少店铺编号 orderCode={}", order.getOrderCode());
+                    continue;
+                }
                 Thread.sleep(300);
                 ErpOrderQueryRequert request = new ErpOrderQueryRequert();
                 request.setPage_size(100);
-                request.setPage_index(pageIndex);
-                request.setShop_id(shopCode);
-                request.setO_ids(batch); // 设置当前批次
-                // 发送请求
+                request.setPage_index(1);
+                request.setShop_id(shopId);
+                request.setO_ids(Collections.singletonList(Long.valueOf(order.getExtendOrderId())));
                 IErpOrderService erpService = getErpService();
-                assert erpService != null;
-                ErpOrderQueryResponse response = erpService.getIntegralOrder(request,"integral");
-                has_next = response.hasNextSafe();
-
-                if (response.getOrders() != null) {
-                    for(ErpOrderQuery orderQuery : response.getOrders()){
-                        if(orderQuery.getDeliverys()!=null&& !orderQuery.getDeliverys().isEmpty()){
-                            for(ErpDeliverys delivery:orderQuery.getDeliverys()){
-                                if(delivery.getDelivery()&& StringUtils.isNotEmpty(delivery.getMail_no())){
-                                    //更新商订单状态 删除REDIS
-                                    fsIntegralOrderService.deliveryIntegralOrderOrder(orderQuery.getCode(),delivery.getMail_no(),delivery.getExpress_code(),delivery.getExpress_name());
-                                    redisCache.deleteObject("delivery"+":"+orderQuery.getO_id());
-                                }
+                if (erpService == null) {
+                    continue;
+                }
+                ErpOrderQueryResponse response = erpService.getIntegralOrder(request, "integral");
+                if (response == null || response.getOrders() == null) {
+                    continue;
+                }
+                for (ErpOrderQuery orderQuery : response.getOrders()) {
+                    if (orderQuery.getDeliverys() == null || orderQuery.getDeliverys().isEmpty()) {
+                        continue;
+                    }
+                    for (ErpDeliverys delivery : orderQuery.getDeliverys()) {
+                        if (delivery.getDelivery() && StringUtils.isNotEmpty(delivery.getMail_no())) {
+                            fsIntegralOrderService.deliveryIntegralOrderOrder(
+                                    orderQuery.getCode(),
+                                    delivery.getMail_no(),
+                                    delivery.getExpress_code(),
+                                    delivery.getExpress_name());
+                            if (orderQuery.getO_id() != null) {
+                                redisCache.deleteObject("delivery" + ":" + orderQuery.getO_id());
                             }
-
                         }
                     }
                 }
-                pageIndex++;
-            } while (has_next);
+            }
         }
     }
 

+ 9 - 6
fs-admin/src/main/java/com/fs/hisStore/task/ErpTask.java

@@ -72,10 +72,10 @@ public class ErpTask {
     }
 
     /**
-     * 页面定时任务使用的无参入口,默认同步昨天整天的数据
+     * 页面定时任务使用的无参入口,默认同步当天整天(今日 00:00:00 ~ 次日 00:00:00)
      */
     public void syncProductFromErp() {
-        String[] range = buildYesterdayRange();
+        String[] range = buildTodayRange();
         syncProductFromErp(range[0], range[1], DEFAULT_PAGE_SIZE);
     }
 
@@ -89,7 +89,10 @@ public class ErpTask {
         }
     }
 
-    private String[] buildYesterdayRange() {
+    /**
+     * 当天整天:今日 00:00:00 ~ 次日 00:00:00(即今日 24:00)。
+     */
+    private String[] buildTodayRange() {
         SimpleDateFormat formatter = new SimpleDateFormat(DATE_TIME_PATTERN);
         Calendar calendar = Calendar.getInstance();
         calendar.set(Calendar.HOUR_OF_DAY, 0);
@@ -97,9 +100,9 @@ public class ErpTask {
         calendar.set(Calendar.SECOND, 0);
         calendar.set(Calendar.MILLISECOND, 0);
         Date todayStart = calendar.getTime();
-        calendar.add(Calendar.DAY_OF_MONTH, -1);
-        Date yesterdayStart = calendar.getTime();
-        return new String[]{formatter.format(yesterdayStart), formatter.format(todayStart)};
+        calendar.add(Calendar.DAY_OF_MONTH, 1);
+        Date tomorrowStart = calendar.getTime();
+        return new String[]{formatter.format(todayStart), formatter.format(tomorrowStart)};
     }
 
 }

+ 2 - 14
fs-admin/src/main/java/com/fs/web/controller/common/CommonController.java

@@ -173,13 +173,7 @@ public class CommonController
     @PostMapping("common/uploadOSS")
     public R uploadOSS(@RequestParam("file") MultipartFile file) throws Exception
     {
-        if (file.isEmpty())
-        {
-            throw new OssException("上传文件不能为空");
-        }
-        // 上传文件
-        String fileName = file.getOriginalFilename();
-        String suffix = fileName.substring(fileName.lastIndexOf("."));
+        String suffix = com.fs.common.utils.file.OssUploadUtils.validateAndGetSuffix(file);
         CloudStorageService storage = OSSFactory.build();
         String url = storage.uploadSuffix(file.getBytes(), suffix);
         return R.ok().put("url",url);
@@ -190,13 +184,7 @@ public class CommonController
         WangUploadVO vo=new WangUploadVO();
         try
         {
-            if (file.isEmpty())
-            {
-                throw new OssException("上传文件不能为空");
-            }
-            // 上传文件
-            String fileName = file.getOriginalFilename();
-            String suffix = fileName.substring(fileName.lastIndexOf("."));
+            String suffix = com.fs.common.utils.file.OssUploadUtils.validateAndGetSuffix(file);
             CloudStorageService storage = OSSFactory.build();
             String url = storage.uploadSuffix(file.getBytes(), suffix);
             vo.setErrno(0);

+ 1 - 1
fs-admin/src/main/resources/application.yml

@@ -4,7 +4,7 @@ server:
 # Spring配置
 spring:
   profiles:
-    active: dev
+    active: druid-hdt-test
 #    active: druid-hdt
 #    active: druid-yzt
 #    active: druid-sxjz-test

+ 6 - 4
fs-ai-call-task/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -1,6 +1,7 @@
 package com.fs.framework.config;
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.parser.Feature;
 import com.alibaba.fastjson.parser.ParserConfig;
 import com.alibaba.fastjson.serializer.SerializerFeature;
 import com.fasterxml.jackson.databind.JavaType;
@@ -14,8 +15,9 @@ import java.nio.charset.Charset;
 
 /**
  * Redis使用FastJson序列化
- * 
-
+ *
+ * C-02 修正:保留 WriteClassName,使 RedisTemplate 能还原 LoginUser 等类型;
+ * AutoType 仅白名单 com.fs. 包,降低任意类反序列化 RCE 风险。
  */
 public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 {
@@ -29,6 +31,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
     static
     {
         ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)
@@ -55,8 +58,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
             return null;
         }
         String str = new String(bytes, DEFAULT_CHARSET);
-
-        return JSON.parseObject(str, clazz);
+        return JSON.parseObject(str, clazz, Feature.SupportAutoType);
     }
 
     public void setObjectMapper(ObjectMapper objectMapper)

+ 1 - 7
fs-ai-call-task/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -98,7 +98,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                 // 过滤请求
                 .authorizeRequests()
                 // 对于登录login 注册register 验证码captchaImage 允许匿名访问
-                .antMatchers("/chat/upload/**","/login", "/register", "/captchaImage").anonymous()
+                .antMatchers("/login", "/register", "/captchaImage").anonymous()
                 .antMatchers(
                         HttpMethod.GET,
                         "/",
@@ -108,22 +108,16 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                         "/**/*.js",
                         "/profile/**"
                 ).permitAll()
-                .antMatchers("**").anonymous()
                 .antMatchers("/qw/getJsapiTicket/**").anonymous()
                 .antMatchers("/msg/**").anonymous()
                 .antMatchers("/msg/**/**").anonymous()
                 .antMatchers("/msg").anonymous()
                 .antMatchers("/app/common/**").anonymous()
                 .antMatchers("/common/getId**").anonymous()
-                .antMatchers("/common/uploadOSS**").anonymous()
-                .antMatchers("/common/uploadWang**").anonymous()
-                .antMatchers("/common/download**").anonymous()
-                .antMatchers("/common/download/resource**").anonymous()
                 .antMatchers("/swagger-ui.html").anonymous()
                 .antMatchers("/swagger-resources/**").anonymous()
                 .antMatchers("/webjars/**").anonymous()
                 .antMatchers("/*/api-docs").anonymous()
-                .antMatchers("/druid/**").anonymous()
                 .antMatchers("/qw/data/**").anonymous()
                 // 除上面外的所有请求全部需要鉴权认证
                 .anyRequest().authenticated()

+ 6 - 4
fs-cid-workflow/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -1,6 +1,7 @@
 package com.fs.framework.config;
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.parser.Feature;
 import com.alibaba.fastjson.parser.ParserConfig;
 import com.alibaba.fastjson.serializer.SerializerFeature;
 import com.fasterxml.jackson.databind.JavaType;
@@ -14,8 +15,9 @@ import java.nio.charset.Charset;
 
 /**
  * Redis使用FastJson序列化
- * 
-
+ *
+ * C-02 修正:保留 WriteClassName,使 RedisTemplate 能还原 LoginUser 等类型;
+ * AutoType 仅白名单 com.fs. 包,降低任意类反序列化 RCE 风险。
  */
 public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 {
@@ -29,6 +31,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
     static
     {
         ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)
@@ -55,8 +58,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
             return null;
         }
         String str = new String(bytes, DEFAULT_CHARSET);
-
-        return JSON.parseObject(str, clazz);
+        return JSON.parseObject(str, clazz, Feature.SupportAutoType);
     }
 
     public void setObjectMapper(ObjectMapper objectMapper)

+ 1 - 7
fs-cid-workflow/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -98,7 +98,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                 // 过滤请求
                 .authorizeRequests()
                 // 对于登录login 注册register 验证码captchaImage 允许匿名访问
-                .antMatchers("/chat/upload/**","/login", "/register", "/captchaImage").anonymous()
+                .antMatchers("/login", "/register", "/captchaImage").anonymous()
                 .antMatchers(
                         HttpMethod.GET,
                         "/",
@@ -108,22 +108,16 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                         "/**/*.js",
                         "/profile/**"
                 ).permitAll()
-                .antMatchers("**").anonymous()
                 .antMatchers("/qw/getJsapiTicket/**").anonymous()
                 .antMatchers("/msg/**").anonymous()
                 .antMatchers("/msg/**/**").anonymous()
                 .antMatchers("/msg").anonymous()
                 .antMatchers("/app/common/**").anonymous()
                 .antMatchers("/common/getId**").anonymous()
-                .antMatchers("/common/uploadOSS**").anonymous()
-                .antMatchers("/common/uploadWang**").anonymous()
-                .antMatchers("/common/download**").anonymous()
-                .antMatchers("/common/download/resource**").anonymous()
                 .antMatchers("/swagger-ui.html").anonymous()
                 .antMatchers("/swagger-resources/**").anonymous()
                 .antMatchers("/webjars/**").anonymous()
                 .antMatchers("/*/api-docs").anonymous()
-                .antMatchers("/druid/**").anonymous()
                 .antMatchers("/qw/data/**").anonymous()
                 // 除上面外的所有请求全部需要鉴权认证
                 .anyRequest().authenticated()

+ 6 - 4
fs-common-api/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -1,6 +1,7 @@
 package com.fs.framework.config;
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.parser.Feature;
 import com.alibaba.fastjson.parser.ParserConfig;
 import com.alibaba.fastjson.serializer.SerializerFeature;
 import com.fasterxml.jackson.databind.JavaType;
@@ -14,8 +15,9 @@ import java.nio.charset.Charset;
 
 /**
  * Redis使用FastJson序列化
- * 
-
+ *
+ * C-02 修正:保留 WriteClassName,使 RedisTemplate 能还原 LoginUser 等类型;
+ * AutoType 仅白名单 com.fs. 包,降低任意类反序列化 RCE 风险。
  */
 public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 {
@@ -29,6 +31,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
     static
     {
         ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)
@@ -55,8 +58,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
             return null;
         }
         String str = new String(bytes, DEFAULT_CHARSET);
-
-        return JSON.parseObject(str, clazz);
+        return JSON.parseObject(str, clazz, Feature.SupportAutoType);
     }
 
     public void setObjectMapper(ObjectMapper objectMapper)

+ 4 - 3
fs-common-api/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -8,9 +8,10 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
 
 /**
- * spring security配置
- * 
-
+ * spring security配置(App/Webhook 端)
+ * <p>
+ * 业务鉴权由 {@code @Login} + AuthorizationInterceptor(或回调签名)完成。
+ * Spring Security 放行 HTTP,敏感接口必须打 {@code @Login} 或做签名校验。
  */
 @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
 public class SecurityConfig extends WebSecurityConfigurerAdapter

+ 186 - 0
fs-common/src/main/java/com/fs/common/utils/file/OssUploadUtils.java

@@ -0,0 +1,186 @@
+package com.fs.common.utils.file;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Set;
+import org.springframework.web.multipart.MultipartFile;
+import com.fs.common.exception.file.OssException;
+import com.fs.common.utils.StringUtils;
+
+/**
+ * OSS 上传安全校验:后缀白名单、大小限制、文件头魔数(防伪装扩展名)。
+ */
+public final class OssUploadUtils
+{
+    /** 通用上传默认上限 100MB */
+    public static final long DEFAULT_MAX_SIZE = 100L * 1024 * 1024;
+
+    /** 图片上传上限 10MB */
+    public static final long IMAGE_MAX_SIZE = 10L * 1024 * 1024;
+
+    /**
+     * OSS 允许后缀(相对本地上传更严:去掉 html/htm 等可执行脚本型)
+     */
+    public static final String[] OSS_ALLOWED_EXTENSION = {
+            "bmp", "gif", "jpg", "jpeg", "png", "webp",
+            "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "pdf",
+            "rar", "zip", "gz", "bz2",
+            "mp4", "avi", "rmvb", "mp3", "wav", "silk"
+    };
+
+    public static final String[] IMAGE_ALLOWED_EXTENSION = MimeTypeUtils.IMAGE_EXTENSION;
+
+    private static final Set<String> MAGIC_SKIP_EXTENSIONS = new HashSet<>(Arrays.asList(
+            "txt", "silk"
+    ));
+
+    private OssUploadUtils()
+    {
+    }
+
+    /**
+     * 校验通用上传并返回安全后缀(含点),如 ".jpg"
+     */
+    public static String validateAndGetSuffix(MultipartFile file)
+    {
+        return validateAndGetSuffix(file, OSS_ALLOWED_EXTENSION, DEFAULT_MAX_SIZE);
+    }
+
+    /**
+     * 校验图片上传并返回安全后缀(含点)
+     */
+    public static String validateAndGetImageSuffix(MultipartFile file)
+    {
+        return validateAndGetSuffix(file, IMAGE_ALLOWED_EXTENSION, IMAGE_MAX_SIZE);
+    }
+
+    public static String validateAndGetSuffix(MultipartFile file, String[] allowedExtension, long maxSize)
+    {
+        if (file == null || file.isEmpty())
+        {
+            throw new OssException("上传文件不能为空");
+        }
+        if (maxSize > 0 && file.getSize() > maxSize)
+        {
+            throw new OssException("文件大小超过限制(" + (maxSize / 1024 / 1024) + "MB)");
+        }
+
+        String originalFilename = file.getOriginalFilename();
+        if (StringUtils.isNotEmpty(originalFilename)
+                && (originalFilename.contains("..") || originalFilename.contains("/") || originalFilename.contains("\\")))
+        {
+            throw new OssException("非法文件名");
+        }
+
+        String extension = FileUploadUtils.getExtension(file);
+        if (StringUtils.isEmpty(extension) || !FileUploadUtils.isAllowedExtension(extension, allowedExtension))
+        {
+            throw new OssException("不允许的文件类型");
+        }
+        extension = extension.toLowerCase(Locale.ROOT);
+
+        if (!MAGIC_SKIP_EXTENSIONS.contains(extension))
+        {
+            byte[] header = readHeader(file, 32);
+            if (!matchesMagic(extension, header))
+            {
+                throw new OssException("文件内容与扩展名不匹配");
+            }
+        }
+
+        return "." + extension;
+    }
+
+    private static byte[] readHeader(MultipartFile file, int length)
+    {
+        byte[] header = new byte[length];
+        try (InputStream in = file.getInputStream())
+        {
+            int read = in.read(header);
+            if (read <= 0)
+            {
+                throw new OssException("无法读取文件内容");
+            }
+        }
+        catch (IOException e)
+        {
+            throw new OssException("读取文件失败");
+        }
+        return header;
+    }
+
+    private static boolean matchesMagic(String extension, byte[] h)
+    {
+        if (h == null || h.length < 4)
+        {
+            return false;
+        }
+        switch (extension)
+        {
+            case "jpg":
+            case "jpeg":
+                return (h[0] & 0xFF) == 0xFF && (h[1] & 0xFF) == 0xD8 && (h[2] & 0xFF) == 0xFF;
+            case "png":
+                return (h[0] & 0xFF) == 0x89 && h[1] == 0x50 && h[2] == 0x4E && h[3] == 0x47;
+            case "gif":
+                return h[0] == 'G' && h[1] == 'I' && h[2] == 'F';
+            case "bmp":
+                return h[0] == 'B' && h[1] == 'M';
+            case "webp":
+                return h[0] == 'R' && h[1] == 'I' && h[2] == 'F' && h[3] == 'F'
+                        && h.length > 11 && h[8] == 'W' && h[9] == 'E' && h[10] == 'B' && h[11] == 'P';
+            case "pdf":
+                return h[0] == '%' && h[1] == 'P' && h[2] == 'D' && h[3] == 'F';
+            case "zip":
+            case "docx":
+            case "xlsx":
+            case "pptx":
+                return h[0] == 'P' && h[1] == 'K';
+            case "rar":
+                return h[0] == 'R' && h[1] == 'a' && h[2] == 'r';
+            case "gz":
+                return (h[0] & 0xFF) == 0x1F && (h[1] & 0xFF) == 0x8B;
+            case "bz2":
+                return h[0] == 'B' && h[1] == 'Z' && h[2] == 'h';
+            case "doc":
+            case "xls":
+            case "ppt":
+                // OLE Compound Document
+                return (h[0] & 0xFF) == 0xD0 && (h[1] & 0xFF) == 0xCF && (h[2] & 0xFF) == 0x11 && (h[3] & 0xFF) == 0xE0;
+            case "mp4":
+            case "avi":
+            case "rmvb":
+                return isVideoContainer(h);
+            case "mp3":
+                return (h[0] == 'I' && h[1] == 'D' && h[2] == '3')
+                        || ((h[0] & 0xFF) == 0xFF && ((h[1] & 0xE0) == 0xE0));
+            case "wav":
+                return h[0] == 'R' && h[1] == 'I' && h[2] == 'F' && h[3] == 'F';
+            default:
+                return true;
+        }
+    }
+
+    private static boolean isVideoContainer(byte[] h)
+    {
+        // ISO BMFF: ....ftyp
+        if (h.length >= 8 && h[4] == 'f' && h[5] == 't' && h[6] == 'y' && h[7] == 'p')
+        {
+            return true;
+        }
+        // RIFF AVI / others
+        if (h[0] == 'R' && h[1] == 'I' && h[2] == 'F' && h[3] == 'F')
+        {
+            return true;
+        }
+        // RealMedia
+        if (h[0] == '.' && h[1] == 'R' && h[2] == 'M' && h[3] == 'F')
+        {
+            return true;
+        }
+        return false;
+    }
+}

+ 16 - 0
fs-common/src/main/java/com/fs/common/utils/sql/SqlUtil.java

@@ -34,4 +34,20 @@ public class SqlUtil
     {
         return value.matches(SQL_PATTERN);
     }
+
+    /**
+     * 仅允许 ASC / DESC,防止 ORDER BY 方向注入
+     */
+    public static String escapeAscDesc(String value)
+    {
+        if (StringUtils.isEmpty(value))
+        {
+            return "desc";
+        }
+        if ("asc".equalsIgnoreCase(value.trim()))
+        {
+            return "asc";
+        }
+        return "desc";
+    }
 }

+ 36 - 3
fs-company-app/src/main/java/com/fs/app/controller/AppBaseController.java

@@ -35,8 +35,41 @@ public class AppBaseController {
 	private ICompanyUserService companyUserService;
 	@Autowired
 	private IFsUserService userService;
+
+	/**
+	 * 与 AuthorizationInterceptor 一致:优先 Header,其次 Query 参数(便于浏览器调试)。
+	 */
+	private String resolveAppToken() {
+		javax.servlet.http.HttpServletRequest request = ServletUtils.getRequest();
+		if (request == null) {
+			return null;
+		}
+		String headerName = jwtUtils.getHeader();
+		if (StringUtils.isEmpty(headerName)) {
+			headerName = "AppToken";
+		}
+		String token = request.getHeader(headerName);
+		if (StringUtils.isBlank(token)) {
+			// 兼容历史硬编码头名
+			token = request.getHeader("APPToken");
+		}
+		if (StringUtils.isBlank(token)) {
+			token = request.getHeader("AppToken");
+		}
+		if (StringUtils.isBlank(token)) {
+			token = request.getParameter(headerName);
+		}
+		if (StringUtils.isBlank(token)) {
+			token = request.getParameter("AppToken");
+		}
+		if (StringUtils.isBlank(token)) {
+			token = request.getParameter("APPToken");
+		}
+		return token;
+	}
+
 	public Long getCompanyId() {
-		String headValue =  ServletUtils.getRequest().getHeader("APPToken");
+		String headValue = resolveAppToken();
 		Claims claims=jwtUtils.getClaimByToken(headValue);
 		if (ObjectUtil.isEmpty(claims)){
 			throw new FSException("未授权,请先登录!");
@@ -52,7 +85,7 @@ public class AppBaseController {
 	}
 	public String getUserId()
 	{
-		String headValue =  ServletUtils.getRequest().getHeader("APPToken");
+		String headValue = resolveAppToken();
 		Claims claims=jwtUtils.getClaimByToken(headValue);
 		if (ObjectUtil.isEmpty(claims)){
 			throw new FSException("未授权,请先登录!");
@@ -61,7 +94,7 @@ public class AppBaseController {
 		return userId;
 	}
 	public Long getCompanyUserId() {
-		String headValue =  ServletUtils.getRequest().getHeader("APPToken");
+		String headValue = resolveAppToken();
 		Claims claims=jwtUtils.getClaimByToken(headValue);
 		String userId = claims.getSubject();
 		return Long.parseLong(userId);

+ 2 - 8
fs-company-app/src/main/java/com/fs/app/controller/CommonController.java

@@ -222,18 +222,12 @@ public class CommonController extends AppBaseController {
 		return R.ok().put("data",list);
 	}
 
+	@Login
 	@ApiOperation("上传")
 	@PostMapping("uploadOSS")
 	public R uploadOSS(@RequestParam("file") MultipartFile file) throws Exception
 	{
-
-		if (file.isEmpty())
-		{
-			throw new OssException("上传文件不能为空");
-		}
-		// 上传文件
-		String fileName = file.getOriginalFilename();
-		String suffix = fileName.substring(fileName.lastIndexOf("."));
+		String suffix = com.fs.common.utils.file.OssUploadUtils.validateAndGetSuffix(file);
 		CloudStorageService storage = OSSFactory.build();
 		String url = storage.uploadSuffix(file.getBytes(), suffix);
 		return R.ok().put("url",url);

+ 105 - 1
fs-company-app/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java

@@ -49,6 +49,9 @@ import me.chanjar.weixin.common.error.WxErrorException;
 import org.apache.commons.collections.CollectionUtils;
 import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.*;
 
 import java.io.InputStream;
@@ -92,6 +95,12 @@ public class FsUserCourseVideoController extends AppBaseController {
     @Autowired
     private ICompanyService companyService;
 
+    @Autowired
+    private IFsCourseWatchPasswordService courseWatchPasswordService;
+
+    @Autowired
+    private IFsCourseMpFollowPosterService courseMpFollowPosterService;
+
     @Login
     @GetMapping("/pageList")
     @ApiOperation("课程分页列表")
@@ -182,6 +191,96 @@ public class FsUserCourseVideoController extends AppBaseController {
         return R.ok(map);
     }
 
+    /**
+     * 注册课:提取小程序链接。传参同 {@link #getGotoWxAppLink}:linkStr + appid。
+     */
+    @Login
+    @GetMapping("/createRegisterMiniLink")
+    @ApiOperation("注册课-提取小程序链接")
+    public ResponseResult<String> createRegisterMiniLink(String linkStr, String appid) {
+        try {
+            R r = courseWatchPasswordService.createRegisterMiniLink(linkStr, appid);
+            return toShareStringResult(r, "url");
+        } catch (Exception e) {
+            log.error("注册课提取小程序链接失败", e);
+            return handleWechatError(e);
+        }
+    }
+
+    /**
+     * 正课:提取看课口令。传参同 {@link #getGotoWxAppLink}:linkStr + appid。
+     */
+    @Login
+    @GetMapping("/createWatchPassword")
+    @ApiOperation("正课-提取看课口令")
+    public ResponseResult<String> createWatchPassword(String linkStr, String appid) {
+        try {
+            R r = courseWatchPasswordService.createWatchPassword(linkStr, appid);
+            return toShareStringResult(r, "password");
+        } catch (Exception e) {
+            log.error("正课提取看课口令失败", e);
+            return ResponseResult.fail(500, e.getMessage() == null ? "提取口令失败" : e.getMessage());
+        }
+    }
+
+    /**
+     * 按当前登录公司绑定的服务号,合成「本地海报底图 + 关注二维码」,上传 OSS。
+     * 返回格式同 getAppContactWay:{ code, msg, data: "https://..." }
+     * 同一服务号默认复用缓存;force=1 强制重新生成。
+     */
+    @Login
+    @GetMapping("/createMpFollowPoster")
+    @ApiOperation("提取服务号关注海报")
+    public R createMpFollowPoster(@RequestParam(value = "force", required = false, defaultValue = "false") boolean force) {
+        return courseMpFollowPosterService.createMpFollowPoster(getCompanyId(), force);
+    }
+
+    /**
+     * 直接返回 PNG 图片流,浏览器打开即可查看/另存(适合调试下载)。
+     * 例:/app/fs/course/createMpFollowPosterDownload?AppToken=xxx
+     */
+    @Login
+    @GetMapping("/createMpFollowPosterDownload")
+    @ApiOperation("下载服务号关注海报PNG")
+    public ResponseEntity<byte[]> createMpFollowPosterDownload() {
+        Long companyId = getCompanyId();
+        byte[] png = courseMpFollowPosterService.createMpFollowPosterPng(companyId);
+        if (png == null || png.length == 0) {
+            String err = courseMpFollowPosterService.getLastError();
+            return ResponseEntity.status(500)
+                    .contentType(MediaType.TEXT_PLAIN)
+                    .body((err == null ? "生成失败" : err).getBytes(java.nio.charset.StandardCharsets.UTF_8));
+        }
+        return ResponseEntity.ok()
+                .contentType(MediaType.IMAGE_PNG)
+                .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"mp-follow-poster.png\"")
+                .body(png);
+    }
+
+    /**
+     * 与 getGotoWxAppLink 一致:成功取业务字符串;失败/含错误文案则 fail。
+     */
+    private ResponseResult<String> toShareStringResult(R r, String dataKey) {
+        if (r == null) {
+            return ResponseResult.fail(500, "生成失败");
+        }
+        Object codeObj = r.get("code");
+        int code = codeObj instanceof Number ? ((Number) codeObj).intValue() : 500;
+        String msg = r.get("msg") == null ? "失败" : String.valueOf(r.get("msg"));
+        if (code != 200) {
+            return ResponseResult.fail(code, msg);
+        }
+        Object val = r.get(dataKey);
+        if (val == null || String.valueOf(val).trim().isEmpty()) {
+            return ResponseResult.fail(500, "生成失败");
+        }
+        String result = String.valueOf(val);
+        if (result.contains("错误") || result.contains("失败")) {
+            return ResponseResult.fail(500, result);
+        }
+        return ResponseResult.ok(result);
+    }
+
     @Login
     @PostMapping("/courseImage")
     @ApiOperation("生成课程海报")
@@ -279,13 +378,18 @@ public class FsUserCourseVideoController extends AppBaseController {
 
 
     /**
-     * 获取跳转微信小程序的链接地址
+     * 获取跳转微信小程序的链接地址。
+     * 公司开启口令看课时,正课返回看课口令(销售复制后发服务号);关闭或注册课仍返回小程序 Scheme。
      */
     @Login
     @GetMapping("/getGotoWxAppLink")
     @ApiOperation("获取跳转微信小程序的链接地址")
     public ResponseResult<String> getGotoWxAppLink(String linkStr,String appid) {
         try {
+            R passwordShare = courseWatchPasswordService.tryCreateWatchPasswordIfEnabled(linkStr, appid);
+            if (passwordShare != null) {
+                return toShareStringResult(passwordShare, "password");
+            }
             String result = courseLinkService.getGotoWxAppLink(linkStr,appid);
             // 检查返回结果是否为空或空白
             if (result == null || result.trim().isEmpty()) {

+ 2 - 1
fs-company-app/src/main/java/com/fs/app/interceptor/AuthorizationInterceptor.java

@@ -37,11 +37,12 @@ public class AuthorizationInterceptor extends HandlerInterceptorAdapter {
             return true;
         }
 
+        // 无 @Login:公开接口,放行;有 @Login:必须校验 Token(App 端鉴权模型)
         if(annotation == null){
             return true;
         }
 
-        // 请求头存在跳过认证标识,直接放行
+        // 业务场景:请求头存在跳过认证标识,直接放行
         String skipAuth = request.getHeader(SKIP_AUTH_HEADER);
         if (StringUtils.isNotEmpty(skipAuth)) {
             return true;

+ 6 - 4
fs-company-app/src/main/java/com/fs/core/config/FastJson2JsonRedisSerializer.java

@@ -1,6 +1,7 @@
 package com.fs.core.config;
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.parser.Feature;
 import com.alibaba.fastjson.parser.ParserConfig;
 import com.alibaba.fastjson.serializer.SerializerFeature;
 import com.fasterxml.jackson.databind.JavaType;
@@ -14,8 +15,9 @@ import java.nio.charset.Charset;
 
 /**
  * Redis使用FastJson序列化
- * 
- 
+ *
+ * C-02 修正:保留 WriteClassName,使 RedisTemplate 能还原 LoginUser 等类型;
+ * AutoType 仅白名单 com.fs. 包,降低任意类反序列化 RCE 风险。
  */
 public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 {
@@ -29,6 +31,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
     static
     {
         ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)
@@ -55,8 +58,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
             return null;
         }
         String str = new String(bytes, DEFAULT_CHARSET);
-
-        return JSON.parseObject(str, clazz);
+        return JSON.parseObject(str, clazz, Feature.SupportAutoType);
     }
 
     public void setObjectMapper(ObjectMapper objectMapper)

+ 5 - 18
fs-company-app/src/main/java/com/fs/core/config/SecurityConfig.java

@@ -6,31 +6,18 @@ import org.springframework.security.config.annotation.web.configuration.WebSecur
 
 
 /**
- * spring security配置
- *
-
+ * spring security配置(App 端)
+ * <p>
+ * App 业务鉴权由 {@code @Login} + AuthorizationInterceptor 完成,不走 Spring Security JWT。
+ * 因此此处放行全部 HTTP,敏感接口必须打 {@code @Login}。
  */
 @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
 public class SecurityConfig extends WebSecurityConfigurerAdapter
 {
 
-    /**
-     * anyRequest          |   匹配所有请求路径
-     * access              |   SpringEl表达式结果为true时可以访问
-     * anonymous           |   匿名可以访问
-     * denyAll             |   用户不能访问
-     * fullyAuthenticated  |   用户完全认证可以访问(非remember-me下自动登录)
-     * hasAnyAuthority     |   如果有参数,参数表示权限,则其中任何一个权限可以访问
-     * hasAnyRole          |   如果有参数,参数表示角色,则其中任何一个角色可以访问
-     * hasAuthority        |   如果有参数,参数表示权限,则其权限可以访问
-     * hasIpAddress        |   如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问
-     * hasRole             |   如果有参数,参数表示角色,则其角色可以访问
-     * permitAll           |   用户可以任意访问
-     * rememberMe          |   允许通过remember-me登录的用户访问
-     * authenticated       |   用户登录后可访问
-     */
     @Override
     protected void configure(HttpSecurity http) throws Exception {
+        // App 鉴权模型:Spring Security 放行 → Interceptor 按 @Login 校验 Token
         http.authorizeRequests()
                 .antMatchers("/**").permitAll()
                 .anyRequest().authenticated()

+ 1 - 2
fs-company/src/main/java/com/fs/chat/controller/ChatUploadController.java

@@ -73,8 +73,7 @@ public class ChatUploadController extends BaseController
         String url=weixinKfService.getFile(qwConfig.getCorpId(),qwConfig.getSecret(),fileDTO);
         vo.setUrl(url);
         // 上传文件
-        String fileName = file.getOriginalFilename();
-        String suffix = fileName.substring(fileName.lastIndexOf("."));
+        String suffix = com.fs.common.utils.file.OssUploadUtils.validateAndGetImageSuffix(file);
         CloudStorageService storage = OSSFactory.build();
         String ossUrl = storage.uploadSuffix(file.getBytes(), suffix);
         return R.ok().put("data",vo).put("ossUrl",ossUrl);

+ 7 - 26
fs-company/src/main/java/com/fs/company/controller/common/CommonController.java

@@ -186,13 +186,7 @@ public class CommonController
     @PostMapping("common/uploadOSS")
     public R uploadOSS(@RequestParam("file") MultipartFile file) throws Exception
     {
-        if (file.isEmpty())
-        {
-            throw new OssException("上传文件不能为空");
-        }
-        // 上传文件
-        String fileName = file.getOriginalFilename();
-        String suffix = fileName.substring(fileName.lastIndexOf("."));
+        String suffix = com.fs.common.utils.file.OssUploadUtils.validateAndGetSuffix(file);
         CloudStorageService storage = OSSFactory.build();
         String url = storage.uploadSuffix(file.getBytes(), suffix);
         return R.ok().put("url",url);
@@ -201,16 +195,10 @@ public class CommonController
     @PostMapping("common/uploadOSS2")
     public R uploadOSS2(@RequestParam("file") MultipartFile file) throws Exception
     {
-        if (file.isEmpty())
-        {
-            throw new OssException("上传文件不能为空");
-        }
-        // 上传文件
-        String fileName = file.getOriginalFilename();
-        String suffix = fileName.substring(fileName.lastIndexOf("."));
-        String prefix = fileName.substring(0, fileName.lastIndexOf("."));
+        String suffix = com.fs.common.utils.file.OssUploadUtils.validateAndGetSuffix(file);
         CloudStorageService storage = OSSFactory.build();
-        String url = storage.upload(file.getBytes(), prefix+System.currentTimeMillis()+suffix);
+        String url = storage.upload(file.getBytes(),
+                com.fs.common.utils.uuid.IdUtils.fastUUID() + System.currentTimeMillis() + suffix);
         return R.ok().put("url",url);
     }
 
@@ -251,9 +239,8 @@ public class CommonController
             throw new OssException("上传文件不能为空");
         }
 
-        // 上传文件
-        String fileName = file.getOriginalFilename();
-        String suffix = fileName.substring(fileName.lastIndexOf("."));
+        String suffix = com.fs.common.utils.file.OssUploadUtils.validateAndGetSuffix(file,
+                new String[]{"mp3", "wav", "silk"}, com.fs.common.utils.file.OssUploadUtils.DEFAULT_MAX_SIZE);
         CloudStorageService storage = OSSFactory.build();
         String mp3Url = storage.uploadSuffix(file.getBytes(), suffix);
         String silkUrl="";
@@ -271,13 +258,7 @@ public class CommonController
         WangUploadVO vo=new WangUploadVO();
         try
         {
-            if (file.isEmpty())
-            {
-                throw new OssException("上传文件不能为空");
-            }
-            // 上传文件
-            String fileName = file.getOriginalFilename();
-            String suffix = fileName.substring(fileName.lastIndexOf("."));
+            String suffix = com.fs.common.utils.file.OssUploadUtils.validateAndGetSuffix(file);
             CloudStorageService storage = OSSFactory.build();
             String url = storage.uploadSuffix(file.getBytes(), suffix);
             vo.setErrno(0);

+ 46 - 25
fs-company/src/main/java/com/fs/company/controller/company/CompanyUserController.java

@@ -64,9 +64,6 @@ import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
 
 import javax.servlet.http.HttpServletResponse;
-import java.net.URL;
-import java.net.HttpURLConnection;
-import java.io.InputStream;
 import java.io.OutputStream;
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
@@ -938,37 +935,61 @@ public class CompanyUserController extends BaseController {
         if (companyUserId == null) {
             return R.error("销售ID不能为空");
         }
-
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         Long companyId = loginUser.getCompany().getCompanyId();
-
-        String rawLinkStr = "pages_user/newUserCoupon?companyId=" + companyId
-                + "&companyUserId=" + companyUserId;
-
-        String linkStr = URLEncoder.encode(rawLinkStr, StandardCharsets.UTF_8.toString());
-        String shareLink = fsCourseLinkService.getGotoWxAppLinkForPreOrder(linkStr, NEW_USER_COUPON_APP_ID);
-
+        String err = validateInviteCompanyUser(companyUserId, companyId);
+        if (err != null) {
+            return R.error(err);
+        }
+        String shareLink = buildInviteShareLink(companyId, companyUserId);
         R result = QRCodeUtils.createAndUpload(shareLink);
         return R.ok().put("data", result.get("url"));
     }
 
+    /**
+     * 下载招新邀请二维码(按销售ID服务端生成,禁止客户端传任意 URL,避免 SSRF)
+     */
+    @PreAuthorize("@ss.hasPermi('company:user:addCodeUrl')")
     @GetMapping("/downloadInviteQR")
-    public void downloadInviteQR(@RequestParam("url") String url, HttpServletResponse response) throws Exception {
-        URL imageUrl = new URL(url);
-        HttpURLConnection conn = (HttpURLConnection) imageUrl.openConnection();
-        conn.setRequestMethod("GET");
-        conn.setConnectTimeout(10000);
-        conn.setReadTimeout(10000);
+    public void downloadInviteQR(@RequestParam("companyUserId") Long companyUserId,
+                                 HttpServletResponse response) throws Exception {
+        if (companyUserId == null) {
+            response.sendError(HttpServletResponse.SC_BAD_REQUEST, "销售ID不能为空");
+            return;
+        }
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getCompany().getCompanyId();
+        String err = validateInviteCompanyUser(companyUserId, companyId);
+        if (err != null) {
+            response.sendError(HttpServletResponse.SC_FORBIDDEN, err);
+            return;
+        }
+        String shareLink = buildInviteShareLink(companyId, companyUserId);
+        byte[] qrBytes = QRCodeUtils.create(shareLink).toByteArray();
         response.setContentType("image/png");
-        response.setHeader("Content-Disposition", "attachment; filename=招新邀请码.png");
-        try (InputStream is = conn.getInputStream();
-             OutputStream os = response.getOutputStream()) {
-            byte[] buffer = new byte[4096];
-            int len;
-            while ((len = is.read(buffer)) != -1) {
-                os.write(buffer, 0, len);
-            }
+        response.setHeader("Content-Disposition",
+                "attachment; filename=" + URLEncoder.encode("招新邀请码.png", StandardCharsets.UTF_8.name()));
+        try (OutputStream os = response.getOutputStream()) {
+            os.write(qrBytes);
             os.flush();
         }
     }
+
+    private String validateInviteCompanyUser(Long companyUserId, Long companyId) {
+        CompanyUser target = companyUserService.selectCompanyUserById(companyUserId);
+        if (target == null) {
+            return "销售不存在";
+        }
+        if (target.getCompanyId() == null || !target.getCompanyId().equals(companyId)) {
+            return "无权操作该销售";
+        }
+        return null;
+    }
+
+    private String buildInviteShareLink(Long companyId, Long companyUserId) throws Exception {
+        String rawLinkStr = "pages_user/newUserCoupon?companyId=" + companyId
+                + "&companyUserId=" + companyUserId;
+        String linkStr = URLEncoder.encode(rawLinkStr, StandardCharsets.UTF_8.toString());
+        return fsCourseLinkService.getGotoWxAppLinkForPreOrder(linkStr, NEW_USER_COUPON_APP_ID);
+    }
 }

+ 43 - 18
fs-company/src/main/java/com/fs/company/utils/AudioUtils.java

@@ -160,25 +160,29 @@ public class AudioUtils {
     public static void transferPcmSilk(String pcmPath, String target) {
         Process process = null;
         try {
-            /**
-             // 1、这一节的,语音长度太长会使音频长度丢失
-             List<String> commend = new ArrayList<>();
-             // 指令,可参照方法注释, 请不要在commend.add()里同时写【-参数 值】
-             commend.add(path + "silk_v3_encoder.exe");
-             commend.add(pcmPath);
-             commend.add(target);
-             commend.add("-tencent");
-             ProcessBuilder builder = new ProcessBuilder();
-             builder.command(commend);
-             process = builder.start();
-             // 如果删除下班这行写process.waitFor() ,太长的语音会阻塞,BufferedReader 打印出来太长的语音也会阻塞
-             process = Runtime.getRuntime().exec("taskkill -f -t -im silk_v3_encoder.exe");
-             */
-            // 方法2,除了会弹出弹窗,没什么问题 cmd /c 极为重要,执行完毕后会自动关闭
-            process = Runtime.getRuntime().exec("cmd /c start " + path + "silk_v3_encoder.exe " + pcmPath + " " + target + " -tencent");
-            process .waitFor();
+            File encoder = resolveSilkEncoder();
+            File pcmFile = new File(pcmPath).getCanonicalFile();
+            File targetFile = new File(target).getCanonicalFile();
+            assertSafeFileArg(pcmFile.getAbsolutePath(), "pcmPath");
+            assertSafeFileArg(targetFile.getAbsolutePath(), "target");
+
+            // H-04: 禁止 cmd /c start 字符串拼接,避免命令注入;参数列表直传不经 shell
+            ProcessBuilder builder = new ProcessBuilder(
+                    encoder.getAbsolutePath(),
+                    pcmFile.getAbsolutePath(),
+                    targetFile.getAbsolutePath(),
+                    "-tencent"
+            );
+            builder.redirectErrorStream(true);
+            process = builder.start();
+            try (java.io.BufferedReader reader = new java.io.BufferedReader(
+                    new java.io.InputStreamReader(process.getInputStream()))) {
+                while (reader.readLine() != null) {
+                    // 排空输出,避免管道阻塞
+                }
+            }
+            process.waitFor();
             Thread.sleep(1000);
-            // 有更好的方法会后续慢慢更新..
         } catch (Exception e) {
             e.printStackTrace();
         } finally {
@@ -187,6 +191,27 @@ public class AudioUtils {
             }
         }
     }
+
+    private static File resolveSilkEncoder() throws IOException {
+        File base = new File(path).getCanonicalFile();
+        File encoder = new File(base, "silk_v3_encoder.exe").getCanonicalFile();
+        if (!encoder.getPath().startsWith(base.getPath())) {
+            throw new IllegalArgumentException("非法编码器路径");
+        }
+        if (!encoder.isFile()) {
+            throw new IllegalStateException("silk_v3_encoder.exe 不存在: " + encoder);
+        }
+        return encoder;
+    }
+
+    private static void assertSafeFileArg(String arg, String name) {
+        if (arg == null || arg.isEmpty()) {
+            throw new IllegalArgumentException(name + "不能为空");
+        }
+        if (arg.indexOf('\0') >= 0 || arg.matches(".*[;&|`$<>\\r\\n].*")) {
+            throw new IllegalArgumentException(name + "包含非法字符");
+        }
+    }
     /**
      * 下载文件从网络 URL 到本地临时文件夹
      *

+ 6 - 4
fs-company/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -1,6 +1,7 @@
 package com.fs.framework.config;
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.parser.Feature;
 import com.alibaba.fastjson.parser.ParserConfig;
 import com.alibaba.fastjson.serializer.SerializerFeature;
 import com.fasterxml.jackson.databind.JavaType;
@@ -14,8 +15,9 @@ import java.nio.charset.Charset;
 
 /**
  * Redis使用FastJson序列化
- * 
-
+ *
+ * C-02 修正:保留 WriteClassName,使 RedisTemplate 能还原 LoginUser 等类型;
+ * AutoType 仅白名单 com.fs. 包,降低任意类反序列化 RCE 风险。
  */
 public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 {
@@ -29,6 +31,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
     static
     {
         ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)
@@ -55,8 +58,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
             return null;
         }
         String str = new String(bytes, DEFAULT_CHARSET);
-
-        return JSON.parseObject(str, clazz);
+        return JSON.parseObject(str, clazz, Feature.SupportAutoType);
     }
 
     public void setObjectMapper(ObjectMapper objectMapper)

+ 1 - 7
fs-company/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -100,7 +100,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                 // 过滤请求
                 .authorizeRequests()
                 // 对于登录login 注册register 验证码captchaImage 允许匿名访问
-                .antMatchers("/chat/upload/**","/login", "/register", "/captchaImage").anonymous()
+                .antMatchers("/login", "/register", "/captchaImage").anonymous()
                 .antMatchers(
                         HttpMethod.GET,
                         "/",
@@ -120,19 +120,13 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                 .antMatchers("/msg/**/**").anonymous()
                 .antMatchers("/msg").anonymous()
                 .antMatchers("/common/getId**").anonymous()
-                .antMatchers("/common/uploadOSS**").anonymous()
                 .antMatchers("/common/proxy/recording").anonymous()
-                .antMatchers("/company/user/common/uploadOSS").anonymous()
                 .antMatchers("/pay/wxPay/payNotify**").anonymous()
-                .antMatchers("/common/uploadWang**").anonymous()
-                .antMatchers("/common/download**").anonymous()
                 .antMatchers("/common/test").anonymous()
-                .antMatchers("/common/download/resource**").anonymous()
                 .antMatchers("/swagger-ui.html").anonymous()
                 .antMatchers("/swagger-resources/**").anonymous()
                 .antMatchers("/webjars/**").anonymous()
                 .antMatchers("/*/api-docs").anonymous()
-                .antMatchers("/druid/**").anonymous()
                 .antMatchers("/qw/data/**").anonymous()
                 .antMatchers("/qw/user/selectCloudByCompany").anonymous()
                 .antMatchers("/wechat/callback/submsgCallback").anonymous()

+ 2 - 8
fs-doctor-app/src/main/java/com/fs/app/controller/CommonController.java

@@ -161,17 +161,11 @@ public class CommonController {
 
 
 
+	@Login
 	@PostMapping("uploadOSS")
 	public R uploadOSS(@RequestParam("file") MultipartFile file) throws Exception
 	{
-
-		if (file.isEmpty())
-		{
-			throw new OssException("上传文件不能为空");
-		}
-		// 上传文件
-		String fileName = file.getOriginalFilename();
-		String suffix = fileName.substring(fileName.lastIndexOf("."));
+		String suffix = com.fs.common.utils.file.OssUploadUtils.validateAndGetSuffix(file);
 		CloudStorageService storage = OSSFactory.build();
 		String url = storage.uploadSuffix(file.getBytes(), suffix);
 		return R.ok().put("url",url);

+ 6 - 4
fs-doctor-app/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -1,6 +1,7 @@
 package com.fs.framework.config;
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.parser.Feature;
 import com.alibaba.fastjson.parser.ParserConfig;
 import com.alibaba.fastjson.serializer.SerializerFeature;
 import com.fasterxml.jackson.databind.JavaType;
@@ -14,8 +15,9 @@ import java.nio.charset.Charset;
 
 /**
  * Redis使用FastJson序列化
- * 
-
+ *
+ * C-02 修正:保留 WriteClassName,使 RedisTemplate 能还原 LoginUser 等类型;
+ * AutoType 仅白名单 com.fs. 包,降低任意类反序列化 RCE 风险。
  */
 public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 {
@@ -29,6 +31,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
     static
     {
         ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)
@@ -55,8 +58,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
             return null;
         }
         String str = new String(bytes, DEFAULT_CHARSET);
-
-        return JSON.parseObject(str, clazz);
+        return JSON.parseObject(str, clazz, Feature.SupportAutoType);
     }
 
     public void setObjectMapper(ObjectMapper objectMapper)

+ 4 - 3
fs-doctor-app/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -8,9 +8,10 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
 
 /**
- * spring security配置
- * 
-
+ * spring security配置(App/Webhook 端)
+ * <p>
+ * 业务鉴权由 {@code @Login} + AuthorizationInterceptor(或回调签名)完成。
+ * Spring Security 放行 HTTP,敏感接口必须打 {@code @Login} 或做签名校验。
  */
 @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
 public class SecurityConfig extends WebSecurityConfigurerAdapter

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

@@ -1,20 +1,23 @@
 package com.fs.framework.config;
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.parser.Feature;
+import com.alibaba.fastjson.parser.ParserConfig;
 import com.alibaba.fastjson.serializer.SerializerFeature;
 import com.fasterxml.jackson.databind.JavaType;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.type.TypeFactory;
 import org.springframework.data.redis.serializer.RedisSerializer;
 import org.springframework.data.redis.serializer.SerializationException;
-import com.alibaba.fastjson.parser.ParserConfig;
 import org.springframework.util.Assert;
+
 import java.nio.charset.Charset;
 
 /**
  * Redis使用FastJson序列化
- * 
-
+ *
+ * C-02 修正:保留 WriteClassName,使 RedisTemplate 能还原 LoginUser 等类型;
+ * AutoType 仅白名单 com.fs. 包,降低任意类反序列化 RCE 风险。
  */
 public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 {
@@ -28,6 +31,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
     static
     {
         ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)
@@ -54,8 +58,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
             return null;
         }
         String str = new String(bytes, DEFAULT_CHARSET);
-
-        return JSON.parseObject(str, clazz);
+        return JSON.parseObject(str, clazz, Feature.SupportAutoType);
     }
 
     public void setObjectMapper(ObjectMapper objectMapper)

+ 0 - 7
fs-framework/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -132,18 +132,11 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                 .antMatchers("/tzPay/*").anonymous()
                 .antMatchers("//his/pay/*").anonymous()
                 .antMatchers("/common/getId**").anonymous()
-                .antMatchers("/common/uploadOSS**").anonymous()
-                .antMatchers("/chat/upload/uploadFile**").anonymous()
-                .antMatchers("/common/uploadWang**").anonymous()
-                .antMatchers("/common/download**").anonymous()
-                .antMatchers("/common/download/resource**").anonymous()
-                .antMatchers("/common/unbindQwUserByServerIds").anonymous()
                 .antMatchers("/common/pushFourDiagnosis").anonymous()
                 .antMatchers("/swagger-ui.html").anonymous()
                 .antMatchers("/swagger-resources/**").anonymous()
                 .antMatchers("/webjars/**").anonymous()
                 .antMatchers("/*/api-docs").anonymous()
-                .antMatchers("/druid/**").anonymous()
                 .antMatchers("/course/userVideo/videoTranscode").anonymous()
                 .antMatchers("/erp/call/**").anonymous()
                 // 除上面外的所有请求全部需要鉴权认证

+ 6 - 4
fs-ipad-task/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -1,6 +1,7 @@
 package com.fs.framework.config;
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.parser.Feature;
 import com.alibaba.fastjson.parser.ParserConfig;
 import com.alibaba.fastjson.serializer.SerializerFeature;
 import com.fasterxml.jackson.databind.JavaType;
@@ -14,8 +15,9 @@ import java.nio.charset.Charset;
 
 /**
  * Redis使用FastJson序列化
- * 
-
+ *
+ * C-02 修正:保留 WriteClassName,使 RedisTemplate 能还原 LoginUser 等类型;
+ * AutoType 仅白名单 com.fs. 包,降低任意类反序列化 RCE 风险。
  */
 public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 {
@@ -29,6 +31,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
     static
     {
         ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)
@@ -55,8 +58,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
             return null;
         }
         String str = new String(bytes, DEFAULT_CHARSET);
-
-        return JSON.parseObject(str, clazz);
+        return JSON.parseObject(str, clazz, Feature.SupportAutoType);
     }
 
     public void setObjectMapper(ObjectMapper objectMapper)

+ 1 - 7
fs-ipad-task/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -98,7 +98,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                 // 过滤请求
                 .authorizeRequests()
                 // 对于登录login 注册register 验证码captchaImage 允许匿名访问
-                .antMatchers("/chat/upload/**","/login", "/register", "/captchaImage").anonymous()
+                .antMatchers("/login", "/register", "/captchaImage").anonymous()
                 .antMatchers(
                         HttpMethod.GET,
                         "/",
@@ -109,22 +109,16 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                         "/profile/**"
                 ).permitAll()
                 .antMatchers("**/errorLogUpload").anonymous()
-                .antMatchers("**").anonymous()
                 .antMatchers("/qw/getJsapiTicket/**").anonymous()
                 .antMatchers("/msg/**").anonymous()
                 .antMatchers("/msg/**/**").anonymous()
                 .antMatchers("/msg").anonymous()
                 .antMatchers("/app/common/**").anonymous()
                 .antMatchers("/common/getId**").anonymous()
-                .antMatchers("/common/uploadOSS**").anonymous()
-                .antMatchers("/common/uploadWang**").anonymous()
-                .antMatchers("/common/download**").anonymous()
-                .antMatchers("/common/download/resource**").anonymous()
                 .antMatchers("/swagger-ui.html").anonymous()
                 .antMatchers("/swagger-resources/**").anonymous()
                 .antMatchers("/webjars/**").anonymous()
                 .antMatchers("/*/api-docs").anonymous()
-                .antMatchers("/druid/**").anonymous()
                 .antMatchers("/qw/data/**").anonymous()
                 // 除上面外的所有请求全部需要鉴权认证
                 .anyRequest().authenticated()

+ 6 - 4
fs-live-app/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -1,6 +1,7 @@
 package com.fs.framework.config;
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.parser.Feature;
 import com.alibaba.fastjson.parser.ParserConfig;
 import com.alibaba.fastjson.serializer.SerializerFeature;
 import com.fasterxml.jackson.databind.JavaType;
@@ -14,8 +15,9 @@ import java.nio.charset.Charset;
 
 /**
  * Redis使用FastJson序列化
- * 
-
+ *
+ * C-02 修正:保留 WriteClassName,使 RedisTemplate 能还原 LoginUser 等类型;
+ * AutoType 仅白名单 com.fs. 包,降低任意类反序列化 RCE 风险。
  */
 public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 {
@@ -29,6 +31,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
     static
     {
         ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)
@@ -55,8 +58,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
             return null;
         }
         String str = new String(bytes, DEFAULT_CHARSET);
-
-        return JSON.parseObject(str, clazz);
+        return JSON.parseObject(str, clazz, Feature.SupportAutoType);
     }
 
     public void setObjectMapper(ObjectMapper objectMapper)

+ 4 - 3
fs-live-app/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -8,9 +8,10 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
 
 /**
- * spring security配置
- * 
-
+ * spring security配置(App/Webhook 端)
+ * <p>
+ * 业务鉴权由 {@code @Login} + AuthorizationInterceptor(或回调签名)完成。
+ * Spring Security 放行 HTTP,敏感接口必须打 {@code @Login} 或做签名校验。
  */
 @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
 public class SecurityConfig extends WebSecurityConfigurerAdapter

+ 6 - 4
fs-qw-api-msg/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -1,6 +1,7 @@
 package com.fs.framework.config;
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.parser.Feature;
 import com.alibaba.fastjson.parser.ParserConfig;
 import com.alibaba.fastjson.serializer.SerializerFeature;
 import com.fasterxml.jackson.databind.JavaType;
@@ -14,8 +15,9 @@ import java.nio.charset.Charset;
 
 /**
  * Redis使用FastJson序列化
- * 
-
+ *
+ * C-02 修正:保留 WriteClassName,使 RedisTemplate 能还原 LoginUser 等类型;
+ * AutoType 仅白名单 com.fs. 包,降低任意类反序列化 RCE 风险。
  */
 public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 {
@@ -29,6 +31,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
     static
     {
         ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)
@@ -55,8 +58,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
             return null;
         }
         String str = new String(bytes, DEFAULT_CHARSET);
-
-        return JSON.parseObject(str, clazz);
+        return JSON.parseObject(str, clazz, Feature.SupportAutoType);
     }
 
     public void setObjectMapper(ObjectMapper objectMapper)

+ 1 - 8
fs-qw-api-msg/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -98,7 +98,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                 // 过滤请求
                 .authorizeRequests()
                 // 对于登录login 注册register 验证码captchaImage 允许匿名访问
-                .antMatchers("/chat/upload/**","/login", "/register", "/captchaImage").anonymous()
+                .antMatchers("/login", "/register", "/captchaImage").anonymous()
                 .antMatchers(
                         HttpMethod.GET,
                         "/",
@@ -108,23 +108,16 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                         "/**/*.js",
                         "/profile/**"
                 ).permitAll()
-
-                .antMatchers("/**").anonymous()
                 .antMatchers("**/errorLogUpload").anonymous()
                 .antMatchers("/msg/**").anonymous()
                 .antMatchers("/msg/**/**").anonymous()
                 .antMatchers("/msg").anonymous()
                 .antMatchers("/testSop").anonymous()
                 .antMatchers("/common/getId**").anonymous()
-                .antMatchers("/common/uploadOSS**").anonymous()
-                .antMatchers("/common/uploadWang**").anonymous()
-                .antMatchers("/common/download**").anonymous()
-                .antMatchers("/common/download/resource**").anonymous()
                 .antMatchers("/swagger-ui.html").anonymous()
                 .antMatchers("/swagger-resources/**").anonymous()
                 .antMatchers("/webjars/**").anonymous()
                 .antMatchers("/*/api-docs").anonymous()
-                .antMatchers("/druid/**").anonymous()
                 .antMatchers("/qw/data/**").anonymous()
                 .antMatchers("/qw/test/**").anonymous()
                 // 除上面外的所有请求全部需要鉴权认证

+ 1 - 8
fs-qw-api/src/main/java/com/fs/app/controller/CommonController.java

@@ -32,14 +32,7 @@ public class CommonController {
     @PostMapping("uploadOSS")
     public R uploadOSS(@RequestParam("file") MultipartFile file) throws Exception
     {
-
-        if (file.isEmpty())
-        {
-            throw new OssException("上传文件不能为空");
-        }
-        // 上传文件
-        String fileName = file.getOriginalFilename();
-        String suffix = fileName.substring(fileName.lastIndexOf("."));
+        String suffix = com.fs.common.utils.file.OssUploadUtils.validateAndGetSuffix(file);
         CloudStorageService storage = OSSFactory.build();
         String url = storage.uploadSuffix(file.getBytes(), suffix);
         return R.ok().put("url",url);

+ 6 - 4
fs-qw-api/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -1,6 +1,7 @@
 package com.fs.framework.config;
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.parser.Feature;
 import com.alibaba.fastjson.parser.ParserConfig;
 import com.alibaba.fastjson.serializer.SerializerFeature;
 import com.fasterxml.jackson.databind.JavaType;
@@ -14,8 +15,9 @@ import java.nio.charset.Charset;
 
 /**
  * Redis使用FastJson序列化
- * 
-
+ *
+ * C-02 修正:保留 WriteClassName,使 RedisTemplate 能还原 LoginUser 等类型;
+ * AutoType 仅白名单 com.fs. 包,降低任意类反序列化 RCE 风险。
  */
 public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 {
@@ -29,6 +31,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
     static
     {
         ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)
@@ -55,8 +58,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
             return null;
         }
         String str = new String(bytes, DEFAULT_CHARSET);
-
-        return JSON.parseObject(str, clazz);
+        return JSON.parseObject(str, clazz, Feature.SupportAutoType);
     }
 
     public void setObjectMapper(ObjectMapper objectMapper)

+ 1 - 8
fs-qw-api/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -98,7 +98,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                 // 过滤请求
                 .authorizeRequests()
                 // 对于登录login 注册register 验证码captchaImage 允许匿名访问
-                .antMatchers("/chat/upload/**","/login", "/register", "/captchaImage").anonymous()
+                .antMatchers("/login", "/register", "/captchaImage").anonymous()
                 .antMatchers(
                         HttpMethod.GET,
                         "/",
@@ -108,22 +108,15 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                         "/**/*.js",
                         "/profile/**"
                 ).permitAll()
-
-                .antMatchers("/**").anonymous()
                 .antMatchers("/msg/**").anonymous()
                 .antMatchers("/msg/**/**").anonymous()
                 .antMatchers("/msg").anonymous()
                 .antMatchers("/testSop").anonymous()
                 .antMatchers("/common/getId**").anonymous()
-                .antMatchers("/common/uploadOSS**").anonymous()
-                .antMatchers("/common/uploadWang**").anonymous()
-                .antMatchers("/common/download**").anonymous()
-                .antMatchers("/common/download/resource**").anonymous()
                 .antMatchers("/swagger-ui.html").anonymous()
                 .antMatchers("/swagger-resources/**").anonymous()
                 .antMatchers("/webjars/**").anonymous()
                 .antMatchers("/*/api-docs").anonymous()
-                .antMatchers("/druid/**").anonymous()
                 .antMatchers("/qw/data/**").anonymous()
                 // 除上面外的所有请求全部需要鉴权认证
                 .anyRequest().authenticated()

+ 6 - 4
fs-qw-mq/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -1,6 +1,7 @@
 package com.fs.framework.config;
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.parser.Feature;
 import com.alibaba.fastjson.parser.ParserConfig;
 import com.alibaba.fastjson.serializer.SerializerFeature;
 import com.fasterxml.jackson.databind.JavaType;
@@ -14,8 +15,9 @@ import java.nio.charset.Charset;
 
 /**
  * Redis使用FastJson序列化
- * 
-
+ *
+ * C-02 修正:保留 WriteClassName,使 RedisTemplate 能还原 LoginUser 等类型;
+ * AutoType 仅白名单 com.fs. 包,降低任意类反序列化 RCE 风险。
  */
 public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 {
@@ -29,6 +31,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
     static
     {
         ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)
@@ -55,8 +58,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
             return null;
         }
         String str = new String(bytes, DEFAULT_CHARSET);
-
-        return JSON.parseObject(str, clazz);
+        return JSON.parseObject(str, clazz, Feature.SupportAutoType);
     }
 
     public void setObjectMapper(ObjectMapper objectMapper)

+ 4 - 3
fs-qw-mq/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -8,9 +8,10 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
 
 /**
- * spring security配置
- * 
-
+ * spring security配置(App/Webhook 端)
+ * <p>
+ * 业务鉴权由 {@code @Login} + AuthorizationInterceptor(或回调签名)完成。
+ * Spring Security 放行 HTTP,敏感接口必须打 {@code @Login} 或做签名校验。
  */
 @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
 public class SecurityConfig extends WebSecurityConfigurerAdapter

+ 6 - 4
fs-qw-task/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -1,6 +1,7 @@
 package com.fs.framework.config;
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.parser.Feature;
 import com.alibaba.fastjson.parser.ParserConfig;
 import com.alibaba.fastjson.serializer.SerializerFeature;
 import com.fasterxml.jackson.databind.JavaType;
@@ -14,8 +15,9 @@ import java.nio.charset.Charset;
 
 /**
  * Redis使用FastJson序列化
- * 
-
+ *
+ * C-02 修正:保留 WriteClassName,使 RedisTemplate 能还原 LoginUser 等类型;
+ * AutoType 仅白名单 com.fs. 包,降低任意类反序列化 RCE 风险。
  */
 public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 {
@@ -29,6 +31,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
     static
     {
         ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)
@@ -55,8 +58,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
             return null;
         }
         String str = new String(bytes, DEFAULT_CHARSET);
-
-        return JSON.parseObject(str, clazz);
+        return JSON.parseObject(str, clazz, Feature.SupportAutoType);
     }
 
     public void setObjectMapper(ObjectMapper objectMapper)

+ 1 - 7
fs-qw-task/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -98,7 +98,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                 // 过滤请求
                 .authorizeRequests()
                 // 对于登录login 注册register 验证码captchaImage 允许匿名访问
-                .antMatchers("/chat/upload/**","/login", "/register", "/captchaImage").anonymous()
+                .antMatchers("/login", "/register", "/captchaImage").anonymous()
                 .antMatchers(
                         HttpMethod.GET,
                         "/",
@@ -108,22 +108,16 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                         "/**/*.js",
                         "/profile/**"
                 ).permitAll()
-                .antMatchers("**").anonymous()
                 .antMatchers("/qw/getJsapiTicket/**").anonymous()
                 .antMatchers("/msg/**").anonymous()
                 .antMatchers("/msg/**/**").anonymous()
                 .antMatchers("/msg").anonymous()
                 .antMatchers("/app/common/**").anonymous()
                 .antMatchers("/common/getId**").anonymous()
-                .antMatchers("/common/uploadOSS**").anonymous()
-                .antMatchers("/common/uploadWang**").anonymous()
-                .antMatchers("/common/download**").anonymous()
-                .antMatchers("/common/download/resource**").anonymous()
                 .antMatchers("/swagger-ui.html").anonymous()
                 .antMatchers("/swagger-resources/**").anonymous()
                 .antMatchers("/webjars/**").anonymous()
                 .antMatchers("/*/api-docs").anonymous()
-                .antMatchers("/druid/**").anonymous()
                 .antMatchers("/qw/data/**").anonymous()
                 // 除上面外的所有请求全部需要鉴权认证
                 .anyRequest().authenticated()

+ 6 - 4
fs-qw-voice/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -1,6 +1,7 @@
 package com.fs.framework.config;
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.parser.Feature;
 import com.alibaba.fastjson.parser.ParserConfig;
 import com.alibaba.fastjson.serializer.SerializerFeature;
 import com.fasterxml.jackson.databind.JavaType;
@@ -14,8 +15,9 @@ import java.nio.charset.Charset;
 
 /**
  * Redis使用FastJson序列化
- * 
-
+ *
+ * C-02 修正:保留 WriteClassName,使 RedisTemplate 能还原 LoginUser 等类型;
+ * AutoType 仅白名单 com.fs. 包,降低任意类反序列化 RCE 风险。
  */
 public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 {
@@ -29,6 +31,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
     static
     {
         ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)
@@ -55,8 +58,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
             return null;
         }
         String str = new String(bytes, DEFAULT_CHARSET);
-
-        return JSON.parseObject(str, clazz);
+        return JSON.parseObject(str, clazz, Feature.SupportAutoType);
     }
 
     public void setObjectMapper(ObjectMapper objectMapper)

+ 4 - 3
fs-qw-voice/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -8,9 +8,10 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
 
 /**
- * spring security配置
- *
-
+ * spring security配置(App/Webhook 端)
+ * <p>
+ * 业务鉴权由 {@code @Login} + AuthorizationInterceptor(或回调签名)完成。
+ * Spring Security 放行 HTTP,敏感接口必须打 {@code @Login} 或做签名校验。
  */
 @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
 public class SecurityConfig extends WebSecurityConfigurerAdapter

+ 43 - 18
fs-qwhook-msg/src/main/java/com/fs/app/utils/AudioUtils.java

@@ -157,25 +157,29 @@ public class AudioUtils {
     public static void transferPcmSilk(String pcmPath, String target) {
         Process process = null;
         try {
-            /**
-             // 1、这一节的,语音长度太长会使音频长度丢失
-             List<String> commend = new ArrayList<>();
-             // 指令,可参照方法注释, 请不要在commend.add()里同时写【-参数 值】
-             commend.add(path + "silk_v3_encoder.exe");
-             commend.add(pcmPath);
-             commend.add(target);
-             commend.add("-tencent");
-             ProcessBuilder builder = new ProcessBuilder();
-             builder.command(commend);
-             process = builder.start();
-             // 如果删除下班这行写process.waitFor() ,太长的语音会阻塞,BufferedReader 打印出来太长的语音也会阻塞
-             process = Runtime.getRuntime().exec("taskkill -f -t -im silk_v3_encoder.exe");
-             */
-            // 方法2,除了会弹出弹窗,没什么问题 cmd /c 极为重要,执行完毕后会自动关闭
-            process = Runtime.getRuntime().exec("cmd /c start " + path + "silk_v3_encoder.exe " + pcmPath + " " + target + " -tencent");
-            process .waitFor();
+            File encoder = resolveSilkEncoder();
+            File pcmFile = new File(pcmPath).getCanonicalFile();
+            File targetFile = new File(target).getCanonicalFile();
+            assertSafeFileArg(pcmFile.getAbsolutePath(), "pcmPath");
+            assertSafeFileArg(targetFile.getAbsolutePath(), "target");
+
+            // H-04: 禁止 cmd /c start 字符串拼接,避免命令注入;参数列表直传不经 shell
+            ProcessBuilder builder = new ProcessBuilder(
+                    encoder.getAbsolutePath(),
+                    pcmFile.getAbsolutePath(),
+                    targetFile.getAbsolutePath(),
+                    "-tencent"
+            );
+            builder.redirectErrorStream(true);
+            process = builder.start();
+            try (java.io.BufferedReader reader = new java.io.BufferedReader(
+                    new java.io.InputStreamReader(process.getInputStream()))) {
+                while (reader.readLine() != null) {
+                    // 排空输出,避免管道阻塞
+                }
+            }
+            process.waitFor();
             Thread.sleep(1000);
-            // 有更好的方法会后续慢慢更新..
         } catch (Exception e) {
             e.printStackTrace();
         } finally {
@@ -185,6 +189,27 @@ public class AudioUtils {
         }
     }
 
+    private static File resolveSilkEncoder() throws java.io.IOException {
+        File base = new File(path).getCanonicalFile();
+        File encoder = new File(base, "silk_v3_encoder.exe").getCanonicalFile();
+        if (!encoder.getPath().startsWith(base.getPath())) {
+            throw new IllegalArgumentException("非法编码器路径");
+        }
+        if (!encoder.isFile()) {
+            throw new IllegalStateException("silk_v3_encoder.exe 不存在: " + encoder);
+        }
+        return encoder;
+    }
+
+    private static void assertSafeFileArg(String arg, String name) {
+        if (arg == null || arg.isEmpty()) {
+            throw new IllegalArgumentException(name + "不能为空");
+        }
+        if (arg.indexOf('\0') >= 0 || arg.matches(".*[;&|`$<>\\r\\n].*")) {
+            throw new IllegalArgumentException(name + "包含非法字符");
+        }
+    }
+
 
     /**
      * mp3转amr(低质量qq语音)

+ 6 - 4
fs-qwhook-msg/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -1,6 +1,7 @@
 package com.fs.framework.config;
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.parser.Feature;
 import com.alibaba.fastjson.parser.ParserConfig;
 import com.alibaba.fastjson.serializer.SerializerFeature;
 import com.fasterxml.jackson.databind.JavaType;
@@ -14,8 +15,9 @@ import java.nio.charset.Charset;
 
 /**
  * Redis使用FastJson序列化
- * 
-
+ *
+ * C-02 修正:保留 WriteClassName,使 RedisTemplate 能还原 LoginUser 等类型;
+ * AutoType 仅白名单 com.fs. 包,降低任意类反序列化 RCE 风险。
  */
 public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 {
@@ -29,6 +31,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
     static
     {
         ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)
@@ -55,8 +58,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
             return null;
         }
         String str = new String(bytes, DEFAULT_CHARSET);
-
-        return JSON.parseObject(str, clazz);
+        return JSON.parseObject(str, clazz, Feature.SupportAutoType);
     }
 
     public void setObjectMapper(ObjectMapper objectMapper)

+ 4 - 3
fs-qwhook-msg/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -8,9 +8,10 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
 
 /**
- * spring security配置
- *
-
+ * spring security配置(App/Webhook 端)
+ * <p>
+ * 业务鉴权由 {@code @Login} + AuthorizationInterceptor(或回调签名)完成。
+ * Spring Security 放行 HTTP,敏感接口必须打 {@code @Login} 或做签名校验。
  */
 @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
 public class SecurityConfig extends WebSecurityConfigurerAdapter

+ 43 - 18
fs-qwhook-sop/src/main/java/com/fs/app/utils/AudioUtils.java

@@ -158,25 +158,29 @@ public class AudioUtils {
     public static void transferPcmSilk(String pcmPath, String target) {
         Process process = null;
         try {
-            /**
-             // 1、这一节的,语音长度太长会使音频长度丢失
-             List<String> commend = new ArrayList<>();
-             // 指令,可参照方法注释, 请不要在commend.add()里同时写【-参数 值】
-             commend.add(path + "silk_v3_encoder.exe");
-             commend.add(pcmPath);
-             commend.add(target);
-             commend.add("-tencent");
-             ProcessBuilder builder = new ProcessBuilder();
-             builder.command(commend);
-             process = builder.start();
-             // 如果删除下班这行写process.waitFor() ,太长的语音会阻塞,BufferedReader 打印出来太长的语音也会阻塞
-             process = Runtime.getRuntime().exec("taskkill -f -t -im silk_v3_encoder.exe");
-             */
-            // 方法2,除了会弹出弹窗,没什么问题 cmd /c 极为重要,执行完毕后会自动关闭
-            process = Runtime.getRuntime().exec("cmd /c start " + path + "silk_v3_encoder.exe " + pcmPath + " " + target + " -tencent");
-            process .waitFor();
+            File encoder = resolveSilkEncoder();
+            File pcmFile = new File(pcmPath).getCanonicalFile();
+            File targetFile = new File(target).getCanonicalFile();
+            assertSafeFileArg(pcmFile.getAbsolutePath(), "pcmPath");
+            assertSafeFileArg(targetFile.getAbsolutePath(), "target");
+
+            // H-04: 禁止 cmd /c start 字符串拼接,避免命令注入;参数列表直传不经 shell
+            ProcessBuilder builder = new ProcessBuilder(
+                    encoder.getAbsolutePath(),
+                    pcmFile.getAbsolutePath(),
+                    targetFile.getAbsolutePath(),
+                    "-tencent"
+            );
+            builder.redirectErrorStream(true);
+            process = builder.start();
+            try (java.io.BufferedReader reader = new java.io.BufferedReader(
+                    new java.io.InputStreamReader(process.getInputStream()))) {
+                while (reader.readLine() != null) {
+                    // 排空输出,避免管道阻塞
+                }
+            }
+            process.waitFor();
             Thread.sleep(1000);
-            // 有更好的方法会后续慢慢更新..
         } catch (Exception e) {
             e.printStackTrace();
         } finally {
@@ -186,6 +190,27 @@ public class AudioUtils {
         }
     }
 
+    private static File resolveSilkEncoder() throws java.io.IOException {
+        File base = new File(path).getCanonicalFile();
+        File encoder = new File(base, "silk_v3_encoder.exe").getCanonicalFile();
+        if (!encoder.getPath().startsWith(base.getPath())) {
+            throw new IllegalArgumentException("非法编码器路径");
+        }
+        if (!encoder.isFile()) {
+            throw new IllegalStateException("silk_v3_encoder.exe 不存在: " + encoder);
+        }
+        return encoder;
+    }
+
+    private static void assertSafeFileArg(String arg, String name) {
+        if (arg == null || arg.isEmpty()) {
+            throw new IllegalArgumentException(name + "不能为空");
+        }
+        if (arg.indexOf('\0') >= 0 || arg.matches(".*[;&|`$<>\\r\\n].*")) {
+            throw new IllegalArgumentException(name + "包含非法字符");
+        }
+    }
+
 
     /**
      * mp3转amr(低质量qq语音)

+ 6 - 4
fs-qwhook-sop/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -1,6 +1,7 @@
 package com.fs.framework.config;
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.parser.Feature;
 import com.alibaba.fastjson.parser.ParserConfig;
 import com.alibaba.fastjson.serializer.SerializerFeature;
 import com.fasterxml.jackson.databind.JavaType;
@@ -14,8 +15,9 @@ import java.nio.charset.Charset;
 
 /**
  * Redis使用FastJson序列化
- * 
-
+ *
+ * C-02 修正:保留 WriteClassName,使 RedisTemplate 能还原 LoginUser 等类型;
+ * AutoType 仅白名单 com.fs. 包,降低任意类反序列化 RCE 风险。
  */
 public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 {
@@ -29,6 +31,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
     static
     {
         ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)
@@ -55,8 +58,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
             return null;
         }
         String str = new String(bytes, DEFAULT_CHARSET);
-
-        return JSON.parseObject(str, clazz);
+        return JSON.parseObject(str, clazz, Feature.SupportAutoType);
     }
 
     public void setObjectMapper(ObjectMapper objectMapper)

+ 4 - 3
fs-qwhook-sop/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -8,9 +8,10 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
 
 /**
- * spring security配置
- *
-
+ * spring security配置(App/Webhook 端)
+ * <p>
+ * 业务鉴权由 {@code @Login} + AuthorizationInterceptor(或回调签名)完成。
+ * Spring Security 放行 HTTP,敏感接口必须打 {@code @Login} 或做签名校验。
  */
 @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
 public class SecurityConfig extends WebSecurityConfigurerAdapter

+ 43 - 18
fs-qwhook/src/main/java/com/fs/app/utils/AudioUtils.java

@@ -157,25 +157,29 @@ public class AudioUtils {
     public static void transferPcmSilk(String pcmPath, String target) {
         Process process = null;
         try {
-            /**
-             // 1、这一节的,语音长度太长会使音频长度丢失
-             List<String> commend = new ArrayList<>();
-             // 指令,可参照方法注释, 请不要在commend.add()里同时写【-参数 值】
-             commend.add(path + "silk_v3_encoder.exe");
-             commend.add(pcmPath);
-             commend.add(target);
-             commend.add("-tencent");
-             ProcessBuilder builder = new ProcessBuilder();
-             builder.command(commend);
-             process = builder.start();
-             // 如果删除下班这行写process.waitFor() ,太长的语音会阻塞,BufferedReader 打印出来太长的语音也会阻塞
-             process = Runtime.getRuntime().exec("taskkill -f -t -im silk_v3_encoder.exe");
-             */
-            // 方法2,除了会弹出弹窗,没什么问题 cmd /c 极为重要,执行完毕后会自动关闭
-            process = Runtime.getRuntime().exec("cmd /c start " + path + "silk_v3_encoder.exe " + pcmPath + " " + target + " -tencent");
-            process .waitFor();
+            File encoder = resolveSilkEncoder();
+            File pcmFile = new File(pcmPath).getCanonicalFile();
+            File targetFile = new File(target).getCanonicalFile();
+            assertSafeFileArg(pcmFile.getAbsolutePath(), "pcmPath");
+            assertSafeFileArg(targetFile.getAbsolutePath(), "target");
+
+            // H-04: 禁止 cmd /c start 字符串拼接,避免命令注入;参数列表直传不经 shell
+            ProcessBuilder builder = new ProcessBuilder(
+                    encoder.getAbsolutePath(),
+                    pcmFile.getAbsolutePath(),
+                    targetFile.getAbsolutePath(),
+                    "-tencent"
+            );
+            builder.redirectErrorStream(true);
+            process = builder.start();
+            try (java.io.BufferedReader reader = new java.io.BufferedReader(
+                    new java.io.InputStreamReader(process.getInputStream()))) {
+                while (reader.readLine() != null) {
+                    // 排空输出,避免管道阻塞
+                }
+            }
+            process.waitFor();
             Thread.sleep(1000);
-            // 有更好的方法会后续慢慢更新..
         } catch (Exception e) {
             e.printStackTrace();
         } finally {
@@ -185,6 +189,27 @@ public class AudioUtils {
         }
     }
 
+    private static File resolveSilkEncoder() throws java.io.IOException {
+        File base = new File(path).getCanonicalFile();
+        File encoder = new File(base, "silk_v3_encoder.exe").getCanonicalFile();
+        if (!encoder.getPath().startsWith(base.getPath())) {
+            throw new IllegalArgumentException("非法编码器路径");
+        }
+        if (!encoder.isFile()) {
+            throw new IllegalStateException("silk_v3_encoder.exe 不存在: " + encoder);
+        }
+        return encoder;
+    }
+
+    private static void assertSafeFileArg(String arg, String name) {
+        if (arg == null || arg.isEmpty()) {
+            throw new IllegalArgumentException(name + "不能为空");
+        }
+        if (arg.indexOf('\0') >= 0 || arg.matches(".*[;&|`$<>\\r\\n].*")) {
+            throw new IllegalArgumentException(name + "包含非法字符");
+        }
+    }
+
 
     /**
      * mp3转amr(低质量qq语音)

+ 6 - 4
fs-qwhook/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -1,6 +1,7 @@
 package com.fs.framework.config;
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.parser.Feature;
 import com.alibaba.fastjson.parser.ParserConfig;
 import com.alibaba.fastjson.serializer.SerializerFeature;
 import com.fasterxml.jackson.databind.JavaType;
@@ -14,8 +15,9 @@ import java.nio.charset.Charset;
 
 /**
  * Redis使用FastJson序列化
- * 
-
+ *
+ * C-02 修正:保留 WriteClassName,使 RedisTemplate 能还原 LoginUser 等类型;
+ * AutoType 仅白名单 com.fs. 包,降低任意类反序列化 RCE 风险。
  */
 public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 {
@@ -29,6 +31,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
     static
     {
         ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)
@@ -55,8 +58,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
             return null;
         }
         String str = new String(bytes, DEFAULT_CHARSET);
-
-        return JSON.parseObject(str, clazz);
+        return JSON.parseObject(str, clazz, Feature.SupportAutoType);
     }
 
     public void setObjectMapper(ObjectMapper objectMapper)

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

@@ -8,9 +8,10 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
 
 /**
- * spring security配置
- *
-
+ * spring security配置(App/Webhook 端)
+ * <p>
+ * 业务鉴权由 {@code @Login} + AuthorizationInterceptor(或回调签名)完成。
+ * Spring Security 放行 HTTP,敏感接口必须打 {@code @Login} 或做签名校验。
  */
 @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
 public class SecurityConfig extends WebSecurityConfigurerAdapter

+ 6 - 4
fs-redis/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -1,6 +1,7 @@
 package com.fs.framework.config;
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.parser.Feature;
 import com.alibaba.fastjson.parser.ParserConfig;
 import com.alibaba.fastjson.serializer.SerializerFeature;
 import com.fasterxml.jackson.databind.JavaType;
@@ -14,8 +15,9 @@ import java.nio.charset.Charset;
 
 /**
  * Redis使用FastJson序列化
- * 
-
+ *
+ * C-02 修正:保留 WriteClassName,使 RedisTemplate 能还原 LoginUser 等类型;
+ * AutoType 仅白名单 com.fs. 包,降低任意类反序列化 RCE 风险。
  */
 public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 {
@@ -29,6 +31,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
     static
     {
         ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)
@@ -55,8 +58,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
             return null;
         }
         String str = new String(bytes, DEFAULT_CHARSET);
-
-        return JSON.parseObject(str, clazz);
+        return JSON.parseObject(str, clazz, Feature.SupportAutoType);
     }
 
     public void setObjectMapper(ObjectMapper objectMapper)

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

@@ -8,9 +8,10 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
 
 /**
- * spring security配置
- * 
-
+ * spring security配置(App/Webhook 端)
+ * <p>
+ * 业务鉴权由 {@code @Login} + AuthorizationInterceptor(或回调签名)完成。
+ * Spring Security 放行 HTTP,敏感接口必须打 {@code @Login} 或做签名校验。
  */
 @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
 public class SecurityConfig extends WebSecurityConfigurerAdapter

+ 6 - 4
fs-repeat-api/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -1,6 +1,7 @@
 package com.fs.framework.config;
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.parser.Feature;
 import com.alibaba.fastjson.parser.ParserConfig;
 import com.alibaba.fastjson.serializer.SerializerFeature;
 import com.fasterxml.jackson.databind.JavaType;
@@ -14,8 +15,9 @@ import java.nio.charset.Charset;
 
 /**
  * Redis使用FastJson序列化
- * 
-
+ *
+ * C-02 修正:保留 WriteClassName,使 RedisTemplate 能还原 LoginUser 等类型;
+ * AutoType 仅白名单 com.fs. 包,降低任意类反序列化 RCE 风险。
  */
 public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
 {
@@ -29,6 +31,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
     static
     {
         ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
+        ParserConfig.getGlobalInstance().addAccept("com.fs.");
     }
 
     public FastJson2JsonRedisSerializer(Class<T> clazz)
@@ -55,8 +58,7 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
             return null;
         }
         String str = new String(bytes, DEFAULT_CHARSET);
-
-        return JSON.parseObject(str, clazz);
+        return JSON.parseObject(str, clazz, Feature.SupportAutoType);
     }
 
     public void setObjectMapper(ObjectMapper objectMapper)

+ 4 - 3
fs-repeat-api/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -8,9 +8,10 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
 
 /**
- * spring security配置
- * 
-
+ * spring security配置(App/Webhook 端)
+ * <p>
+ * 业务鉴权由 {@code @Login} + AuthorizationInterceptor(或回调签名)完成。
+ * Spring Security 放行 HTTP,敏感接口必须打 {@code @Login} 或做签名校验。
  */
 @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
 public class SecurityConfig extends WebSecurityConfigurerAdapter

+ 22 - 12
fs-service/src/main/java/com/fs/ai/service/impl/BaiduAIServiceImpl.java

@@ -27,18 +27,28 @@ import java.util.*;
 @Service
 public class BaiduAIServiceImpl implements IBaiduAIService {
 
-    String clientId="LUP3P6NA0q9CEAmUhjnWXt4Q";
-    String clientSecret="Esxtj5QVM2klCZV1HfIRYutW5mJ2AP5G";
-    String grantType="client_credentials";
-    String plugins="4v4ki9a5t1hxdv92";
+    String grantType = "client_credentials";
 
+    private String clientId() {
+        return com.fs.common.config.EnvSecrets.get("BAIDU_AI_CLIENT_ID");
+    }
+
+    private String clientSecret() {
+        return com.fs.common.config.EnvSecrets.get("BAIDU_AI_CLIENT_SECRET");
+    }
 
-    String secretKey="bce-v3/ALTAK-P03N0q1ET2QKrbq3RGdgB/456be350fac6b3fe41ced46cada2cc287e04f5fc";
+    private String plugins() {
+        return com.fs.common.config.EnvSecrets.getOptional("BAIDU_AI_PLUGINS", "4v4ki9a5t1hxdv92");
+    }
+
+    private String secretKey() {
+        return com.fs.common.config.EnvSecrets.get("BAIDU_AI_SECRET_KEY");
+    }
 
     public static  BaiduAITokenVO vo=null;
 
     public BaiduAITokenVO getToken(){
-        String url="https://aip.baidubce.com/oauth/2.0/token?client_id="+clientId+"&client_secret="+clientSecret+"&grant_type="+grantType;
+        String url="https://aip.baidubce.com/oauth/2.0/token?client_id="+clientId()+"&client_secret="+clientSecret()+"&grant_type="+grantType;
         String result = HttpUtil.post(url, "");
         vo=JSONUtil.toBean(result,BaiduAITokenVO.class);
         return vo;
@@ -47,7 +57,7 @@ public class BaiduAIServiceImpl implements IBaiduAIService {
     @Override
     public BaiduAIMsgVO callAI(String msg) {
         this.getToken();
-        String url="https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/plugin/"+plugins+"/?access_token="+vo.getAccess_token();
+        String url="https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/plugin/"+plugins()+"/?access_token="+vo.getAccess_token();
         JSONObject param = JSONUtil.createObj();
         param.set("query", msg);
         param.set("verbose",false);
@@ -61,7 +71,7 @@ public class BaiduAIServiceImpl implements IBaiduAIService {
     @Override
     public String createDataset(String datasetName) {
 
-        Dataset dataset = new Dataset(secretKey);
+        Dataset dataset = new Dataset(secretKey());
         String datasetId = null;
         try {
             datasetId = dataset.createDataset(datasetName);
@@ -78,7 +88,7 @@ public class BaiduAIServiceImpl implements IBaiduAIService {
 
     @Override
     public void delDocument(String datasetId, String docId) {
-        Dataset dataset = new Dataset(secretKey,datasetId);
+        Dataset dataset = new Dataset(secretKey(),datasetId);
         try {
             dataset.deleteDocument(docId);
         } catch (IOException e) {
@@ -91,7 +101,7 @@ public class BaiduAIServiceImpl implements IBaiduAIService {
 
     @Override
     public String[] addDocument(String datasetId, String file) {
-        Dataset dataset = new Dataset(secretKey,datasetId);
+        Dataset dataset = new Dataset(secretKey(),datasetId);
         String[] documentIds = new String[0];
         try {
             documentIds = dataset.addDocuments(new ArrayList<>(Collections.singletonList(file)), false, null, false);
@@ -105,7 +115,7 @@ public class BaiduAIServiceImpl implements IBaiduAIService {
 
     @Override
     public DocumentListResponse getDocument(String datasetId,int page, int limit, String keywork) {
-        Dataset dataset = new Dataset(secretKey,datasetId);
+        Dataset dataset = new Dataset(secretKey(),datasetId);
         try {
             return dataset.getDocumentList(page,limit,"");
         } catch (IOException e) {
@@ -118,7 +128,7 @@ public class BaiduAIServiceImpl implements IBaiduAIService {
 
     @Override
     public BaiduAIMsgResultVO run(String appId, String conversationId, String msg) {
-        AgentBuilder agentBuilder = new AgentBuilder(appId,secretKey);
+        AgentBuilder agentBuilder = new AgentBuilder(appId,secretKey());
         BaiduAIMsgResultVO vo=new BaiduAIMsgResultVO();
 //        String conversationId = "9c2bea5f-595d-4952-a8de-4b5eca878fde";
         try {

+ 27 - 0
fs-service/src/main/java/com/fs/common/config/EnvSecrets.java

@@ -0,0 +1,27 @@
+package com.fs.common.config;
+
+/**
+ * 从进程环境变量读取密钥(C-03)。
+ * 启动前请加载 env.secrets,或在 IDE/系统中配置同名环境变量。
+ */
+public final class EnvSecrets {
+
+    private EnvSecrets() {
+    }
+
+    public static String get(String name) {
+        String value = System.getenv(name);
+        if (value == null || value.trim().isEmpty()) {
+            throw new IllegalStateException("缺少环境变量: " + name + ",请参考项目根目录 env.secrets.example");
+        }
+        return value.trim();
+    }
+
+    public static String getOptional(String name, String defaultValue) {
+        String value = System.getenv(name);
+        if (value == null || value.trim().isEmpty()) {
+            return defaultValue;
+        }
+        return value.trim();
+    }
+}

+ 29 - 0
fs-service/src/main/java/com/fs/common/config/ExternalSecretConfig.java

@@ -0,0 +1,29 @@
+package com.fs.common.config;
+
+import com.fs.feishu.util.SecureTokenUtil;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+
+import javax.annotation.PostConstruct;
+
+/**
+ * 将环境变量/配置注入到静态工具类(飞书 Token AES 等)。
+ * YAML 中使用 ${ENV_NAME:},由环境变量覆盖。
+ */
+@Configuration
+public class ExternalSecretConfig {
+
+    @Value("${feishu.token.aes-key:}")
+    private String feishuAesKey;
+
+    @PostConstruct
+    public void init() {
+        String key = feishuAesKey;
+        if (key == null || key.trim().isEmpty()) {
+            key = System.getenv("FEISHU_TOKEN_AES_KEY");
+        }
+        if (key != null && !key.trim().isEmpty()) {
+            SecureTokenUtil.setSecretKey(key.trim());
+        }
+    }
+}

+ 4 - 0
fs-service/src/main/java/com/fs/company/domain/Company.java

@@ -112,6 +112,10 @@ public class Company extends BaseEntity
      * 点播配置-公众号appId
      */
     private String courseMaAppId;
+    /**
+     * 口令看课开关 0关闭 1开启(开启后正课复制出口令,关闭则生成小程序看课链接)
+     */
+    private Integer watchPasswordEnabled;
     /**
      * 点播配置-小程序appId
      */

+ 2 - 2
fs-service/src/main/java/com/fs/company/mapper/CompanyDeptMapper.java

@@ -102,7 +102,7 @@ public interface CompanyDeptMapper
      * @param companyId 公司id
      * @return 部门数据
      */
-    @Select("select * from company_dept where company_id = ${companyId}")
+    @Select("select * from company_dept where company_id = #{companyId}")
     List<CompanyDept> selectDeptDataByCompanyId(@Param("companyId") Long companyId);
 
     @Select("select dept_id from company_dept where parent_id=#{parentId}")
@@ -115,7 +115,7 @@ public interface CompanyDeptMapper
     @Select("select * from company_dept where status='0' and del_flag='0'")
     List<CompanyDept> queryDeptDataAll();
 
-    @Select("select dept_name from company_dept where dept_id=${deptId} limit 1")
+    @Select("select dept_name from company_dept where dept_id=#{deptId} limit 1")
     String selectDeptNameById(@Param("deptId") Long deptId);
 
     @Select("select dept_id,dept_name from company_dept where dept_name=#{deptName} limit 1")

+ 1 - 1
fs-service/src/main/java/com/fs/company/mapper/CompanySmsLogsMapper.java

@@ -123,7 +123,7 @@ public interface CompanySmsLogsMapper
             "and l.status = #{maps.status}" +
             "</if>" +
             "<if test = 'maps.userNickName != null and maps.userNickName != \"\" '> " +
-            "and u.nick_name like '%${maps.userNickName}%' " +
+            "and u.nick_name like concat('%', #{maps.userNickName}, '%') " +
             "</if>" +
             "<if test = 'maps.customerNo != null  and maps.customerNo!=\"\"  '> " +
             "and cc.customer_no = #{maps.customerNo}" +

+ 4 - 4
fs-service/src/main/java/com/fs/company/mapper/CompanyTcmReportMapper.java

@@ -83,7 +83,7 @@ public interface CompanyTcmReportMapper
             "</if>" +
 
             "<if test = 'maps.userNickName != null and maps.userNickName != \"\" '> " +
-            "and u.nick_name like '%${maps.userNickName}%'" +
+            "and u.nick_name like concat('%', #{maps.userNickName}, '%')" +
             "</if>" +
             "<if test = 'maps.companyUserId != null '> " +
             " and r.company_user_id = #{maps.companyUserId}" +
@@ -121,7 +121,7 @@ public interface CompanyTcmReportMapper
             "</if>" +
 
             "<if test = 'maps.userNickName != null and maps.userNickName != \"\" '> " +
-            "and u.nick_name like '%${maps.userNickName}%'" +
+            "and u.nick_name like concat('%', #{maps.userNickName}, '%')" +
             "</if>" +
             "<if test = 'maps.companyUserId != null '> " +
             " and r.company_user_id = #{maps.companyUserId}" +
@@ -169,7 +169,7 @@ public interface CompanyTcmReportMapper
             "</if>" +
 
             "<if test = 'maps.userNickName != null and maps.userNickName != \"\" '> " +
-            "and comu.nick_name like '%${maps.userNickName}%'" +
+            "and comu.nick_name like concat('%', #{maps.userNickName}, '%')" +
             "</if>" +
 
             "<if test = 'maps.scheduleId != null   and maps.scheduleId != \"\"     '> " +
@@ -246,7 +246,7 @@ public interface CompanyTcmReportMapper
             "<foreach collection=\"maps.companyId.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
             "</if>" +
             "<if test = 'maps.userNickName != null and maps.userNickName != \"\" '> " +
-            "and cu.nick_name like '%${maps.userNickName}%'" +
+            "and cu.nick_name like concat('%', #{maps.userNickName}, '%')" +
             "</if>" +
             "<if test = 'maps.companyUserId != null '> " +
             " and r.company_user_id = #{maps.companyUserId}" +

+ 2 - 2
fs-service/src/main/java/com/fs/company/mapper/CompanyUserMapper.java

@@ -236,10 +236,10 @@ public interface CompanyUserMapper
     @Select("select * from company_user where user_id=#{userId}")
     CompanyUser selectCompanyUserByUserId(Long userId);
 
-    @Select("select concat(nick_name,'_',user_name) from company_user where user_id=${userId} limit 1")
+    @Select("select concat(nick_name,'_',user_name) from company_user where user_id=#{userId} limit 1")
     String selectCompanyUserNameUserById(Long userId);
 
-    @Select("select distinct user_id from company_user where parent_id=${companyUserId}")
+    @Select("select distinct user_id from company_user where parent_id=#{companyUserId}")
     List<Long> selectUserAllCompanyUserId(@Param("companyUserId") Long companyUserId);
 
     List<CompanyUser> getAllUserListLimit(@Param("companyId") Long companyId,

+ 3 - 3
fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceCallerMapper.java

@@ -74,13 +74,13 @@ public interface CompanyVoiceCallerMapper
             "and vc.company_user_id = #{maps.companyUserId}" +
             "</if>" +
             "<if test = 'maps.callerNo != null and maps.callerNo != \"\" '> " +
-            "and vc.caller_no like '%${maps.callerNo}%' " +
+            "and vc.caller_no like concat('%', #{maps.callerNo}, '%') " +
             "</if>" +
             "<if test = 'maps.mobile != null and maps.mobile != \"\" '> " +
-            "and vc.mobile like '%${maps.mobile}%' " +
+            "and vc.mobile like concat('%', #{maps.mobile}, '%') " +
             "</if>" +
             "<if test = 'maps.companyUserNickName != null and maps.companyUserNickName != \"\" '> " +
-            "and u.nick_name like '%${maps.companyUserNickName}%' " +
+            "and u.nick_name like concat('%', #{maps.companyUserNickName}, '%') " +
             "</if>" +
             "<if test = 'maps.status != null '> " +
             "and vc.status = #{maps.status}" +

+ 6 - 6
fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceLogsMapper.java

@@ -83,13 +83,13 @@ public interface CompanyVoiceLogsMapper
             "and l.customer_id = #{maps.customerId}" +
             "</if>" +
             "<if test = 'maps.userNickName != null and maps.userNickName != \"\" '> " +
-            "and u.nick_name like '%${maps.userNickName}%' " +
+            "and u.nick_name like concat('%', #{maps.userNickName}, '%') " +
             "</if>" +
             "<if test = 'maps.callerPhone != null and maps.callerPhone != \"\" '> " +
-            "and l.caller_phone like '%${maps.callerPhone}%' " +
+            "and l.caller_phone like concat('%', #{maps.callerPhone}, '%') " +
             "</if>" +
             "<if test = 'maps.calleePhone != null and maps.calleePhone != \"\" '> " +
-            "and l.callee_phone like '%${maps.calleePhone}%' " +
+            "and l.callee_phone like concat('%', #{maps.calleePhone}, '%') " +
             "</if>" +
             "<if test = 'maps.status != null '> " +
             "and l.status = #{maps.status}" +
@@ -128,13 +128,13 @@ public interface CompanyVoiceLogsMapper
             "and l.customer_id = #{maps.customerId}" +
             "</if>" +
             "<if test = 'maps.userNickName != null and maps.userNickName != \"\" '> " +
-            "and u.nick_name like '%${maps.userNickName}%' " +
+            "and u.nick_name like concat('%', #{maps.userNickName}, '%') " +
             "</if>" +
             "<if test = 'maps.callerPhone != null and maps.callerPhone != \"\" '> " +
-            "and l.caller_phone like '%${maps.callerPhone}%' " +
+            "and l.caller_phone like concat('%', #{maps.callerPhone}, '%') " +
             "</if>" +
             "<if test = 'maps.calleePhone != null and maps.calleePhone != \"\" '> " +
-            "and l.callee_phone like '%${maps.calleePhone}%' " +
+            "and l.callee_phone like concat('%', #{maps.calleePhone}, '%') " +
             "</if>" +
             "<if test = 'maps.status != null '> " +
             "and l.status = #{maps.status}" +

+ 1 - 1
fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceMobileMapper.java

@@ -66,7 +66,7 @@ public interface CompanyVoiceMobileMapper
             "from company_voice_mobile m left join company_voice_api a  on a.api_id=m.api_id left join company c  on c.company_id=m.company_id  " +
             "where 1=1 " +
             "<if test = 'maps.mobile != null and maps.mobile != \"\" '> " +
-            "and m.mobile like '%${maps.mobile}%'" +
+            "and m.mobile like concat('%', #{maps.mobile}, '%')" +
             "</if>" +
             "<if test = 'maps.apiId != null  '> " +
             "and m.api_id = #{maps.apiId}" +

+ 5 - 0
fs-service/src/main/java/com/fs/company/service/ICompanyService.java

@@ -180,6 +180,11 @@ public interface ICompanyService
      */
     void changeRedPacketState(Long companyId, Integer state);
 
+    /**
+     * 修改公司口令看课开关
+     */
+    void changeWatchPasswordEnabled(Long companyId, Integer enabled);
+
     /**
      * 自动关闭红包余额小于等于0的销售公司红包开关
      */

+ 20 - 0
fs-service/src/main/java/com/fs/company/service/impl/CompanyServiceImpl.java

@@ -865,6 +865,9 @@ public class CompanyServiceImpl implements ICompanyService
             companyVO.setRedPackageMoney(companyRedPacketBalanceUtil.getCacheRedPacketBalance(companyVO.getCompanyId()));
             // 查询使用的小程序
             companyVO.setWatchMiniAppName(companyMiniappService.getFirstWatchMiniAppNameByCompanyId(companyVO.getCompanyId()));
+            if (companyVO.getWatchPasswordEnabled() == null) {
+                companyVO.setWatchPasswordEnabled(0);
+            }
         });
         return companyVOList;
     }
@@ -1502,6 +1505,23 @@ public class CompanyServiceImpl implements ICompanyService
         }
     }
 
+    /**
+     * 修改公司口令看课开关
+     */
+    @Transactional(rollbackFor = Exception.class)
+    @Override
+    public void changeWatchPasswordEnabled(Long companyId, Integer enabled) {
+        Company company = companyMapper.selectCompanyById(companyId);
+        if (company == null || !Arrays.asList(0, 1).contains(enabled)) {
+            throw new RuntimeException("参数错误");
+        }
+        if (Integer.valueOf(1).equals(enabled) && StringUtils.isEmpty(company.getCourseMaAppId())) {
+            throw new RuntimeException("请先在公司编辑中绑定服务号点播配置,再开启口令看课");
+        }
+        company.setWatchPasswordEnabled(enabled);
+        companyMapper.updateCompany(company);
+    }
+
     /**
      * 自动关闭红包余额小于等于0的销售公司红包开关
      */

+ 1 - 7
fs-service/src/main/java/com/fs/company/service/impl/CompanyUserServiceImpl.java

@@ -1002,13 +1002,7 @@ public class CompanyUserServiceImpl implements ICompanyUserService
     }
     @Override
     public String uploadQrCode(MultipartFile file, String userId) throws IOException {
-        if (file.isEmpty())
-        {
-            throw new OssException("上传文件不能为空");
-        }
-        // 上传文件
-        String fileName = file.getOriginalFilename();
-        String suffix = fileName.substring(fileName.lastIndexOf("."));
+        String suffix = com.fs.common.utils.file.OssUploadUtils.validateAndGetImageSuffix(file);
         CloudStorageService storage = OSSFactory.build();
         String url = storage.uploadSuffix(file.getBytes(), suffix);
 

+ 10 - 0
fs-service/src/main/java/com/fs/company/vo/CompanyVO.java

@@ -108,4 +108,14 @@ public class CompanyVO implements Serializable
      * 公司看课小程序(有主显示主,没主显示副,都没有显示空)
      */
     private String watchMiniAppName;
+
+    /**
+     * 服务号点播配置 appId(口令看课归属)
+     */
+    private String courseMaAppId;
+
+    /**
+     * 口令看课开关 0关闭 1开启
+     */
+    private Integer watchPasswordEnabled;
 }

+ 3 - 30
fs-service/src/main/java/com/fs/core/config/WxMpConfiguration.java

@@ -5,14 +5,10 @@ import lombok.AllArgsConstructor;
 import me.chanjar.weixin.mp.api.WxMpMessageRouter;
 import me.chanjar.weixin.mp.api.WxMpService;
 import me.chanjar.weixin.mp.api.impl.WxMpServiceImpl;
-import me.chanjar.weixin.mp.config.impl.WxMpDefaultConfigImpl;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 
-import java.util.List;
-import java.util.stream.Collectors;
-
 import static me.chanjar.weixin.common.api.WxConsts.EventType;
 import static me.chanjar.weixin.common.api.WxConsts.EventType.SUBSCRIBE;
 import static me.chanjar.weixin.common.api.WxConsts.EventType.UNSUBSCRIBE;
@@ -36,36 +32,13 @@ public class WxMpConfiguration {
     private final UnsubscribeHandler unsubscribeHandler;
     private final SubscribeHandler subscribeHandler;
     private final ScanHandler scanHandler;
-    private final WxMpProperties properties;
+    private final WxMpDynamicConfigService wxMpDynamicConfigService;
 
     @Bean
     public WxMpService wxMpService() {
-        // 代码里 getConfigs()处报错的同学,请注意仔细阅读项目说明,你的IDE需要引入lombok插件!!!!
-        final List<WxMpProperties.MpConfig> configs = this.properties.getConfigs();
-        if (configs == null) {
-            throw new RuntimeException("大哥,拜托先看下项目首页的说明(readme文件),添加下相关配置,注意别配错了!");
-        }
-
         WxMpService service = new WxMpServiceImpl();
-        service.setMultiConfigStorages(configs
-            .stream().map(a -> {
-                WxMpDefaultConfigImpl configStorage;
-//                if (this.properties.isUseRedis()) {
-//                    final WxMpProperties.RedisConfig redisConfig = this.properties.getRedisConfig();
-//                    JedisPoolConfig poolConfig = new JedisPoolConfig();
-//                    JedisPool jedisPool = new JedisPool(poolConfig, redisConfig.getHost(), redisConfig.getPort(),
-//                        redisConfig.getTimeout(), redisConfig.getPassword());
-//                    configStorage = new WxMpRedisConfigImpl(new JedisWxRedisOps(jedisPool), a.getAppId());
-//                } else {
-//                    configStorage = new WxMpDefaultConfigImpl();
-//                }
-                    configStorage = new WxMpDefaultConfigImpl();
-                configStorage.setAppId(a.getAppId());
-                configStorage.setSecret(a.getSecret());
-                configStorage.setToken(a.getToken());
-                configStorage.setAesKey(a.getAesKey());
-                return configStorage;
-            }).collect(Collectors.toMap(WxMpDefaultConfigImpl::getAppId, a -> a, (o, n) -> o)));
+        // courseMa.config type=2 + yml wx.mp.configs
+        wxMpDynamicConfigService.applyTo(service);
         return service;
     }
 

+ 187 - 0
fs-service/src/main/java/com/fs/core/config/WxMpDynamicConfigService.java

@@ -0,0 +1,187 @@
+package com.fs.core.config;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.utils.StringUtils;
+import com.fs.course.config.CourseMaConfig;
+import com.fs.system.service.ISysConfigService;
+import lombok.extern.slf4j.Slf4j;
+import me.chanjar.weixin.mp.api.WxMpService;
+import me.chanjar.weixin.mp.config.WxMpConfigStorage;
+import me.chanjar.weixin.mp.config.impl.WxMpDefaultConfigImpl;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 从 courseMa.config(type=2) + yml wx.mp.configs 动态组装多服务号配置。
+ * <p>
+ * WxJava 多账号时通过 ThreadLocal({@code WxMpConfigStorageHolder}) 取当前号;
+ * 请求线程未 switchover 时 {@code getWxMpConfigStorage()} 会为 null(典型 NPE:OAuth getAccessToken)。
+ * 默认账号固定为 yml 第一个(老授权服务号)。
+ */
+@Slf4j
+@Service
+public class WxMpDynamicConfigService {
+
+    @Autowired
+    private WxMpProperties properties;
+    @Autowired
+    private ISysConfigService configService;
+
+    /** yml 第一个公众号,兼容旧授权登录 */
+    private volatile String defaultAuthAppId;
+
+    /**
+     * 构建多账号 storage;yml 先入,courseMa type=2 追加;同 appId 合并时不覆盖空 secret。
+     */
+    public Map<String, WxMpConfigStorage> buildStorages() {
+        Map<String, WxMpConfigStorage> map = new LinkedHashMap<>();
+        String firstYmlAppId = null;
+
+        // 1) yml 兜底(老授权号等)
+        List<WxMpProperties.MpConfig> ymlConfigs = properties != null ? properties.getConfigs() : null;
+        if (ymlConfigs != null) {
+            for (WxMpProperties.MpConfig a : ymlConfigs) {
+                if (a == null || StringUtils.isEmpty(a.getAppId())) {
+                    continue;
+                }
+                if (firstYmlAppId == null) {
+                    firstYmlAppId = a.getAppId();
+                }
+                map.put(a.getAppId(), toStorage(a.getAppId(), a.getSecret(), a.getToken(), a.getAesKey()));
+            }
+        }
+
+        // 2) courseMa.config type=2 覆盖/追加(secret/token/aesKey 为空时保留已有值)
+        String json = configService.selectConfigByKey("courseMa.config");
+        if (StringUtils.isNotEmpty(json)) {
+            try {
+                List<CourseMaConfig> list = JSON.parseArray(json, CourseMaConfig.class);
+                if (list != null) {
+                    for (CourseMaConfig cfg : list) {
+                        if (cfg == null || StringUtils.isEmpty(cfg.getAppid())) {
+                            continue;
+                        }
+                        // type=2 为服务号;兼容数字/字符串
+                        String type = cfg.getType() == null ? "" : String.valueOf(cfg.getType()).trim();
+                        if (!"2".equals(type)) {
+                            continue;
+                        }
+                        map.put(cfg.getAppid().trim(), mergeStorage(map.get(cfg.getAppid().trim()),
+                                cfg.getAppid().trim(), cfg.getSecret(), cfg.getToken(), cfg.getAesKey()));
+                    }
+                }
+            } catch (Exception e) {
+                log.error("解析 courseMa.config 失败,服务号将仅使用 yml wx.mp.configs", e);
+            }
+        }
+
+        this.defaultAuthAppId = firstYmlAppId != null ? firstYmlAppId
+                : (map.isEmpty() ? null : map.keySet().iterator().next());
+        return map;
+    }
+
+    public String getDefaultAuthAppId() {
+        if (StringUtils.isEmpty(defaultAuthAppId)) {
+            buildStorages();
+        }
+        return defaultAuthAppId;
+    }
+
+    public void applyTo(WxMpService service) {
+        Map<String, WxMpConfigStorage> map = buildStorages();
+        if (map.isEmpty()) {
+            throw new IllegalStateException(
+                    "未找到可用服务号配置:请在系统参数 courseMa.config 配置 type=2,或在 yml 配置 wx.mp.configs");
+        }
+        String defaultId = getDefaultAuthAppId();
+        if (StringUtils.isEmpty(defaultId) || !map.containsKey(defaultId)) {
+            defaultId = map.keySet().iterator().next();
+            this.defaultAuthAppId = defaultId;
+        }
+        // 显式指定默认号,避免 ThreadLocal 未设置时取不到配置
+        service.setMultiConfigStorages(map, defaultId);
+        log.info("已加载微信服务号配置 {} 个, defaultAuthAppId={}: {}", map.size(), defaultId, map.keySet());
+    }
+
+    /**
+     * 切换到指定 appId;若不存在则先 reload 再试。
+     *
+     * @return true 切换成功
+     */
+    public boolean switchoverOrReload(WxMpService service, String appId) {
+        if (StringUtils.isEmpty(appId) || service == null) {
+            return false;
+        }
+        if (service.switchover(appId)) {
+            return true;
+        }
+        log.info("服务号 {} 未在内存中,尝试从 courseMa.config 重新加载", appId);
+        return forceReloadAndSwitchover(service, appId);
+    }
+
+    /**
+     * 强制从 courseMa.config / yml 重建后再切换(改 token 后无需重启即可生效)。
+     */
+    public boolean forceReloadAndSwitchover(WxMpService service, String appId) {
+        if (StringUtils.isEmpty(appId) || service == null) {
+            return false;
+        }
+        try {
+            applyTo(service);
+        } catch (Exception e) {
+            log.error("重新加载服务号配置失败, appId={}", appId, e);
+            return false;
+        }
+        boolean ok = service.switchover(appId);
+        if (!ok) {
+            log.warn("强制重载后仍无法切换到服务号 appId={}", appId);
+        }
+        return ok;
+    }
+
+    /**
+     * 当前请求线程绑定服务号:优先入参 appId,否则默认授权号(yml 第一项)。
+     * 多服务号场景下 OAuth/Jsapi 等调用前必须执行,否则 getWxMpConfigStorage() 为 null。
+     */
+    public void ensureMpForRequest(WxMpService service, String appId) {
+        String target = StringUtils.isNotEmpty(appId) ? appId : getDefaultAuthAppId();
+        if (StringUtils.isEmpty(target)) {
+            throw new IllegalStateException("未配置默认授权服务号(wx.mp.configs)");
+        }
+        if (!switchoverOrReload(service, target)) {
+            throw new IllegalStateException("未找到服务号配置: " + target);
+        }
+    }
+
+    private static WxMpDefaultConfigImpl mergeStorage(WxMpConfigStorage existing,
+                                                      String appId, String secret, String token, String aesKey) {
+        String s = secret;
+        String t = token;
+        String a = aesKey;
+        if (existing != null) {
+            if (StringUtils.isEmpty(s)) {
+                s = existing.getSecret();
+            }
+            if (StringUtils.isEmpty(t)) {
+                t = existing.getToken();
+            }
+            if (StringUtils.isEmpty(a)) {
+                a = existing.getAesKey();
+            }
+        }
+        return toStorage(appId, s, t, a);
+    }
+
+    private static WxMpDefaultConfigImpl toStorage(String appId, String secret, String token, String aesKey) {
+        WxMpDefaultConfigImpl storage = new WxMpDefaultConfigImpl();
+        storage.setAppId(appId == null ? null : appId.trim());
+        storage.setSecret(secret == null ? null : secret.trim());
+        storage.setToken(token == null ? null : token.trim());
+        storage.setAesKey(aesKey == null ? null : aesKey.trim());
+        return storage;
+    }
+}

+ 9 - 11
fs-service/src/main/java/com/fs/course/GetObjectDemo.java

@@ -17,31 +17,29 @@ import com.qcloud.cos.region.Region;
 import com.qcloud.cos.utils.IOUtils;
 
 public class GetObjectDemo {
-    private static String secretId = "AKIDiMq9lDf2EOM9lIfqqfKo7FNgM5meD0sT";
-    private static String secretKey = "u5SuS80342xzx8FRBukza9lVNHKNMSaB";
     private static String bucketName = "hylj-1323137866";
     private static String region = "ap-chongqing";
-    private static COSClient cosClient = createClient();
+    private static COSClient cosClient;
 
     public static void main(String[] args) {
         try {
+            cosClient = createClient();
             getObjectInputStreamDemo();
         } catch (Exception e) {
             e.printStackTrace();
         } finally {
-            cosClient.shutdown();
+            if (cosClient != null) {
+                cosClient.shutdown();
+            }
         }
     }
 
     private static COSClient createClient() {
-        // 初始化用户身份信息(secretId, secretKey)
-        COSCredentials cred = new BasicCOSCredentials(secretId,secretKey);
-        // 设置bucket的区域, COS地域的简称请参照 https://www.qcloud.com/document/product/436/6224
+        COSCredentials cred = new BasicCOSCredentials(
+                com.fs.common.config.EnvSecrets.get("TENCENT_COS_SECRET_ID"),
+                com.fs.common.config.EnvSecrets.get("TENCENT_COS_SECRET_KEY"));
         ClientConfig clientConfig = new ClientConfig(new Region(region));
-        // 生成cos客户端
-        COSClient cosclient = new COSClient(cred, clientConfig);
-
-        return cosclient;
+        return new COSClient(cred, clientConfig);
     }
 
 //    private static void getObjectToFileDemo() {

+ 36 - 0
fs-service/src/main/java/com/fs/course/config/CourseConfig.java

@@ -97,6 +97,42 @@ public class CourseConfig implements Serializable {
      */
     private String isRedPackageBalanceDeduction;
 
+    /**
+     * 服务号口令看课:小程序卡片封面素材 media_id(上传到对应服务号素材库)
+     */
+    private String watchPasswordThumbMediaId;
+
+    /**
+     * 服务号关注海报本地根目录,文件名为 {appId}.png 或 {appId}.jpg
+     * 例:C:/Users/Administrator/Desktop/mp-poster
+     * (目录下放置 {服务号appId}.png|jpg;也可直接填图片完整路径)
+     */
+    private String mpPosterDir;
+
+    /**
+     * 服务号关注海报底部提示文案(仅底部拼接模式使用)
+     */
+    private String mpPosterTip;
+
+    /**
+     * 二维码布局:auto=自动识别底图白框;frame=使用下方手动坐标;bottom=拼在海报底部(旧逻辑)
+     */
+    private String mpPosterQrLayout;
+
+    /**
+     * 手动布局:二维码左上角 X(像素,相对底图)
+     */
+    private Integer mpPosterQrX;
+
+    /**
+     * 手动布局:二维码左上角 Y(像素,相对底图)
+     */
+    private Integer mpPosterQrY;
+
+    /**
+     * 手动布局:二维码边长(像素)
+     */
+    private Integer mpPosterQrSize;
 
     @Data
     public static class DisabledTimeVo{

+ 5 - 0
fs-service/src/main/java/com/fs/course/config/CourseMaConfig.java

@@ -50,4 +50,9 @@ public class CourseMaConfig {
      */
     private String username;
 
+    /**
+     * 口令看课小程序卡片封面:该服务号素材库中的 media_id(type=2 时使用)
+     */
+    private String thumbMediaId;
+
 }

+ 43 - 0
fs-service/src/main/java/com/fs/course/domain/FsCourseWatchPassword.java

@@ -0,0 +1,43 @@
+package com.fs.course.domain;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 正课看课口令 fs_course_watch_password
+ */
+@Data
+public class FsCourseWatchPassword implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private Long id;
+    /** 看课口令 */
+    private String password;
+    private Long companyId;
+    private Long companyUserId;
+    /** 营期课节天ID */
+    private Long daysId;
+    private Long periodId;
+    private Long courseId;
+    private Long videoId;
+    /** 卡片标题 */
+    private String title;
+    /** 看课参数快照 JSON */
+    private String courseJson;
+    /** 小程序 pagepath */
+    private String pagePath;
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date expireTime;
+    /** 1有效 0失效 */
+    private Integer status;
+    private Integer useCount;
+    /** 0=不限制 */
+    private Integer maxUse;
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date updateTime;
+}

+ 7 - 3
fs-service/src/main/java/com/fs/course/domain/FsUserCoursePeriodDays.java

@@ -83,9 +83,13 @@ public class FsUserCoursePeriodDays extends BaseEntityTow {
     @TableField(exist = false)
     private List<Long> videoIds;
 
-    /** 未注册链接视频ID列表 */
+    /** 勾选为注册课的小节视频ID列表(不勾选默认正课) */
     @TableField(exist = false)
-    private  List<Long> noRegisterVideoIds;
+    private List<Long> registerVideoIds;
+
+    /** @deprecated 兼容旧入参,请用 registerVideoIds */
+    @TableField(exist = false)
+    private List<Long> noRegisterVideoIds;
 
     @TableField(exist = false)
     private String courseName;
@@ -105,7 +109,7 @@ public class FsUserCoursePeriodDays extends BaseEntityTow {
     private Long projectId;
 
     /*
-     * 注册链接开关;0关闭1开启
+     * 课型:0正课(默认) 1注册课
      */
     private  Integer  registrationSwitch;
 

+ 21 - 0
fs-service/src/main/java/com/fs/course/mapper/FsCourseWatchPasswordMapper.java

@@ -0,0 +1,21 @@
+package com.fs.course.mapper;
+
+import com.fs.course.domain.FsCourseWatchPassword;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * 正课看课口令 Mapper
+ */
+public interface FsCourseWatchPasswordMapper {
+
+    int insertFsCourseWatchPassword(FsCourseWatchPassword record);
+
+    FsCourseWatchPassword selectValidByPassword(@Param("password") String password);
+
+    FsCourseWatchPassword selectValidByCompanyAndPassword(@Param("companyId") Long companyId,
+                                                          @Param("password") String password);
+
+    int countByPassword(@Param("password") String password);
+
+    int incrementUseCount(@Param("id") Long id);
+}

+ 5 - 0
fs-service/src/main/java/com/fs/course/mapper/FsSalesUserPeriodRelationMapper.java

@@ -98,4 +98,9 @@ public interface FsSalesUserPeriodRelationMapper {
      */
     public FsSalesUserPeriodRelation selectByUserPeriodAndDays(@Param("userId") Long userId, @Param("periodId") Long periodId,
                                                                @Param("daysId") Long daysId);
+
+    /**
+     * 是否已绑定某销售(任意营期/课节即可,用于跨营期放行看课)
+     */
+    FsSalesUserPeriodRelation selectBySalesAndUser(@Param("salesId") Long salesId, @Param("userId") Long userId);
 }

+ 1 - 1
fs-service/src/main/java/com/fs/course/mapper/FsUserCoursePeriodMapper.java

@@ -131,7 +131,7 @@ public interface FsUserCoursePeriodMapper
     void endPeriod(@Param("now") LocalDate now);
 
 
-    @Select("select * from fs_user_course_period where find_in_set(${companyId},company_id) and #{previousDay} >= period_starting_time and #{previousDay} <= period_end_time ")
+    @Select("select * from fs_user_course_period where find_in_set(#{companyId},company_id) and #{previousDay} >= period_starting_time and #{previousDay} <= period_end_time ")
     List<Long> queryPeriod(@Param("companyId") Long companyId,
                            @Param("previousDay") LocalDate previousDay);
 

+ 1 - 0
fs-service/src/main/java/com/fs/course/param/FsCourseWatchLogListParam.java

@@ -106,6 +106,7 @@ public class FsCourseWatchLogListParam extends BaseEntity implements Serializabl
     private Long deptId;
     private List<Long> deptIds;
     private String ids;
+    private List<String> idsList;
 
     private Integer watchType;
 

+ 46 - 0
fs-service/src/main/java/com/fs/course/param/newfs/FsCourseWatchShareParam.java

@@ -0,0 +1,46 @@
+package com.fs.course.param.newfs;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+/**
+ * 注册课小程序链接 / 正课口令 入参
+ */
+@Data
+@ApiModel(description = "注册课链接/正课口令入参")
+public class FsCourseWatchShareParam {
+
+    @ApiModelProperty(value = "视频id", required = true)
+    private Long videoId;
+
+    @ApiModelProperty(value = "公司id")
+    private Long companyId;
+
+    @ApiModelProperty(value = "销售id")
+    private Long companyUserId;
+
+    @ApiModelProperty(value = "课程id", required = true)
+    private Long courseId;
+
+    @ApiModelProperty(value = "链接有效时长(分钟),口令过期时间优先使用")
+    private Integer effectiveDuration;
+
+    @ApiModelProperty(value = "营期id")
+    private Long periodId;
+
+    @ApiModelProperty(value = "营期课节天id(daysId)", required = true)
+    private Long id;
+
+    @ApiModelProperty(value = "项目id")
+    private Long projectId;
+
+    @ApiModelProperty(value = "卡片标题,可选,默认取视频标题")
+    private String title;
+
+    @ApiModelProperty(value = "最大使用次数,0或不传表示不限制")
+    private Integer maxUse;
+
+    @ApiModelProperty(value = "指定小程序appId,可选")
+    private String appId;
+}

+ 26 - 0
fs-service/src/main/java/com/fs/course/service/IFsCourseMpFollowPosterService.java

@@ -0,0 +1,26 @@
+package com.fs.course.service;
+
+import com.fs.common.core.domain.R;
+
+/**
+ * 服务号关注海报:本地底图 + 微信关注二维码合成。
+ */
+public interface IFsCourseMpFollowPosterService {
+
+    /**
+     * 按公司绑定的服务号生成关注海报,上传 OSS 后 data 返回图片 URL(同 getAppContactWay)。
+     * 同一服务号会复用缓存,避免重复上传占空间;force=true 时强制重新生成。
+     *
+     * @param companyId 销售公司ID
+     * @param force     是否强制刷新
+     */
+    R createMpFollowPoster(Long companyId, boolean force);
+
+    /**
+     * 生成海报 PNG 字节(不上传),失败返回 null,错误信息见 {@link #getLastError()}。
+     */
+    byte[] createMpFollowPosterPng(Long companyId);
+
+    /** 最近一次失败原因(配合 createMpFollowPosterPng) */
+    String getLastError();
+}

+ 15 - 0
fs-service/src/main/java/com/fs/course/service/IFsCourseMpMaterialService.java

@@ -0,0 +1,15 @@
+package com.fs.course.service;
+
+import com.fs.common.core.domain.R;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * 服务号永久素材:口令卡片封面 media_id。
+ */
+public interface IFsCourseMpMaterialService {
+
+    /**
+     * 上传图片到指定服务号永久素材库,返回 media_id。
+     */
+    R uploadImage(String appId, MultipartFile file);
+}

+ 40 - 0
fs-service/src/main/java/com/fs/course/service/IFsCourseWatchPasswordService.java

@@ -0,0 +1,40 @@
+package com.fs.course.service;
+
+import com.fs.common.core.domain.R;
+import com.fs.course.domain.FsCourseWatchPassword;
+
+/**
+ * 服务号口令看课 / 注册课小程序链接。
+ *
+ * <p><b>注册课 / 正课颗粒度</b>:仅看营期课节天 {@code registrationSwitch}(1=注册课)。
+ * <ul>
+ *   <li>注册课:isShowShare(daysId)==true → 仅允许提取小程序 Scheme</li>
+ *   <li>正课:否则 → 仅允许提取口令</li>
+ * </ul>
+ * 入参与 {@code getGotoWxAppLink} 一致:linkStr(含 course JSON)+ appid(可选)。
+ */
+public interface IFsCourseWatchPasswordService {
+
+    /**
+     * 注册课:用 linkStr 生成小程序 Scheme。非注册课报错。
+     *
+     * @param linkStr 同 getGotoWxAppLink,如 /pages_course/videovip?course={...}
+     * @param appId   可选,不传则按 linkStr 内 companyId 解析看课小程序
+     */
+    R createRegisterMiniLink(String linkStr, String appId);
+
+    /**
+     * 正课:用 linkStr 生成看课口令。注册课报错。
+     * 公司未开启口令看课时,回退为小程序看课链接。
+     */
+    R createWatchPassword(String linkStr, String appId);
+
+    /**
+     * 若公司开启口令看课且为可解析的正课链接,则生成口令;否则返回 null(调用方走原小程序链接)。
+     */
+    R tryCreateWatchPasswordIfEnabled(String linkStr, String appId);
+
+    FsCourseWatchPassword selectValidByPassword(String password);
+
+    boolean incrementUseCount(Long id);
+}

+ 8 - 3
fs-service/src/main/java/com/fs/course/service/IFsUserCoursePeriodDaysService.java

@@ -53,9 +53,9 @@ public interface IFsUserCoursePeriodDaysService extends IService<FsUserCoursePer
     int updateFsUserCoursePeriodDays(FsUserCoursePeriodDays fsUserCoursePeriodDays);
 
     /**
-     * 是否显示分享链接按钮
-     * @param id
-     * @return
+     * 是否为注册课(仅看小节 registrationSwitch=1,不依赖营期公司开关)
+     * @param id 营期课节天 id(daysId)
+     * @return true=注册课
      */
     boolean isShowShare(Long id);
 
@@ -140,4 +140,9 @@ public interface IFsUserCoursePeriodDaysService extends IService<FsUserCoursePer
      */
     int deletePeriodDaysAndReorder(Long[] ids, boolean reorder);
 
+    /**
+     * 批量将小节转为注册课(registration_switch=1)
+     */
+    R batchToRegisterCourse(Long[] ids);
+
 }

+ 457 - 0
fs-service/src/main/java/com/fs/course/service/impl/FsCourseMpFollowPosterServiceImpl.java

@@ -0,0 +1,457 @@
+package com.fs.course.service.impl;
+
+import cn.hutool.json.JSONUtil;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.Company;
+import com.fs.company.service.ICompanyService;
+import com.fs.core.config.WxMpDynamicConfigService;
+import com.fs.course.config.CourseConfig;
+import com.fs.course.service.IFsCourseMpFollowPosterService;
+import com.fs.system.oss.CloudStorageService;
+import com.fs.system.oss.OSSFactory;
+import com.fs.system.service.ISysConfigService;
+import lombok.extern.slf4j.Slf4j;
+import me.chanjar.weixin.mp.api.WxMpService;
+import me.chanjar.weixin.mp.bean.result.WxMpQrCodeTicket;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import javax.imageio.ImageIO;
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 服务号关注海报合成:上传 OSS,返回短链;也可直接取 PNG 字节。
+ */
+@Slf4j
+@Service
+public class FsCourseMpFollowPosterServiceImpl implements IFsCourseMpFollowPosterService {
+
+    private static final String SCENE_STR = "mp_poster";
+    private static final int BOTTOM_HEIGHT = 260;
+    private static final int QR_SIZE = 180;
+    private static final String DEFAULT_TIP = "扫码关注服务号";
+    /** 海报 URL 缓存前缀:同一服务号 + 同一底图/布局可复用 */
+    private static final String CACHE_KEY_PREFIX = "course:mpFollowPoster:url:";
+    private static final int CACHE_DAYS = 90;
+
+    @Autowired
+    private ICompanyService companyService;
+    @Autowired
+    private ISysConfigService configService;
+    @Autowired
+    private WxMpService wxMpService;
+    @Autowired
+    private WxMpDynamicConfigService wxMpDynamicConfigService;
+    @Autowired
+    private RedisCache redisCache;
+
+    private final ThreadLocal<String> lastError = new ThreadLocal<>();
+
+    @Override
+    public String getLastError() {
+        return lastError.get();
+    }
+
+    @Override
+    public R createMpFollowPoster(Long companyId, boolean force) {
+        if (companyId == null) {
+            return R.error("公司ID不能为空");
+        }
+        Company company = companyService.selectCompanyById(companyId);
+        if (company == null) {
+            return R.error("公司不存在");
+        }
+        String appId = company.getCourseMaAppId();
+        if (StringUtils.isEmpty(appId)) {
+            return R.error("公司未绑定服务号,请联系管理员配置服务号点播");
+        }
+
+        CourseConfig courseConfig = loadCourseConfig();
+        String posterDir = courseConfig != null ? courseConfig.getMpPosterDir() : null;
+        if (StringUtils.isEmpty(posterDir)) {
+            return R.error("未配置服务号海报目录 course.config.mpPosterDir");
+        }
+        File posterFile = resolvePosterFile(posterDir, appId);
+        if (posterFile == null || !posterFile.isFile()) {
+            return R.error("未找到服务号海报文件。请将「服务号海报目录」配成目录(如 F:/hdt/mp-poster),"
+                    + "并把海报放到:" + Paths.get(posterDir).resolve(appId + ".png").toString().replace('\\', '/')
+                    + "(或 .jpg);也可直接配置为该图片完整路径");
+        }
+
+        String cacheKey = buildCacheKey(appId, posterFile, courseConfig);
+        if (!force) {
+            String cachedUrl = redisCache.getCacheObject(cacheKey);
+            if (StringUtils.isNotEmpty(cachedUrl)) {
+                log.info("命中服务号关注海报缓存, appId={}, companyId={}", appId, companyId);
+                return R.ok().put("data", cachedUrl);
+            }
+        }
+
+        byte[] png = createMpFollowPosterPng(companyId);
+        if (png == null) {
+            return R.error(StringUtils.isEmpty(getLastError()) ? "生成服务号关注海报失败" : getLastError());
+        }
+        try {
+            CloudStorageService storage = OSSFactory.build();
+            if (storage == null) {
+                return R.error("未配置云存储 sys.oss.cloudStorage,无法上传海报");
+            }
+            // 固定对象路径,同服务号覆盖写入,减少 OSS 碎片文件
+            String objectKey = "mp-follow-poster/" + appId + ".png";
+            String url = storage.upload(png, objectKey);
+            if (StringUtils.isEmpty(url) || url.startsWith("上传失败")) {
+                // 部分存储实现不支持自定义 path,回退随机路径
+                url = storage.uploadSuffix(png, ".png");
+            }
+            if (StringUtils.isEmpty(url) || url.startsWith("上传失败")) {
+                return R.error("海报上传云存储失败");
+            }
+            redisCache.setCacheObject(cacheKey, url, CACHE_DAYS, TimeUnit.DAYS);
+            return R.ok().put("data", url);
+        } catch (Exception e) {
+            log.error("上传服务号关注海报失败, companyId={}", companyId, e);
+            return R.error("海报上传失败:" + (e.getMessage() == null ? "未知错误" : e.getMessage()));
+        } finally {
+            lastError.remove();
+        }
+    }
+
+    private String buildCacheKey(String appId, File posterFile, CourseConfig cfg) {
+        StringBuilder sb = new StringBuilder(CACHE_KEY_PREFIX);
+        sb.append(appId).append(':');
+        sb.append(posterFile.lastModified()).append(':');
+        if (cfg == null) {
+            return sb.append("default").toString();
+        }
+        sb.append(StringUtils.isEmpty(cfg.getMpPosterQrLayout()) ? "auto" : cfg.getMpPosterQrLayout()).append(':');
+        sb.append(cfg.getMpPosterQrX()).append(':');
+        sb.append(cfg.getMpPosterQrY()).append(':');
+        sb.append(cfg.getMpPosterQrSize()).append(':');
+        sb.append(StringUtils.isEmpty(cfg.getMpPosterTip()) ? DEFAULT_TIP : cfg.getMpPosterTip());
+        return sb.toString();
+    }
+
+    @Override
+    public byte[] createMpFollowPosterPng(Long companyId) {
+        lastError.remove();
+        if (companyId == null) {
+            lastError.set("公司ID不能为空");
+            return null;
+        }
+        Company company = companyService.selectCompanyById(companyId);
+        if (company == null) {
+            lastError.set("公司不存在");
+            return null;
+        }
+        String appId = company.getCourseMaAppId();
+        if (StringUtils.isEmpty(appId)) {
+            lastError.set("公司未绑定服务号,请联系管理员配置服务号点播");
+            return null;
+        }
+
+        CourseConfig courseConfig = loadCourseConfig();
+        String posterDir = courseConfig != null ? courseConfig.getMpPosterDir() : null;
+        if (StringUtils.isEmpty(posterDir)) {
+            lastError.set("未配置服务号海报目录 course.config.mpPosterDir");
+            return null;
+        }
+
+        File posterFile = resolvePosterFile(posterDir, appId);
+        if (posterFile == null || !posterFile.isFile()) {
+            lastError.set("未找到服务号海报文件。请将「服务号海报目录」配成目录(如 F:/hdt/mp-poster),"
+                    + "并把海报放到:" + Paths.get(posterDir).resolve(appId + ".png").toString().replace('\\', '/')
+                    + "(或 .jpg);也可直接配置为该图片完整路径");
+            return null;
+        }
+
+        try {
+            wxMpDynamicConfigService.ensureMpForRequest(wxMpService, appId);
+            WxMpQrCodeTicket ticket = wxMpService.getQrcodeService().qrCodeCreateLastTicket(SCENE_STR);
+            File qrFile = wxMpService.getQrcodeService().qrCodePicture(ticket);
+            if (qrFile == null || !qrFile.isFile()) {
+                lastError.set("获取服务号关注二维码失败");
+                return null;
+            }
+
+            BufferedImage background = ImageIO.read(posterFile);
+            if (background == null) {
+                lastError.set("海报图片读取失败");
+                return null;
+            }
+            BufferedImage qrImage = ImageIO.read(qrFile);
+            if (qrImage == null) {
+                lastError.set("关注二维码图片读取失败");
+                return null;
+            }
+
+            String tipText = courseConfig.getMpPosterTip();
+            if (StringUtils.isEmpty(tipText)) {
+                tipText = DEFAULT_TIP;
+            }
+            return composePosterPng(background, qrImage, tipText, courseConfig);
+        } catch (Exception e) {
+            log.error("createMpFollowPosterPng failed, companyId={}, appId={}", companyId, appId, e);
+            lastError.set("生成服务号关注海报失败:"
+                    + (e.getMessage() == null ? "未知错误" : e.getMessage()));
+            return null;
+        }
+    }
+
+    private CourseConfig loadCourseConfig() {
+        try {
+            String json = configService.selectConfigByKey("course.config");
+            if (StringUtils.isEmpty(json)) {
+                return null;
+            }
+            return JSONUtil.toBean(json, CourseConfig.class);
+        } catch (Exception e) {
+            log.warn("parse course.config failed", e);
+            return null;
+        }
+    }
+
+    /**
+     * 支持两种配置:
+     * 1) 目录:{mpPosterDir}/{appId}.png|jpg
+     * 2) 直接填图片完整路径(兼容误配成文件)
+     */
+    private File resolvePosterFile(String posterDir, String appId) {
+        if (StringUtils.isEmpty(posterDir)) {
+            return null;
+        }
+        Path configured = Paths.get(posterDir.trim());
+        if (Files.isRegularFile(configured)) {
+            return configured.toFile();
+        }
+        Path dir = configured;
+        String name = configured.getFileName() != null ? configured.getFileName().toString().toLowerCase() : "";
+        if (name.endsWith(".png") || name.endsWith(".jpg") || name.endsWith(".jpeg")) {
+            Path parent = configured.getParent();
+            if (parent != null) {
+                dir = parent;
+            }
+        }
+        String[] exts = {".png", ".jpg", ".jpeg", ".PNG", ".JPG", ".JPEG"};
+        for (String ext : exts) {
+            Path path = dir.resolve(appId + ext);
+            if (Files.isRegularFile(path)) {
+                return path.toFile();
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 合成海报。
+     * layout=auto:识别底图白框并把二维码放进去;
+     * layout=frame:用配置的 x/y/size;
+     * layout=bottom:旧逻辑,拼在底部。
+     */
+    private byte[] composePosterPng(BufferedImage background, BufferedImage qrImage, String tipText,
+                                    CourseConfig courseConfig) throws Exception {
+        String layout = courseConfig != null ? courseConfig.getMpPosterQrLayout() : null;
+        if (StringUtils.isEmpty(layout)) {
+            layout = "auto";
+        }
+        layout = layout.trim().toLowerCase();
+
+        if ("bottom".equals(layout)) {
+            return composeBottomPoster(background, qrImage, tipText);
+        }
+
+        int[] box = null;
+        if ("frame".equals(layout)
+                && courseConfig.getMpPosterQrX() != null
+                && courseConfig.getMpPosterQrY() != null
+                && courseConfig.getMpPosterQrSize() != null
+                && courseConfig.getMpPosterQrSize() > 0) {
+            int size = courseConfig.getMpPosterQrSize();
+            box = new int[]{courseConfig.getMpPosterQrX(), courseConfig.getMpPosterQrY(), size, size};
+        } else {
+            box = findLargestWhiteFrame(background);
+            if (box == null && courseConfig != null
+                    && courseConfig.getMpPosterQrX() != null
+                    && courseConfig.getMpPosterQrY() != null
+                    && courseConfig.getMpPosterQrSize() != null
+                    && courseConfig.getMpPosterQrSize() > 0) {
+                int size = courseConfig.getMpPosterQrSize();
+                box = new int[]{courseConfig.getMpPosterQrX(), courseConfig.getMpPosterQrY(), size, size};
+            }
+        }
+
+        if (box == null) {
+            log.warn("未识别到海报白框,回退底部拼接模式");
+            return composeBottomPoster(background, qrImage, tipText);
+        }
+
+        return composeOverlayPoster(background, qrImage, box);
+    }
+
+    private byte[] composeBottomPoster(BufferedImage background, BufferedImage qrImage, String tipText) throws Exception {
+        int width = background.getWidth();
+        int height = background.getHeight();
+        int totalHeight = height + BOTTOM_HEIGHT;
+
+        BufferedImage combined = new BufferedImage(width, totalHeight, BufferedImage.TYPE_INT_RGB);
+        Graphics2D g = combined.createGraphics();
+        g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+        g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
+        g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+
+        g.setColor(Color.WHITE);
+        g.fillRect(0, 0, width, totalHeight);
+        g.drawImage(background, 0, 0, null);
+
+        g.setColor(Color.WHITE);
+        g.fillRect(0, height, width, BOTTOM_HEIGHT);
+
+        int qrX = (width - QR_SIZE) / 2;
+        int qrY = height + 20;
+        g.drawImage(qrImage, qrX, qrY, QR_SIZE, QR_SIZE, null);
+
+        g.setFont(new Font("Microsoft YaHei", Font.PLAIN, 18));
+        g.setColor(new Color(51, 51, 51));
+        FontMetrics fm = g.getFontMetrics();
+        int tipWidth = fm.stringWidth(tipText);
+        int tipX = Math.max(0, (width - tipWidth) / 2);
+        int tipY = qrY + QR_SIZE + 28;
+        g.drawString(tipText, tipX, tipY);
+
+        g.dispose();
+        return toPngBytes(combined);
+    }
+
+    /**
+     * @param box [x, y, w, h] 白框区域;二维码居中内嵌并留边距
+     */
+    private byte[] composeOverlayPoster(BufferedImage background, BufferedImage qrImage, int[] box) throws Exception {
+        int width = background.getWidth();
+        int height = background.getHeight();
+        BufferedImage combined = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
+        Graphics2D g = combined.createGraphics();
+        g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+        g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
+
+        g.drawImage(background, 0, 0, null);
+
+        int boxX = box[0];
+        int boxY = box[1];
+        int boxW = box[2];
+        int boxH = box[3];
+        int pad = Math.max(8, Math.min(boxW, boxH) / 12);
+        int qrSize = Math.max(40, Math.min(boxW, boxH) - pad * 2);
+        int qrX = boxX + (boxW - qrSize) / 2;
+        int qrY = boxY + (boxH - qrSize) / 2;
+        g.drawImage(qrImage, qrX, qrY, qrSize, qrSize, null);
+
+        g.dispose();
+        log.info("海报二维码已放入白框 x={}, y={}, size={}, box=[{},{},{},{}]",
+                qrX, qrY, qrSize, boxX, boxY, boxW, boxH);
+        return toPngBytes(combined);
+    }
+
+    private static byte[] toPngBytes(BufferedImage image) throws Exception {
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        ImageIO.write(image, "png", baos);
+        return baos.toByteArray();
+    }
+
+    /**
+     * 在底图中寻找最大近白色连通区域(海报中间白框)。
+     * @return [x, y, w, h],找不到返回 null
+     */
+    private int[] findLargestWhiteFrame(BufferedImage img) {
+        int w = img.getWidth();
+        int h = img.getHeight();
+        if (w < 50 || h < 50) {
+            return null;
+        }
+        int step = Math.max(2, Math.min(w, h) / 300);
+        boolean[][] visited = new boolean[h][w];
+        int bestArea = 0;
+        int[] best = null;
+        int minArea = (int) (w * h * 0.015);
+        int maxArea = (int) (w * h * 0.45);
+
+        for (int y = 0; y < h; y += step) {
+            for (int x = 0; x < w; x += step) {
+                if (visited[y][x] || !isNearWhite(img, x, y)) {
+                    continue;
+                }
+                int minX = x, maxX = x, minY = y, maxY = y;
+                java.util.ArrayDeque<int[]> queue = new java.util.ArrayDeque<>();
+                queue.add(new int[]{x, y});
+                visited[y][x] = true;
+                int count = 0;
+                while (!queue.isEmpty()) {
+                    int[] p = queue.poll();
+                    int cx = p[0], cy = p[1];
+                    count++;
+                    if (cx < minX) minX = cx;
+                    if (cx > maxX) maxX = cx;
+                    if (cy < minY) minY = cy;
+                    if (cy > maxY) maxY = cy;
+                    // 四邻域步进,控制连通规模
+                    int[][] dirs = {{step, 0}, {-step, 0}, {0, step}, {0, -step}};
+                    for (int[] d : dirs) {
+                        int nx = cx + d[0];
+                        int ny = cy + d[1];
+                        if (nx < 0 || ny < 0 || nx >= w || ny >= h || visited[ny][nx]) {
+                            continue;
+                        }
+                        if (!isNearWhite(img, nx, ny)) {
+                            visited[ny][nx] = true;
+                            continue;
+                        }
+                        visited[ny][nx] = true;
+                        queue.add(new int[]{nx, ny});
+                    }
+                    // 防止超大白底把整张图吃掉
+                    if (count > maxArea / (step * step) + 1000) {
+                        break;
+                    }
+                }
+                int bw = maxX - minX + 1;
+                int bh = maxY - minY + 1;
+                int area = bw * bh;
+                if (area < minArea || area > maxArea) {
+                    continue;
+                }
+                double ratio = bw * 1.0 / bh;
+                if (ratio < 0.35 || ratio > 2.8) {
+                    continue;
+                }
+                // 白框应大致在画面中部,排除四角装饰白点
+                double cx = (minX + maxX) / 2.0 / w;
+                double cy = (minY + maxY) / 2.0 / h;
+                if (cx < 0.15 || cx > 0.85 || cy < 0.2 || cy > 0.92) {
+                    continue;
+                }
+                if (area > bestArea) {
+                    bestArea = area;
+                    // 向内收缩一点,避开金边
+                    int inset = Math.max(2, Math.min(bw, bh) / 25);
+                    best = new int[]{minX + inset, minY + inset, Math.max(10, bw - inset * 2), Math.max(10, bh - inset * 2)};
+                }
+            }
+        }
+        return best;
+    }
+
+    private static boolean isNearWhite(BufferedImage img, int x, int y) {
+        int rgb = img.getRGB(x, y);
+        int r = (rgb >> 16) & 0xff;
+        int g = (rgb >> 8) & 0xff;
+        int b = rgb & 0xff;
+        return r >= 245 && g >= 245 && b >= 245;
+    }
+}

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio