Compare commits

..

10 Commits

Author SHA1 Message Date
03cefc9048 Merge remote-tracking branch 'origin/master' 2026-06-21 10:32:58 +08:00
f5f9e3a19d fix(system): 调整小程序配置读取顺序
- 修改 getMpWxSetting 方法,优先读取 sys_setting(mp-weixin)
- 读取失败时回退到 db_websopy.app_config(category=wechat)
- 更新异常日志内容,明确读取失败顺序
- 适配业务需求调整配置优先级
- 影响所有调用 getMpWxSetting 的相关方法调用流程
2026-06-21 10:32:39 +08:00
7f341c2399 Merge remote-tracking branch 'origin/master' 2026-06-20 22:03:11 +08:00
2982818a0c 10198添加手机添加下级逻辑 2026-06-20 22:03:07 +08:00
307c209565 fix(appconfig): 修复跨表查询以确保产品有效性
- selectByCategory SQL 增加 INNER JOIN app_product,确保只返回该租户下有效产品配置
- 加入关联条件 ap.product_id = ac.app_id 和 ap.tenant_id = #{tenantId}
- 保证查询结果中 app_config 的 app_id 必须对应有效且属于当前租户的产品
- 维护原有租户过滤和配置类型过滤逻辑,增强数据准确性
- 该改动对 Service 和 Controller 层无影响,无需修改调用逻辑
2026-06-18 17:17:45 +08:00
bae1f75533 Merge remote-tracking branch 'origin/master' 2026-06-18 16:32:00 +08:00
797a140f11 refactor(wxlogin): 改造微信配置读取优先从 app_config 获取
- 新增 AppConfig 实体及跨库 Mapper、XML 实现 db_websopy.app_config 查询
- 增加 AppConfigService 提供带缓存的配置读取接口,缓存两小时
- WxLoginController 注入 AppConfigService,新增私有方法 getMpWxSetting()
- getMpWxSetting() 优先读取 app_config(category=wechat),失败回退 sys_setting.mp-weixin
- 替换微信小程序配置获取调用点,统一通过 getMpWxSetting() 获取配置
- 避免跨库 TenantLineInterceptor 干扰,Mapper 方法加 @InterceptorIgnore 标注
- 修正 StringRedisTemplate 注入避免 Bean 名冲突
- 新增读取失败或无配置时的降级和异常处理逻辑
- 日志补充便于排查配置读取过程及缓存情况
2026-06-18 16:31:04 +08:00
fb75156a03 支付方式新增子商户id、子应用字段 2026-06-10 12:39:58 +08:00
779f90e813 feat(user): 新增开发者筛选条件
- 在UserParam中添加isDeveloper字段及其注解说明
- 在UserMapper.xml中添加isDeveloper的查询条件判断
- 支持根据开发者身份进行用户筛选查询
2026-06-05 17:19:31 +08:00
2a880df691 feat(auth): 新增开发者短信验证码登录功能
- 增加根据手机号查询最近登录开发者账号接口,忽略租户隔离
- 实现开发者登录时手机号无注册时的错误提示
- 在登录流程中支持场景参数,区分开发者登录逻辑
- 新增开发者短信验证码登录API及相关校验逻辑
- 将开发者短信登录接口加入安全配置免验证路径
- 在User实体中添加isDeveloper字段标识开发者账号
- 记录登录操作日志,包含成功与失败原因
- 支持token有效期配置优先取缓存中的注册配置
2026-05-31 11:30:07 +08:00
17 changed files with 526 additions and 15 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

@@ -0,0 +1,27 @@
# 2026-06-21 工作日志
## WxLoginController 配置读取顺序调整
### 修改内容
修改了 `WxLoginController.java` 中的 `getMpWxSetting` 方法,调整小程序配置读取顺序:
**修改前:**
- 优先:`db_websopy.app_config`category=wechat
- 兜底:`sys_setting.mp-weixin`
**修改后:**
- 优先:`sys_setting.mp-weixin`
- 兜底:`db_websopy.app_config`category=wechat
### 修改原因
业务需求变更需要优先从系统设置sys_setting读取小程序配置数据库配置app_config作为兜底方案。
### 影响范围
影响所有调用 `getMpWxSetting` 方法的地方:
- `getOpenIdByCode` - 获取 openid
- `getAccessToken` - 获取 access_token
- `loginByOpenId` - openid 无感登录
- `getWxOpenId` / `getWxOpenIdOnly` - 获取微信 openId
### 文件位置
`/Users/gxwebsoft/JAVA/com.gxwebsoft.core/src/main/java/com/gxwebsoft/common/system/controller/WxLoginController.java`

