ソースを参照

个微相关提示

吴树波 3 時間 前
コミット
195809c72c

+ 11 - 0
docs/wx_mp_subscribe.sql

@@ -0,0 +1,11 @@
+-- 微信公众号订阅通知功能 - 数据库变更脚本
+-- 注意:此脚本需要在所有租户库中执行
+-- 因为是SaaS多租户架构,每个租户有独立的数据库
+
+-- =============================================
+-- 1. 租户库SQL (在每个租户数据库中执行)
+-- =============================================
+
+-- 为company_user表新增公众号相关字段
+ALTER TABLE company_user ADD COLUMN mp_open_id VARCHAR(64) DEFAULT NULL COMMENT '微信公众号OpenId';
+ALTER TABLE company_user ADD COLUMN mp_subscribed INT(1) DEFAULT 0 COMMENT '是否已订阅公众号通知 (0未订阅 1已订阅)';

+ 124 - 0
fs-admin/src/main/java/com/fs/wx/controller/WxMpPortalController.java

@@ -0,0 +1,124 @@
+package com.fs.wx.controller;
+
+import com.fs.core.config.WxMpProperties;
+import com.fs.wx.mp.service.WxMpSubscribeService;
+import lombok.extern.slf4j.Slf4j;
+import me.chanjar.weixin.mp.api.WxMpMessageRouter;
+import me.chanjar.weixin.mp.api.WxMpService;
+import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage;
+import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 微信公众号消息回调入口
+ * 接收微信服务器推送的事件消息(关注、扫码等)
+ * 配置在微信公众号后台的消息服务器URL
+ */
+@Slf4j
+@RestController
+@RequestMapping("/wx/mp/portal")
+public class WxMpPortalController {
+
+    @Autowired
+    private WxMpProperties wxMpProperties;
+
+    @Autowired
+    private WxMpMessageRouter messageRouter;
+
+    /**
+     * 微信服务器验证接口(GET)
+     */
+    @GetMapping(produces = "text/plain;charset=utf-8")
+    public String authGet(
+            @RequestParam(name = "signature", required = false) String signature,
+            @RequestParam(name = "timestamp", required = false) String timestamp,
+            @RequestParam(name = "nonce", required = false) String nonce,
+            @RequestParam(name = "echostr", required = false) String echostr) {
+
+        log.info("接收到微信服务器认证消息:signature={}, timestamp={}, nonce={}, echostr={}",
+                signature, timestamp, nonce, echostr);
+
+        if (StringUtils.isAnyBlank(signature, timestamp, nonce, echostr)) {
+            throw new IllegalArgumentException("请求参数非法");
+        }
+
+        try {
+            WxMpService wxMpService = wxMpProperties.createFirstWxMpService();
+            if (wxMpService.checkSignature(timestamp, nonce, signature)) {
+                return echostr;
+            }
+        } catch (Exception e) {
+            log.error("微信签名验证异常", e);
+        }
+
+        return "非法请求";
+    }
+
+    /**
+     * 微信消息回调接口(POST)
+     * 处理关注、扫码、菜单等事件
+     */
+    @PostMapping(produces = "application/xml; charset=UTF-8")
+    public String post(
+            @RequestBody String requestBody,
+            @RequestParam("signature") String signature,
+            @RequestParam("timestamp") String timestamp,
+            @RequestParam("nonce") String nonce,
+            @RequestParam("openid") String openid,
+            @RequestParam(name = "encrypt_type", required = false) String encType,
+            @RequestParam(name = "msg_signature", required = false) String msgSignature) {
+
+        log.info("接收微信请求:openid={}, signature={}, encType={}, timestamp={}, nonce={}",
+                openid, signature, encType, timestamp, nonce);
+        log.debug("微信请求体:{}", requestBody);
+
+        try {
+            WxMpService wxMpService = wxMpProperties.createFirstWxMpService();
+
+            if (!wxMpService.checkSignature(timestamp, nonce, signature)) {
+                throw new IllegalArgumentException("非法请求,可能属于伪造的请求!");
+            }
+
+            String out;
+            if (encType == null) {
+                // 明文消息
+                WxMpXmlMessage inMessage = WxMpXmlMessage.fromXml(requestBody);
+                WxMpXmlOutMessage outMessage = this.route(inMessage);
+                if (outMessage == null) {
+                    return "";
+                }
+                out = outMessage.toXml();
+            } else if ("aes".equalsIgnoreCase(encType)) {
+                // 加密消息
+                WxMpXmlMessage inMessage = WxMpXmlMessage.fromEncryptedXml(
+                        requestBody, wxMpService.getWxMpConfigStorage(), timestamp, nonce, msgSignature);
+                log.debug("消息解密后内容:{}", inMessage.toString());
+                WxMpXmlOutMessage outMessage = this.route(inMessage);
+                if (outMessage == null) {
+                    return "";
+                }
+                out = outMessage.toEncryptedXml(wxMpService.getWxMpConfigStorage());
+            } else {
+                return "";
+            }
+
+            log.debug("组装回复信息:{}", out);
+            return out;
+
+        } catch (Exception e) {
+            log.error("处理微信消息异常", e);
+            return "";
+        }
+    }
+
+    private WxMpXmlOutMessage route(WxMpXmlMessage message) {
+        try {
+            return this.messageRouter.route(message);
+        } catch (Exception e) {
+            log.error("路由微信消息异常", e);
+        }
+        return null;
+    }
+}

+ 4 - 4
fs-company-app/src/main/resources/application.yml

@@ -1,9 +1,9 @@
 server:
-  # 服务器的HTTP端口,默认为8080
-  port: 8007
-
 # Spring配置
 spring:
   profiles:
-#    active: druid-fcky-test
+    #    active: druid-fcky-test
     active: dev
+
+  # 服务器的HTTP端口,默认为8080
+  port: 8007

+ 404 - 0
fs-company/src/main/java/com/fs/company/controller/wx/WxMpSubscribeController.java

