Explorar o código

update 服务号授权

ct hai 3 días
pai
achega
92623578b0

+ 258 - 0
fs-service/src/main/java/com/fs/wx/mp/service/TenantCourseWxMpServiceProvider.java

@@ -0,0 +1,258 @@
+package com.fs.wx.mp.service;
+
+import cn.hutool.crypto.digest.DigestUtil;
+import cn.hutool.json.JSONUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.fs.common.config.RedisTenantContext;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.SecurityUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.course.config.CourseConfig;
+import com.fs.course.domain.FsCoursePlaySourceConfig;
+import com.fs.course.service.IFsCoursePlaySourceConfigService;
+import com.fs.system.service.ISysConfigService;
+import lombok.Data;
+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.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.io.Serializable;
+import java.net.URI;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 按当前租户从 course.config + fs_course_play_source_config(type=2) 构建微信公众号 WxMpService。
+ */
+@Service
+public class TenantCourseWxMpServiceProvider {
+
+    private static final Logger log = LoggerFactory.getLogger(TenantCourseWxMpServiceProvider.class);
+
+    /** 公众号类型:2 */
+    private static final int MP_TYPE = 2;
+
+    private static final String REDIS_CONFIG_KEY = "wx:mp:course:auth:config";
+
+    /** 配置元数据短缓存,secret 变更后通过 fingerprint 自动失效 */
+    private static final int CACHE_MINUTES = 10;
+
+    private final ConcurrentHashMap<String, WxMpService> localServiceCache = new ConcurrentHashMap<>();
+
+    private final ConcurrentHashMap<Long, Object> tenantLocks = new ConcurrentHashMap<>();
+
+    @Autowired
+    private ISysConfigService configService;
+
+    @Autowired
+    private IFsCoursePlaySourceConfigService playSourceConfigService;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    /**
+     * 获取当前租户课程授权公众号 WxMpService(配置来自 course.config 的 userCourseAuthDomain)。
+     */
+    public WxMpService getWxMpService() {
+        Long tenantId = resolveTenantId();
+        WxMpConfigCache configCache = loadConfigCache(tenantId);
+        return buildOrGetLocalService(tenantId, configCache);
+    }
+
+    /**
+     * 清理当前租户公众号配置缓存(secret 变更后可调用)。
+     */
+    public void evictCache() {
+        Long tenantId = resolveTenantId();
+        redisCache.deleteObject(REDIS_CONFIG_KEY);
+        String tenantPrefix = (tenantId == null ? "0" : String.valueOf(tenantId)) + ":";
+        localServiceCache.keySet().removeIf(k -> k.startsWith(tenantPrefix));
+        log.info("[WxMp] 已清理租户 tenantId={} 的公众号配置缓存", tenantId);
+    }
+
+    /**
+     * 按指定 appId 获取 WxMpService(须与 course.config 解析出的 appId 一致)。
+     */
+    public WxMpService getWxMpService(String appId) {
+        WxMpService service = getWxMpService();
+        if (StringUtils.isNotEmpty(appId) && service.getWxMpConfigStorage() != null
+                && !appId.equals(service.getWxMpConfigStorage().getAppId())) {
+            throw new ServiceException("公众号appId与课程授权配置不一致");
+        }
+        return service;
+    }
+
+    private WxMpConfigCache loadConfigCache(Long tenantId) {
+        // 始终以数据库为准,Redis 仅作短缓存;secret 变更后 fingerprint 不同会自动刷新
+        WxMpConfigCache dbConfig = loadFromDb();
+        WxMpConfigCache cached = redisCache.getCacheObject(REDIS_CONFIG_KEY);
+        if (cached != null && cached.isValid()
+                && dbConfig.getConfigFingerprint().equals(cached.getConfigFingerprint())) {
+            return cached;
+        }
+        synchronized (tenantLock(tenantId)) {
+            dbConfig = loadFromDb();
+            cached = redisCache.getCacheObject(REDIS_CONFIG_KEY);
+            if (cached != null && cached.isValid()
+                    && dbConfig.getConfigFingerprint().equals(cached.getConfigFingerprint())) {
+                return cached;
+            }
+            redisCache.setCacheObject(REDIS_CONFIG_KEY, dbConfig, CACHE_MINUTES, TimeUnit.MINUTES);
+            purgeStaleLocalServices(tenantId);
+            log.info("[WxMp] 租户 tenantId={} 刷新公众号配置 appId={} fingerprint={}",
+                    tenantId, dbConfig.getAppId(), dbConfig.getConfigFingerprint());
+            return dbConfig;
+        }
+    }
+
+    private WxMpConfigCache loadFromDb() {
+        String courseJson = configService.selectConfigByKey("course.config");
+        if (StringUtils.isEmpty(courseJson)) {
+            throw new ServiceException("未配置 course.config,无法初始化微信公众号");
+        }
+        CourseConfig courseConfig = JSONUtil.toBean(courseJson, CourseConfig.class);
+        String appId = resolveAppId(courseConfig);
+        if (StringUtils.isEmpty(appId)) {
+            throw new ServiceException("course.config 中 userCourseAuthDomain 未配置有效 appid");
+        }
+
+        String domainAppId = extractAppIdFromUrl(courseConfig.getUserCourseAuthDomain());
+        String mpAppId = StringUtils.trimToNull(courseConfig.getMpAppId());
+        if (StringUtils.isNotEmpty(domainAppId) && StringUtils.isNotEmpty(mpAppId)
+                && !domainAppId.equals(mpAppId)) {
+            log.warn("[WxMp] course.config 授权 appId 不一致: userCourseAuthDomain={}, mpAppId={}",
+                    domainAppId, mpAppId);
+        }
+
+        LambdaQueryWrapper<FsCoursePlaySourceConfig> wrapper = Wrappers.<FsCoursePlaySourceConfig>lambdaQuery()
+                .eq(FsCoursePlaySourceConfig::getAppid, appId)
+                .eq(FsCoursePlaySourceConfig::getType, MP_TYPE)
+                .eq(FsCoursePlaySourceConfig::getIsDel, 0)
+                .orderByDesc(FsCoursePlaySourceConfig::getUpdateTime)
+                .last("limit 1");
+        FsCoursePlaySourceConfig sourceConfig = playSourceConfigService.getOne(wrapper);
+        if (sourceConfig == null) {
+            throw new ServiceException("未找到 appId=" + appId + " 且 type=2 的公众号点播配置,请检查点播配置表 secret 是否与微信公众平台一致");
+        }
+        String secret = StringUtils.trim(sourceConfig.getSecret());
+        if (StringUtils.isEmpty(secret)) {
+            throw new ServiceException("公众号点播配置 secret 为空,appId=" + appId);
+        }
+
+        WxMpConfigCache cache = new WxMpConfigCache();
+        cache.setAppId(StringUtils.trim(sourceConfig.getAppid()));
+        cache.setSecret(secret);
+        cache.setToken(sourceConfig.getToken() == null ? "" : sourceConfig.getToken().trim());
+        cache.setAesKey(sourceConfig.getAesKey() == null ? "" : sourceConfig.getAesKey().trim());
+        cache.setConfigFingerprint(buildFingerprint(cache.getAppId(), cache.getSecret()));
+        log.debug("[WxMp] 加载配置 appId={} secretLen={}", cache.getAppId(), secret.length());
+        return cache;
+    }
+
+    private WxMpService buildOrGetLocalService(Long tenantId, WxMpConfigCache configCache) {
+        String cacheKey = localKey(tenantId, configCache.getConfigFingerprint());
+        return localServiceCache.computeIfAbsent(cacheKey, k -> createWxMpService(configCache));
+    }
+
+    private void purgeStaleLocalServices(Long tenantId) {
+        String tenantPrefix = (tenantId == null ? "0" : String.valueOf(tenantId)) + ":";
+        localServiceCache.keySet().removeIf(k -> k.startsWith(tenantPrefix));
+    }
+
+    private WxMpService createWxMpService(WxMpConfigCache configCache) {
+        WxMpDefaultConfigImpl storage = new WxMpDefaultConfigImpl();
+        storage.setAppId(configCache.getAppId());
+        storage.setSecret(configCache.getSecret());
+        storage.setToken(configCache.getToken());
+        storage.setAesKey(configCache.getAesKey());
+
+        WxMpServiceImpl service = new WxMpServiceImpl();
+        service.setWxMpConfigStorage(storage);
+        return service;
+    }
+
+    /**
+     * 从 userCourseAuthDomain 解析 appid,例如:
+     * https://auth.ylrzfs.com/weixinOauth?appid=wx93ce67750e3cfba3
+     */
+    static String resolveAppId(CourseConfig courseConfig) {
+        if (courseConfig == null) {
+            return null;
+        }
+        String appId = extractAppIdFromUrl(courseConfig.getUserCourseAuthDomain());
+        if (StringUtils.isNotEmpty(appId)) {
+            return appId;
+        }
+        return StringUtils.trimToNull(courseConfig.getMpAppId());
+    }
+
+    static String extractAppIdFromUrl(String url) {
+        if (StringUtils.isEmpty(url)) {
+            return null;
+        }
+        try {
+            URI uri = URI.create(url.trim());
+            String query = uri.getQuery();
+            if (StringUtils.isNotEmpty(query)) {
+                for (String pair : query.split("&")) {
+                    String[] kv = pair.split("=", 2);
+                    if (kv.length == 2 && "appid".equalsIgnoreCase(kv[0].trim())) {
+                        return kv[1].trim();
+                    }
+                }
+            }
+        } catch (Exception ignored) {
+            // fall through
+        }
+        int idx = url.indexOf("appid=");
+        if (idx < 0) {
+            return null;
+        }
+        String rest = url.substring(idx + 6);
+        int amp = rest.indexOf('&');
+        return (amp > 0 ? rest.substring(0, amp) : rest).trim();
+    }
+
+    private Long resolveTenantId() {
+        Long tenantId = RedisTenantContext.getTenantId();
+        if (tenantId == null) {
+            tenantId = SecurityUtils.getTenantId();
+        }
+        return tenantId;
+    }
+
+    private static String localKey(Long tenantId, String fingerprint) {
+        return (tenantId == null ? "0" : tenantId) + ":" + fingerprint;
+    }
+
+    private static String buildFingerprint(String appId, String secret) {
+        return DigestUtil.md5Hex(appId + ":" + StringUtils.trim(secret));
+    }
+
+    private Object tenantLock(Long tenantId) {
+        long key = tenantId == null ? 0L : tenantId;
+        return tenantLocks.computeIfAbsent(key, k -> new Object());
+    }
+
+    @Data
+    public static class WxMpConfigCache implements Serializable {
+        private static final long serialVersionUID = 1L;
+
+        private String appId;
+        private String secret;
+        private String token;
+        private String aesKey;
+        private String configFingerprint;
+
+        boolean isValid() {
+            return StringUtils.isNotEmpty(appId) && StringUtils.isNotEmpty(secret)
+                    && StringUtils.isNotEmpty(configFingerprint);
+        }
+    }
+}