View File

@@ -55,6 +55,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
"/api/sendSmsCaptcha",
"/api/loginBySms",
"/api/loginBySuperAdminSms",
"/api/loginByDeveloperSms",
"/api/system/user/regByPhone",
"/api/parseToken/*",
"/api/login-alipay/*",

View File

@@ -469,6 +469,12 @@ public class MainController extends BaseController {
return fail("该手机号码未注册超级管理员账号!");
}
}
if (param.getScene() != null && param.getScene().equals("developerLogin")) {
final User developer = userService.getLastLoginDeveloperByPhone(param.getPhone());
if (ObjectUtil.isEmpty(developer)) {
return fail("该手机号码未注册开发者账号!");
}
}
Integer tenantId = getTenantId();
@@ -758,6 +764,65 @@ public class MainController extends BaseController {
return success("登录成功", new LoginResult(access_token, user));
}
@Operation(summary = "开发者短信验证码登录")
@PostMapping("/loginByDeveloperSms")
public ApiResult<LoginResult> loginByDeveloperSms(@RequestBody LoginParam param, HttpServletRequest request) {
if (param == null) {
return fail("参数不能为空", null);
}
final String phone = param.getPhone();
if (!CommonUtil.isValidPhoneNumber(phone)) {
return fail("请输入有效的手机号码", null);
}
String code = param.getCode();
if (StrUtil.isBlank(code)) {
code = param.getSmsCode();
}
if (StrUtil.isBlank(code)) {
return fail("验证码不能为空", null);
}
String smsCode = redisUtil.get("code:" + phone);
String devSmsCode = redisUtil.get(CACHE_KEY_VERIFICATION_CODE_BY_DEV_SMS);
if (!StrUtil.equals(code, smsCode) && !StrUtil.equals(code, devSmsCode)) {
String message = "验证码不正确";
loginRecordService.saveAsync(phone, LoginRecord.TYPE_ERROR, message, null, request);
return fail(message, null);
}
User user = userService.getLastLoginDeveloperByPhone(phone);
if (user == null) {
String message = "用户不存在";
loginRecordService.saveAsync(phone, LoginRecord.TYPE_ERROR, message, null, request);
return fail(message, null);
}
if (!Boolean.TRUE.equals(user.getIsDeveloper())) {
String message = "非开发者账号不允许登录";
loginRecordService.saveAsync(phone, LoginRecord.TYPE_ERROR, message, user.getTenantId(), request);
return fail(message, null);
}
if (!user.getStatus().equals(0)) {
String message = "账号被冻结";
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_ERROR, message, user.getTenantId(), request);
return fail(message, null);
}
Long tokenExpireTime = configProperties.getTokenExpireTime();
final JSONObject register = cacheClient.getSettingInfo("register", user.getTenantId());
if (register != null) {
final String ExpireTime = register.getString("tokenExpireTime");
if (ExpireTime != null) {
tokenExpireTime = Long.valueOf(ExpireTime);
}
}
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
tokenExpireTime, configProperties.getTokenKey());
redisUtil.set("access_token:" + user.getUserId(), access_token, tokenExpireTime, TimeUnit.SECONDS);
return success("登录成功", new LoginResult(access_token, user));
}
@Transactional(rollbackFor = {Exception.class}, isolation = Isolation.SERIALIZABLE)
@Operation(summary = "账号注册")
@PostMapping("/register")

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.result.LoginResult;
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.Operation;
import org.springframework.data.redis.core.StringRedisTemplate;
@@ -67,6 +68,8 @@ public class WxLoginController extends BaseController {
private UserRefereeService userRefereeService;
@Resource
private UserSyncService userSyncService;
@Resource
private AppConfigService appConfigService;
public WxLoginController(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
@@ -196,6 +199,7 @@ public class WxLoginController extends BaseController {
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
if (getTenantId() != null && getTenantId().equals(10198)) activateShopUserMemberByPhone(phone);
return success("登录成功", new LoginResult(access_token, user));
}
@@ -252,7 +256,7 @@ public class WxLoginController extends BaseController {
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_REGISTER, null, user.getTenantId(), request);
if (getTenantId() != null && getTenantId().equals(10198)) activateShopUserMemberByPhone(phone);
return success("注册并登录成功", new LoginResult(access_token, user));
} catch (BusinessException e) {
@@ -264,6 +268,7 @@ public class WxLoginController extends BaseController {
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
activateShopUserMemberByPhone(phone);
return success("登录成功", new LoginResult(access_token, user));
}
}
@@ -325,6 +330,7 @@ public class WxLoginController extends BaseController {
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
activateShopUserMemberByPhone(phone);
return success("登录成功", new LoginResult(access_token, user));
}
@@ -398,6 +404,7 @@ public class WxLoginController extends BaseController {
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_REGISTER, null, user.getTenantId(), request);
activateShopUserMemberByPhone(phone);
return success("注册并登录成功", new LoginResult(access_token, user));
@@ -410,6 +417,7 @@ public class WxLoginController extends BaseController {
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
activateShopUserMemberByPhone(phone);
return success("登录成功", new LoginResult(access_token, user));
}
}
@@ -417,6 +425,26 @@ public class WxLoginController extends BaseController {
}
}
private void activateShopUserMemberByPhone(String phone) {
if (StrUtil.isBlank(phone)) {
return;
}
try {
String apiUrl = "https://paopao-api.websoft.top/api/shop/shop-user-member/activate-by-phone";
String normalizedPhone = phone.trim();
JSONObject body = new JSONObject();
body.put("phone", normalizedPhone);
String response = HttpRequest.post(apiUrl)
.header("Content-Type", "application/json")
.body(body.toJSONString())
.execute()
.body();
System.out.println("激活会员记录响应: " + response);
} catch (Exception e) {
System.err.println("激活会员记录失败,但不影响登录流程: " + e.getMessage());
}
}
@Operation(summary = "微信授权手机号码并更新")
@Transactional(rollbackFor = {Exception.class})
@PostMapping("/updatePhoneByMpWx")
@@ -433,6 +461,27 @@ public class WxLoginController extends BaseController {
return fail("更新失败");
}
/**
* 优先读取 sys_setting (mp-weixin),不存在或异常时回退到 db_websopy.app_configcategory=wechat
*
* @param tenantId 租户ID传 null 时使用当前请求租户)
* @return JSONObject 配置内容(含 appId / appSecret 等字段)
*/
private JSONObject getMpWxSetting(Integer tenantId) {
Integer tid = tenantId != null ? tenantId : getTenantId();
try {
// 优先sys_setting.mp-weixin
JSONObject setting = settingService.getBySettingKey("mp-weixin");
if (setting != null && !setting.isEmpty()) {
return setting;
}
} catch (Exception e) {
System.err.println("[WxLoginController] 读取 sys_setting 失败,回退 app_config: " + e.getMessage());
}
// 兜底db_websopy.app_configcategory=wechat
return appConfigService.getByCategory("wechat", tid);
}
/**
* 新用户注册
*/
@@ -494,8 +543,8 @@ public class WxLoginController extends BaseController {
// 获取openid
private JSONObject getOpenIdByCode(UserParam userParam) {
try {
// 获取微信小程序配置信息
JSONObject setting = settingService.getBySettingKey("mp-weixin");
// 获取微信小程序配置信息(优先 app_config回退 sys_setting
JSONObject setting = getMpWxSetting(null);
// 获取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";
// 执行get请求
@@ -689,8 +738,8 @@ public class WxLoginController extends BaseController {
tenantId = getTenantId();
}
String key = ACCESS_TOKEN_KEY.concat(":").concat(tenantId.toString());
// 获取微信小程序配置信息
JSONObject setting = settingService.getBySettingKey("mp-weixin");
// 获取微信小程序配置信息(优先 app_config回退 sys_setting
JSONObject setting = getMpWxSetting(tenantId);
if (setting == null) {
throw new BusinessException("请先配置小程序");
}
@@ -808,7 +857,7 @@ public class WxLoginController extends BaseController {
// 请求微信接口获取openid
String apiUrl = "https://api.weixin.qq.com/sns/jscode2session";
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 appSecret = setting.getString("appSecret");
map.put("appid", appId);
@@ -835,7 +884,7 @@ public class WxLoginController extends BaseController {
String apiUrl = "https://api.weixin.qq.com/sns/jscode2session";
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 appSecret = setting.getString("appSecret");
map.put("appid", appId);
@@ -926,15 +975,15 @@ public class WxLoginController extends BaseController {
@Operation(summary = "openid无感登录")
@PostMapping("/loginByOpenId")
public ApiResult<?> loginByOpenId(@RequestBody Mp mp, HttpServletRequest request) {
// 获取小程序配置信息
String key1 = "AppId:".concat(mp.getTenantId().toString());
String key2 = "AppSecret:".concat(mp.getTenantId().toString());
String AppId = redisUtil.get(key1);
String AppSecret = redisUtil.get(key2);
// 优先读 app_configcategory=wechat回退 sys_setting.mp-weixin
final JSONObject setting = getMpWxSetting(mp.getTenantId());
if (setting == null) {
return fail("小程序未配置");
}
final String AppId = setting.getString("appId");
final String AppSecret = setting.getString("appSecret");
if (StrUtil.isBlank(AppId) || StrUtil.isBlank(AppSecret)) {
final JSONObject setting = settingService.getBySettingKey("mp-weixin");
AppId = setting.getString("appId");
AppSecret = setting.getString("appSecret");
return fail("小程序配置不完整,请检查 appId 和 appSecret");
}
// 请求微信接口获取openid

View File

@@ -76,6 +76,12 @@ public class Payment implements Serializable {
@Schema(description = "备注")
private String comments;
@Schema(description = "子应用APPID")
private String subAppId;
@Schema(description = "子商户ID")
private String subMchId;
@Schema(description = "文章排序(数字越小越靠前)")
private Integer sortNumber;

View File

@@ -199,6 +199,9 @@ public class User implements UserDetails {
@Schema(description = "是否超级管理员")
private Boolean isSuperAdmin;
@Schema(description = "是否开发者")
private Boolean isDeveloper;
@Schema(description = "租户管理员ID")
@TableField(exist = false)
private Integer adminId;

View File

@@ -99,4 +99,13 @@ public interface UserMapper extends BaseMapper<User> {
@InterceptorIgnore(tenantLine = "true")
User selectLastLoginSuperAdminByPhone(@Param("phone") String phone);
/**
* 根据手机号查询最近登录的开发者账号(忽略租户隔离)
*
* @param phone 手机号
* @return User
*/
@InterceptorIgnore(tenantLine = "true")
User selectLastLoginDeveloperByPhone(@Param("phone") String phone);
}

View File

@@ -125,6 +125,9 @@
<if test="param.isAdmin != null">
AND a.is_admin = #{param.isAdmin}
</if>
<if test="param.isDeveloper != null">
AND a.is_developer = #{param.isDeveloper}
</if>
<if test="param.templateId != null">
AND a.template_id = #{param.templateId}
</if>
@@ -403,4 +406,28 @@
LIMIT 1
</select>
<!-- 根据手机号查询最近登录的开发者账号(忽略租户隔离) -->
<select id="selectLastLoginDeveloperByPhone" resultType="com.gxwebsoft.common.system.entity.User">
SELECT u.*
FROM sys_user u
LEFT JOIN (
SELECT tenant_id,
username,
MAX(create_time) AS last_login_time
FROM sys_login_record
WHERE login_type = 0
GROUP BY tenant_id, username
) lr ON lr.tenant_id = u.tenant_id
AND (lr.username = u.username OR lr.username = u.phone)
WHERE u.deleted = 0
AND u.status = 0
AND u.phone = #{phone}
AND u.is_developer = 1
ORDER BY lr.last_login_time DESC,
u.update_time DESC,
u.create_time DESC,
u.user_id DESC
LIMIT 1
</select>
</mapper>

View File

@@ -225,6 +225,10 @@ public class UserParam extends BaseParam {
@TableField(exist = false)
private Boolean isAdmin;
@Schema(description = "是否开发者")
@TableField(exist = false)
private Boolean isDeveloper;
@Schema(description = "是否企业管理员")
private Integer isOrganizationAdmin;

View File

@@ -134,6 +134,14 @@ public interface UserService extends IService<User>, UserDetailsService {
*/
User getLastLoginSuperAdminByPhone(String phone);
/**
* 根据手机号查询最近登录的开发者账号(忽略租户隔离)
*
* @param phone 手机号
* @return 用户信息
*/
User getLastLoginDeveloperByPhone(String phone);
List<User> pageAll(UserParam param);
User getByUserId(String userId);

View File

@@ -375,6 +375,11 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
return baseMapper.selectLastLoginSuperAdminByPhone(phone);
}
@Override
public User getLastLoginDeveloperByPhone(String phone) {
return baseMapper.selectLastLoginDeveloperByPhone(phone);
}
@Override
public List<User> pageAll(UserParam param) {
return baseMapper.pageRelAll(param);

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;
}
}