Compare commits

...

14 Commits

Author SHA1 Message Date
13c48df4b3 fix(app_config): 修复小程序配置全局查询问题
- 调整 AppConfigMapper.xml,使用 bound_tenant_id 精确匹配绑定租户ID
- 修改 AppConfigService 去除 tenantId 为空直接返回的逻辑,支持全局查询
- 修改 WxLoginController,调用 getByCategory 时传入 null 以支持全局查询
- 新增实体字段 boundTenantId 用于区分绑定租户ID
- application-dev.yml 启用 SQL 日志和服务调试日志,便于问题排查
- 统一日志打印详细查询过程及结果,方便调试检查配置加载情况
2026-06-21 11:56:59 +08:00
6eb1c67516 fix(system): 调整小程序配置读取顺序
- 修改 getMpWxSetting 方法,优先读取 sys_setting(mp-weixin)
- 读取失败时回退到 db_websopy.app_config(category=wechat)
- 更新异常日志内容,明确读取失败顺序
- 适配业务需求调整配置优先级
- 影响所有调用 getMpWxSetting 的相关方法调用流程
2026-06-21 11:10:20 +08:00
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
ec3bededa4 feat(auth): 新增超级管理员手机号短信验证码登录功能
- 在登录接口中增加对superAdminLogin场景的手机号校验
- 提供超级管理员短信验证码登录接口,支持验证码校验和超级管理员身份验证
- 超级管理员登录成功后生成访问token并缓存
- 在UserMapper中添加根据手机号查询最近登录超级管理员账号的方法
- 在UserService及其实现中增加对应的查询接口
- 更新SecurityConfig,放行超级管理员短信登录接口的权限验证
- 增强登录记录保存逻辑,包含错误和成功的登录记录保存
2026-05-31 10:52:33 +08:00
e2c84b94b9 fix(UserBalanceLog): 优化 SQL 参数空值校验
- 在多个 SQL 查询条件中添加参数非空且非空字符串判断
- 防止空字符串导致的错误匹配或异常
- 统一调整语句格式,保持代码规范和可读性
- 优化关联查询时参数过滤逻辑,提升查询准确性
2026-05-27 08:22:05 +08:00
20 changed files with 776 additions and 22 deletions

View File

@@ -0,0 +1,60 @@
# 2026-05-13 工作记录
## 卡密充值功能开发
### 后端paopao-java
**已完成文件:**
- `ShopRechargeCodeController.java` - 充值兑换码管理
- `ShopRechargeCodeService.java` - Service接口
- `ShopRechargeCodeServiceImpl.java` - Service实现
- `ShopRechargeCode.java` - 实体类
- `ShopRechargeRecord.java` - 充值记录实体
- `ShopRechargeCodeMapper.java` - Mapper接口
- `ShopRechargeCodeParam.java` - 查询参数
- `ShopRechargeRecordMapper.java` - 充值记录Mapper
- `ShopRechargeRecordService.java` - 充值记录Service
- `ShopRechargeRecordServiceImpl.java` - 充值记录Service实现
- `ShopRechargeRecordController.java` - 充值记录Controller (新增)
- `ShopRechargeRecordParam.java` - 充值记录查询参数 (新增)
**API接口**
- `GET /api/shop/recharge-code/page` - 分页查询兑换码
- `POST /api/shop/recharge-code/generate` - 生成兑换码
- `POST /api/shop/recharge-code/batch-generate` - 批量生成兑换码
- `POST /api/shop/recharge-code/use` - 使用兑换码
- `GET /api/shop/recharge-code/info` - 获取兑换码信息
- `GET /api/shop/recharge-code/export/{batchNo}` - 导出批次兑换码
- `DELETE /api/shop/recharge-code/{id}` - 删除兑换码
- `GET /api/shop/recharge-record/page` - 分页查询充值记录
### 前端paopao-vue
**已完成文件:**
- `/api/shop/shopRechargeCode/index.ts` - 兑换码API
- `/api/shop/shopRechargeCode/model/index.ts` - 兑换码Model
- `/api/shop/shopRechargeRecord/index.ts` - 充值记录API (新增)
- `/api/shop/shopRechargeRecord/model.ts` - 充值记录Model (新增)
- `/views/shop/shopRechargeCode/index.vue` - 兑换码管理页面 (整合Tab)
- `/views/shop/shopRechargeRecord/index.vue` - 兑换记录页面 (新增)
**功能特性:**
- 生成单个/批量兑换码
- 兑换码状态管理(未使用/已使用/已过期)
- 兑换明细查询
- 统计卡片(总数量、未使用、已使用、已过期)
### 数据库
**SQL文件**
- `/sql/shop_recharge_code.sql` - 兑换码表建表SQL
- `/sql/shop_recharge_record.sql` - 充值记录表建表SQL (新增)
**需要执行的SQL**
1. 创建 `shop_recharge_record`
2. 配置后台菜单权限
### 待办事项
- [ ] 执行数据库建表SQL
- [ ] 配置后台菜单权限
- [ ] 测试完整流程

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,68 @@
# 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`
---
## app_config 查询不到数据问题修复
### 问题现象
调用 `selectByCategory(tenantId, "wechat")` 查不到数据,但数据库中确实有记录。
### 根本原因
**tenant_id 不匹配!**
| 来源 | tenant_id |
|------|-----------|
| 数据库 app_config 表wechat配置 | **5** |
| getTenantId() 返回的当前请求租户 | **16411** |
SQL 的 WHERE 条件 `ac.tenant_id = #{tenantId}` 用当前请求租户ID16411去匹配数据库中的记录5自然查不到。
### 修复方案
小程序配置通常是全局共享的,不应按当前请求租户过滤。修改了以下文件:
1. **AppConfigMapper.xml**
- 使用 MyBatis 动态 SQL `<if test="tenantId != null">`
- 当 tenantId 为 null 时,不加 `tenant_id` 过滤条件,查询所有租户下的配置
2. **AppConfigService.java**
- 移除 tenantId 为空时直接返回 null 的逻辑
- 支持全局查询模式
3. **WxLoginController.java**
- 调用 `getByCategory("wechat", null)` 传入 null触发全局查询
4. **application-dev.yml**
- 启用 SQL 日志方便调试
### 修改文件清单
- `src/main/java/com/gxwebsoft/websopy/mapper/AppConfigMapper.xml`
- `src/main/java/com/gxwebsoft/websopy/service/AppConfigService.java`
- `src/main/java/com/gxwebsoft/common/system/controller/WxLoginController.java`
- `src/main/resources/application-dev.yml`