+ 13 - 1
fs-user-app/src/main/java/com/fs/app/controller/store/WxH5MpScrmController.java

@@ -4,6 +4,7 @@ import cn.hutool.core.date.DateTime;
 import com.fs.app.param.FsUserLoginByMpParam;
 import com.fs.app.utils.JwtUtils;
 import com.fs.common.core.domain.R;
+import com.fs.common.exception.ServiceException;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.company.domain.Company;
 import com.fs.company.domain.CompanyUser;
@@ -23,6 +24,7 @@ import lombok.extern.slf4j.Slf4j;
 import me.chanjar.weixin.common.bean.WxOAuth2UserInfo;
 import me.chanjar.weixin.common.bean.oauth2.WxOAuth2AccessToken;
 import me.chanjar.weixin.common.error.WxErrorException;
+import com.fs.wx.mp.service.TenantCourseWxMpServiceProvider;
 import me.chanjar.weixin.mp.api.WxMpService;
 import org.apache.commons.lang3.StringUtils;
 import org.slf4j.Logger;
@@ -47,7 +49,7 @@ import java.util.concurrent.TimeUnit;
 public class WxH5MpScrmController {
     Logger logger = LoggerFactory.getLogger(getClass());
     @Autowired
-    private WxMpService wxMpService;
+    private TenantCourseWxMpServiceProvider tenantCourseWxMpServiceProvider;
 
     @Autowired
     private IFsUserScrmService userService;
@@ -92,6 +94,7 @@ public class WxH5MpScrmController {
         }
 
         try {
+            WxMpService wxMpService = tenantCourseWxMpServiceProvider.getWxMpService();
             // 获取微信用户信息
             WxOAuth2AccessToken wxMpOAuth2AccessToken = wxMpService.getOAuth2Service().getAccessToken(param.getCode());
             WxOAuth2UserInfo wxMpUser = wxMpService.getOAuth2Service().getUserInfo(wxMpOAuth2AccessToken, null);
@@ -116,7 +119,16 @@ public class WxH5MpScrmController {
             return generateLoginResult(user);
 
 
+        } catch (ServiceException e) {
+            log.error("课程公众号配置异常: {}", e.getMessage());
+            return R.error(e.getMessage());
         } catch (WxErrorException e) {
+            if (e.getError() != null && e.getError().getErrorCode() == 40001) {
+                tenantCourseWxMpServiceProvider.evictCache();
+                log.error("微信40001 AppSecret无效,已清理公众号配置缓存,请核对点播配置表(type=2) secret 与微信公众平台是否一致: {}",
+                        e.getMessage());
+                return R.error("公众号 AppSecret 配置错误,请在「点播配置」中核对 type=2 公众号的 secret 是否与微信后台一致");
+            }
             this.logger.error(e.getMessage(), e);
             return R.error("授权失败," + e.getMessage());
         }

+ 6 - 1
fs-user-app/src/main/java/com/fs/app/controller/store/WxMpScrmController.java

@@ -18,6 +18,7 @@ import me.chanjar.weixin.common.bean.menu.WxMenu;
 import me.chanjar.weixin.common.bean.oauth2.WxOAuth2AccessToken;
 import me.chanjar.weixin.common.error.WxErrorException;
 import me.chanjar.weixin.mp.api.WxMpMenuService;
+import com.fs.wx.mp.service.TenantCourseWxMpServiceProvider;
 import me.chanjar.weixin.mp.api.WxMpService;
 import me.chanjar.weixin.mp.api.WxMpUserService;
 import me.chanjar.weixin.mp.bean.result.WxMpUser;
@@ -41,7 +42,7 @@ import java.util.concurrent.TimeUnit;
 public class WxMpScrmController {
     Logger logger= LoggerFactory.getLogger(getClass());
     @Autowired
-    private WxMpService wxMpService;
+    private TenantCourseWxMpServiceProvider tenantCourseWxMpServiceProvider;
     private static final String MP_TOKEN = "U2qmxEbsp0PJFoLRvUDvIjVi9XPzuVc2";
 
     private static final String EncodingAESKey = "P3HE7Gd1PJVQqCLoOMop5uYfjx9LwfY53rnC3VUuLZS";
@@ -70,6 +71,7 @@ public class WxMpScrmController {
             @RequestParam("nonce") String nonce,
             @RequestParam("echostr") String echostr) {
       logger.info("数据回调URLServer-微信调用dataGet请求");
+      WxMpService wxMpService = tenantCourseWxMpServiceProvider.getWxMpService();
       boolean check = wxMpService.checkSignature(timestamp,nonce,signature);
       logger.info("验证结果:{}",check);
       if (check){
@@ -80,6 +82,7 @@ public class WxMpScrmController {
 
     @PostMapping("/createMenu")
     public R createMenu(@RequestBody WxMenu wxMenu) throws WxErrorException{
+      WxMpService wxMpService = tenantCourseWxMpServiceProvider.getWxMpService();
       WxMpMenuService wxMpMenuService = wxMpService.getMenuService();
       try{
         wxMpMenuService.menuCreate(wxMenu);
@@ -97,6 +100,7 @@ public class WxMpScrmController {
         return R.error("code不存在");
       }
       try{
+        WxMpService wxMpService = tenantCourseWxMpServiceProvider.getWxMpService();
         WxOAuth2AccessToken wxMpOAuth2AccessToken = wxMpService.getOAuth2Service().getAccessToken(param.getCode());
         WxOAuth2UserInfo wxMpUser = wxMpService.getOAuth2Service().getUserInfo(wxMpOAuth2AccessToken, null);
         WxMpUserService wxMpUserService = wxMpService.getUserService();
@@ -150,6 +154,7 @@ public class WxMpScrmController {
     public R getWxConfig(@RequestParam String url) throws WxErrorException {
         try {
             String sLink = URLDecoder.decode(url, "UTF-8");
+            WxMpService wxMpService = tenantCourseWxMpServiceProvider.getWxMpService();
             final WxJsapiSignature jsapiSignature = wxMpService.createJsapiSignature(sLink);
             return R.ok().put("data", jsapiSignature);
         } catch (UnsupportedEncodingException e) {

+ 3 - 1
fs-user-app/src/main/java/com/fs/app/controller/store/WxUserScrmController.java

@@ -31,6 +31,7 @@ import lombok.Synchronized;
 import me.chanjar.weixin.common.bean.WxOAuth2UserInfo;
 import me.chanjar.weixin.common.bean.oauth2.WxOAuth2AccessToken;
 import me.chanjar.weixin.common.error.WxErrorException;
+import com.fs.wx.mp.service.TenantCourseWxMpServiceProvider;
 import me.chanjar.weixin.mp.api.WxMpService;
 import org.apache.commons.lang3.StringUtils;
 import org.slf4j.Logger;
@@ -60,7 +61,7 @@ public class WxUserScrmController extends AppBaseController {
     @Autowired
     private WxMpProperties mpProperties;
     @Autowired
-    private WxMpService wxMpService;
+    private TenantCourseWxMpServiceProvider tenantCourseWxMpServiceProvider;
 
     @Autowired
     private IFsUserScrmService userService;
@@ -477,6 +478,7 @@ public class WxUserScrmController extends AppBaseController {
         }
         try{
 
+            WxMpService wxMpService = tenantCourseWxMpServiceProvider.getWxMpService();
             WxOAuth2AccessToken wxMpOAuth2AccessToken = wxMpService.getOAuth2Service().getAccessToken(param.getCode());
             WxOAuth2UserInfo wxMpUser = wxMpService.getOAuth2Service().getUserInfo(wxMpOAuth2AccessToken, null);
             //如果开启了UnionId