|
|
@@ -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);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|