View File

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

View File

@@ -463,6 +463,18 @@ public class MainController extends BaseController {
return fail("该手机号码未注册!");
}
}
if (param.getScene() != null && param.getScene().equals("superAdminLogin")) {
final User superAdmin = userService.getLastLoginSuperAdminByPhone(param.getPhone());
if (ObjectUtil.isEmpty(superAdmin)) {
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();
@@ -693,6 +705,124 @@ public class MainController extends BaseController {
return success("登录成功", new LoginResult(access_token, user));
}
@Operation(summary = "超级管理员短信验证码登录")
@PostMapping("/loginBySuperAdminSms")
public ApiResult<LoginResult> loginBySuperAdminSms(@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.getLastLoginSuperAdminByPhone(phone);
if (user == null) {
String message = "用户不存在";
loginRecordService.saveAsync(phone, LoginRecord.TYPE_ERROR, message, null, request);
return fail(message, null);
}
if (!Boolean.TRUE.equals(user.getIsSuperAdmin())) {
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));
}
@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,36 @@ public class WxLoginController extends BaseController {
return fail("更新失败");
}
/**
* 优先读取 sys_setting (mp-weixin),不存在或异常时回退到 db_websopy.app_configcategory=wechat
*
* 使用 bound_tenant_id 字段精确匹配当前请求租户绑定的小程序配置
*
* @param tenantId 租户ID传 null 时使用当前请求租户)
* @return JSONObject 配置内容(含 appId / appSecret 等字段)
*/
private JSONObject getMpWxSetting(Integer tenantId) {
Integer tid = tenantId != null ? tenantId : getTenantId();
System.out.println("[WxLoginController] getMpWxSetting 开始, 传入tenantId=" + tenantId + ", 实际使用tid=" + tid);
try {
// 优先sys_setting.mp-weixin按当前租户查询
JSONObject setting = settingService.getBySettingKey("mp-weixin");
if (setting != null && !setting.isEmpty()) {
System.out.println("[WxLoginController] 从 sys_setting 读取到配置, appId=" + setting.getString("appId"));
return setting;
}
System.out.println("[WxLoginController] sys_setting 无配置,准备回退到 app_config, boundTenantId=" + tid);
} catch (Exception e) {
System.err.println("[WxLoginController] 读取 sys_setting 失败,回退 app_config: " + e.getMessage());
e.printStackTrace();
}
// 兜底:按 bound_tenant_id 精确查询 db_websopy.app_config
System.out.println("[WxLoginController] 调用 appConfigService.getByCategory(\"wechat\", " + tid + ")");
JSONObject result = appConfigService.getByCategory("wechat", tid);
System.out.println("[WxLoginController] app_config 返回结果: " + result);
return result;
}
/**
* 新用户注册
*/
@@ -494,8 +552,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 +747,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 +866,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 +893,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 +984,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

@@ -90,4 +90,22 @@ public interface UserMapper extends BaseMapper<User> {
@InterceptorIgnore(tenantLine = "true")
Integer countByPhone(@Param("phone") String phone);
/**
* 根据手机号查询最近登录的超级管理员账号(忽略租户隔离)
*
* @param phone 手机号
* @return 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

@@ -18,7 +18,7 @@
<if test="param.scene != null">
AND a.scene = #{param.scene}
</if>
<if test="param.sceneMultiple != null">
<if test="param.sceneMultiple != null and param.sceneMultiple != ''">
AND a.scene IN
<foreach collection="param.sceneMultiple.split(',')" item="item" separator="," open="(" close=")">
#{item}
@@ -30,16 +30,16 @@
<if test="param.balance != null">
AND a.balance = #{param.balance}
</if>
<if test="param.describe != null">
<if test="param.describe != null and param.describe != ''">
AND a.describe LIKE CONCAT('%', #{param.describe}, '%')
</if>
<if test="param.remark != null">
<if test="param.remark != null and param.remark != ''">
AND a.remark LIKE CONCAT('%', #{param.remark}, '%')
</if>
<if test="param.sortNumber != null">
AND a.sort_number = #{param.sortNumber}
</if>
<if test="param.comments != null">
<if test="param.comments != null and param.comments != ''">
AND a.comments LIKE CONCAT('%', #{param.comments}, '%')
</if>
<if test="param.status != null">
@@ -51,7 +51,7 @@
<if test="param.deleted == null">
AND a.deleted = 0
</if>
<if test="param.merchantCode != null">
<if test="param.merchantCode != null and param.merchantCode != ''">
AND a.merchant_code LIKE CONCAT('%', #{param.merchantCode}, '%')
</if>
<if test="param.createTimeStart != null">
@@ -60,7 +60,7 @@
<if test="param.createTimeEnd != null">
AND a.create_time &lt;= #{param.createTimeEnd}
</if>
<if test="param.keywords != null">
<if test="param.keywords != null and param.keywords != ''">
AND (
a.user_id = #{param.keywords}
OR b.nickname LIKE CONCAT('%', #{param.keywords}, '%')

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>
@@ -379,4 +382,52 @@
AND phone = #{phone}
</select>
<!-- 根据手机号查询最近登录的超级管理员账号(忽略租户隔离) -->
<select id="selectLastLoginSuperAdminByPhone" 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_super_admin = 1
ORDER BY lr.last_login_time DESC,
u.update_time DESC,
u.create_time DESC,
u.user_id DESC
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

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

View File

@@ -370,6 +370,16 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
return baseMapper.selectListAllRel(userParam);
}
@Override
public User getLastLoginSuperAdminByPhone(String phone) {
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,55 @@
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;
/** 应用绑定的租户ID用于精确匹配当前请求租户的配置 */
@TableField("bound_tenant_id")
private Integer boundTenantId;
@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,31 @@
<?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
注意:
1. 使用 bound_tenant_id 字段精确匹配当前请求租户的绑定配置
2. 表名带库名前缀 db_websopy.app_config该表在 db_websopy 库中)
3. Mapper 方法已加 @InterceptorIgnore(tenantLine = "true")
TenantLineInnerInterceptor 不会自动追加 tenant_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.bound_tenant_id AS boundTenantId,
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
WHERE ac.deleted = 0
AND ac.bound_tenant_id = #{tenantId}
AND ac.config_type = #{configType}
ORDER BY ac.config_id DESC
</select>
</mapper>

View File

@@ -0,0 +1,136 @@
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()) {
log.info("[AppConfigService] 命中 Redis 缓存 configType={}, tenantId={}, cacheKey={}, 缓存内容={}",
configType, tenantId, cacheKey, cached);
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 {
log.info("[AppConfigService] 开始查询 app_config, configType={}, tenantId={}", configType, tenantId);
list = appConfigMapper.selectByCategory(tenantId, configType);
log.info("[AppConfigService] 查询结果: {} 条记录", list == null ? 0 : list.size());
if (list != null && !list.isEmpty()) {
for (AppConfig c : list) {
log.info("[AppConfigService] 配置项: configKey={}, configValue={}, configType={}, tenantId={}",
c.getConfigKey(), c.getConfigValue(), c.getConfigType(), c.getTenantId());
}
}
} 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." 前缀)
// 注意:按 config_id 降序后,相同 configKey 的后出现的会覆盖先出现的
// 所以降序后,最新插入/更新的配置会生效
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());
}
// 使用 putIfAbsent 保留先出现的(即 config_id 大的/最新的),
// 但如果需要后出现的覆盖,改为 result.put(key, c.getConfigValue())
result.putIfAbsent(key, c.getConfigValue()); // 保留第一个(最新)
log.info("[AppConfigService] 组装JSON: key={}, value={} (tenantId={}, configId={})",
key, c.getConfigValue(), c.getTenantId(), c.getConfigId());
}
// 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;
}
}

View File

@@ -22,6 +22,8 @@ logging:
level:
com.gxwebsoft: DEBUG
com.baomidou.mybatisplus: DEBUG
com.gxwebsoft.websopy.mapper: DEBUG
com.gxwebsoft.websopy.service: DEBUG
socketio:
host: localhost #IP地址