Merge remote-tracking branch 'origin/master'

This commit is contained in:
2026-06-20 22:03:11 +08:00
7 changed files with 344 additions and 14 deletions

View File

@@ -0,0 +1,58 @@
# 2026-06-17
## WxLoginController 配置读取改造app_config 跨表查询)
### 需求
- `WxLoginController.loginByMpWxPhone` 等微信小程序相关接口读取配置时,优先从 `gxwebsoft_core.app_config`db_websopy 库)读取,命中失败时回退到 `gxwebsoft_core.sys_setting.mp-weixin`
- app_config 是 KV 行式存储:`id, tenant_id, config_key, config_value, category`(如 `category=wechat``config_key=wechat.appId`)。
### 方案(无需新建数据源)
- 直接 MyBatis XML 跨表:`FROM gxwebsoft_core.app_config`
- 走主数据源,依赖 gxwebsoft_core 库账号对 app_config 有 SELECT 权限
### 新增文件
- `src/main/java/com/gxwebsoft/websopy/entity/AppConfig.java` —— 实体
- `src/main/java/com/gxwebsoft/websopy/mapper/AppConfigMapper.java` —— Mapper声明 selectByCategory
- `src/main/java/com/gxwebsoft/websopy/mapper/AppConfigMapper.xml` —— XML 跨表查询(**XML 与 Mapper 同包**
- `src/main/java/com/gxwebsoft/websopy/service/AppConfigService.java` —— 带 Redis 缓存
### 改造 WxLoginController
- 注入 `AppConfigService appConfigService`
- 新增私有方法 `getMpWxSetting(Integer tenantId)`:先读 app_config category=wechat失败回退 `settingService.getBySettingKey("mp-weixin")`
- 替换 5 处调用:
- `getOpenIdByCode`(约 521 行)
- `getAccessToken(Integer)`(约 716 行)
- `getWxOpenId`(约 834 行)
- `getWxOpenIdOnly`(约 861 行)
- `loginByOpenId`(约 959 行,写回 AppId/AppSecret Redis 缓存保持一致)
### 关键细节
- 缓存 Key 前缀 `appConfig:{tenantId}:{category}`(与 setting:* 隔离)
- 缓存 TTL 2 小时
- 配置文件组装:去掉 `config_key` 中的 `wechat.` 前缀,直接得到 `appId`/`appSecret`
- 编译错误原因及修复Mapper interface 必须显式声明 selectByCategory 方法(编译期不查 XML
### 编译验证
- `./mvnw clean compile -DskipTests` 通过 ✅
### 运行期 BeanCreationException 修复2026-06-18 补记)
- 报错:`Bean named 'redisTemplate' expected StringRedisTemplate but was actually RedisTemplate`
- 原因:`@Resource` 按字段名bean name注入容器中 `redisTemplate``RedisTemplate<Object,Object>`,而 `StringRedisTemplate` bean 叫 `stringRedisTemplate`
- 修复:`AppConfigService` 字段名从 `redisTemplate` 改为 `stringRedisTemplate`4 处引用同步修改
- **项目规律:统一用字段名 `stringRedisTemplate`,禁止用 `redisTemplate` 注入 StringRedisTemplate**
### 运行期 SQLSyntaxErrorException 修复2026-06-18 补记)
- 报错:`Table 'gxwebsoft_core.app_config' doesn't exist` + TenantLineInnerInterceptor 自动追加 `AND tenant_id = 10611`
- 原因 1app_config 表实际在 `db_websopy` 库中,不在 gxwebsoft_core
- 原因 2`TenantLineInnerInterceptor` 对所有 SQL 自动追加 tenant_id 条件,导致参数重复
- 实际表结构(从用户截图确认):
- 主键 `config_id`(不是 id
- 分类字段 `config_type`(不是 category
-`deleted` 逻辑删除字段
- tenant_id 示例值 105885
- 修复:
1. XML 表名改为 `db_websopy.app_config`
2. Mapper 方法加 `@InterceptorIgnore(tenantLine = "true")`(与项目其他跨库 Mapper 一致)
3. 实体类字段对齐实际表结构configId/configType/deleted
4. Service 参数名 category → configType 全部对齐
- **关键教训:跨库查询必须加 @InterceptorIgnore(tenantLine="true"),且表名必须带正确库名前缀**

View File

@@ -0,0 +1,11 @@
# 2026-06-18 工作日志
## AppConfigMapper.xml 添加关联查询
**改动文件**: `src/main/java/com/gxwebsoft/websopy/mapper/AppConfigMapper.xml`
- `selectByCategory` SQL 增加了 `INNER JOIN db_websopy.app_product`,关联条件:
- `ap.product_id = ac.app_id`
- `ap.tenant_id = #{tenantId}`
- 目的:确保只返回该租户下有效产品的配置,防止 app_config 中的 app_id 指向非该租户的产品
- Service 层和 Controller 层无需改动,不影响现有调用

View File

@@ -25,6 +25,7 @@ import com.gxwebsoft.common.system.param.LoginParam;
import com.gxwebsoft.common.system.param.UserParam; import com.gxwebsoft.common.system.param.UserParam;
import com.gxwebsoft.common.system.result.LoginResult; import com.gxwebsoft.common.system.result.LoginResult;
import com.gxwebsoft.common.system.service.*; import com.gxwebsoft.common.system.service.*;
import com.gxwebsoft.websopy.service.AppConfigService;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
@@ -67,6 +68,8 @@ public class WxLoginController extends BaseController {
private UserRefereeService userRefereeService; private UserRefereeService userRefereeService;
@Resource @Resource
private UserSyncService userSyncService; private UserSyncService userSyncService;
@Resource
private AppConfigService appConfigService;
public WxLoginController(StringRedisTemplate redisTemplate) { public WxLoginController(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate; this.redisTemplate = redisTemplate;
@@ -458,6 +461,26 @@ public class WxLoginController extends BaseController {
return fail("更新失败"); return fail("更新失败");
} }
/**
* 优先读取 db_websopy.app_configcategory=wechat不存在或异常时回退到 sys_setting (mp-weixin)
*
* @param tenantId 租户ID传 null 时使用当前请求租户)
* @return JSONObject 配置内容(含 appId / appSecret 等字段)
*/
private JSONObject getMpWxSetting(Integer tenantId) {
Integer tid = tenantId != null ? tenantId : getTenantId();
try {
JSONObject wechat = appConfigService.getByCategory("wechat", tid);
if (wechat != null && !wechat.isEmpty()) {
return wechat;
}
} catch (Exception e) {
System.err.println("[WxLoginController] 读取 app_config 失败,回退 sys_setting: " + e.getMessage());
}
// 兜底:原 sys_setting.mp-weixin
return settingService.getBySettingKey("mp-weixin");
}
/** /**
* 新用户注册 * 新用户注册
*/ */
@@ -519,8 +542,8 @@ public class WxLoginController extends BaseController {
// 获取openid // 获取openid
private JSONObject getOpenIdByCode(UserParam userParam) { private JSONObject getOpenIdByCode(UserParam userParam) {
try { try {
// 获取微信小程序配置信息 // 获取微信小程序配置信息(优先 app_config回退 sys_setting
JSONObject setting = settingService.getBySettingKey("mp-weixin"); JSONObject setting = getMpWxSetting(null);
// 获取openId // 获取openId
String apiUrl = "https://api.weixin.qq.com/sns/jscode2session?appid=" + setting.getString("appId") + "&secret=" + setting.getString("appSecret") + "&js_code=" + userParam.getCode() + "&grant_type=authorization_code"; String apiUrl = "https://api.weixin.qq.com/sns/jscode2session?appid=" + setting.getString("appId") + "&secret=" + setting.getString("appSecret") + "&js_code=" + userParam.getCode() + "&grant_type=authorization_code";
// 执行get请求 // 执行get请求
@@ -714,8 +737,8 @@ public class WxLoginController extends BaseController {
tenantId = getTenantId(); tenantId = getTenantId();
} }
String key = ACCESS_TOKEN_KEY.concat(":").concat(tenantId.toString()); String key = ACCESS_TOKEN_KEY.concat(":").concat(tenantId.toString());
// 获取微信小程序配置信息 // 获取微信小程序配置信息(优先 app_config回退 sys_setting
JSONObject setting = settingService.getBySettingKey("mp-weixin"); JSONObject setting = getMpWxSetting(tenantId);
if (setting == null) { if (setting == null) {
throw new BusinessException("请先配置小程序"); throw new BusinessException("请先配置小程序");
} }
@@ -833,7 +856,7 @@ public class WxLoginController extends BaseController {
// 请求微信接口获取openid // 请求微信接口获取openid
String apiUrl = "https://api.weixin.qq.com/sns/jscode2session"; String apiUrl = "https://api.weixin.qq.com/sns/jscode2session";
final HashMap<String, Object> map = new HashMap<>(); final HashMap<String, Object> map = new HashMap<>();
final JSONObject setting = settingService.getBySettingKey("mp-weixin"); final JSONObject setting = getMpWxSetting(null);
final String appId = setting.getString("appId"); final String appId = setting.getString("appId");
final String appSecret = setting.getString("appSecret"); final String appSecret = setting.getString("appSecret");
map.put("appid", appId); map.put("appid", appId);
@@ -860,7 +883,7 @@ public class WxLoginController extends BaseController {
String apiUrl = "https://api.weixin.qq.com/sns/jscode2session"; String apiUrl = "https://api.weixin.qq.com/sns/jscode2session";
final HashMap<String, Object> map = new HashMap<>(); final HashMap<String, Object> map = new HashMap<>();
final JSONObject setting = settingService.getBySettingKey("mp-weixin"); final JSONObject setting = getMpWxSetting(null);
final String appId = setting.getString("appId"); final String appId = setting.getString("appId");
final String appSecret = setting.getString("appSecret"); final String appSecret = setting.getString("appSecret");
map.put("appid", appId); map.put("appid", appId);
@@ -951,15 +974,15 @@ public class WxLoginController extends BaseController {
@Operation(summary = "openid无感登录") @Operation(summary = "openid无感登录")
@PostMapping("/loginByOpenId") @PostMapping("/loginByOpenId")
public ApiResult<?> loginByOpenId(@RequestBody Mp mp, HttpServletRequest request) { public ApiResult<?> loginByOpenId(@RequestBody Mp mp, HttpServletRequest request) {
// 获取小程序配置信息 // 优先读 app_configcategory=wechat回退 sys_setting.mp-weixin
String key1 = "AppId:".concat(mp.getTenantId().toString()); final JSONObject setting = getMpWxSetting(mp.getTenantId());
String key2 = "AppSecret:".concat(mp.getTenantId().toString()); if (setting == null) {
String AppId = redisUtil.get(key1); return fail("小程序未配置");
String AppSecret = redisUtil.get(key2); }
final String AppId = setting.getString("appId");
final String AppSecret = setting.getString("appSecret");
if (StrUtil.isBlank(AppId) || StrUtil.isBlank(AppSecret)) { if (StrUtil.isBlank(AppId) || StrUtil.isBlank(AppSecret)) {
final JSONObject setting = settingService.getBySettingKey("mp-weixin"); return fail("小程序配置不完整,请检查 appId 和 appSecret");
AppId = setting.getString("appId");
AppSecret = setting.getString("appSecret");
} }
// 请求微信接口获取openid // 请求微信接口获取openid

View File

@@ -0,0 +1,51 @@
package com.gxwebsoft.websopy.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
/**
* app_configdb_websopy跨表映射实体
* 实际查询通过 XML 使用全限定表名 db_websopy.app_config
*
* 实际表结构:
* config_id, app_id, config_key, config_value, config_type,
* is_encrypted, is_secret, updated_time, created_time,
* description, sort_number, tenant_id, deleted
*
* @author WebSoft
* @since 2026-06-17
*/
@Data
public class AppConfig {
private static final long serialVersionUID = 1L;
@TableId(value = "config_id", type = IdType.AUTO)
private Integer configId;
@TableField("app_id")
private Integer appId;
@TableField("tenant_id")
private Integer tenantId;
@TableField("config_key")
private String configKey;
@TableField("config_value")
private String configValue;
/** 分类字段,如 wechat / payment / sms */
@TableField("config_type")
private String configType;
@TableField("is_encrypted")
private Integer isEncrypted;
@TableField("is_secret")
private Integer isSecret;
@TableField("description")
private String description;
}

View File

@@ -0,0 +1,35 @@
package com.gxwebsoft.websopy.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.gxwebsoft.websopy.entity.AppConfig;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* app_configdb_websopy跨表查询 Mapper
*
* 注意selectByCategory 标注了 @InterceptorIgnore(tenantLine = "true")
* 因为 XML 中使用全限定表名 db_websopy.app_config
* TenantLineInnerInterceptor 会自动追加 tenant_id 条件导致参数重复。
* 同时 app_config 的 tenant_id 与 gxwebsoft_core 不是同一套。
*
* @author WebSoft
* @since 2026-06-17
*/
@Mapper
public interface AppConfigMapper extends BaseMapper<AppConfig> {
/**
* 跨表查询 db_websopy.app_config按 config_type 分组)
*
* @param tenantId 租户IDapp_config 表中的 tenant_id
* @param configType 配置分类(如 wechat
* @return 配置项列表
*/
@InterceptorIgnore(tenantLine = "true")
List<AppConfig> selectByCategory(@Param("tenantId") Integer tenantId,
@Param("configType") String configType);
}

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gxwebsoft.websopy.mapper.AppConfigMapper">
<!--
跨表查询 db_websopy.app_config关联 app_product 校验产品有效性
注意:
1. 表名带库名前缀 db_websopy.app_config该表在 db_websopy 库中)
2. Mapper 方法已加 @InterceptorIgnore(tenantLine = "true")
TenantLineInnerInterceptor 不会自动追加 tenant_id 条件
3. 手动传入 tenantId 参数精确匹配 app_config 自身的租户
4. INNER JOIN app_product确保只返回该租户下有效产品app_product.product_id = app_config.app_id的配置
-->
<select id="selectByCategory" resultType="com.gxwebsoft.websopy.entity.AppConfig">
SELECT ac.config_id AS configId,
ac.app_id AS appId,
ac.tenant_id AS tenantId,
ac.config_key AS configKey,
ac.config_value AS configValue,
ac.config_type AS configType,
ac.is_encrypted AS isEncrypted,
ac.is_secret AS isSecret,
ac.description
FROM db_websopy.app_config ac
INNER JOIN db_websopy.app_product ap
ON ap.product_id = ac.app_id
AND ap.tenant_id = #{tenantId}
WHERE ac.deleted = 0
AND ac.tenant_id = #{tenantId}
AND ac.config_type = #{configType}
</select>
</mapper>

View File

@@ -0,0 +1,119 @@
package com.gxwebsoft.websopy.service;
import com.alibaba.fastjson.JSONObject;
import com.gxwebsoft.websopy.entity.AppConfig;
import com.gxwebsoft.websopy.mapper.AppConfigMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* app_configdb_websopy配置读取服务
* 优先读 Redis 缓存,未命中跨表查 gxwebsoft_core.app_config 并写入缓存
*
* @author WebSoft
* @since 2026-06-17
*/
@Slf4j
@Service
public class AppConfigService {
@Resource
private AppConfigMapper appConfigMapper;
// 字段名与 bean 名保持一致stringRedisTemplateStringRedisTemplate bean
// 不能用 redisTemplate容器中该名字对应的是 RedisTemplate<Object, Object>
@Resource
private StringRedisTemplate stringRedisTemplate;
/** 缓存有效期2 小时(与微信 access_token 一致,避免长时间 stale */
private static final long CACHE_TTL_HOURS = 2L;
/** 缓存 Key 前缀,避免与 setting:* 冲突 */
private static final String CACHE_KEY_PREFIX = "appConfig:";
/**
* 按 config_type 读取 app_config 全部配置项,并组装为 JSON
*
* @param configType 配置分类(如 wechat对应 app_config.config_type 字段)
* @param tenantId 租户 ID必传
* @return 拼装后的 JSON若 config_type 下无记录或发生异常,返回 null由调用方回退到 sys_setting
*/
public JSONObject getByCategory(String configType, Integer tenantId) {
if (tenantId == null) {
log.warn("[AppConfigService] tenantId 为空,跳过 configType={}", configType);
return null;
}
String cacheKey = buildCacheKey(configType, tenantId);
// 1. 命中 Redis 直接返回
String cached = stringRedisTemplate.opsForValue().get(cacheKey);
if (cached != null && !cached.isEmpty()) {
try {
return JSONObject.parseObject(cached);
} catch (Exception e) {
log.warn("[AppConfigService] 缓存解析失败,删除后回源 configType={}, tenantId={}, err={}",
configType, tenantId, e.getMessage());
stringRedisTemplate.delete(cacheKey);
}
}
// 2. 跨表查 db_websopy.app_config
List<AppConfig> list;
try {
list = appConfigMapper.selectByCategory(tenantId, configType);
} catch (Exception e) {
log.error("[AppConfigService] 跨表查询失败 configType={}, tenantId={}, err={}",
configType, tenantId, e.getMessage(), e);
return null;
}
if (list == null || list.isEmpty()) {
log.info("[AppConfigService] 未找到配置 configType={}, tenantId={}", configType, tenantId);
return null;
}
// 3. 组装为 JSON去掉 config_key 中的 "configType." 前缀)
JSONObject result = new JSONObject();
String prefix = configType + ".";
for (AppConfig c : list) {
String key = c.getConfigKey();
if (key == null) {
continue;
}
if (key.startsWith(prefix)) {
key = key.substring(prefix.length());
}
result.put(key, c.getConfigValue());
}
// 4. 写缓存 2 小时
try {
stringRedisTemplate.opsForValue().set(cacheKey, result.toJSONString(),
CACHE_TTL_HOURS, TimeUnit.HOURS);
} catch (Exception e) {
log.warn("[AppConfigService] 写缓存失败(不影响返回) err={}", e.getMessage());
}
log.info("[AppConfigService] 从 app_config 读取配置 configType={}, tenantId={}, fields={}",
configType, tenantId, result.size());
return result;
}
/**
* 主动失效缓存(供配置变更时调用)
*/
public void evict(String configType, Integer tenantId) {
if (tenantId == null) {
return;
}
stringRedisTemplate.delete(buildCacheKey(configType, tenantId));
}
private String buildCacheKey(String configType, Integer tenantId) {
return CACHE_KEY_PREFIX + tenantId + ":" + configType;
}
}