@@ -0,0 +1,404 @@
+package com.fs.company.controller.wx;
+
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.company.domain.CompanyConfig;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.service.ICompanyConfigService;
+import com.fs.core.config.WxMpProperties;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.framework.datasource.TenantDataSourceManager;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.service.TenantInfoService;
+import com.fs.wx.mp.service.WxMpSubscribeService;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import lombok.extern.slf4j.Slf4j;
+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.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+/**
+ * 微信公众号订阅通知控制器
+ * 提供给销售端(CompanyUI)调用的接口
+ * 所有接口路径包含 {tenantCode} 以支持多租户数据源切换
+ * 配置从 company_config 表读取(key: wx:mp:subscribe:config),无配置时回退到 sys_config
+ */
+@Slf4j
+@RestController
+@RequestMapping("/wxmp/subscribe/{tenantCode}")
+public class WxMpSubscribeController extends BaseController {
+
+    private static final String SUBSCRIBE_CONFIG_KEY = "wx:mp:subscribe:config";
+
+    /** 按tenantCode缓存WxMpService实例,避免每次请求都创建新实例导致access_token无法复用 */
+    private static final ConcurrentHashMap<String, WxMpService> WX_MP_SERVICE_CACHE = new ConcurrentHashMap<>();
+
+    @Autowired
+    private WxMpSubscribeService wxMpSubscribeService;
+
+    @Autowired
+    private WxMpProperties wxMpProperties;
+
+    @Autowired
+    private TenantDataSourceManager tenantDataSourceManager;
+
+    @Autowired
+    private TenantInfoService tenantInfoService;
+
+    @Autowired
+    private ICompanyConfigService companyConfigService;
+
+    /**
+     * 根据tenantCode查询租户信息并切换数据源(用于匿名接口)
+     */
+    private void switchTenant(String tenantCode) {
+        if (StringUtils.isBlank(tenantCode)) {
+            log.warn("[WxMpSubscribe] tenantCode为空,无法切换数据源");
+            return;
+        }
+        TenantInfo tenantInfo = tenantInfoService.selectTenantInfoByCode(tenantCode);
+        if (tenantInfo != null) {
+            tenantDataSourceManager.ensureSwitchByTenantId(tenantInfo.getId());
+        } else {
+            log.warn("[WxMpSubscribe] 未找到tenantCode={}对应的租户信息", tenantCode);
+        }
+    }
+
+    /**
+     * 从company_config读取订阅配置JSON
+     * 数据源切换后,租户数据库中company_config按key查询即可
+     */
+    private JSONObject getSubscribeConfigJson() {
+        try {
+            CompanyConfig config = companyConfigService.selectCompanyConfigByServerKey(SUBSCRIBE_CONFIG_KEY);
+            if (config != null && StringUtils.isNotBlank(config.getConfigValue())) {
+                return JSONObject.parseObject(config.getConfigValue());
+            }
+        } catch (Exception e) {
+            log.warn("[WxMpSubscribe] 从company_config读取订阅配置失败", e);
+        }
+        return null;
+    }
+
+    /**
+     * 获取当前请求的tenantCode(从数据源上下文推断)
+     * 缓存key使用 tenantCode + appId 组合,配置变更时自动失效
+     */
+    private String getCacheKey(String tenantCode, String appId) {
+        return tenantCode + ":" + appId;
+    }
+
+    /**
+     * 根据company_config获取WxMpService(带缓存),无配置时回退到sys_config
+     * 缓存WxMpService实例以复用access_token,避免每次请求都调用微信API
+     */
+    private WxMpService getSubscribeWxMpService(String tenantCode) {
+        JSONObject configJson = getSubscribeConfigJson();
+        if (configJson != null) {
+            String appId = configJson.getString("appId");
+            String secret = configJson.getString("secret");
+            if (StringUtils.isNotBlank(appId) && StringUtils.isNotBlank(secret)) {
+                String cacheKey = getCacheKey(tenantCode, appId);
+                WxMpService cached = WX_MP_SERVICE_CACHE.get(cacheKey);
+                if (cached != null) {
+                    return cached;
+                }
+                WxMpDefaultConfigImpl wxConfig = new WxMpDefaultConfigImpl();
+                wxConfig.setAppId(appId);
+                wxConfig.setSecret(secret);
+                wxConfig.setToken(configJson.getString("token"));
+                wxConfig.setAesKey(configJson.getString("aesKey"));
+                WxMpService wxMpService = new WxMpServiceImpl();
+                wxMpService.setWxMpConfigStorage(wxConfig);
+                WX_MP_SERVICE_CACHE.put(cacheKey, wxMpService);
+                log.info("[WxMpSubscribe] 创建并缓存WxMpService实例,tenantCode={}, appId={}", tenantCode, appId);
+                return wxMpService;
+            }
+        }
+        // 回退到sys_config(不缓存,因为sys_config的配置可能变化)
+        log.warn("[WxMpSubscribe] company_config中未找到订阅配置,回退到sys_config");
+        return wxMpProperties.createFirstWxMpService();
+    }
+
+    /**
+     * 清除指定租户的WxMpService缓存(配置变更时调用)
+     */
+    public static void clearWxMpServiceCache(String tenantCode) {
+        WX_MP_SERVICE_CACHE.keySet().removeIf(key -> key.startsWith(tenantCode + ":"));
+        log.info("[WxMpSubscribe] 已清除tenantCode={}的WxMpService缓存", tenantCode);
+    }
+
+    /**
+     * 从company_config获取订阅模板ID列表,无配置时回退到sys_config
+     */
+    private List<String> getSubscribeTemplateIdsFromConfig() {
+        JSONObject configJson = getSubscribeConfigJson();
+        if (configJson != null) {
+            JSONArray arr = configJson.getJSONArray("subscribeTemplateIds");
+            if (arr != null && !arr.isEmpty()) {
+                List<String> ids = new ArrayList<>();
+                for (int i = 0; i < arr.size(); i++) {
+                    ids.add(arr.getString(i));
+                }
+                return ids;
+            }
+        }
+        // 回退到sys_config
+        log.warn("[WxMpSubscribe] company_config中未找到订阅模板配置,回退到sys_config");
+        return wxMpProperties.getSubscribeTemplateIds();
+    }
+
+    /**
+     * 从company_config的pushTemplates中按类型查找推送模板
+     * 未匹配类型时返回第一个模板,未配置pushTemplates时返回null
+     *
+     * @param type 推送模板类型,如"个微AI转人工"
+     * @return 匹配的模板JSONObject(含templateId、type、title),或null
+     */
+    private JSONObject getPushTemplateByType(String type) {
+        JSONObject configJson = getSubscribeConfigJson();
+        if (configJson != null) {
+            JSONArray arr = configJson.getJSONArray("pushTemplates");
+            if (arr != null && !arr.isEmpty()) {
+                for (int i = 0; i < arr.size(); i++) {
+                    JSONObject tmpl = arr.getJSONObject(i);
+                    if (type.equals(tmpl.getString("type"))) {
+                        return tmpl;
+                    }
+                }
+                // 未匹配类型时返回第一个
+                return arr.getJSONObject(0);
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 生成订阅通知二维码(base64图片)
+     *
+     * @param tenantCode 租户编码
+     * @param userId 员工用户ID
+     */
+    @GetMapping("/qrcode/{userId}")
+    public AjaxResult generateQrCode(@PathVariable String tenantCode, @PathVariable Long userId) {
+        try {
+            switchTenant(tenantCode);
+            String base64Image = wxMpSubscribeService.generateSubscribeQrCode(userId, tenantCode);
+            return AjaxResult.success("生成二维码成功", base64Image);
+        } catch (Exception e) {
+            log.error("生成订阅二维码失败,tenantCode={}, userId={}", tenantCode, userId, e);
+            return AjaxResult.error("生成二维码失败:" + e.getMessage());
+        } finally {
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+
+    /**
+     * 查询员工订阅状态(匿名访问,由HTML页面调用)
+     */
+    @GetMapping("/status/{userId}")
+    public AjaxResult getSubscribeStatus(@PathVariable String tenantCode, @PathVariable Long userId) {
+        try {
+            switchTenant(tenantCode);
+            CompanyUser user = wxMpSubscribeService.getSubscribeStatus(userId);
+            if (user == null) {
+                return AjaxResult.error("未找到用户");
+            }
+            Map<String, Object> data = new HashMap<>();
+            data.put("mpOpenId", user.getMpOpenId());
+            data.put("mpSubscribed", user.getMpSubscribed());
+            data.put("bound", StringUtils.isNotBlank(user.getMpOpenId()));
+            return AjaxResult.success(data);
+        } finally {
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+
+    /**
+     * 确认订阅通知(匿名访问,由HTML页面调用)
+     */
+    @GetMapping("/confirm")
+    public AjaxResult confirmSubscribe(@PathVariable String tenantCode, @RequestParam Long userId) {
+        try {
+            switchTenant(tenantCode);
+            wxMpSubscribeService.confirmSubscribe(userId);
+            return AjaxResult.success("订阅成功");
+        } catch (Exception e) {
+            log.error("确认订阅失败,tenantCode={}, userId={}", tenantCode, userId, e);
+            return AjaxResult.error("订阅失败:" + e.getMessage());
+        } finally {
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+
+    /**
+     * 获取微信公众号JS-SDK配置(匿名访问)
+     */
+    @GetMapping("/jsapiSignature")
+    public AjaxResult getJsapiSignature(@PathVariable String tenantCode, @RequestParam String url) {
+        try {
+            switchTenant(tenantCode);
+            WxMpService wxMpService = getSubscribeWxMpService(tenantCode);
+            me.chanjar.weixin.common.bean.WxJsapiSignature signature = wxMpService.createJsapiSignature(url);
+            Map<String, Object> data = new HashMap<>();
+            data.put("appId", signature.getAppId());
+            data.put("timestamp", signature.getTimestamp());
+            data.put("nonceStr", signature.getNonceStr());
+            data.put("signature", signature.getSignature());
+            return AjaxResult.success(data);
+        } catch (Exception e) {
+            log.error("获取JS-SDK签名失败,tenantCode={}, url={}", tenantCode, url, e);
+            return AjaxResult.error("获取签名失败:" + e.getMessage());
+        } finally {
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+
+    /**
+     * 通过微信OAuth code绑定openId(匿名访问,由HTML页面调用)
+     */
+    @GetMapping("/bindOpenId")
+    public AjaxResult bindOpenId(@PathVariable String tenantCode, @RequestParam String code, @RequestParam Long userId) {
+        try {
+            switchTenant(tenantCode);
+            WxMpService wxMpService = getSubscribeWxMpService(tenantCode);
+            String openId = wxMpSubscribeService.bindOpenIdByOAuthCode(code, userId, wxMpService);
+            if (openId != null) {
+                Map<String, Object> data = new HashMap<>();
+                data.put("openId", openId);
+                data.put("bound", true);
+                return AjaxResult.success("绑定成功", data);
+            }
+            return AjaxResult.error("绑定失败,未获取到openId");
+        } catch (Exception e) {
+            log.error("OAuth绑定openId失败,tenantCode={}, code={}, userId={}", tenantCode, code, userId, e);
+            return AjaxResult.error("绑定失败:" + e.getMessage());
+        } finally {
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+
+    /**
+     * 获取微信公众号appId(匿名访问,用于HTML页面构建OAuth授权链接)
+     */
+    @GetMapping("/appId")
+    public AjaxResult getAppId(@PathVariable String tenantCode) {
+        try {
+            switchTenant(tenantCode);
+            WxMpService wxMpService = getSubscribeWxMpService(tenantCode);
+            Map<String, Object> data = new HashMap<>();
+            data.put("appId", wxMpService.getWxMpConfigStorage().getAppId());
+            return AjaxResult.success(data);
+        } catch (Exception e) {
+            log.error("获取appId失败,tenantCode={}", tenantCode, e);
+            return AjaxResult.error("获取appId失败");
+        } finally {
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+
+    /**
+     * 获取订阅消息模板ID列表(匿名访问,用于HTML页面调用wx.requestSubscribeMessage)
+     */
+    @GetMapping("/templateIds")
+    public AjaxResult getTemplateIds(@PathVariable String tenantCode) {
+        try {
+            switchTenant(tenantCode);
+            List<String> templateIds = getSubscribeTemplateIdsFromConfig();
+            Map<String, Object> data = new HashMap<>();
+            data.put("templateIds", templateIds);
+            return AjaxResult.success(data);
+        } catch (Exception e) {
+            log.error("获取模板ID失败,tenantCode={}", tenantCode, e);
+            return AjaxResult.error("获取模板ID失败");
+        } finally {
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+
+    /**
+     * 推送公众号订阅消息给已订阅的员工
+     * 需要登录认证,由销售端(CompanyUI)管理员操作
+     *
+     * @param tenantCode 租户编码
+     * @param params     请求参数:userId(员工ID), customerName(客户姓名), type(推送类型,如"个微AI转人工")
+     */
+    @PostMapping("/push")
+    public AjaxResult pushSubscribeMessage(@PathVariable String tenantCode,
+                                            @RequestBody Map<String, Object> params) {
+        try {
+            // 参数校验
+            Object userIdObj = params.get("userId");
+            String customerName = (String) params.get("customerName");
+            String type = (String) params.get("type");
+
+            if (userIdObj == null) {
+                return AjaxResult.error("缺少参数:userId");
+            }
+            if (StringUtils.isBlank(customerName)) {
+                return AjaxResult.error("缺少参数:customerName");
+            }
+            if (StringUtils.isBlank(type)) {
+                type = "个微AI转人工";
+            }
+
+            Long userId;
+            if (userIdObj instanceof Integer) {
+                userId = ((Integer) userIdObj).longValue();
+            } else if (userIdObj instanceof Long) {
+                userId = (Long) userIdObj;
+            } else {
+                userId = Long.parseLong(userIdObj.toString());
+            }
+
+            // thing类型字段限制20字符
+            if (customerName.length() > 20) {
+                customerName = customerName.substring(0, 20);
+            }
+
+            switchTenant(tenantCode);
+
+            // 从company_config查找推送模板
+            JSONObject pushTemplate = getPushTemplateByType(type);
+            if (pushTemplate == null || StringUtils.isBlank(pushTemplate.getString("templateId"))) {
+                return AjaxResult.error("未配置推送模板,请在「企业配置→订阅配置」中添加推送消息模板");
+            }
+            String templateId = pushTemplate.getString("templateId");
+
+            // 获取WxMpService(带缓存)
+            WxMpService wxMpService = getSubscribeWxMpService(tenantCode);
+
+            // 构建模板数据:thing2=客户姓名, time6=下单时间(当前时间)
+            String orderTime = new SimpleDateFormat("yyyy年MM月dd日 HH:mm").format(new Date());
+            Map<String, String> data = new HashMap<>();
+            data.put("thing2", customerName);
+            data.put("time6", orderTime);
+
+            boolean success = wxMpSubscribeService.sendSubscribePush(userId, wxMpService, templateId, data);
+            if (success) {
+                return AjaxResult.success("推送成功,员工将在微信中收到订阅消息通知");
+            } else {
+                return AjaxResult.error("推送失败");
+            }
+        } catch (RuntimeException e) {
+            log.error("[WxMpSubscribe] 推送订阅消息失败,tenantCode={}", tenantCode, e);
+            return AjaxResult.error(e.getMessage());
+        } catch (Exception e) {
+            log.error("[WxMpSubscribe] 推送订阅消息异常,tenantCode={}", tenantCode, e);
+            return AjaxResult.error("推送异常:" + e.getMessage());
+        } finally {
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+}

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

@@ -137,6 +137,13 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                 .antMatchers("/live/LiveMixLiuTestOpen/**").anonymous()
                 .antMatchers("/company/companyVoiceRobotic/callerResult4EasyCall").anonymous()
                 .antMatchers("/companyWorkflow/externalApi/page").permitAll()
+                // 微信公众号订阅通知(匿名接口,{tenantCode}为路径变量)
+                .antMatchers("/wxmp/subscribe/*/confirm").permitAll()
+                .antMatchers("/wxmp/subscribe/*/jsapiSignature").permitAll()
+                .antMatchers("/wxmp/subscribe/*/status/**").permitAll()
+                .antMatchers("/wxmp/subscribe/*/bindOpenId").permitAll()
+                .antMatchers("/wxmp/subscribe/*/appId").permitAll()
+                .antMatchers("/wxmp/subscribe/*/templateIds").permitAll()
                 // 除上面外的所有请求全部需要鉴权认证
                 .anyRequest().authenticated()
                 .and()

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

@@ -98,6 +98,13 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                 .authorizeRequests()
                 // 对于登录login 注册register 验证码captchaImage 允许匿名访问
                 .antMatchers("/login", "/register", "/captchaImage","/getWechatQrCode","/checkWechatScan","/callback","/checkIsNeedCheck").anonymous()
+                .antMatchers("/wx/mp/portal/**").anonymous()
+                .antMatchers("/wxmp/subscribe/*/confirm").permitAll()
+                .antMatchers("/wxmp/subscribe/*/jsapiSignature").permitAll()
+                .antMatchers("/wxmp/subscribe/*/status/**").permitAll()
+                .antMatchers("/wxmp/subscribe/*/bindOpenId").permitAll()
+                .antMatchers("/wxmp/subscribe/*/appId").permitAll()
+                .antMatchers("/wxmp/subscribe/*/templateIds").permitAll()
                 .antMatchers("/app/common/test").anonymous()
                 .antMatchers("/ad/adDyApi/authorized").anonymous()
                 .antMatchers(

+ 22 - 17
fs-ipad-task/src/main/java/com/fs/app/task/SendMsg.java

@@ -194,25 +194,30 @@ public class SendMsg {
             }
             TaskContext newCtx = new TaskContext();
             qwMap.put(e.getId(), newCtx);
-            CompletableFuture.runAsync(() -> {
-                try {
-                    log.info("开始任务:{}", e.getQwUserName());
-                    // 手动切换数据源到配置的租户
-                    sopTenantDataSourceAspect.switchTenant(tenantId);
-                    processUser(e, delayStart, delayEnd, miniMap, newCtx);
-                } catch (Exception exception) {
-                    log.error("发送错误:", exception);
-                } finally {
-                    log.info("删除任务:{}", e.getQwUserName());
-                    // 清理数据源
-                    sopTenantDataSourceAspect.clear();
+            try {
+                CompletableFuture.runAsync(() -> {
+                    try {
+                        log.info("开始任务:{}", e.getQwUserName());
+                        // 手动切换数据源到配置的租户
+                        sopTenantDataSourceAspect.switchTenant(tenantId);
+                        processUser(e, delayStart, delayEnd, miniMap, newCtx);
+                    } catch (Exception exception) {
+                        log.error("发送错误:", exception);
+                    } finally {
+                        log.info("删除任务:{}", e.getQwUserName());
+                        // 清理数据源
+                        sopTenantDataSourceAspect.clear();
+                        qwMap.remove(e.getId());
+                    }
+                }, customThreadPool).exceptionally(ex -> {
+                    log.error("任务异步执行异常:{}, 错误: {}", e.getQwUserName(), ex.getMessage(), ex);
                     qwMap.remove(e.getId());
-                }
-            }, customThreadPool).exceptionally(ex -> {
-                log.error("任务提交失败:{}, 错误: {}", e.getQwUserName(), ex.getMessage(), ex);
+                    return null;
+                });
+            } catch (Exception ex) {
+                log.error("任务提交到线程池失败:{}, 错误: {}", e.getQwUserName(), ex.getMessage());
                 qwMap.remove(e.getId());
-                return null;
-            });
+            }
         });
     }
 

+ 6 - 0
fs-service/src/main/java/com/fs/company/domain/CompanyUser.java

@@ -190,6 +190,12 @@ public class CompanyUser extends BaseEntity
     /** 微信小程序OPENID(如果有小程序授权) */
     private String  maOpenId;
 
+    /** 微信公众号OPENID(用于服务号订阅通知) */
+    private String mpOpenId;
+
+    /** 是否已订阅公众号通知(0未订阅 1已订阅) */
+    private Integer mpSubscribed;
+
     /** 是否需要单独注册会员,1-是,0-否(用于个微销售分享看课) */
     private Integer isNeedRegisterMember;
 

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

@@ -374,4 +374,10 @@ public interface CompanyUserMapper
     CompanyUserAnalyseVO selectCompanyUserPhoneLogCount(@Param("companyUserId") Long id);
 
     int unbindCidServer(@Param("companyUserId") Long companyUserId);
+
+    @Update("UPDATE company_user SET mp_open_id = #{mpOpenId}, mp_subscribed = #{mpSubscribed} WHERE user_id = #{userId}")
+    int updateMpOpenIdAndSubscribed(@Param("userId") Long userId, @Param("mpOpenId") String mpOpenId, @Param("mpSubscribed") Integer mpSubscribed);
+
+    @Update("UPDATE company_user SET mp_subscribed = #{mpSubscribed} WHERE user_id = #{userId}")
+    int updateMpSubscribed(@Param("userId") Long userId, @Param("mpSubscribed") Integer mpSubscribed);
 }

+ 2 - 0
fs-service/src/main/java/com/fs/config/saas/ProjectConfig.java

@@ -169,6 +169,8 @@ public class ProjectConfig {
             private Boolean useRedis;
             private RedisConfig redisConfig;
             private List<Config> configs;
+            /** 微信公众号订阅消息模板ID列表,用于wx.requestSubscribeMessage() */
+            private List<String> subscribeTemplateIds;
 
             @Data
             public static class RedisConfig {

+ 11 - 0
fs-service/src/main/java/com/fs/core/config/WxMpProperties.java

@@ -393,4 +393,15 @@ public class WxMpProperties {
     public ProjectConfig.Wx.Mp getWxMpConfig() {
         return getWxMpConfigFromDB();
     }
+
+    /**
+     * 获取订阅消息模板ID列表
+     */
+    public List<String> getSubscribeTemplateIds() {
+        ProjectConfig.Wx.Mp mpConfig = getWxMpConfigFromDB();
+        if (mpConfig == null || mpConfig.getSubscribeTemplateIds() == null) {
+            return new ArrayList<>();
+        }
+        return mpConfig.getSubscribeTemplateIds();
+    }
 }

+ 66 - 3
fs-service/src/main/java/com/fs/wx/mp/handler/ScanHandler.java

@@ -1,22 +1,85 @@
 package com.fs.wx.mp.handler;
 
+import com.fs.wx.mp.builder.TextBuilder;
+import com.fs.wx.mp.service.WxMpSubscribeService;
+import lombok.extern.slf4j.Slf4j;
 import me.chanjar.weixin.common.error.WxErrorException;
 import me.chanjar.weixin.common.session.WxSessionManager;
 import me.chanjar.weixin.mp.api.WxMpService;
 import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage;
 import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Component;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
 
+import javax.servlet.http.HttpServletRequest;
 import java.util.Map;
 
 
+@Slf4j
 @Component("ScanMpHandler")
 public class ScanHandler extends AbstractHandler {
 
+    @Autowired
+    private WxMpSubscribeService wxMpSubscribeService;
+
     @Override
-    public WxMpXmlOutMessage handle(WxMpXmlMessage wxMpXmlMessage, Map<String, Object> map,
+    public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage, Map<String, Object> map,
                                     WxMpService wxMpService, WxSessionManager wxSessionManager) throws WxErrorException {
-        // 扫码事件处理
+        String openId = wxMessage.getFromUser();
+        String eventKey = wxMessage.getEventKey();
+        log.info("扫码事件处理,openId={}, eventKey={}", openId, eventKey);
+
+        // 处理订阅通知场景的扫码
+        Long userId = WxMpSubscribeService.extractUserIdFromScene(eventKey);
+        if (userId != null) {
+            // 绑定openId到员工
+            wxMpSubscribeService.bindOpenIdToUser(openId, userId);
+            log.info("已关注用户扫码绑定openId,userId={}, openId={}", userId, openId);
+
+            // 返回提示消息,引导用户前往HTML页面确认订阅
+            try {
+                return new TextBuilder().build(
+                        "您已绑定通知服务。\n请点击下方链接确认订阅通知:\n" +
+                        buildSubscribePageUrl(userId),
+                        wxMessage, wxMpService);
+            } catch (Exception e) {
+                log.error("发送订阅确认链接失败", e);
+            }
+        }
+
         return null;
     }
-}
+
+    /**
+     * 构建订阅确认页面URL
+     * 使用当前系统请求的域名构建完整URL
+     */
+    private String buildSubscribePageUrl(Long userId) {
+        String domain = getCurrentDomain();
+        return domain + "/subscribe/index.html?userId=" + userId;
+    }
+
+    /**
+     * 获取当前系统域名(从回调请求中提取)
+     */
+    private String getCurrentDomain() {
+        try {
+            ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+            if (attributes != null) {
+                HttpServletRequest request = attributes.getRequest();
+                String scheme = request.getScheme();
+                String serverName = request.getServerName();
+                int port = request.getServerPort();
+                if ((scheme.equals("http") && port == 80) || (scheme.equals("https") && port == 443)) {
+                    return scheme + "://" + serverName;
+                }
+                return scheme + "://" + serverName + ":" + port;
+            }
+        } catch (Exception e) {
+            log.warn("获取当前请求域名失败", e);
+        }
+        return "https://YOUR_DOMAIN";
+    }
+}

+ 63 - 18
fs-service/src/main/java/com/fs/wx/mp/handler/SubscribeHandler.java

@@ -1,67 +1,112 @@
 package com.fs.wx.mp.handler;
 
 import com.fs.wx.mp.builder.TextBuilder;
+import com.fs.wx.mp.service.WxMpSubscribeService;
+import lombok.extern.slf4j.Slf4j;
 import me.chanjar.weixin.common.error.WxErrorException;
 import me.chanjar.weixin.common.session.WxSessionManager;
 import me.chanjar.weixin.mp.api.WxMpService;
 import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage;
 import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage;
 import me.chanjar.weixin.mp.bean.result.WxMpUser;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Component;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
 
+import javax.servlet.http.HttpServletRequest;
 import java.util.Map;
 
+
+@Slf4j
 @Component("SubscribeMpHandler")
 public class SubscribeHandler extends AbstractHandler {
 
+    @Autowired
+    private WxMpSubscribeService wxMpSubscribeService;
+
     @Override
     public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage,
                                     Map<String, Object> context, WxMpService weixinService,
                                     WxSessionManager sessionManager) throws WxErrorException {
 
-        this.logger.info("新关注用户 OPENID: " + wxMessage.getFromUser());
+        String openId = wxMessage.getFromUser();
+        log.info("新关注用户 OPENID: {}", openId);
 
         // 获取微信用户基本信息
         try {
             WxMpUser userWxInfo = weixinService.getUserService()
-                .userInfo(wxMessage.getFromUser(), null);
+                .userInfo(openId, null);
             if (userWxInfo != null) {
                 // TODO 可以添加关注用户到本地数据库
             }
         } catch (WxErrorException e) {
             if (e.getError().getErrorCode() == 48001) {
-                this.logger.info("该公众号没有获取用户信息权限!");
+                log.info("该公众号没有获取用户信息权限!");
             }
         }
 
+        // 处理扫码关注场景(带参数二维码)
+        String eventKey = wxMessage.getEventKey();
+        if (StringUtils.isNotBlank(eventKey) && eventKey.startsWith("qrscene_")) {
+            String sceneStr = eventKey.substring("qrscene_".length());
+            Long userId = WxMpSubscribeService.extractUserIdFromScene(sceneStr);
+            if (userId != null) {
+                // 绑定openId到员工
+                wxMpSubscribeService.bindOpenIdToUser(openId, userId);
+                log.info("扫码关注绑定openId,userId={}, openId={}", userId, openId);
 
-        WxMpXmlOutMessage responseResult = null;
-        try {
-            responseResult = this.handleSpecial(wxMessage);
-        } catch (Exception e) {
-            this.logger.error(e.getMessage(), e);
-        }
-
-        if (responseResult != null) {
-            return responseResult;
+                // 返回提示消息,引导用户前往HTML页面确认订阅
+                try {
+                    return new TextBuilder().build(
+                            "感谢关注!您已成功绑定通知服务。\n请点击下方链接确认订阅通知:\n" +
+                            buildSubscribePageUrl(userId),
+                            wxMessage, weixinService);
+                } catch (Exception e) {
+                    log.error("发送订阅确认链接失败", e);
+                }
+            }
         }
 
+        // 非订阅场景的默认关注回复
         try {
             return new TextBuilder().build("感谢关注", wxMessage, weixinService);
         } catch (Exception e) {
-            this.logger.error(e.getMessage(), e);
+            log.error(e.getMessage(), e);
         }
 
         return null;
     }
 
     /**
-     * 处理特殊请求,比如如果是扫码进来的,可以做相应处理
+     * 构建订阅确认页面URL
+     * 使用当前系统请求的域名构建完整URL
      */
-    private WxMpXmlOutMessage handleSpecial(WxMpXmlMessage wxMessage)
-        throws Exception {
-        //TODO
-        return null;
+    private String buildSubscribePageUrl(Long userId) {
+        String domain = getCurrentDomain();
+        return domain + "/subscribe/index.html?userId=" + userId;
     }
 
+    /**
+     * 获取当前系统域名(从回调请求中提取)
+     */
+    private String getCurrentDomain() {
+        try {
+            ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+            if (attributes != null) {
+                HttpServletRequest request = attributes.getRequest();
+                String scheme = request.getScheme();
+                String serverName = request.getServerName();
+                int port = request.getServerPort();
+                if ((scheme.equals("http") && port == 80) || (scheme.equals("https") && port == 443)) {
+                    return scheme + "://" + serverName;
+                }
+                return scheme + "://" + serverName + ":" + port;
+            }
+        } catch (Exception e) {
+            log.warn("获取当前请求域名失败", e);
+        }
+        return "https://YOUR_DOMAIN";
+    }
 }

+ 303 - 0
fs-service/src/main/java/com/fs/wx/mp/service/WxMpSubscribeService.java

@@ -0,0 +1,303 @@
+package com.fs.wx.mp.service;
+
+import com.fs.common.QRutils;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.mapper.CompanyUserMapper;
+import com.fs.core.config.WxMpProperties;
+import com.alibaba.fastjson.JSONObject;
+import lombok.extern.slf4j.Slf4j;
+import me.chanjar.weixin.common.bean.oauth2.WxOAuth2AccessToken;
+import me.chanjar.weixin.mp.api.WxMpService;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.Map;
+
+/**
+ * 微信公众号订阅通知服务
+ * 处理员工关注公众号并订阅通知的业务逻辑
+ */
+@Slf4j
+@Service
+public class WxMpSubscribeService {
+
+    /** 二维码场景值前缀,用于区分订阅通知场景 */
+    public static final String SCENE_PREFIX = "mpsub_";
+
+    /** 二维码尺寸 */
+    private static final int QR_CODE_SIZE = 300;
+
+    @Autowired
+    private CompanyUserMapper companyUserMapper;
+
+    @Autowired
+    private WxMpProperties wxMpProperties;
+
+    /**
+     * 生成订阅通知二维码(base64图片)
+     * 二维码内容为订阅页面URL,员工扫码后直接打开订阅页面
+     *
+     * @param userId 员工用户ID
+     * @param tenantCode 租户编码,用于构建订阅页面URL中的租户标识
+     * @return base64编码的二维码图片数据
+     */
+    public String generateSubscribeQrCode(Long userId, String tenantCode) {
+        String subscribeUrl = buildSubscribePageUrl(userId, tenantCode);
+        String base64Image = QRutils.getQRCodeBase64(subscribeUrl, QR_CODE_SIZE, QR_CODE_SIZE);
+        log.info("生成订阅二维码成功,userId={}, tenantCode={}, url={}", userId, tenantCode, subscribeUrl);
+        return base64Image;
+    }
+
+    /**
+     * 通过OAuth code获取openId并绑定到员工(使用默认wxMpProperties创建WxMpService)
+     *
+     * @param code 微信OAuth授权code
+     * @param userId 员工用户ID
+     * @return openId
+     */
+    public String bindOpenIdByOAuthCode(String code, Long userId) {
+        return bindOpenIdByOAuthCode(code, userId, wxMpProperties.createFirstWxMpService());
+    }
+
+    /**
+     * 通过OAuth code获取openId并绑定到员工(使用外部传入的WxMpService)
+     * 用于从company_config读取公众号配置后构建的WxMpService
+     *
+     * @param code 微信OAuth授权code
+     * @param userId 员工用户ID
+     * @param wxMpService 微信公众号服务实例
+     * @return openId
+     */
+    public String bindOpenIdByOAuthCode(String code, Long userId, WxMpService wxMpService) {
+        if (StringUtils.isBlank(code) || userId == null) {
+            log.warn("OAuth绑定参数无效,code={}, userId={}", code, userId);
+            return null;
+        }
+        try {
+            WxOAuth2AccessToken accessToken =
+                    wxMpService.getOAuth2Service().getAccessToken(code);
+            String openId = accessToken.getOpenId();
+            if (StringUtils.isNotBlank(openId)) {
+                bindOpenIdToUser(openId, userId);
+                log.info("OAuth绑定openId成功,userId={}, openId={}", userId, openId);
+                return openId;
+            }
+        } catch (Exception e) {
+            log.error("OAuth获取openId失败,code={}, userId={}", code, userId, e);
+        }
+        return null;
+    }
+
+    /**
+     * 绑定公众号openId到员工
+     *
+     * @param openId 微信公众号openId
+     * @param userId 员工用户ID
+     */
+    public void bindOpenIdToUser(String openId, Long userId) {
+        if (StringUtils.isBlank(openId) || userId == null) {
+            log.warn("绑定openId参数无效,openId={}, userId={}", openId, userId);
+            return;
+        }
+        CompanyUser user = companyUserMapper.selectCompanyUserById(userId);
+        if (user == null) {
+            log.warn("绑定openId时未找到用户,userId={}", userId);
+            return;
+        }
+        companyUserMapper.updateMpOpenIdAndSubscribed(userId, openId, 0);
+        log.info("绑定openId成功,userId={}, openId={}", userId, openId);
+    }
+
+    /**
+     * 确认订阅通知
+     *
+     * @param userId 员工用户ID
+     */
+    public void confirmSubscribe(Long userId) {
+        if (userId == null) {
+            log.warn("确认订阅参数无效,userId=null");
+            return;
+        }
+        CompanyUser user = companyUserMapper.selectCompanyUserById(userId);
+        if (user == null) {
+            log.warn("确认订阅时未找到用户,userId={}", userId);
+            return;
+        }
+        if (StringUtils.isBlank(user.getMpOpenId())) {
+            log.warn("用户尚未绑定公众号openId,无法确认订阅,userId={}", userId);
+            throw new RuntimeException("用户尚未关注公众号,无法确认订阅");
+        }
+        companyUserMapper.updateMpSubscribed(userId, 1);
+        log.info("确认订阅成功,userId={}", userId);
+    }
+
+    /**
+     * 查询员工订阅状态
+     *
+     * @param userId 员工用户ID
+     * @return 员工信息(含mpOpenId和mpSubscribed)
+     */
+    public CompanyUser getSubscribeStatus(Long userId) {
+        return companyUserMapper.selectCompanyUserById(userId);
+    }
+
+    /**
+     * 向已订阅的员工推送公众号订阅消息
+     * 优先使用WxJava SDK发送,若SDK API不可用则回退到HTTP直调微信接口
+     *
+     * @param userId      员工用户ID
+     * @param wxMpService 微信公众号服务实例(从company_config构建,带access_token缓存)
+     * @param templateId  订阅消息模板ID
+     * @param data        模板数据,key为字段名(如thing2),value为字段值
+     * @return 推送是否成功
+     */
+    public boolean sendSubscribePush(Long userId, WxMpService wxMpService, String templateId, Map<String, String> data) {
+        if (userId == null || wxMpService == null || StringUtils.isBlank(templateId)) {
+            log.warn("[WxMpSubscribe] 推送参数无效,userId={}, templateId={}", userId, templateId);
+            return false;
+        }
+        CompanyUser user = companyUserMapper.selectCompanyUserById(userId);
+        if (user == null) {
+            throw new RuntimeException("未找到用户,userId=" + userId);
+        }
+        if (StringUtils.isBlank(user.getMpOpenId())) {
+            throw new RuntimeException("该员工尚未绑定公众号,请先引导员工完成订阅");
+        }
+        if (user.getMpSubscribed() == null || user.getMpSubscribed() != 1) {
+            throw new RuntimeException("该员工尚未订阅通知,请先引导员工完成订阅");
+        }
+
+        String openId = user.getMpOpenId();
+        log.info("[WxMpSubscribe] 开始推送订阅消息,userId={}, openId={}, templateId={}", userId, openId, templateId);
+
+        // 优先尝试SDK方式
+        try {
+            return sendViaSdk(wxMpService, openId, templateId, data);
+        } catch (NoSuchMethodError | NoClassDefFoundError e) {
+            log.warn("[WxMpSubscribe] SDK不支持订阅消息发送API,回退到HTTP直调,error={}", e.getMessage());
+        } catch (Exception e) {
+            log.warn("[WxMpSubscribe] SDK发送订阅消息异常,回退到HTTP直调", e);
+        }
+
+        // HTTP直调回退
+        return sendViaHttp(wxMpService, openId, templateId, data);
+    }
+
+    /**
+     * 通过WxJava SDK发送公众号订阅消息
+     * SDK的WxMpSubscribeMessage使用dataMap(Map<String,String>)存储模板数据
+     * 发送方法为 wxMpService.getSubscribeMsgService().send(message)
+     */
+    private boolean sendViaSdk(WxMpService wxMpService, String openId, String templateId, Map<String, String> data) throws Exception {
+        me.chanjar.weixin.mp.bean.subscribe.WxMpSubscribeMessage message =
+                me.chanjar.weixin.mp.bean.subscribe.WxMpSubscribeMessage.builder()
+                        .toUser(openId)
+                        .templateId(templateId)
+                        .dataMap(data)
+                        .build();
+
+        wxMpService.getSubscribeMsgService().send(message);
+        log.info("[WxMpSubscribe] SDK推送订阅消息成功,openId={}, templateId={}", openId, templateId);
+        return true;
+    }
+
+    /**
+     * 通过HTTP直调微信API发送公众号订阅消息(SDK回退方案)
+     * 接口: POST https://api.weixin.qq.com/cgi-bin/message/subscribe/bizsend
+     */
+    private boolean sendViaHttp(WxMpService wxMpService, String openId, String templateId, Map<String, String> data) {
+        try {
+            String accessToken = wxMpService.getAccessToken();
+            String url = "https://api.weixin.qq.com/cgi-bin/message/subscribe/bizsend?access_token=" + accessToken;
+
+            JSONObject body = new JSONObject();
+            body.put("touser", openId);
+            body.put("template_id", templateId);
+
+            JSONObject dataJson = new JSONObject();
+            for (Map.Entry<String, String> entry : data.entrySet()) {
+                JSONObject valueObj = new JSONObject();
+                valueObj.put("value", entry.getValue());
+                dataJson.put(entry.getKey(), valueObj);
+            }
+            body.put("data", dataJson);
+
+            RestTemplate restTemplate = new RestTemplate();
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            HttpEntity<String> entity = new HttpEntity<>(body.toJSONString(), headers);
+
+            String response = restTemplate.postForObject(url, entity, String.class);
+            JSONObject result = JSONObject.parseObject(response);
+            int errcode = result.getIntValue("errcode");
+            if (errcode == 0) {
+                log.info("[WxMpSubscribe] HTTP推送订阅消息成功,openId={}, templateId={}", openId, templateId);
+                return true;
+            } else {
+                log.error("[WxMpSubscribe] HTTP推送订阅消息失败,errcode={}, errmsg={}", errcode, result.getString("errmsg"));
+                throw new RuntimeException("推送失败:" + result.getString("errmsg"));
+            }
+        } catch (RuntimeException e) {
+            throw e;
+        } catch (Exception e) {
+            log.error("[WxMpSubscribe] HTTP推送订阅消息异常,openId={}", openId, e);
+            throw new RuntimeException("推送异常:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 从场景值中提取用户ID(兼容微信带参数二维码场景)
+     *
+     * @param sceneStr 场景值,格式:mpsub_{userId}
+     * @return 用户ID,如果不是订阅场景则返回null
+     */
+    public static Long extractUserIdFromScene(String sceneStr) {
+        if (StringUtils.isBlank(sceneStr) || !sceneStr.startsWith(SCENE_PREFIX)) {
+            return null;
+        }
+        try {
+            return Long.parseLong(sceneStr.substring(SCENE_PREFIX.length()));
+        } catch (NumberFormatException e) {
+            log.warn("解析场景值中的userId失败,sceneStr={}", sceneStr);
+            return null;
+        }
+    }
+
+    /**
+     * 构建订阅确认页面URL(使用当前系统域名,包含tenantCode和userId)
+     */
+    private String buildSubscribePageUrl(Long userId, String tenantCode) {
+        String domain = getCurrentDomain();
+        return domain + "/subscribe/index.html?tenantCode=" + tenantCode + "&userId=" + userId;
+    }
+
+    /**
+     * 获取当前系统域名(从请求上下文中提取)
+     */
+    private String getCurrentDomain() {
+        try {
+            ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+            if (attributes != null) {
+                HttpServletRequest request = attributes.getRequest();
+                String scheme = request.getScheme();
+                String serverName = request.getServerName();
+                int port = request.getServerPort();
+                if ((scheme.equals("http") && port == 80) || (scheme.equals("https") && port == 443)) {
+                    return scheme + "://" + serverName;
+                }
+                return scheme + "://" + serverName + ":" + port;
+            }
+        } catch (Exception e) {
+            log.warn("获取当前请求域名失败", e);
+        }
+        return "https://YOUR_DOMAIN";
+    }
+}

+ 1 - 0
fs-service/src/main/resources/db/tenant-initTable.sql

@@ -18887,3 +18887,4 @@ CREATE TABLE `crm_customer_call_app_log`
     INDEX              `company_and_company_user_idx`(`company_id`, `company_user_id`) USING BTREE,
     INDEX              `customer_id_idx`(`customer_id`) USING BTREE
 ) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = DYNAMIC;
+ALTER TABLE company_user ADD COLUMN mp_subscribed INT(1) DEFAULT 0 COMMENT '是否已订阅公众号通知 (0未订阅 1已订阅)';

+ 4 - 1
fs-service/src/main/resources/mapper/company/CompanyUserMapper.xml

@@ -47,6 +47,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="unionId"    column="union_id"    />
         <result property="cidServerId" column="cid_server_id"/>
         <result property="analyseData" column="analyse_data"/>
+        <result property="mpOpenId" column="mp_open_id"/>
+        <result property="mpSubscribed" column="mp_subscribed"/>
         <association property="dept"    column="dept_id" javaType="CompanyDept" resultMap="deptResult" />
         <collection  property="roles"   javaType="java.util.List"        resultMap="RoleResult" />
     </resultMap>
@@ -455,7 +457,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
                u.create_time,u.id_card, u.remark,u.user_type,u.open_id,u.qr_code_weixin,u.qr_code_wecom,u.jpush_id,u.domain,u.is_audit,u.address_id,
                d.dept_id, d.parent_id, d.dept_name, d.order_num, d.leader, d.status as dept_status,
                r.role_id, r.role_name, r.role_key, r.role_sort, r.data_scope, r.status as role_status,
-               u.is_need_register_member, u.is_allowed_all_register,u.doctor_id,u.union_id,u.cid_server_id,u.analyse_data
+               u.is_need_register_member, u.is_allowed_all_register,u.doctor_id,u.union_id,u.cid_server_id,u.analyse_data,
+               u.mp_open_id, u.mp_subscribed
         from company_user u
                  left join company_dept d on u.dept_id = d.dept_id
                  left join company_user_role ur on u.user_id = ur.user_id

+ 20 - 15
fs-wx-ipad-task/src/main/java/com/fs/app/task/SendMsg.java

@@ -140,23 +140,28 @@ public class SendMsg {
             }
             TaskContext newCtx = new TaskContext();
             qwMap.put(e.getId(), newCtx);
-            CompletableFuture.runAsync(() -> {
-                try {
-                    log.info("开始任务:{}", e.getWxNickName());
-                    sopTenantDataSourceAspect.switchTenant(tenantId);
-                    processUser(e, delayStart, delayEnd, newCtx);
-                } catch (Exception exception) {
-                    log.error("发送错误:", exception);
-                } finally {
-                    log.info("删除任务:{}", e.getWxNickName());
-                    sopTenantDataSourceAspect.clear();
+            try {
+                CompletableFuture.runAsync(() -> {
+                    try {
+                        log.info("开始任务:{}", e.getWxNickName());
+                        sopTenantDataSourceAspect.switchTenant(tenantId);
+                        processUser(e, delayStart, delayEnd, newCtx);
+                    } catch (Exception exception) {
+                        log.error("发送错误:", exception);
+                    } finally {
+                        log.info("删除任务:{}", e.getWxNickName());
+                        sopTenantDataSourceAspect.clear();
+                        qwMap.remove(e.getId());
+                    }
+                }, customThreadPool).exceptionally(ex -> {
+                    log.error("任务异步执行异常:{}, 错误: {}", e.getWxNickName(), ex.getMessage());
                     qwMap.remove(e.getId());
-                }
-            }, customThreadPool).exceptionally(ex -> {
-                log.error("任务提交失败:{}, 错误: {}", e.getWxNickName(), ex.getMessage());
+                    return null;
+                });
+            } catch (Exception ex) {
+                log.error("任务提交到线程池失败:{}, 错误: {}", e.getWxNickName(), ex.getMessage());
                 qwMap.remove(e.getId());
-                return null;
-            });
+            }
         });
     }