Compare commits
169 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 397c3c4b08 | |||
| a705539e77 | |||
| c3e46f4bc8 | |||
| 86e73b68b7 | |||
| 0923635575 | |||
| ea5021c3f0 | |||
| efccb786fe | |||
| 792d35c1c0 | |||
| e7a12d8828 | |||
| c88b41fd46 | |||
| f751d17771 | |||
| 539819d474 | |||
| d93c4684ee | |||
| 58972885ad | |||
| c585647dec | |||
| 477b028e82 | |||
| 0fda607612 | |||
| 70bd81c45d | |||
| 262a9e0183 | |||
| 4a827abe9b | |||
| 4db7a11624 | |||
| 280224e646 | |||
| d8f8662456 | |||
| e77353b478 | |||
| e487f021dc | |||
| 5ca3280ddc | |||
| b9ec1fcdb9 | |||
| a6a2e14abf | |||
| 18696f91a6 | |||
| 49e6cd2197 | |||
| f7f7b30813 | |||
| b043270e52 | |||
| f720adc2a8 | |||
| 2e51710e3a | |||
| 644188288a | |||
| d532f6717f | |||
| 4612155457 | |||
| 907439044a | |||
| 39d3c4c894 | |||
| c4177f167d | |||
| 2581a77f09 | |||
| a8896e4523 | |||
| 5f6df3dc26 | |||
| 3bacfe8b8f | |||
| 9fdc708bb8 | |||
| a02521e64b | |||
| 3ffd3255ee | |||
| 14f49e43dd | |||
| 73f0878fb2 | |||
| 34c6076068 | |||
| cb2f856ec7 | |||
| ac166e340d | |||
| 61d8c6317e | |||
| 7217325756 | |||
| c60cf13dd0 | |||
| 6bb36b1554 | |||
| dbe4f2962d | |||
| eb9bfd167e | |||
| 18db0f7d44 | |||
| b2487572a6 | |||
| 592345018e | |||
| e20503269d | |||
| 7f53b39c56 | |||
| 89571c81a4 | |||
| aa287ba655 | |||
| 4220de534a | |||
| 5e9dc385f5 | |||
| ba0e78d4c2 | |||
| aa7f3f7a6e | |||
| ce73d14d1c | |||
| c0c140fcdf | |||
| 54ee652584 | |||
| f8595dd2d5 | |||
| d49617a547 | |||
| 4ba69b2ce2 | |||
| b119a92d94 | |||
| 81ac636fb1 | |||
| 0aa65d0195 | |||
| 09ef6558ae | |||
| bb34831208 | |||
| b8c147c641 | |||
| fc98ee0509 | |||
| a220b505e6 | |||
| ab85f48a40 | |||
| 13c48df4b3 | |||
| 6eb1c67516 | |||
| 03cefc9048 | |||
| f5f9e3a19d | |||
| 7f341c2399 | |||
| 2982818a0c | |||
| 307c209565 | |||
| bae1f75533 | |||
| 797a140f11 | |||
| fb75156a03 | |||
| 779f90e813 | |||
| 2a880df691 | |||
| ec3bededa4 | |||
| e2c84b94b9 | |||
| f7e3cad931 | |||
| 6a48299e12 | |||
| ed9d500e5d | |||
| 64e9674d0e | |||
| 6804a0a824 | |||
| a3c4b74d33 | |||
| c3bd90f234 | |||
| 5579f7494e | |||
| e9532ae4d7 | |||
| 2d012dbd7f | |||
| 5637690424 | |||
| 6cb23a8eee | |||
| e2520001c9 | |||
| f894c53184 | |||
| 5f253695c4 | |||
| 05c67811ed | |||
| 7d562db19c | |||
| 5e66c4c65b | |||
| 5b3363d1ae | |||
| a57eb804eb | |||
| 789b8ddeca | |||
| 7aaf25c1ac | |||
| 1d5b65bcc0 | |||
| f382df7976 | |||
| 9353fb6b85 | |||
| bf12ed397c | |||
| 0343979b5d | |||
| 185a3cf7f5 | |||
| dc8cbb43a8 | |||
| 5c081c48c7 | |||
| 9a778898d5 | |||
| ed9c59dae6 | |||
| 1c9c2dfd82 | |||
| f4e7e48254 | |||
| d1ad38c69f | |||
| 345afab954 | |||
| fa09d9dc23 | |||
| 3e7f34fa0d | |||
| 04ec9659b0 | |||
| a8372cedad | |||
| ca3651165c | |||
| 3549e687f6 | |||
| 181801abdb | |||
| e73e0fb6b5 | |||
| 3c89cbce40 | |||
| 03c94624d0 | |||
| 9ec04fef3a | |||
| 003a248d37 | |||
| 4ff46dbefe | |||
| 8a1b729e91 | |||
| 97b735757d | |||
| 655e6a6205 | |||
| 17c9aa0bcd | |||
| 4eba22e42d | |||
| aa910870cc | |||
| 7927559b70 | |||
| 3131b20c5b | |||
| 88b2e9977c | |||
| fd4d00a86d | |||
| 2303f46fca | |||
| 808766eeac | |||
| aafb2ef113 | |||
| f38859f211 | |||
| 4b7cd9d0d3 | |||
| 0efca0fb02 | |||
| 853651e80d | |||
| 823c56293d | |||
| 6462e51bc8 | |||
| d63df710b3 | |||
| b2b6b1306f | |||
| 00ea325ebf |
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.6.3/apache-maven-3.6.3-bin.zip
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
|
||||||
wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar
|
wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"sessions": {
|
||||||
|
"da2fb4dc76a94426a7dfef478f674489": [
|
||||||
|
{
|
||||||
|
"expertId": "SeniorDeveloper",
|
||||||
|
"name": "Will",
|
||||||
|
"profession": "高级开发工程师",
|
||||||
|
"avatarUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/avatars/02-Engineering/SeniorDeveloper/SeniorDeveloper.png",
|
||||||
|
"promptUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/experts/02-Engineering/SeniorDeveloper/SeniorDeveloper_zh.md",
|
||||||
|
"usedAt": 1775495439006,
|
||||||
|
"industryId": "all"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"90ac41da355a447a8c29ed992c8beede": [
|
||||||
|
{
|
||||||
|
"expertId": "SeniorDeveloper",
|
||||||
|
"name": "Will",
|
||||||
|
"profession": "高级开发工程师",
|
||||||
|
"avatarUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/avatars/02-Engineering/SeniorDeveloper/SeniorDeveloper.png",
|
||||||
|
"promptUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/experts/02-Engineering/SeniorDeveloper/SeniorDeveloper_zh.md",
|
||||||
|
"usedAt": 1775720823455,
|
||||||
|
"industryId": "all"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"c7ea2a41e7d147e887ab506386658772": [
|
||||||
|
{
|
||||||
|
"expertId": "SeniorDeveloper",
|
||||||
|
"name": "Will",
|
||||||
|
"profession": "高级开发工程师",
|
||||||
|
"avatarUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/avatars/02-Engineering/SeniorDeveloper/SeniorDeveloper.png",
|
||||||
|
"promptUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/experts/02-Engineering/SeniorDeveloper/SeniorDeveloper_zh.md",
|
||||||
|
"usedAt": 1775720823455,
|
||||||
|
"industryId": "all"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"11ef16ee251d4624968d1e84c0fb1de9": [
|
||||||
|
{
|
||||||
|
"expertId": "SeniorDeveloper",
|
||||||
|
"name": "Will",
|
||||||
|
"profession": "高级开发工程师",
|
||||||
|
"avatarUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/avatars/02-Engineering/SeniorDeveloper/SeniorDeveloper.png",
|
||||||
|
"promptUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/experts/02-Engineering/SeniorDeveloper/SeniorDeveloper_zh.md",
|
||||||
|
"usedAt": 1775866025894,
|
||||||
|
"industryId": "all"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lastUpdated": 1775868870779
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
## 2026-04-06 - 修复编译错误和用户同步问题
|
||||||
|
|
||||||
|
### 修复的问题
|
||||||
|
1. **QrLoginServiceImpl.java编译错误**: 修复了`CommonUtil`类找不到的编译错误
|
||||||
|
- 原始错误: `java: cannot find symbol class CommonUtil location: package com.gxwebsoft.common.core.utils`
|
||||||
|
|
||||||
|
2. **WxOfficialController.java编译错误**: 缺少`DateUtil`导入
|
||||||
|
- 真正的问题根源,导致链式编译失败
|
||||||
|
|
||||||
|
3. **用户同步失败**: `Column 'tenant_id' cannot be null` 数据库完整性约束错误
|
||||||
|
- 问题: 在同步用户到websopy系统时,没有传递tenant_id字段
|
||||||
|
- 结果: websopy端插入`app_user_cache`表时tenant_id为NULL,违反非空约束
|
||||||
|
|
||||||
|
### 解决方案
|
||||||
|
1. **CommonUtil.java修复**: 添加缺失的导入`import com.gxwebsoft.common.core.utils.JSONUtil;`
|
||||||
|
- 原因: `CommonUtil.responseError()`方法使用了`JSONUtil.toJSONString()`但未导入
|
||||||
|
|
||||||
|
2. **WxOfficialController.java修复**:
|
||||||
|
- 添加缺失的导入`import cn.hutool.core.date.DateUtil;`
|
||||||
|
- 添加详细的调试日志,跟踪扫码登录流程
|
||||||
|
- 在`processWxUser`方法中添加"已存在绑定用户"的日志
|
||||||
|
- 在`completeQrLogin`方法中添加开始和完成的详细日志
|
||||||
|
|
||||||
|
3. **UserSyncService.java修复**:
|
||||||
|
- 在同步数据中添加`tenantId`字段: `userCache.put("tenantId", tenantIdValue)`
|
||||||
|
- 处理tenantId可能为null的情况,默认传0: `Integer tenantIdValue = user.getTenantId() != null ? user.getTenantId() : 0`
|
||||||
|
- 更新日志输出,包含tenantId信息
|
||||||
|
|
||||||
|
### 验证结果
|
||||||
|
- 执行`mvn clean compile`成功通过,编译无错误
|
||||||
|
- 所有代码修改语法正确,无编译警告
|
||||||
|
|
||||||
|
### 技术要点
|
||||||
|
1. **Java编译链式失败**: 一个文件的编译错误可能导致依赖它的其他文件也出现"找不到符号"的错误
|
||||||
|
2. **数据库完整性约束**: REST API调用时需要确保传递所有必填字段
|
||||||
|
3. **分布式系统调试**: 当同步到外部系统失败时,需要检查两边数据格式的一致性
|
||||||
|
4. **日志分级**: 在不同阶段添加适当的System.out和log.info日志,便于问题排查
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
## 2026-04-07 工作记录
|
||||||
|
|
||||||
|
### 微信扫码登录问题修复
|
||||||
|
1. 修复了UserSyncService中tenant_id字段名问题(从tenantId改为tenant_id)
|
||||||
|
2. 同时发送两种格式的tenant_id字段确保兼容性
|
||||||
|
3. 修改了WxOfficialController,在同步前从数据库重新加载用户对象
|
||||||
|
4. 添加了详细的调试日志便于问题排查
|
||||||
|
|
||||||
|
### 待解决问题
|
||||||
|
1. websopy侧app_user_cache同步失败(tenant_id为null)
|
||||||
|
2. 扫码成功后需跳转到强制绑定手机号页面
|
||||||
|
3. 注册成功后应跳转到控制台/console
|
||||||
|
|
||||||
|
### websopy-pc前端修改需求分析
|
||||||
|
1. **状态检查逻辑更新**:需处理新的nextAction字段(bind_phone, redirect, login等)
|
||||||
|
2. **新增绑定手机号页面**:用于新用户绑定手机号流程
|
||||||
|
3. **页面跳转逻辑**:登录成功/绑定成功后跳转到/console
|
||||||
|
4. **API调用更新**:适应新的响应字段格式
|
||||||
|
|
||||||
|
### 前端代码已完成的修改
|
||||||
|
1. **API接口修改**:
|
||||||
|
- 更新QRLoginStatusResult接口,新增nextAction、redirectUrl、successMessage、needBindPhone等字段
|
||||||
|
- 修改checkQRLoginStatus函数,使用真实后端API调用
|
||||||
|
- 新增bindPhoneForQrLogin函数,处理绑定手机号API调用
|
||||||
|
- 修改generateQRLoginToken函数,使用真实后端API调用
|
||||||
|
|
||||||
|
2. **状态管理修改**:
|
||||||
|
- 更新auth store接口,添加bindPhoneForQRLogin方法
|
||||||
|
- 修改checkQRStatus方法,支持新的返回字段处理
|
||||||
|
- 添加绑定手机号处理逻辑
|
||||||
|
|
||||||
|
3. **组件修改**:
|
||||||
|
- 创建BindPhone组件:用于处理需要绑定手机号的场景
|
||||||
|
- 修改QRLogin组件:支持根据nextAction显示不同UI,自动处理绑定手机号流程
|
||||||
|
- 组件现在可以正确处理:
|
||||||
|
- nextAction: 'bind_phone' → 显示绑定手机号表单
|
||||||
|
- nextAction: 'redirect' → 自动跳转到/console
|
||||||
|
- needBindPhone: true → 显示绑定提示
|
||||||
|
|
||||||
|
4. **完整流程支持**:
|
||||||
|
- 用户扫码登录 → 后端返回nextAction: 'bind_phone' → 前端显示绑定手机号页面
|
||||||
|
- 用户输入手机号和验证码 → 调用绑定手机号API → 绑定成功后自动登录并跳转/console
|
||||||
|
- 用户已有手机号 → 后端返回nextAction: 'redirect' → 前端自动跳转到/console
|
||||||
|
|
||||||
|
### 后端修改完成
|
||||||
|
1. **QrLoginStatusResponse新增字段**:
|
||||||
|
- nextAction: 下一步操作指示
|
||||||
|
- redirectUrl: 跳转URL(当nextAction为redirect时)
|
||||||
|
- successMessage: 成功消息
|
||||||
|
2. **QrLoginServiceImpl逻辑更新**:
|
||||||
|
- 用户没有手机号时,nextAction设为bind_phone
|
||||||
|
- 用户有手机号且登录成功时,nextAction设为redirect,redirectUrl设为/console
|
||||||
|
## 扫码登录用户同步时机修改 (01:58)
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
1. **WxOfficialController.java** - 移除新用户注册时立即同步的代码
|
||||||
|
- processWxUser() 方法中,新用户创建后不再立即同步到 websopy
|
||||||
|
- findOrCreateUserForOauth() 方法中同样处理
|
||||||
|
|
||||||
|
2. **QrLoginServiceImpl.java** - 在绑定手机号成功后同步
|
||||||
|
- 新增 UserSyncService 注入
|
||||||
|
- 在 bindPhone() 方法中,绑定手机号成功后从数据库重新加载用户并同步
|
||||||
|
|
||||||
|
### 目的
|
||||||
|
确保用户数据同步到 websopy 时,手机号字段已有值,避免无效的缓存数据。
|
||||||
|
|
||||||
|
|
||||||
|
## MQ消息队列实现 (02:40)
|
||||||
|
|
||||||
|
### 创建的文件
|
||||||
|
1. **SyncMessage.java** - 统一消息实体
|
||||||
|
- 位置: `com.gxwebsoft.common.mq.message`
|
||||||
|
- 支持 USER_SYNC, TENANT_SYNC 等消息类型
|
||||||
|
- 支持 CREATE, UPDATE, DELETE 事件类型
|
||||||
|
|
||||||
|
2. **SyncMessageProducer.java** - 消息生产者接口
|
||||||
|
- 位置: `com.gxwebsoft.common.mq.producer`
|
||||||
|
- 预留抽象层,便于将来切换到RocketMQ
|
||||||
|
|
||||||
|
3. **RabbitMQConfig.java** - RabbitMQ配置类
|
||||||
|
- 位置: `com.gxwebsoft.common.mq.config`
|
||||||
|
- 定义交换机、队列、死信队列
|
||||||
|
- 配置JSON序列化
|
||||||
|
|
||||||
|
4. **RabbitMQSyncProducer.java** - RabbitMQ生产者实现
|
||||||
|
- 位置: `com.gxwebsoft.common.mq.producer.impl`
|
||||||
|
- 实现确认回调和Return回调
|
||||||
|
|
||||||
|
5. **UserSyncConsumer.java** - 用户同步消费者
|
||||||
|
- 位置: `com.gxwebsoft.common.mq.consumer`
|
||||||
|
- 监听用户同步消息,调用UserSyncService同步到websopy
|
||||||
|
- 支持重试机制和死信队列
|
||||||
|
|
||||||
|
### 修改的文件
|
||||||
|
1. **pom.xml** - 添加 spring-boot-starter-amqp 依赖
|
||||||
|
2. **UserSyncService.java** - 添加Map参数的重载方法
|
||||||
|
3. **QrLoginServiceImpl.java** - 改用MQ发送消息
|
||||||
|
4. **application.yml** - 添加RabbitMQ配置
|
||||||
|
|
||||||
|
### 使用方式
|
||||||
|
配置 `sync.mq.enabled: false` 可临时禁用MQ,回退到原有直接同步方式。
|
||||||
|
|
||||||
|
|
||||||
|
## websopy 端 MQ 消费者实现 (02:50)
|
||||||
|
|
||||||
|
### 创建的文件
|
||||||
|
1. **SyncMessage.java** - websopy 端消息实体
|
||||||
|
- 位置: `com.gxwebsoft.common.mq.message`
|
||||||
|
- 简化版,复用 server-api 发来的消息格式
|
||||||
|
|
||||||
|
2. **RabbitMQConfig.java** - websopy 端 RabbitMQ 配置
|
||||||
|
- 位置: `com.gxwebsoft.common.mq.config`
|
||||||
|
- 配置交换机、队列、死信队列
|
||||||
|
|
||||||
|
3. **SyncMessageConsumer.java** - websopy 端消息消费者
|
||||||
|
- 位置: `com.gxwebsoft.common.mq.consumer`
|
||||||
|
- 监听 USER_SYNC 消息,调用 AppUserCacheService 保存/更新/删除用户
|
||||||
|
|
||||||
|
### 修改的文件
|
||||||
|
1. **pom.xml** - 添加 spring-boot-starter-amqp 依赖
|
||||||
|
2. **application.yml** - 添加 RabbitMQ 连接配置和开关
|
||||||
|
|
||||||
|
### 工作流程
|
||||||
|
server-api (生产者) -> RabbitMQ -> websopy-java (消费者) -> AppUserCacheService
|
||||||
|
|
||||||
|
## Spring Boot启动错误修复 (03:01)
|
||||||
|
|
||||||
|
### 问题描述
|
||||||
|
服务器启动失败,错误信息显示`objectMapper` bean定义冲突:
|
||||||
|
```
|
||||||
|
The bean 'objectMapper', defined in class path resource [com/gxwebsoft/common/mq/config/RabbitMQConfig.class], could not be registered.
|
||||||
|
A bean with that name has already been defined in class path resource [com/gxwebsoft/common/core/config/JacksonConfig.class] and overriding is disabled.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 根本原因
|
||||||
|
1. **JacksonConfig.java** 定义了 `@Primary objectMapper()` bean(第22行)
|
||||||
|
2. **RabbitMQConfig.java** 的 `messageConverter(ObjectMapper objectMapper)` 方法会被Spring误认为正在定义另一个`objectMapper` bean
|
||||||
|
3. **Spring Boot 2.5.15默认禁止bean定义覆盖**
|
||||||
|
|
||||||
|
### 修复方案(双保险)
|
||||||
|
1. **代码修复**:修改RabbitMQConfig.java中的messageConverter方法
|
||||||
|
```java
|
||||||
|
// 修改前(有问题的参数注入):
|
||||||
|
@Bean
|
||||||
|
public MessageConverter messageConverter(ObjectMapper objectMapper) {
|
||||||
|
return new Jackson2JsonMessageConverter(objectMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改后(直接在方法内创建ObjectMapper):
|
||||||
|
@Bean
|
||||||
|
public MessageConverter messageConverter() {
|
||||||
|
ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
objectMapper.registerModule(new JavaTimeModule());
|
||||||
|
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||||
|
objectMapper.disable(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS);
|
||||||
|
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||||
|
return new Jackson2JsonMessageConverter(objectMapper);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **配置修复**:在所有application配置文件中启用bean定义覆盖
|
||||||
|
- **application.yml**: 添加 `spring.main.allow-bean-definition-overriding: true`
|
||||||
|
- **application-dev.yml**: 在现有的 `spring.main.allow-circular-references` 下添加
|
||||||
|
- **application-prod.yml**: 在数据源配置区域添加
|
||||||
|
|
||||||
|
### 影响
|
||||||
|
修复后服务器应能正常启动,MQ消息队列和扫码登录功能均可正常工作。
|
||||||
|
|
||||||
|
### 技术细节
|
||||||
|
- 问题源于Spring的bean解析机制:带有参数的`@Bean`方法会被Spring尝试解析参数
|
||||||
|
- 修改后的方案消除了参数依赖,避免Spring误解
|
||||||
|
- 启用bean定义覆盖作为安全备份,确保即使有其他bean冲突也能启动
|
||||||
|
|
||||||
|
## Hutool库API兼容性修复 (21:07)
|
||||||
|
|
||||||
|
### 问题描述
|
||||||
|
编译错误:`HttpResponse` 类没有 `bytes()` 方法
|
||||||
|
```
|
||||||
|
/Users/gxwebsoft/JAVA/com.gxwebsoft.core/src/main/java/com/gxwebsoft/auto/service/impl/QrLoginServiceImpl.java:156:19
|
||||||
|
java: cannot find symbol
|
||||||
|
symbol: method bytes()
|
||||||
|
location: class cn.hutool.http.HttpResponse
|
||||||
|
```
|
||||||
|
|
||||||
|
### 原因分析
|
||||||
|
- 项目使用的是Hutool 5.8.25版本
|
||||||
|
- 在Hutool 5.x版本中,`HttpResponse`类没有`bytes()`方法
|
||||||
|
- 正确的方法应该是`bodyBytes()`用于获取二进制响应,或`body()`用于获取字符串响应
|
||||||
|
|
||||||
|
### 修复方法
|
||||||
|
修改 `QrLoginServiceImpl.java` 第156行:
|
||||||
|
```java
|
||||||
|
// 修改前(错误的API):
|
||||||
|
.execute().bytes();
|
||||||
|
|
||||||
|
// 修改后(正确的API):
|
||||||
|
.execute().bodyBytes();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 影响
|
||||||
|
修复后项目可以成功编译,微信小程序码生成功能可以正常工作。
|
||||||
|
|
||||||
|
### 版本兼容性说明
|
||||||
|
- Hutool 4.x版本可能支持`.bytes()`方法
|
||||||
|
- Hutool 5.x版本使用`.bodyBytes()`和`.body()`方法
|
||||||
|
- 项目中使用的是Hutool 5.8.25,应保持API一致性
|
||||||
|
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# 2026-04-09 工作记录
|
||||||
|
|
||||||
|
## UserServiceImpl 添加 MQ 同步功能
|
||||||
|
|
||||||
|
在 `UserServiceImpl` 中统一添加了用户数据变更时的 MQ 消息发送逻辑:
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
1. **添加依赖注入**:注入 `SyncMessageProducer`,使用 `@Autowired(required = false)` 避免 MQ 未启用时报错
|
||||||
|
2. **saveUser() 方法**:用户创建成功后发送 `CREATE` 事件到 websopy
|
||||||
|
3. **updateUser() 方法**:用户更新成功后发送 `UPDATE` 事件到 websopy
|
||||||
|
4. **addUser() 方法**:注释说明已通过 saveUser 发送 MQ,避免重复
|
||||||
|
|
||||||
|
### 触发场景
|
||||||
|
现在以下操作都会触发 MQ 同步:
|
||||||
|
- 用户注册(短信登录自动注册、普通注册、管理员注册)
|
||||||
|
- 后台添加用户
|
||||||
|
- 用户更新信息
|
||||||
|
- 扫码绑定手机号(原有逻辑)
|
||||||
|
|
||||||
|
### 日志输出
|
||||||
|
- 创建:`用户创建后发送MQ消息同步到websopy: userId={}, phone={}`
|
||||||
|
- 更新:`用户更新后发送MQ消息同步到websopy: userId={}, phone={}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MQ 架构改造 - 方案 A 实现
|
||||||
|
|
||||||
|
将用户同步架构从 "core 系统消费后 HTTP 转发" 改为 "各子系统直接消费 MQ"。
|
||||||
|
|
||||||
|
### core 系统改动 (com.gxwebsoft.core)
|
||||||
|
|
||||||
|
#### 1. RabbitMQConfig.java
|
||||||
|
- **Exchange 类型**:从 `DirectExchange` 改为 `TopicExchange`
|
||||||
|
- **删除队列定义**:core 系统不再定义消费队列,只负责发送
|
||||||
|
- **Routing Key 格式**:`user.sync.{targetSystem}`
|
||||||
|
|
||||||
|
#### 2. RabbitMQSyncProducer.java
|
||||||
|
- **动态 Routing Key**:根据 `targetSystem` 构建 routing key
|
||||||
|
- **代码**:`"user.sync." + targetSystem.toLowerCase()`
|
||||||
|
|
||||||
|
#### 3. 删除 UserSyncConsumer.java
|
||||||
|
- core 系统不再消费用户同步消息
|
||||||
|
|
||||||
|
#### 4. UserSyncService.java
|
||||||
|
- **废弃 HTTP 同步逻辑**:所有 HTTP 调用方法已删除
|
||||||
|
- **标记 @Deprecated**:保留空实现以兼容旧代码
|
||||||
|
- **同步方式**:现在统一通过 MQ 自动触发
|
||||||
|
|
||||||
|
### websopy 系统改动 (websopy-java)
|
||||||
|
|
||||||
|
#### 1. RabbitMQConfig.java
|
||||||
|
```java
|
||||||
|
// websopy 专用队列
|
||||||
|
public static final String SYNC_QUEUE_WEBSOPY = "user.sync.websopy.queue";
|
||||||
|
public static final String SYNC_ROUTING_KEY_WEBSOPY = "user.sync.websopy";
|
||||||
|
|
||||||
|
// Topic Exchange(与 core 系统共用)
|
||||||
|
@Bean
|
||||||
|
public TopicExchange syncExchange() {
|
||||||
|
return new TopicExchange(SYNC_EXCHANGE, true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// websopy 专用队列绑定
|
||||||
|
@Bean
|
||||||
|
public Binding syncBindingWebsopy() {
|
||||||
|
return BindingBuilder.bind(syncQueueWebsopy())
|
||||||
|
.to(syncExchange())
|
||||||
|
.with(SYNC_ROUTING_KEY_WEBSOPY);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. SyncMessageConsumer.java
|
||||||
|
- **监听队列**:改为 `SYNC_QUEUE_WEBSOPY` (`user.sync.websopy.queue`)
|
||||||
|
- **死信队列**:改为 `DLQ_QUEUE_WEBSOPY` (`user.sync.websopy.dlq`)
|
||||||
|
|
||||||
|
### 新架构流程
|
||||||
|
```
|
||||||
|
用户操作 → UserServiceImpl.saveUser/updateUser
|
||||||
|
↓
|
||||||
|
MQ Producer (routing key = user.sync.websopy)
|
||||||
|
↓
|
||||||
|
Topic Exchange (sync.exchange)
|
||||||
|
↓
|
||||||
|
┌───────────┼───────────┐
|
||||||
|
↓ ↓ ↓
|
||||||
|
websopy 子系统B 子系统C
|
||||||
|
(消费者) (消费者) (消费者)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 消息格式
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"messageId": "uuid",
|
||||||
|
"messageType": "USER_SYNC",
|
||||||
|
"eventType": "CREATE|UPDATE|DELETE",
|
||||||
|
"targetSystem": "websopy",
|
||||||
|
"data": {
|
||||||
|
"userId": 123,
|
||||||
|
"username": "xxx",
|
||||||
|
"nickname": "xxx",
|
||||||
|
"avatar": "xxx",
|
||||||
|
"phone": "xxx",
|
||||||
|
"status": 1,
|
||||||
|
"tenantId": 1
|
||||||
|
},
|
||||||
|
"createTime": "2026-04-09T16:00:00",
|
||||||
|
"retryCount": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 优势
|
||||||
|
- **解耦**:core 系统只负责发送,不关心哪些子系统消费
|
||||||
|
- **扩展性**:新增子系统只需添加自己的消费者,无需修改 core
|
||||||
|
- **可靠性**:各子系统独立消费,互不影响
|
||||||
|
- **符合 MQ 设计**:消息广播到多个订阅者
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# 2026-04-11 工作日志
|
||||||
|
|
||||||
|
## 扫码登录 access_token 自动恢复机制
|
||||||
|
|
||||||
|
### 问题背景
|
||||||
|
- `WX_ACCESS_TOKEN:{tenantId}` 缓存过期后,微信 API 返回 40001/42001 等错误
|
||||||
|
- 之前需要手动删除 Redis 缓存才能恢复
|
||||||
|
|
||||||
|
### 解决方案
|
||||||
|
实现了 access_token 自动清理和重试机制:
|
||||||
|
|
||||||
|
#### 1. QrLoginServiceImpl 改动
|
||||||
|
- `generateMiniprogramQrCode()` 添加重试逻辑
|
||||||
|
- 首次失败 → 清理缓存 → 重试
|
||||||
|
- 新增 `doGenerateMiniprogramQrCode()` 私有方法
|
||||||
|
|
||||||
|
#### 2. WxService 新增方法
|
||||||
|
- `getAccessTokenForcibly(tenantId)` - 强制刷新 token
|
||||||
|
- 先删除 Redis 缓存
|
||||||
|
- 直接从微信 API 获取新 token
|
||||||
|
|
||||||
|
#### 3. WxLoginController 改动
|
||||||
|
- `getPhoneByCode()` 检测 token 相关错误时自动清理缓存
|
||||||
|
- 新增 `isTokenRelatedError()` 方法识别 40001/42001 等错误码
|
||||||
|
|
||||||
|
### 关键文件
|
||||||
|
- `QrLoginServiceImpl.java` - 扫码登录服务
|
||||||
|
- `WxService.java` - 微信公共服务
|
||||||
|
- `WxLoginController.java` - 微信登录控制器
|
||||||
|
|
||||||
|
### 影响范围
|
||||||
|
- 扫码登录生成小程序码 ✅
|
||||||
|
- 小程序手机号授权登录 ✅
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# 2026-04-21 工作日志
|
||||||
|
|
||||||
|
## loginBySms 租户10519特例
|
||||||
|
- 文件:`MainController.java` → `loginBySms` 接口
|
||||||
|
- 变更:普通用户登录时,租户ID=10519 使用硬编码万能验证码 `170083`,跳过从 Redis 读取 `CACHE_KEY_VERIFICATION_CODE_BY_DEV_SMS`
|
||||||
|
- 超级管理员路径无需此特例(超管不区分租户)
|
||||||
@@ -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
|
||||||
|
- [ ] 配置后台菜单权限
|
||||||
|
- [ ] 测试完整流程
|
||||||
@@ -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`
|
||||||
|
- 原因 1:app_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"),且表名必须带正确库名前缀**
|
||||||
@@ -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 层无需改动,不影响现有调用
|
||||||
@@ -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}` 用当前请求租户ID(16411)去匹配数据库中的记录(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`
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# 2026-06-28 工作记录
|
||||||
|
|
||||||
|
## AliOssController 上传接口优化
|
||||||
|
|
||||||
|
**文件**: `src/main/java/com/gxwebsoft/common/system/controller/AliOssController.java`
|
||||||
|
|
||||||
|
**问题**: 上传文件时,无论文件类型如何,都无条件拼接 `?x-oss-process=image/...` 图片处理参数,导致非图片文件的 thumbnail/url 无效。
|
||||||
|
|
||||||
|
**修改内容**:
|
||||||
|
1. 将 `FileServerUtil.getContentType(upload)` 调用移到设置 thumbnail/url 之前
|
||||||
|
2. 根据 contentType 判断文件类型,分别处理:
|
||||||
|
- **图片** (`image/*`): 照常拼接 OSS 图片处理参数(缩略图 100x100 + 预览图 750px宽)
|
||||||
|
- **视频** (`video/*`): 使用 OSS 视频截帧 `?x-oss-process=video/snapshot,t_0,f_jpg,w_100,h_100,m_fast` 生成缩略图
|
||||||
|
- **其他文件**: thumbnail 和 url 直接设为原始文件 URL,不加任何处理参数
|
||||||
|
|
||||||
|
**依赖**: 使用了已有的 `FileServerUtil.isImage(String contentType)` 方法判断图片类型。
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# 2026-06-29
|
||||||
|
|
||||||
|
## fix: UserMapper.xml LEFT JOIN sys_company 导致分页查询记录重复
|
||||||
|
|
||||||
|
- 问题:`SELECT * FROM sys_user WHERE is_developer = 1` 只有5条记录,但 `/api/system/user/page?isDeveloper=true` 返回多条重复记录
|
||||||
|
- 原因:`LEFT JOIN sys_company g ON g.tenant_id = t.tenant_id` 中,一个 tenant_id 可能在 sys_company 中有多条公司记录,产生笛卡尔积
|
||||||
|
- 修复:将 `LEFT JOIN sys_company` 改为子查询,按 `tenant_id` 分组取第一条记录
|
||||||
|
```sql
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT tenant_id, MAX(company_name) as company_name, MAX(company_logo) as company_logo
|
||||||
|
FROM sys_company
|
||||||
|
GROUP BY tenant_id
|
||||||
|
) g ON g.tenant_id = t.tenant_id
|
||||||
|
```
|
||||||
|
- 同步修改了 `target/classes/` 下的编译版本
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 2026-07-11 工作日志
|
||||||
|
|
||||||
|
## 补充用户审核查询接口
|
||||||
|
- **问题**:`GET /api/system/user/audit/{userId}` 返回404,接口不存在
|
||||||
|
- **原因**:只有 `PUT /api/system/user/audit`(执行审核操作),缺少查询用户审核信息的GET接口
|
||||||
|
- **修复**:在 `UserController.java` 中新增 `getAuditInfo` 方法,返回 userId、auditStatus、rejectReason
|
||||||
|
|
||||||
|
## 修复前端审核接口请求方法不匹配
|
||||||
|
- **问题**:`Request method 'POST' not supported` 报错
|
||||||
|
- **原因**:前端 `auditUser` API 用 POST + 路径参数 `/audit/{id}`,后端是 PUT + body 参数 `/audit`
|
||||||
|
- **修复**:修改 `guilixu-admin/src/api/system/user/index.ts`,改为 PUT 请求、去掉路径参数、字段名映射(status→auditStatus, remark→rejectReason)、userId 放入 body
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 2026-07-21 工作记录
|
||||||
|
|
||||||
|
## 邀请成为开发者报错分析(UID:35739)
|
||||||
|
- 报错「该用户不是平台开发者,无法邀请为开发者角色」来源:`websopy-java` 项目(非 com.gxwebsoft.core)。
|
||||||
|
- 拦截点共 3 处,逻辑一致:邀请角色为 `developer` 时,跨库查 `gxwebsoft_core.sys_user.is_developer`,必须为 `1` 才放行:
|
||||||
|
- `com/gxwebsoft/app/service/impl/AppUserServiceImpl.java:168-172`(API: POST /api/app/developer/.../invite)
|
||||||
|
- `com/gxwebsoft/app/service/impl/AppInviteServiceImpl.java:257-261`
|
||||||
|
- `com/gxwebsoft/app/controller/AppMpInviteController.java:179-183`(小程序邀请)
|
||||||
|
- 「平台开发者」定义:
|
||||||
|
- `sys_user.type = 2`(见 AppUserController /check-access 判断)
|
||||||
|
- `sys_user.is_developer = 1`(邀请 developer 角色的硬性门槛,由 SysUserCrossDbMapper.selectIsDeveloperByUserId 查询)
|
||||||
|
- 关键发现:websopy-java 与 com.gxwebsoft.core 后端代码中**没有任何写入 is_developer=1 / type=2 的逻辑**,即「成为开发者」的赋权由 websopy-pc 前端开发者门户注册/认证流程或数据库操作完成(后端仅做读取校验)。
|
||||||
|
- 结论:UID:35739 的 `sys_user.is_developer` 为 0 或 NULL → 报错。需先让其完成「成为开发者」注册使 is_developer=1(并建议同步 type=2)。
|
||||||
|
- 潜在 Bug:邀请门槛用 is_developer,而 /check-access 用 type=2。若两字段不一致,会出现「能进开发者中心但无法被邀请为开发者」的矛盾。
|
||||||
|
|
||||||
|
## 统一开发者身份判定为 is_developer=1(已实施)
|
||||||
|
- 用户选定方案:把开发者身份判定统一为「sys_user.is_developer = 1」。
|
||||||
|
- 改动(websopy-java 项目):
|
||||||
|
- `AppUserController.checkAccess()`:原 `selectUserType()==2` → 改为 `selectIsDeveloperByUserId()==1`,同步更新 Javadoc。
|
||||||
|
- `AppProductServiceImpl` 转让应用所有权校验:原 `selectUserType()==2` → 改为 `selectIsDeveloperByUserId()==1`。
|
||||||
|
- 三处邀请门槛(AppUserServiceImpl / AppInviteServiceImpl / AppMpInviteController)本就使用 is_developer=1,无需改。
|
||||||
|
- 结果:全站「平台开发者」判定统一为 is_developer=1 单一标准。
|
||||||
|
- 注意:此变更使 is_developer 成为唯一依据;存量仅 type=2、无 is_developer 的开发者将失去权限。需数据回填:`UPDATE gxwebsoft_core.sys_user SET is_developer=1 WHERE type=2 AND deleted=0;`(先备份)。UID:35739 仍需 is_developer=1 才能被邀请。
|
||||||
|
|
||||||
|
## Tenant 实体补充 superAdminUserId 字段
|
||||||
|
- 用户已在 `TenantMapper.xml` 的 `selectSql` 中 SELECT `u.user_id as superAdminUserId`(LEFT JOIN gxwebsoft_core.sys_user u ON u.tenant_id=a.tenant_id AND u.is_super_admin=1 AND u.deleted=0)。
|
||||||
|
- 在 `Tenant.java` 中补充 `private Integer superAdminUserId;` 并标注 `@TableField(exist = false)`,使 MyBatis 能映射该衍生字段(不是 sys_tenant 真实列,不参与增改)。
|
||||||
|
- `selectPageRel` / `selectListRel` 用 resultType=Tenant,可直接返回该字段。
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# 2026-07-23 工作记录
|
||||||
|
|
||||||
|
## 短信验证码接口 sendSmsCaptchaByAdmin 发送失败排查
|
||||||
|
- 现象:线上 `https://site.websoft.top/api/_server/sendSmsCaptchaByAdmin` 调用手机 15678895934 失败,返回 `401 请先登录`。
|
||||||
|
- 根因:`com/gxwebsoft/common/core/security/SecurityConfig.java` 的 `permitAll` 白名单里只有 `/api/sendSmsCaptcha`、`/api/sendEmailCaptcha`,没有 `/api/sendSmsCaptchaByAdmin`。该接口被 Spring Security 拦截,未登录请求直接 401,控制器逻辑未执行,验证码未发出。
|
||||||
|
- 已修复:在 SecurityConfig 的 permitAll antMatchers 中加入 `/api/sendSmsCaptchaByAdmin`(位于 `/api/sendSmsCaptcha` 之后)。
|
||||||
|
- 说明:接口内部已有 `getByPhoneAndAdmin` 管理员手机号校验,开放 permitAll 安全;线上代理前缀为 `/api/_server/**`,转发到后端 `/api/**`。
|
||||||
|
- 验证方式:用 curl 直接打 `/api/_server/sendSmsCaptchaByAdmin` 复现 401;同前缀的 `/api/sendSmsCaptcha` 因在白名单可正常进入(需确认部署后 ByAdmin 不再 401)。
|
||||||
|
|
||||||
|
## 关键文件
|
||||||
|
- SecurityConfig.java:`com/gxwebsoft/common/core/security/SecurityConfig.java`
|
||||||
|
- MainController.sendSmsCaptchaByAdmin:`com/gxwebsoft/common/system/controller/MainController.java:656`
|
||||||
|
- 管理员校验 SQL:`UserMapper.xml selectAdminByPhoneCrossTenant`(sys_user.deleted=0 AND phone=? AND (is_super_admin=1 OR is_admin=1))
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 2026-08-10 工作日志
|
||||||
|
|
||||||
|
## 邮件模板风格统一:注册成功邮件对齐验证码邮件
|
||||||
|
|
||||||
|
**背景**:用户看到两封 WebSoft 邮件风格不统一(企业官网开通成功 vs 邮箱验证码),要求把注册成功模板改成验证码风格。
|
||||||
|
|
||||||
|
**关键发现(重要)**:
|
||||||
|
- `register-success.html` 模板此前**并未被真实开通流程使用**。`TenantServiceImpl.java` 的租户开通逻辑(约 650-673 行)走的是纯文本 `emailRecordService.sendEmail(...)`,且构建的 content 是拼接文本,根本没用 HTML 模板。
|
||||||
|
- `EmailTemplateUtil.sendRegisterSuccessEmail` 原本签名是 `(username, phone, password, email, tenantId)`,只传了这些变量,和模板里的 `siteName/siteUrl/adminUrl/account` 对不上,所以旧模板这些字段一直为空。
|
||||||
|
- `MainController.java:1428` 的"注册网站平台会员"分支也调用了该方法,属于账号注册场景误用"企业官网开通"模板的历史错配(旧逻辑下邮件大半空白)。
|
||||||
|
|
||||||
|
**改动**:
|
||||||
|
1. 重写 `src/main/resources/templates/register-success.html`:对齐 `captcha.html` 的视觉系统(图片 Logo、`.card` 圆角灰卡、`#1d1d1f` 文字、`#06c` 链接、黑色 pill 登录按钮、发送时间、统一页脚)。变量:`siteName, siteUrl, adminUrl, account, password, helpUrl, sendTime`。
|
||||||
|
2. `TenantServiceImpl`:开通邮件从 `sendEmail`(纯文本)改为 `sendHtmlEmail("register-success.html", data, ...)`,补传 `sendTime`(用 `DateUtil.now()`),并加 try-catch 降级为纯文本(`sendHtmlEmail` 抛受检异常 `MessagingException`)。
|
||||||
|
3. `EmailTemplateUtil.sendRegisterSuccessEmail`:签名改为 `(siteName, siteUrl, adminUrl, account, password, email, tenantId)`,正确传模板变量 + `sendTime`。
|
||||||
|
4. `EmailTestController` 的 `/register-success` 测试端点:更新为传新签名的示例数据。
|
||||||
|
5. `MainController:1428`:用兜底值(`siteUrl/adminUrl = https://websoft.top`、`siteName = username`)修复编译,但语义上仍属错配,待产品决策。
|
||||||
|
|
||||||
|
**验证**:`./mvnw -o -q compile` 通过。
|
||||||
|
|
||||||
|
**待用户决策**:`MainController` 的"注册网站平台会员"分支语义是账号注册,是否应为它单独做"账号注册成功"邮件(可能需专用模板),而非复用企业官网开通模板。
|
||||||
|
|
||||||
|
**测试方式**:重新构建启动后,用 `POST /api/email-test/register-success?email=xxx` 预览真实渲染效果。
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# 2026-09-01 工作记录
|
||||||
|
|
||||||
|
## 修复 getOrderQRCodeUnlimited scene 参数 bug
|
||||||
|
- 文件:`src/main/java/com/gxwebsoft/common/system/controller/WxLoginController.java`
|
||||||
|
- 问题:后台管理端调用 `/getOrderQRCodeUnlimited/e9?page=pages/event/detail/index` 时,因未传 `scene`,回退逻辑生成 `scene=orderNo=e9`,而 `pages/event/detail/index` 页面期望 `scene=e9`(直接读 options.scene)。
|
||||||
|
- 修复:scene 取值优先级改为 `显式 scene > 自定义页面用原始 orderNo > 兼容原订单核销(orderNo=xxx)`;page 无参时默认 `package/admin/order-scan`。
|
||||||
|
- 兼容性:原订单核销用法(不带参数)行为不变;自定义页面默认用原始 orderNo;需 `orderNo=` 格式可显式传 `?scene=orderNo=e9`。
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# 2026-09-03
|
||||||
|
|
||||||
|
## 修复:素材管理搜索报 Unknown column 'b.name'
|
||||||
|
|
||||||
|
- 文件:`src/main/java/com/gxwebsoft/common/system/mapper/xml/FileRecordMapper.xml`
|
||||||
|
- 根因:`selectSql` 的 `keywords` 模糊搜索块(2024-11-25 提交 cf4f010 引入,疑似从 UserVerifyMapper 复制过来未改别名)写成了 `b.name` / `b.comments`。
|
||||||
|
该块中 `a` = sys_file_record,`b` = sys_user;sys_user **没有** `name` 字段(只有 username / nickname / real_name / phone),故 MySQL 报 `Unknown column 'b.name' in 'where clause'`。
|
||||||
|
- 修复:关键词改为搜索文件侧字段 `a.name` / `a.path` / `a.comments`,加上传者 `b.username` / `b.nickname` / `b.real_name`;
|
||||||
|
`a.create_user_id = #{param.keywords}` 用 `<if test="param.keywords.matches('[0-9]+')">` 包住,避免非数字关键词被 MySQL 隐式转成 0。
|
||||||
|
- 教训:跨 mapper 复制动态 SQL 片段时,务必核对表别名与目标表字段(sys_user 无 name 列)。
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 系统架构和工作流程记录
|
||||||
|
|
||||||
|
## websopy系统架构
|
||||||
|
1. **Java后端服务**:提供API接口,处理微信扫码登录、用户同步等
|
||||||
|
2. **websopy-pc网站**:前端用户界面,调用后端API
|
||||||
|
3. **微信生态集成**:
|
||||||
|
- 微信公众号扫码登录
|
||||||
|
- 用户信息同步到websopy系统
|
||||||
|
|
||||||
|
## 扫码登录绑定手机号流程
|
||||||
|
1. **后端已完成修改**:
|
||||||
|
- QrLoginServiceImpl添加nextAction逻辑
|
||||||
|
- QrLoginStatusResponse新增nextAction、redirectUrl、successMessage字段
|
||||||
|
- 用户没有手机号时设为bind_phone状态
|
||||||
|
- 用户有手机号时可直接跳转/console
|
||||||
|
|
||||||
|
2. **websopy-pc前端需要修改**:
|
||||||
|
- 状态轮询逻辑需要处理nextAction字段
|
||||||
|
- 需要创建绑定手机号页面
|
||||||
|
- 需要调用绑定手机号API
|
||||||
|
- 需要实现登录成功后跳转逻辑
|
||||||
|
|
||||||
|
## 关键API接口
|
||||||
|
- `/api/qr-login/generate` - 生成扫码token
|
||||||
|
- `/api/qr-login/status/{token}` - 检查扫码状态
|
||||||
|
- `/api/qr-login/bind-phone` - 绑定手机号
|
||||||
|
|
||||||
|
## 错误修复历史
|
||||||
|
- 修复了`Column 'tenant_id' cannot be null`错误
|
||||||
|
- 用户同步时确保同时发送tenantId和tenant_id两种格式
|
||||||
|
- 同步用户前从数据库重新加载确保数据完整
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
# 开放平台对接说明(server-api / gxwebsoft_core)
|
||||||
|
|
||||||
|
> 记录日期:2026-09-22 | 分支:`codex/open-platform-integration`
|
||||||
|
> 上游控制面:`base-api`(开放平台,负责应用、凭证、权限与令牌签发)
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
开放平台(`base-api`)只负责签发令牌,业务数据仍在业务服务里。第三方应用拿着
|
||||||
|
`base-api` 签发的 RS256 access token 调用业务接口时,业务服务需要自行完成:
|
||||||
|
|
||||||
|
1. 用 JWKS 公钥验签,校验 `iss` / `aud` / `exp`;
|
||||||
|
2. 校验令牌的 `scope` 是否包含接口所需权限;
|
||||||
|
3. 从令牌的 `tenant_id` 取租户,作为数据隔离条件。
|
||||||
|
|
||||||
|
本服务(`server.websoft.top`)在此之上新增了一条独立的**开放接口链路**,
|
||||||
|
对内控制台链路零影响。
|
||||||
|
|
||||||
|
## 2. 关键设计
|
||||||
|
|
||||||
|
### 2.1 两条安全链互不干扰
|
||||||
|
|
||||||
|
本服务原有 `SecurityConfig` 有两个特点,都不能被开放接口继承:
|
||||||
|
|
||||||
|
| 原有行为 | 位置 | 对开放接口的风险 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `antMatchers(GET, "/**").permitAll()`,即所有 GET 在框架层放行 | `SecurityConfig` | 开放接口挂上去默认就是公开的 |
|
||||||
|
| 内部令牌是 HS256 对称密钥,由全局 `JwtAuthenticationFilter` 解析 | `JwtAuthenticationFilter` | 用内部密钥解析 RS256 令牌会抛 `UnsupportedJwtException` |
|
||||||
|
|
||||||
|
因此新增 `OpenPlatformSecurityConfig`(`@Order(1)`),只匹配 `/api/open/**`,
|
||||||
|
`anyRequest().authenticated()` 且无任何 `permitAll`;原 `SecurityConfig` 降为 `@Order(2)`。
|
||||||
|
`JwtAuthenticationFilter` 增加 `shouldNotFilter` 跳过 `/api/open/**`
|
||||||
|
(该过滤器是 `@Component`,会被 Spring Boot 额外注册为普通 Servlet Filter,
|
||||||
|
仅靠安全链隔离并不够)。
|
||||||
|
|
||||||
|
### 2.2 租户只来自令牌
|
||||||
|
|
||||||
|
原有租户取值顺序是「请求头 `tenantId` → 请求头 `Domain` → 登录用户」
|
||||||
|
(见 `MybatisPlusConfig` 与 `BaseController.getTenantId`),
|
||||||
|
意味着客户端可以用一个请求头指定租户。开放链路必须堵死这一点:
|
||||||
|
|
||||||
|
`OpenTenantInterceptor` 在请求进入 controller 前,从已验签的 JWT 取 `tenant_id`
|
||||||
|
写入 `OpenTenantContext`;`MybatisPlusConfig.getTenantId()` 以它作为**最高优先级**,
|
||||||
|
不再读取请求头。令牌没有 `tenant_id` 时直接返回 403,不回退。
|
||||||
|
|
||||||
|
### 2.3 订单表需要显式租户条件
|
||||||
|
|
||||||
|
`sys_order` 在 `MybatisPlusConfig` 的 `ignoreTable` 白名单里,**不受多租户插件管辖**,
|
||||||
|
内部接口是靠「非平台租户强制 `userId = 登录用户`」实现隔离的(按人而非按租户)。
|
||||||
|
因此 `OrderMapper.xml` 补了一条显式条件:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<if test="param.tenantId != null">
|
||||||
|
AND a.tenant_id = #{param.tenantId}
|
||||||
|
</if>
|
||||||
|
```
|
||||||
|
|
||||||
|
`PageParam` 会跳过名为 `tenantId` 的字段,该参数此前无人设置,所以对既有调用无影响。
|
||||||
|
|
||||||
|
## 3. 代码结构
|
||||||
|
|
||||||
|
```
|
||||||
|
本服务的业务侧(留在本仓库)
|
||||||
|
com.gxwebsoft.openplatform
|
||||||
|
├─ constant/OpenScopes.java scope 常量
|
||||||
|
├─ param/OpenOrderPageParam.java 入参白名单(刻意不含 tenantId)
|
||||||
|
├─ param/OpenUserPageParam.java 用户查询入参白名单
|
||||||
|
├─ vo/OpenOrderVO.java 出参裁剪 + 手机号脱敏
|
||||||
|
├─ vo/OpenUserVO.java 出参裁剪 + 手机号/邮箱脱敏,不含密码字段
|
||||||
|
├─ controller/OpenOrderController.java /api/open/v1/order/page
|
||||||
|
└─ controller/OpenUserController.java /api/open/v1/user/page
|
||||||
|
|
||||||
|
平台层(已迁出到独立仓库 websoft-platform,作为构件依赖引入)
|
||||||
|
com.gxwebsoft.platform.openapi
|
||||||
|
├─ OpenApi 标记注解,异常处理器按它匹配
|
||||||
|
├─ config/OpenPlatformProperties.java 配置:JWKS / iss / aud / 前缀 / 脱敏
|
||||||
|
├─ config/OpenPlatformJwtConfig.java JwtDecoder + iss/aud 校验器
|
||||||
|
├─ config/OpenPlatformSecurityConfig.java @Order(1),只匹配 /api/open/**
|
||||||
|
├─ config/OpenPlatformWebMvcConfig.java 给 /api/open/** 挂租户拦截器
|
||||||
|
├─ context/OpenCaller.java 调用方身份(全部来自令牌)
|
||||||
|
├─ context/OpenTenantContext.java 开放链路租户 ThreadLocal
|
||||||
|
├─ web/OpenTenantInterceptor.java 绑定与清理租户上下文
|
||||||
|
├─ web/OpenPlatformAuthenticationEntryPoint.java 未认证响应(不含 error 字段)
|
||||||
|
├─ web/OpenPlatformAccessDeniedHandler.java 无权限响应(不含 error 字段)
|
||||||
|
├─ web/OpenPlatformExceptionAdvice.java 开放接口专用异常处理,不外泄内部信息
|
||||||
|
├─ web/OpenPageResult.java 对外分页结构 {list,total,page,limit}
|
||||||
|
└─ web/JsonResponseWriter.java 响应写出(不依赖业务侧 CommonUtil)
|
||||||
|
```
|
||||||
|
|
||||||
|
> **迁移说明(2026-09-22)**:上述平台层文件原先以源码形式放在本仓库的
|
||||||
|
> `com.gxwebsoft.openplatform.{config,context,web}` 下,现已抽到独立仓库
|
||||||
|
> `websoft-platform` 并作为 `websoft-platform-openapi` 构件依赖引入。
|
||||||
|
> 内核 `Constants` / `ApiResult` / `PageResult` / `BusinessException` 同样已迁移,
|
||||||
|
> 但**包名保持不变**,所以本服务其余代码一行 import 都不用改。
|
||||||
|
>
|
||||||
|
> 对外开放的 controller 必须加 `@OpenApi` 注解——平台层的异常处理器按注解匹配,
|
||||||
|
> 漏加会导致第三方收到带 `error` 字段(内部异常信息)的响应。
|
||||||
|
>
|
||||||
|
> 详见 `websoft-platform/docs/PLATFORM_LAYER.md`。
|
||||||
|
|
||||||
|
既有文件改动:
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
| --- | --- |
|
||||||
|
| `pom.xml` | 新增 `spring-boot-starter-oauth2-resource-server` |
|
||||||
|
| `SecurityConfig` | 加 `@Order(2)` 让位给开放链 |
|
||||||
|
| `JwtAuthenticationFilter` | 加 `shouldNotFilter`,跳过 `/api/open/**` |
|
||||||
|
| `MybatisPlusConfig` | 租户取值优先读 `OpenTenantContext` |
|
||||||
|
| `OrderMapper.xml` | 补 `tenant_id` 查询条件 |
|
||||||
|
| `TenantController.page` | **安全修复**:未登录直接拒绝,见第 6 节 |
|
||||||
|
| `application.yml` | 新增 `open-platform.*` 配置块 |
|
||||||
|
|
||||||
|
## 4. 配置项
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
open-platform:
|
||||||
|
enabled: true
|
||||||
|
jwk-set-uri: https://base-api.websoft.top/api/v1/oauth/jwks
|
||||||
|
issuer: https://base-api.websoft.top/api
|
||||||
|
audience: websoft-open-platform
|
||||||
|
path-prefix: /api/open
|
||||||
|
mask-sensitive: true
|
||||||
|
```
|
||||||
|
|
||||||
|
> **不要改用 `spring.security.oauth2.resourceserver.jwt.issuer-uri`**。
|
||||||
|
> 配置该项后 Spring Security 会在启动时请求 `{issuer}/.well-known/openid-configuration`
|
||||||
|
> 做 OIDC 发现,而 `base-api` 未提供该文档(实测返回 `{"code":401,"message":"请先登录"}`),
|
||||||
|
> 会导致启动直接失败。这里只配 JWKS 地址,`iss` / `aud` 用显式校验器声明。
|
||||||
|
|
||||||
|
本地联调时可临时指向自建 JWKS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw spring-boot:run -Dspring-boot.run.arguments="\
|
||||||
|
--open-platform.jwk-set-uri=http://127.0.0.1:18999/jwks.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 接口契约
|
||||||
|
|
||||||
|
### 5.1 订单列表
|
||||||
|
|
||||||
|
`GET /api/open/v1/order/page`
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 认证 | `Authorization: Bearer <base-api accessToken>` |
|
||||||
|
| 权限 | scope 含 `shop:shopOrder:list`,否则 403 |
|
||||||
|
| 数据范围 | 令牌 `tenant_id` 对应租户的订单,**不接受也不识别 `tenantId` 参数或请求头** |
|
||||||
|
| 分页 | `page`(默认 1)、`limit`(默认 20,上限 100) |
|
||||||
|
| 过滤 | `orderNo`(模糊)、`type`、`orderStatus`、`payStatus`、`payType`、`createTimeStart`、`createTimeEnd` |
|
||||||
|
| 时间格式 | `yyyy-MM-dd HH:mm:ss`,输出库中存储的挂钟时间,不做时区换算 |
|
||||||
|
|
||||||
|
响应沿用平台约定(**HTTP 状态码固定 200**,业务结果看 `code`;不返回 `error` 字段):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"orderId": 1,
|
||||||
|
"orderNo": "1856321928276439040",
|
||||||
|
"orderStatus": 1,
|
||||||
|
"payStatus": true,
|
||||||
|
"payPrice": 100.00,
|
||||||
|
"phone": "138****0316",
|
||||||
|
"createTime": "2026-09-01 10:00:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 6,
|
||||||
|
"page": 1,
|
||||||
|
"limit": 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
典型错误:
|
||||||
|
|
||||||
|
| code | message | 触发条件 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 401 | 令牌缺失或无效 | 未带令牌、签名错误、已过期、`iss`/`aud` 不匹配 |
|
||||||
|
| 403 | 权限不足,请确认应用已获得该接口权限 | 令牌没有 `shop:shopOrder:list` |
|
||||||
|
| 403 | 令牌缺少租户信息,无法确定数据范围 | 令牌没有 `tenant_id` |
|
||||||
|
|
||||||
|
### 5.2 用户列表
|
||||||
|
|
||||||
|
`GET /api/open/v1/user/page`
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 认证 | `Authorization: Bearer <base-api accessToken>` |
|
||||||
|
| 权限 | scope 含 `sys:user:list`,否则 403 |
|
||||||
|
| 数据范围 | 令牌 `tenant_id` 对应租户的用户 |
|
||||||
|
| 分页 | `page`(默认 1)、`limit`(默认 20,上限 100) |
|
||||||
|
| 过滤 | `username`(模糊)、`nickname`(模糊)、`type`、`status`、`createTimeStart`、`createTimeEnd` |
|
||||||
|
|
||||||
|
出参只包含 `userId` / `userCode` / `username` / `nickname` / `realName` / `type` / `sex` /
|
||||||
|
`sexName` / `phone` / `email` / `emailVerified` / `organizationId` / `organizationName` /
|
||||||
|
`status` / `auditStatus` / `createTime`。
|
||||||
|
|
||||||
|
> **为什么必须用独立 VO**:`User` 实体对 `password`、`payPassword` 都没有 `@JsonIgnore`,
|
||||||
|
> 而 `UserMapper` 用的是 `SELECT a.*`。内部接口 `/api/system/user/page` 直接把实体返回,
|
||||||
|
> 响应里带着密码哈希;开放接口如果照抄这个写法就会把凭证交给第三方。
|
||||||
|
> `OpenUserVOTest` 里有一条断言专门防止这种回归。
|
||||||
|
|
||||||
|
> **scope 命名说明**:`shop:shopOrder:list` 取自 `sys_menu.authority` 里已有的权限点
|
||||||
|
> (菜单 157795「查询」、182274「项目订单」),与内部权限体系保持一致。
|
||||||
|
> 但注意 `gxwebsoft_core` 库里**没有 `shop_*` 表**(只有 `sys_order` / `sys_order_goods`),
|
||||||
|
> 当前该 scope 守卫的是 `sys_order` 的数据。若后续要对外开放的是商城订单,
|
||||||
|
> 需要把接口指向商城所在的服务与库,而不是复用本 controller。
|
||||||
|
|
||||||
|
## 6. 随本次改动修复的既有问题
|
||||||
|
|
||||||
|
`TenantController.page`(`GET /api/system/tenant/page`)原先没有 `@PreAuthorize`,
|
||||||
|
而 `SecurityConfig` 对所有 GET 放行;未登录且未指定 `userId` 时会走「无过滤」分支,
|
||||||
|
**匿名即可分页读取全部租户**(含租户名称、编码、手机号)。已补登录校验:
|
||||||
|
未登录返回 `{"code":401,"message":"请先登录"}`。
|
||||||
|
|
||||||
|
登录页的多租户选择来自登录响应里的 `tenants` 列表(`loginBySelectTenant` 流程),
|
||||||
|
不依赖该接口,因此不影响登录。
|
||||||
|
|
||||||
|
> 同类风险可能还有其它「无 `@PreAuthorize` 的 GET 接口」。彻底收口需要先把
|
||||||
|
> `SecurityConfig` 里 GET 的 `/**` 白名单摘掉,再按真实流量补显式白名单,
|
||||||
|
> 影响面较大,建议单独排期。
|
||||||
|
|
||||||
|
### 6.1 用户密码哈希可通过免登录接口读取(已修复)
|
||||||
|
|
||||||
|
`GET /api/system/user/getByUserId/{userId}` 在 `SecurityConfig` 的 GET 白名单里,
|
||||||
|
实测**无需任何凭证**,只带一个 `tenantId` 请求头即可取到用户实体,
|
||||||
|
而响应里包含 `password` 与 `payPassword` 两个 BCrypt 哈希字段:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H 'tenantId: <任意租户ID>' 'https://server.websoft.top/api/system/user/getByUserId/<用户ID>'
|
||||||
|
# => {"code":0,"data":{"userId":..., "password":"$2a$10$...", "payPassword":"..."}}
|
||||||
|
```
|
||||||
|
|
||||||
|
根因有两点叠加:`User` 实体对 `password` / `payPassword` 没有做序列化限制,
|
||||||
|
且 `UserMapper` 使用 `SELECT a.*`;同时该接口无 `@PreAuthorize` 又落在 GET 白名单里。
|
||||||
|
`GET /api/system/user/withoutAuth` 属于同一族(免登录 + 返回实体)。
|
||||||
|
|
||||||
|
修复方式是在实体上把这两个字段设为只写:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
|
||||||
|
private String password;
|
||||||
|
```
|
||||||
|
|
||||||
|
只影响序列化(不再输出),不影响任何依赖反序列化写入密码的流程。
|
||||||
|
`password` 与 `payPassword` 都已加上该注解,因此所有返回 `User` 实体的接口一并收敛,
|
||||||
|
包括 `getByUserId`、`withoutAuth`、`getByPhone`、`getByUnionid`、`list`、`page` 等。
|
||||||
|
|
||||||
|
> **同步报文已显式保留**:`RabbitMQSyncProducer.sendUserSyncMessage` 把 User 转成 Map 时
|
||||||
|
> 用的是同一个 ObjectMapper,实体改成只写后密码会从报文里消失。为避免悄悄改变
|
||||||
|
> websopy 侧收到的内容,生产者在转换后显式补回 `password` / `payPassword`。
|
||||||
|
> 是否继续在 MQ 报文里带密码哈希,需要 websopy 侧确认后另行决定。
|
||||||
|
> `UserCredentialSerializationTest` 用三个用例锁住了「HTTP 不输出、反序列化可写入、MQ 报文不变」。
|
||||||
|
|
||||||
|
### 6.2 同一批免登录接口仍存在的问题(未修复,需决策)
|
||||||
|
|
||||||
|
修掉密码后,这一族接口仍然免登录返回完整用户实体,且租户由 `tenantId` 请求头指定:
|
||||||
|
|
||||||
|
| 接口 | 问题 |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/system/user/getByUserId/{userId}` | 免登录返回用户详情,含 `idCard`(完整 18 位身份证号)、手机号、邮箱、余额 |
|
||||||
|
| `GET /api/system/user/withoutAuth` | 免登录,一次返回该租户**全部**用户(不受 page/limit 限制,实测租户 5 返回 163 条 × 107 字段) |
|
||||||
|
| `GET /api/system/user/getByPhone/{phone}` | 免登录按手机号查用户,可被枚举(`mp-react-nextjs` 在用) |
|
||||||
|
| `GET /api/system/user/getByUnionid/{unionid}` | 免登录 |
|
||||||
|
| `PUT /api/system/user/updateWithoutLogin` | **完全没有校验**,任何人都能改用户资料 |
|
||||||
|
| `POST /api/system/user/batchBackUserId` | **完全没有校验**,可批量建用户(`mp-react-nextjs` 在用) |
|
||||||
|
|
||||||
|
另有 `updateUserBalanceWithoutLogin`、`addUserBalanceWithoutLogin`、`getUserWithoutLogin`、
|
||||||
|
`updateUserOfficeOpenidWithoutLogin` 使用硬编码常量 `authCode == "1700083"` 作为唯一凭证,
|
||||||
|
该常量对所有租户相同且写在源码里,等同于弱口令。
|
||||||
|
|
||||||
|
`id_card` 列存的是**完整 18 位身份证号**(库里 3173 条有值),与实体上的
|
||||||
|
「身份证号(脱敏)」注释不符,需要单独决定是脱敏还是仅对管理员可见——会直接影响
|
||||||
|
租户后台的实名审核页面,因此未擅自改动。
|
||||||
|
|
||||||
|
## 7. 如何新增一个开放接口
|
||||||
|
|
||||||
|
1. 在 `OpenScopes` 增加 scope 常量,并确保 `base-api` 的权限字典里有同名 scope;
|
||||||
|
2. 在 `controller` 下新建对外 controller,路径以 `/api/open/v1/` 开头;
|
||||||
|
3. 方法上加 `@PreAuthorize("hasAuthority('SCOPE_xxx')")`;
|
||||||
|
4. 入参使用**独立的白名单 DTO**,不要直接暴露内部 `XxxParam`;
|
||||||
|
5. 需要租户时用 `OpenTenantContext.getTenantId()`,不要从参数或请求头取;
|
||||||
|
6. 出参使用独立的 VO,不要直接返回实体;
|
||||||
|
7. 若目标表在 `MybatisPlusConfig` 的 `ignoreTable` 名单里(如 `sys_order`),
|
||||||
|
必须在 Mapper 里显式加租户条件。
|
||||||
|
|
||||||
|
## 8. 验证方式
|
||||||
|
|
||||||
|
单元测试(不依赖 base-api 在线):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw -o test -Dtest='OpenPlatformJwtValidationTest,OpenTenantBindingTest,OpenOrderVOTest'
|
||||||
|
```
|
||||||
|
|
||||||
|
覆盖:`iss`/`aud`/`exp` 三条校验规则、租户只来自令牌(伪造 `tenantId` 请求头无效)、
|
||||||
|
令牌缺租户时拒绝、请求结束清理上下文、出参脱敏与字段裁剪。
|
||||||
|
|
||||||
|
端到端脚本 `scripts/verify-open-api.sh`,一条命令跑完「换令牌 → 解令牌 → 调接口 → 负向用例」:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 真实链路(需要应用凭证)
|
||||||
|
OPEN_CLIENT_ID=xxx OPEN_CLIENT_SECRET=yyy ./scripts/verify-open-api.sh
|
||||||
|
|
||||||
|
# 已有令牌时跳过换令牌
|
||||||
|
OPEN_ACCESS_TOKEN=eyJ... BUSINESS_BASE_URL=http://127.0.0.1:8080 TENANT_EXPECT=6 ./scripts/verify-open-api.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
判定标准:
|
||||||
|
|
||||||
|
1. 令牌 `scope` 含 `shop:shopOrder:list`、含 `tenant_id`;
|
||||||
|
2. 订单列表 `code=0`,`total` 与 `SELECT COUNT(*) FROM sys_order WHERE deleted=0 AND tenant_id=?` 一致;
|
||||||
|
3. 不带令牌返回 401;
|
||||||
|
4. 伪造 `tenantId` 请求头不改变返回结果。
|
||||||
|
|
||||||
|
本地自建 JWKS(不依赖 base-api)的联调方式见第 4 节。
|
||||||
|
|
||||||
|
## 9. 待办
|
||||||
|
|
||||||
|
- [ ] `base-api` 的权限字典补充 `shop:shopOrder:list` 等业务 scope,并在应用管理页可勾选;
|
||||||
|
- [ ] 开放流量的限流与配额(当前 `open_app.rate_limit` 只作用于令牌签发);
|
||||||
|
- [ ] 调用审计埋点(`client_id` / `tenant_id` / `scope` 全链路);
|
||||||
|
- [ ] `SecurityConfig` 的 GET `/**` 白名单收口;
|
||||||
|
- [ ] 第二个业务服务出现时,把 `openplatform` 配置与拦截器抽成共享 starter。
|
||||||
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<groupId>com.gxwebsoft</groupId>
|
<groupId>com.gxwebsoft</groupId>
|
||||||
<artifactId>server-api</artifactId>
|
<artifactId>server-api</artifactId>
|
||||||
<version>1.0</version>
|
<version>2.0</version>
|
||||||
|
|
||||||
<name>server-api</name>
|
<name>server-api</name>
|
||||||
<description>WebSoftApi project for Spring Boot</description>
|
<description>WebSoftApi project for Spring Boot</description>
|
||||||
@@ -24,9 +24,35 @@
|
|||||||
<maven.compiler.target>17</maven.compiler.target>
|
<maven.compiler.target>17</maven.compiler.target>
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||||
|
<!-- 平台层版本:升级平台能力只改这一处 -->
|
||||||
|
<websoft-platform.version>1.0.1</websoft-platform.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<!-- 强制 okhttp/okio 版本,避免 minio-java(要求 okhttp>=4.11.0) 与微信/支付 SDK 引入的 okhttp3 旧版冲突 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.squareup.okhttp3</groupId>
|
||||||
|
<artifactId>okhttp</artifactId>
|
||||||
|
<version>4.12.0</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.squareup.okio</groupId>
|
||||||
|
<artifactId>okio</artifactId>
|
||||||
|
<version>3.6.0</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
<!-- 强制 okhttp 4.12.0:minio-java 要求 okhttp>=4.11.0,放在 dependencies 首位确保胜出,
|
||||||
|
避免运行时 NoSuchMethodError(okhttp3.RequestBody.create 等) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.squareup.okhttp3</groupId>
|
||||||
|
<artifactId>okhttp</artifactId>
|
||||||
|
<version>4.12.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- spring-boot-devtools -->
|
<!-- spring-boot-devtools -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
@@ -179,6 +205,21 @@
|
|||||||
<artifactId>spring-boot-starter-security</artifactId>
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
平台层:内核(统一返回/状态码/业务异常)+ 开放平台资源服务能力。
|
||||||
|
|
||||||
|
这些类原先以源码形式散在本仓库里,每个业务仓库各复制一份,改一处要改 40 处。
|
||||||
|
现在改为依赖构件,升级只要改下面的版本号。
|
||||||
|
|
||||||
|
配套约定:BOM 只管理 com.gxwebsoft 的构件,不接管 Spring Boot 版本,
|
||||||
|
所以本服务仍可自行决定 Boot 版本。
|
||||||
|
-->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.gxwebsoft</groupId>
|
||||||
|
<artifactId>websoft-platform-openapi</artifactId>
|
||||||
|
<version>${websoft-platform.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- jjwt - 升级到安全版本 -->
|
<!-- jjwt - 升级到安全版本 -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.jsonwebtoken</groupId>
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
@@ -297,6 +338,14 @@
|
|||||||
<version>3.17.4</version>
|
<version>3.17.4</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- MinIO 官方 Java SDK:用于对接 MinIO 兼容 S3 存储。
|
||||||
|
注意 aliyun OSS SDK 无 AWS4-HMAC-SHA256 签名器,无法对接 MinIO,故 minio 分支单独用此 SDK。 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.minio</groupId>
|
||||||
|
<artifactId>minio</artifactId>
|
||||||
|
<version>8.5.7</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- 阿里云 内容安全审核 -->
|
<!-- 阿里云 内容安全审核 -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.aliyun</groupId>
|
<groupId>com.aliyun</groupId>
|
||||||
@@ -316,6 +365,19 @@
|
|||||||
<version>1.0.0.14</version>
|
<version>1.0.0.14</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- spring-boot-starter-amqp for RabbitMQ -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-amqp</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- javax.annotation-api for Java 9+ compatibility (previously included in JDK) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>javax.annotation</groupId>
|
||||||
|
<artifactId>javax.annotation-api</artifactId>
|
||||||
|
<version>1.3.2</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- knife4j - 升级到兼容版本 -->
|
<!-- knife4j - 升级到兼容版本 -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.github.xiaoymin</groupId>
|
<groupId>com.github.xiaoymin</groupId>
|
||||||
|
|||||||
Executable
+180
@@ -0,0 +1,180 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# 开放平台接入验证脚本
|
||||||
|
#
|
||||||
|
# 作用:验证「应用凭证 → 换取令牌 → 携带令牌调用业务接口 → 租户隔离与权限生效」整条链路。
|
||||||
|
#
|
||||||
|
# 用法一(真实链路,需要应用凭证):
|
||||||
|
# OPEN_CLIENT_ID=xxx OPEN_CLIENT_SECRET=yyy ./scripts/verify-open-api.sh
|
||||||
|
#
|
||||||
|
# 用法二(已有令牌,跳过换令牌,用于本地/联调):
|
||||||
|
# OPEN_ACCESS_TOKEN=eyJ... BUSINESS_BASE_URL=http://127.0.0.1:8080 ./scripts/verify-open-api.sh
|
||||||
|
#
|
||||||
|
# 可选环境变量:
|
||||||
|
# OPEN_BASE_URL 开放平台地址,默认 https://base-api.websoft.top/api
|
||||||
|
# BUSINESS_BASE_URL 业务服务地址,默认 http://127.0.0.1:8000
|
||||||
|
# OPEN_SCOPE 期望的权限标识,默认 shop:shopOrder:list
|
||||||
|
# OPEN_PATH 被测接口路径,默认 /api/open/v1/order/page
|
||||||
|
# 测用户列表:OPEN_PATH=/api/open/v1/user/page OPEN_SCOPE=sys:user:list
|
||||||
|
# TENANT_EXPECT 期望命中本租户的记录数,留空则不校验数量(需与数据库核对后填写)
|
||||||
|
#
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
||||||
|
|
||||||
|
OPEN_BASE_URL="${OPEN_BASE_URL:-https://base-api.websoft.top/api}"
|
||||||
|
BUSINESS_BASE_URL="${BUSINESS_BASE_URL:-http://127.0.0.1:8000}"
|
||||||
|
OPEN_SCOPE="${OPEN_SCOPE:-shop:shopOrder:list}"
|
||||||
|
OPEN_PATH="${OPEN_PATH:-/api/open/v1/order/page}"
|
||||||
|
|
||||||
|
ok() { echo -e "${GREEN}✔${NC} $1"; }
|
||||||
|
bad() { echo -e "${RED}✘${NC} $1"; }
|
||||||
|
warn() { echo -e "${YELLOW}!${NC} $1"; }
|
||||||
|
step() { echo -e "\n${BLUE}== $1 ==${NC}"; }
|
||||||
|
|
||||||
|
need_cmd() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || { bad "缺少命令:$1"; exit 1; }
|
||||||
|
}
|
||||||
|
need_cmd curl
|
||||||
|
need_cmd python3
|
||||||
|
|
||||||
|
# 只解包 ApiResult,不校验业务语义
|
||||||
|
json_field() {
|
||||||
|
python3 -c "
|
||||||
|
import json,sys
|
||||||
|
try:
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
except Exception:
|
||||||
|
print('')
|
||||||
|
sys.exit(0)
|
||||||
|
cur = data
|
||||||
|
for part in sys.argv[1].split('.'):
|
||||||
|
if isinstance(cur, dict):
|
||||||
|
cur = cur.get(part)
|
||||||
|
else:
|
||||||
|
cur = None
|
||||||
|
break
|
||||||
|
print('' if cur is None else cur)
|
||||||
|
" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
decode_jwt_payload() {
|
||||||
|
python3 -c "
|
||||||
|
import base64, json, sys
|
||||||
|
token = sys.argv[1].strip()
|
||||||
|
parts = token.split('.')
|
||||||
|
if len(parts) < 2:
|
||||||
|
print('{}')
|
||||||
|
sys.exit(0)
|
||||||
|
payload = parts[1].replace('-', '+').replace('_', '/')
|
||||||
|
payload += '=' * ((4 - len(payload) % 4) % 4)
|
||||||
|
try:
|
||||||
|
print(json.dumps(json.loads(base64.b64decode(payload)), ensure_ascii=False, indent=2))
|
||||||
|
except Exception as e:
|
||||||
|
print('{}')
|
||||||
|
" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
step "0. 目标地址"
|
||||||
|
echo " 开放平台:$OPEN_BASE_URL"
|
||||||
|
echo " 业务服务:$BUSINESS_BASE_URL"
|
||||||
|
echo " 期望权限:$OPEN_SCOPE"
|
||||||
|
echo " 接口路径:$OPEN_PATH"
|
||||||
|
|
||||||
|
step "1. 获取 access token"
|
||||||
|
if [ -n "${OPEN_ACCESS_TOKEN:-}" ]; then
|
||||||
|
ACCESS_TOKEN="$OPEN_ACCESS_TOKEN"
|
||||||
|
ok "使用已提供的 OPEN_ACCESS_TOKEN"
|
||||||
|
else
|
||||||
|
if [ -z "${OPEN_CLIENT_ID:-}" ] || [ -z "${OPEN_CLIENT_SECRET:-}" ]; then
|
||||||
|
bad "请设置 OPEN_CLIENT_ID / OPEN_CLIENT_SECRET,或直接提供 OPEN_ACCESS_TOKEN"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
TOKEN_RESPONSE=$(curl -s -m 20 -X POST "$OPEN_BASE_URL/v1/oauth/token" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"grantType\":\"client_credentials\",\"clientId\":\"$OPEN_CLIENT_ID\",\"clientSecret\":\"$OPEN_CLIENT_SECRET\"}")
|
||||||
|
echo "$TOKEN_RESPONSE" | python3 -m json.tool 2>/dev/null | head -20 || echo "$TOKEN_RESPONSE"
|
||||||
|
CODE=$(echo "$TOKEN_RESPONSE" | json_field code)
|
||||||
|
if [ "$CODE" != "0" ]; then
|
||||||
|
bad "换取令牌失败:$(echo "$TOKEN_RESPONSE" | json_field message)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | json_field data.accessToken)
|
||||||
|
ok "换取令牌成功"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$ACCESS_TOKEN" ]; then
|
||||||
|
bad "accessToken 为空"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "2. 解析令牌声明(仅本地解码,不代表已验签)"
|
||||||
|
decode_jwt_payload "$ACCESS_TOKEN"
|
||||||
|
|
||||||
|
CLAIMS=$(decode_jwt_payload "$ACCESS_TOKEN")
|
||||||
|
TOKEN_SCOPE=$(echo "$CLAIMS" | json_field scope)
|
||||||
|
TOKEN_TENANT=$(echo "$CLAIMS" | json_field tenant_id)
|
||||||
|
|
||||||
|
if echo " $TOKEN_SCOPE " | grep -q " $OPEN_SCOPE "; then
|
||||||
|
ok "令牌包含权限 $OPEN_SCOPE"
|
||||||
|
else
|
||||||
|
bad "令牌不含 $OPEN_SCOPE,当前 scope:${TOKEN_SCOPE:-(空)}"
|
||||||
|
warn "请确认:1) base-api 权限字典已登记该 scope;2) 已在应用管理里勾选;3) 重新换过令牌"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$TOKEN_TENANT" ] && [ "$TOKEN_TENANT" != "None" ]; then
|
||||||
|
ok "令牌携带租户 tenant_id=$TOKEN_TENANT(业务侧据此隔离数据)"
|
||||||
|
else
|
||||||
|
bad "令牌没有 tenant_id,业务接口会返回 403"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "3. 携带令牌调用开放接口"
|
||||||
|
RESPONSE=$(curl -s -m 20 -H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||||
|
"$BUSINESS_BASE_URL$OPEN_PATH?page=1&limit=5")
|
||||||
|
echo "$RESPONSE" | python3 -m json.tool 2>/dev/null | head -40 || echo "$RESPONSE"
|
||||||
|
|
||||||
|
CODE=$(echo "$RESPONSE" | json_field code)
|
||||||
|
if [ "$CODE" != "0" ]; then
|
||||||
|
bad "调用失败:code=$CODE message=$(echo "$RESPONSE" | json_field message)"
|
||||||
|
warn "404 说明业务服务未部署该开放接口;403 说明权限或租户不满足"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ok "接口调用成功,total=$(echo "$RESPONSE" | json_field data.total)"
|
||||||
|
|
||||||
|
if [ -n "${TENANT_EXPECT:-}" ]; then
|
||||||
|
TOTAL=$(echo "$RESPONSE" | json_field data.total)
|
||||||
|
if [ "$TOTAL" = "$TENANT_EXPECT" ]; then
|
||||||
|
ok "条数与预期一致($TOTAL)"
|
||||||
|
else
|
||||||
|
bad "条数不符:接口返回 $TOTAL,预期 $TENANT_EXPECT"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "4. 负向用例"
|
||||||
|
|
||||||
|
NO_TOKEN=$(curl -s -m 20 "$BUSINESS_BASE_URL$OPEN_PATH?page=1&limit=1")
|
||||||
|
NO_TOKEN_CODE=$(echo "$NO_TOKEN" | json_field code)
|
||||||
|
if [ "$NO_TOKEN_CODE" = "401" ]; then
|
||||||
|
ok "未携带令牌被拒绝(code=401)"
|
||||||
|
else
|
||||||
|
bad "未携带令牌竟然返回 code=$NO_TOKEN_CODE,开放接口可能未走独立安全链"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$TOKEN_TENANT" ] && [ "$TOKEN_TENANT" != "None" ]; then
|
||||||
|
SPOOF=$(curl -s -m 20 -H "Authorization: Bearer $ACCESS_TOKEN" -H "tenantId: 1" \
|
||||||
|
"$BUSINESS_BASE_URL$OPEN_PATH?page=1&limit=5")
|
||||||
|
SPOOF_TOTAL=$(echo "$SPOOF" | json_field data.total)
|
||||||
|
BASE_TOTAL=$(echo "$RESPONSE" | json_field data.total)
|
||||||
|
if [ "$SPOOF_TOTAL" = "$BASE_TOTAL" ]; then
|
||||||
|
ok "伪造 tenantId 请求头无效(仍返回本租户数据)"
|
||||||
|
else
|
||||||
|
bad "伪造 tenantId 改变了结果($BASE_TOTAL -> $SPOOF_TOTAL),存在越权风险"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
ok "验证完成"
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.gxwebsoft.auto.controller;
|
package com.gxwebsoft.auto.controller;
|
||||||
|
|
||||||
|
import com.gxwebsoft.auto.dto.QrLoginBindPhoneRequest;
|
||||||
import com.gxwebsoft.auto.dto.QrLoginConfirmRequest;
|
import com.gxwebsoft.auto.dto.QrLoginConfirmRequest;
|
||||||
import com.gxwebsoft.auto.dto.QrLoginGenerateResponse;
|
import com.gxwebsoft.auto.dto.QrLoginGenerateResponse;
|
||||||
import com.gxwebsoft.auto.dto.QrLoginStatusResponse;
|
import com.gxwebsoft.auto.dto.QrLoginStatusResponse;
|
||||||
|
import com.gxwebsoft.auto.dto.WechatScanRequest;
|
||||||
|
import com.gxwebsoft.auto.dto.WechatScanResponse;
|
||||||
import com.gxwebsoft.auto.service.QrLoginService;
|
import com.gxwebsoft.auto.service.QrLoginService;
|
||||||
import com.gxwebsoft.common.core.web.BaseController;
|
import com.gxwebsoft.common.core.web.BaseController;
|
||||||
import com.gxwebsoft.common.core.web.ApiResult;
|
import com.gxwebsoft.common.core.web.ApiResult;
|
||||||
@@ -28,6 +31,12 @@ public class QrLoginController extends BaseController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private QrLoginService qrLoginService;
|
private QrLoginService qrLoginService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private com.gxwebsoft.common.system.service.WxService wxService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private javax.servlet.http.HttpServletRequest request;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成扫码登录token
|
* 生成扫码登录token
|
||||||
*/
|
*/
|
||||||
@@ -85,4 +94,54 @@ public class QrLoginController extends BaseController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公众号关注注册后绑定手机号
|
||||||
|
*/
|
||||||
|
@Operation(summary = "绑定手机号并完成扫码登录")
|
||||||
|
@PostMapping("/bind-phone")
|
||||||
|
public ApiResult<?> bindPhone(@Valid @RequestBody QrLoginBindPhoneRequest request) {
|
||||||
|
try {
|
||||||
|
QrLoginStatusResponse response = qrLoginService.bindPhone(request);
|
||||||
|
return success("绑定成功", response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return fail(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信扫码登录确认(H5页面调用)
|
||||||
|
*/
|
||||||
|
@Operation(summary = "微信扫码登录确认")
|
||||||
|
@PostMapping("/wechat-scan")
|
||||||
|
public ApiResult<?> wechatScanConfirm(@Valid @RequestBody WechatScanRequest request) {
|
||||||
|
try {
|
||||||
|
WechatScanResponse response = qrLoginService.wechatScanConfirm(request);
|
||||||
|
return success("操作成功", response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return fail(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取微信网页授权 URL(用于 H5 扫码页面重定向)
|
||||||
|
*/
|
||||||
|
@Operation(summary = "获取微信网页授权URL")
|
||||||
|
@GetMapping("/wechat-oauth-url")
|
||||||
|
public ApiResult<?> getWechatOAuthUrl(@Parameter(description = "扫码登录token") @RequestParam String token) {
|
||||||
|
try {
|
||||||
|
String appId = wxService.getOfficialAppId(getTenantId());
|
||||||
|
// 回调地址,指向 H5 扫码确认页面
|
||||||
|
String redirectUri = java.net.URLEncoder.encode(
|
||||||
|
"https://" + request.getHeader("Host") + "/wx-scan?token=" + token,
|
||||||
|
java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
// 构造微信 OAuth 授权 URL
|
||||||
|
String oauthUrl = String.format(
|
||||||
|
"https://open.weixin.qq.com/connect/oauth2/authorize?appid=%s&redirect_uri=%s&response_type=code&scope=snsapi_userinfo&state=%s#wechat_redirect",
|
||||||
|
appId, redirectUri, token);
|
||||||
|
return success("获取成功", oauthUrl);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return fail(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.gxwebsoft.auto.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import javax.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫码登录绑定手机号请求
|
||||||
|
*
|
||||||
|
* @author 科技小王子
|
||||||
|
* @since 2026-04-06
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(description = "扫码登录绑定手机号请求")
|
||||||
|
public class QrLoginBindPhoneRequest {
|
||||||
|
|
||||||
|
@Schema(description = "扫码登录token")
|
||||||
|
@NotBlank(message = "token不能为空")
|
||||||
|
private String token;
|
||||||
|
|
||||||
|
@Schema(description = "手机号")
|
||||||
|
@NotBlank(message = "手机号不能为空")
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
@Schema(description = "短信验证码")
|
||||||
|
@NotBlank(message = "验证码不能为空")
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -4,8 +4,6 @@ import lombok.AllArgsConstructor;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.util.Date;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 扫码登录数据模型
|
* 扫码登录数据模型
|
||||||
*
|
*
|
||||||
@@ -23,7 +21,7 @@ public class QrLoginData {
|
|||||||
private String token;
|
private String token;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 状态: pending-等待扫码, scanned-已扫码, confirmed-已确认, expired-已过期
|
* 状态: pending-等待扫码, scanned-已扫码, confirmed-已确认, bind_phone-待绑定手机号, expired-已过期
|
||||||
*/
|
*/
|
||||||
private String status;
|
private String status;
|
||||||
|
|
||||||
@@ -57,4 +55,14 @@ public class QrLoginData {
|
|||||||
*/
|
*/
|
||||||
private Integer tenantId;
|
private Integer tenantId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否需要绑定手机号
|
||||||
|
*/
|
||||||
|
private Boolean needBindPhone;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态提示信息
|
||||||
|
*/
|
||||||
|
private String message;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,12 +26,24 @@ public class QrLoginGenerateResponse {
|
|||||||
@Schema(description = "微信小程序页面路径")
|
@Schema(description = "微信小程序页面路径")
|
||||||
private String miniprogramPath;
|
private String miniprogramPath;
|
||||||
|
|
||||||
@Schema(description = "微信小程序码图片URL")
|
@Schema(description = "微信小程序码图片URL(已废弃,改用base64)")
|
||||||
private String miniprogramQrCodeUrl;
|
private String miniprogramQrCodeUrl;
|
||||||
|
|
||||||
|
@Schema(description = "微信小程序码图片Base64(扫码后直接打开小程序,优先使用)")
|
||||||
|
private String miniprogramQrCode;
|
||||||
|
|
||||||
@Schema(description = "过期时间(秒)")
|
@Schema(description = "过期时间(秒)")
|
||||||
private Long expiresIn;
|
private Long expiresIn;
|
||||||
|
|
||||||
|
@Schema(description = "微信扫码登录H5页面URL")
|
||||||
|
private String wechatScanUrl;
|
||||||
|
|
||||||
|
@Schema(description = "微信公众号AppID")
|
||||||
|
private String wechatAppId;
|
||||||
|
|
||||||
|
@Schema(description = "微信公众号带参数二维码图片URL")
|
||||||
|
private String wechatQrCodeUrl;
|
||||||
|
|
||||||
// 保持向后兼容的构造函数
|
// 保持向后兼容的构造函数
|
||||||
public QrLoginGenerateResponse(String token, String qrCodeContent, Long expiresIn) {
|
public QrLoginGenerateResponse(String token, String qrCodeContent, Long expiresIn) {
|
||||||
this.token = token;
|
this.token = token;
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package com.gxwebsoft.auto.dto;
|
package com.gxwebsoft.auto.dto;
|
||||||
|
|
||||||
import com.gxwebsoft.common.system.entity.User;
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
import io.swagger.models.auth.In;
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.AllArgsConstructor;
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
@@ -15,17 +13,16 @@ import lombok.NoArgsConstructor;
|
|||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
|
||||||
@Schema(description = "扫码登录状态响应")
|
@Schema(description = "扫码登录状态响应")
|
||||||
public class QrLoginStatusResponse {
|
public class QrLoginStatusResponse {
|
||||||
|
|
||||||
@Schema(description = "状态: pending-等待扫码, scanned-已扫码, confirmed-已确认, expired-已过期")
|
@Schema(description = "状态: pending-等待扫码, scanned-已扫码, confirmed-已确认, bind_phone-待绑定手机号, expired-已过期")
|
||||||
private String status;
|
private String status;
|
||||||
|
|
||||||
@Schema(description = "JWT访问令牌(仅在confirmed状态时返回)")
|
@Schema(description = "JWT访问令牌(仅在confirmed状态时返回)")
|
||||||
private String accessToken;
|
private String accessToken;
|
||||||
|
|
||||||
@Schema(description = "用户信息(仅在confirmed状态时返回)")
|
@Schema(description = "用户信息")
|
||||||
private User userInfo;
|
private User userInfo;
|
||||||
|
|
||||||
@Schema(description = "剩余过期时间(秒)")
|
@Schema(description = "剩余过期时间(秒)")
|
||||||
@@ -34,4 +31,27 @@ public class QrLoginStatusResponse {
|
|||||||
@Schema(description = "租户ID")
|
@Schema(description = "租户ID")
|
||||||
private Integer tenantId;
|
private Integer tenantId;
|
||||||
|
|
||||||
|
@Schema(description = "是否需要绑定手机号")
|
||||||
|
private Boolean needBindPhone;
|
||||||
|
|
||||||
|
@Schema(description = "状态提示信息")
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
@Schema(description = "下一步操作:bind_phone-绑定手机号, redirect-跳转, login-直接登录")
|
||||||
|
private String nextAction;
|
||||||
|
|
||||||
|
@Schema(description = "跳转URL(当nextAction为redirect时使用)")
|
||||||
|
private String redirectUrl;
|
||||||
|
|
||||||
|
@Schema(description = "成功消息")
|
||||||
|
private String successMessage;
|
||||||
|
|
||||||
|
public QrLoginStatusResponse(String status, String accessToken, User userInfo, Long expiresIn, Integer tenantId) {
|
||||||
|
this.status = status;
|
||||||
|
this.accessToken = accessToken;
|
||||||
|
this.userInfo = userInfo;
|
||||||
|
this.expiresIn = expiresIn;
|
||||||
|
this.tenantId = tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.gxwebsoft.auto.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import javax.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信扫码登录请求(用于 H5 页面回调)
|
||||||
|
*
|
||||||
|
* @author 科技小王子
|
||||||
|
* @since 2026-04-06
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(description = "微信扫码登录请求")
|
||||||
|
public class WechatScanRequest {
|
||||||
|
|
||||||
|
@Schema(description = "扫码登录token")
|
||||||
|
@NotBlank(message = "token不能为空")
|
||||||
|
private String token;
|
||||||
|
|
||||||
|
@Schema(description = "微信公众号授权code")
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
@Schema(description = "微信unionId(如果已获取)")
|
||||||
|
private String unionId;
|
||||||
|
|
||||||
|
@Schema(description = "微信openId")
|
||||||
|
private String openId;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.gxwebsoft.auto.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信扫码登录响应
|
||||||
|
*
|
||||||
|
* @author 科技小王子
|
||||||
|
* @since 2026-04-06
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "微信扫码登录响应")
|
||||||
|
public class WechatScanResponse {
|
||||||
|
|
||||||
|
@Schema(description = "状态:success-登录成功,bind_required-需要绑定账号,not_bound-账号未绑定")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Schema(description = "JWT访问令牌")
|
||||||
|
private String accessToken;
|
||||||
|
|
||||||
|
@Schema(description = "用户信息")
|
||||||
|
private Object userInfo;
|
||||||
|
|
||||||
|
@Schema(description = "提示信息")
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
@Schema(description = "租户ID")
|
||||||
|
private Integer tenantId;
|
||||||
|
|
||||||
|
public static WechatScanResponse success(String accessToken, Object userInfo, Integer tenantId) {
|
||||||
|
return new WechatScanResponse("success", accessToken, userInfo, "登录成功", tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static WechatScanResponse needBind(String message) {
|
||||||
|
return new WechatScanResponse("bind_required", null, null, message, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static WechatScanResponse notBound(String message) {
|
||||||
|
return new WechatScanResponse("not_bound", null, null, message, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.gxwebsoft.auto.service;
|
package com.gxwebsoft.auto.service;
|
||||||
|
|
||||||
|
import com.gxwebsoft.auto.dto.QrLoginBindPhoneRequest;
|
||||||
import com.gxwebsoft.auto.dto.QrLoginConfirmRequest;
|
import com.gxwebsoft.auto.dto.QrLoginConfirmRequest;
|
||||||
import com.gxwebsoft.auto.dto.QrLoginGenerateResponse;
|
import com.gxwebsoft.auto.dto.QrLoginGenerateResponse;
|
||||||
import com.gxwebsoft.auto.dto.QrLoginStatusResponse;
|
import com.gxwebsoft.auto.dto.QrLoginStatusResponse;
|
||||||
|
import com.gxwebsoft.auto.dto.WechatScanRequest;
|
||||||
|
import com.gxwebsoft.auto.dto.WechatScanResponse;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 扫码登录服务接口
|
* 扫码登录服务接口
|
||||||
@@ -43,4 +46,20 @@ public interface QrLoginService {
|
|||||||
*/
|
*/
|
||||||
boolean scanQrCode(String token);
|
boolean scanQrCode(String token);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关注后绑定手机号并完成登录
|
||||||
|
*
|
||||||
|
* @param request 绑定手机号请求
|
||||||
|
* @return QrLoginStatusResponse
|
||||||
|
*/
|
||||||
|
QrLoginStatusResponse bindPhone(QrLoginBindPhoneRequest request);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信扫码登录确认(H5页面调用)
|
||||||
|
*
|
||||||
|
* @param request 微信扫码登录请求
|
||||||
|
* @return WechatScanResponse
|
||||||
|
*/
|
||||||
|
WechatScanResponse wechatScanConfirm(WechatScanRequest request);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,37 @@
|
|||||||
package com.gxwebsoft.auto.service.impl;
|
package com.gxwebsoft.auto.service.impl;
|
||||||
|
|
||||||
import cn.hutool.core.date.DateUtil;
|
import cn.hutool.core.date.DateUtil;
|
||||||
import cn.hutool.core.io.FileUtil;
|
|
||||||
import cn.hutool.core.lang.UUID;
|
import cn.hutool.core.lang.UUID;
|
||||||
|
import cn.hutool.core.util.DesensitizedUtil;
|
||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
import cn.hutool.http.HttpRequest;
|
import cn.hutool.http.HttpRequest;
|
||||||
|
import cn.hutool.http.HttpUtil;
|
||||||
import com.alibaba.fastjson.JSON;
|
import com.alibaba.fastjson.JSON;
|
||||||
|
import com.alibaba.fastjson.JSONObject;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.gxwebsoft.auto.dto.*;
|
import com.gxwebsoft.auto.dto.*;
|
||||||
import com.gxwebsoft.auto.service.QrLoginService;
|
import com.gxwebsoft.auto.service.QrLoginService;
|
||||||
import com.gxwebsoft.common.core.config.ConfigProperties;
|
import com.gxwebsoft.common.core.config.ConfigProperties;
|
||||||
import com.gxwebsoft.common.core.security.JwtSubject;
|
import com.gxwebsoft.common.core.security.JwtSubject;
|
||||||
import com.gxwebsoft.common.core.security.JwtUtil;
|
import com.gxwebsoft.common.core.security.JwtUtil;
|
||||||
|
import com.gxwebsoft.common.core.utils.CommonUtil;
|
||||||
import com.gxwebsoft.common.core.utils.RedisUtil;
|
import com.gxwebsoft.common.core.utils.RedisUtil;
|
||||||
|
import com.gxwebsoft.common.mq.producer.SyncMessageProducer;
|
||||||
import com.gxwebsoft.common.system.entity.User;
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
|
import com.gxwebsoft.common.system.entity.UserOauth;
|
||||||
|
import com.gxwebsoft.common.system.service.UserOauthService;
|
||||||
import com.gxwebsoft.common.system.service.UserService;
|
import com.gxwebsoft.common.system.service.UserService;
|
||||||
import com.gxwebsoft.common.system.service.WxService;
|
import com.gxwebsoft.common.system.service.WxService;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import java.util.Date;
|
||||||
import java.io.File;
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import static com.gxwebsoft.common.core.constants.PlatformConstants.MP_OFFICIAL;
|
||||||
import static com.gxwebsoft.common.core.constants.RedisConstants.*;
|
import static com.gxwebsoft.common.core.constants.RedisConstants.*;
|
||||||
|
import static com.gxwebsoft.common.core.constants.WebsiteConstants.CACHE_KEY_VERIFICATION_CODE_BY_DEV_SMS;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 扫码登录服务实现
|
* 扫码登录服务实现
|
||||||
@@ -47,96 +55,251 @@ public class QrLoginServiceImpl implements QrLoginService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private WxService wxService;
|
private WxService wxService;
|
||||||
|
|
||||||
private static final String QR_LOGIN_TOKEN = "QR_LOGIN_TOKEN";
|
@Autowired(required = false)
|
||||||
|
private UserOauthService userOauthService;
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
private SyncMessageProducer syncMessageProducer;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public QrLoginGenerateResponse generateQrLoginToken(Integer tenantId) {
|
public QrLoginGenerateResponse generateQrLoginToken(Integer tenantId) {
|
||||||
// 生成唯一的扫码登录token
|
|
||||||
String token = UUID.randomUUID().toString(true);
|
String token = UUID.randomUUID().toString(true);
|
||||||
|
|
||||||
// 创建扫码登录数据
|
|
||||||
QrLoginData qrLoginData = new QrLoginData();
|
QrLoginData qrLoginData = new QrLoginData();
|
||||||
qrLoginData.setToken(token);
|
qrLoginData.setToken(token);
|
||||||
qrLoginData.setStatus(QR_LOGIN_STATUS_PENDING);
|
qrLoginData.setStatus(QR_LOGIN_STATUS_PENDING);
|
||||||
qrLoginData.setTenantId(tenantId);
|
qrLoginData.setTenantId(tenantId);
|
||||||
|
qrLoginData.setNeedBindPhone(false);
|
||||||
|
qrLoginData.setMessage("等待微信扫码");
|
||||||
qrLoginData.setCreateTime(DateUtil.formatDateTime(DateUtil.date()));
|
qrLoginData.setCreateTime(DateUtil.formatDateTime(DateUtil.date()));
|
||||||
qrLoginData.setExpireTime(DateUtil.formatDateTime(DateUtil.offsetSecond(DateUtil.date(), QR_LOGIN_TOKEN_TTL.intValue())));
|
qrLoginData.setExpireTime(DateUtil.formatDateTime(DateUtil.offsetSecond(DateUtil.date(), QR_LOGIN_TOKEN_TTL.intValue())));
|
||||||
|
|
||||||
// 存储到Redis,设置过期时间
|
|
||||||
String redisKey = QR_LOGIN_TOKEN_KEY + token;
|
String redisKey = QR_LOGIN_TOKEN_KEY + token;
|
||||||
redisUtil.set(redisKey, qrLoginData, QR_LOGIN_TOKEN_TTL, TimeUnit.SECONDS);
|
redisUtil.set(redisKey, qrLoginData, QR_LOGIN_TOKEN_TTL, TimeUnit.SECONDS);
|
||||||
|
|
||||||
log.info("生成扫码登录token: {}", token);
|
log.info("生成扫码登录token: {}", token);
|
||||||
|
|
||||||
// 构造响应对象
|
|
||||||
QrLoginGenerateResponse response = new QrLoginGenerateResponse();
|
QrLoginGenerateResponse response = new QrLoginGenerateResponse();
|
||||||
response.setToken(token);
|
response.setToken(token);
|
||||||
response.setExpiresIn(QR_LOGIN_TOKEN_TTL);
|
response.setExpiresIn(QR_LOGIN_TOKEN_TTL);
|
||||||
|
// 二维码内容:使用自定义协议,前端据此生成base64二维码
|
||||||
// APP扫码内容
|
response.setQrCodeContent("websopy://login?token=" + token);
|
||||||
response.setQrCodeContent("qr-login:" + token);
|
// 小程序路径(用于小程序扫码直接打开)
|
||||||
|
|
||||||
// 微信小程序路径
|
|
||||||
response.setMiniprogramPath("/pages/qr-login?token=" + token);
|
response.setMiniprogramPath("/pages/qr-login?token=" + token);
|
||||||
|
|
||||||
// 生成微信小程序码
|
// 扫码跳转URL(前端生成二维码时使用此URL)
|
||||||
try {
|
try {
|
||||||
String miniprogramQrCodeUrl = generateMiniprogramQrCode(token,tenantId);
|
String baseUrl = configProperties.getWechatScanUrl();
|
||||||
response.setMiniprogramQrCodeUrl(miniprogramQrCodeUrl);
|
if (StrUtil.isBlank(baseUrl)) {
|
||||||
|
baseUrl = "https://websopy.websoft.top";
|
||||||
|
}
|
||||||
|
String wechatScanUrl = baseUrl + "/wx-scan?token=" + token;
|
||||||
|
response.setWechatScanUrl(wechatScanUrl);
|
||||||
|
log.info("扫码跳转URL: {}", wechatScanUrl);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("生成微信小程序码失败: {}", e.getMessage());
|
log.warn("获取扫码跳转URL失败: {}", e.getMessage());
|
||||||
// 小程序码生成失败不影响整体功能,继续返回其他信息
|
// 降级:使用默认域名
|
||||||
|
response.setWechatScanUrl("https://websopy.websoft.top/wx-scan?token=" + token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成小程序码(通过微信API生成小程序码,返回Base64图片,扫码后直接打开小程序确认页面)
|
||||||
|
try {
|
||||||
|
String miniprogramQrCodeBase64 = generateMiniprogramQrCode(token, tenantId);
|
||||||
|
if (StrUtil.isNotBlank(miniprogramQrCodeBase64)) {
|
||||||
|
response.setMiniprogramQrCode(miniprogramQrCodeBase64);
|
||||||
|
log.info("生成小程序码成功(Base64,长度: {})", miniprogramQrCodeBase64.length());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("生成小程序码失败: {}", e.getMessage(), e);
|
||||||
|
// 生成失败不影响主流程,继续使用H5方式
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成小程序码(用于PC端扫码登录)
|
||||||
|
* 调用微信API生成无限制小程序码,返回Base64图片,扫码后直接打开小程序确认页面
|
||||||
|
* 具备自动重试机制:首次失败后清理缓存并重试一次
|
||||||
|
*
|
||||||
|
* @param token 扫码登录token
|
||||||
|
* @param tenantId 租户ID
|
||||||
|
* @return 小程序码图片Base64字符串
|
||||||
|
*/
|
||||||
|
private String generateMiniprogramQrCode(String token, Integer tenantId) {
|
||||||
|
// 构建 access_token 的 Redis key(与 WxService 保持一致)
|
||||||
|
String accessTokenKey = "WX_ACCESS_TOKEN:" + (tenantId != null ? tenantId : 10048);
|
||||||
|
|
||||||
|
// 第一次尝试生成
|
||||||
|
String result = doGenerateMiniprogramQrCode(token, tenantId, accessTokenKey, false);
|
||||||
|
if (result != null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第一次失败,清理缓存并重试(确保下次能拿到最新的 access_token)
|
||||||
|
log.info("小程序码首次生成失败,清理缓存后重试...");
|
||||||
|
clearAccessTokenCache(accessTokenKey, tenantId);
|
||||||
|
|
||||||
|
// 第二次尝试生成(强制刷新 token)
|
||||||
|
return doGenerateMiniprogramQrCode(token, tenantId, accessTokenKey, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行小程序码生成
|
||||||
|
*
|
||||||
|
* @param token 扫码登录token
|
||||||
|
* @param tenantId 租户ID
|
||||||
|
* @param accessTokenKey access_token 的 Redis key
|
||||||
|
* @param forceRefresh 是否强制刷新 access_token
|
||||||
|
* @return 小程序码 Base64 字符串,失败返回 null
|
||||||
|
*/
|
||||||
|
private String doGenerateMiniprogramQrCode(String token, Integer tenantId, String accessTokenKey, boolean forceRefresh) {
|
||||||
|
try {
|
||||||
|
// 获取小程序access_token
|
||||||
|
String accessToken = forceRefresh
|
||||||
|
? wxService.getAccessTokenForcibly(tenantId) // 强制从微信获取新token
|
||||||
|
: wxService.getAccessToken(tenantId);
|
||||||
|
|
||||||
|
if (StrUtil.isBlank(accessToken)) {
|
||||||
|
log.warn("获取小程序access_token失败,跳过生成小程序码");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用微信API生成小程序码
|
||||||
|
String apiUrl = "https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=" + accessToken;
|
||||||
|
|
||||||
|
HashMap<String, Object> params = new HashMap<>();
|
||||||
|
// scene 必须是字符串,最大 32 字符,直接传 token(32位UUID)刚好满足限制
|
||||||
|
// 小程序端通过 router.params.scene 获取此 token
|
||||||
|
params.put("scene", token);
|
||||||
|
params.put("page", "passport/qr-confirm/index"); // 小程序确认页面路径(子包)
|
||||||
|
params.put("env_version", "release"); // release/trial/develop
|
||||||
|
params.put("width", 280); // 二维码宽度
|
||||||
|
params.put("auto_color", false); // 不自动配置颜色
|
||||||
|
|
||||||
|
// 发送请求并获取二进制响应
|
||||||
|
byte[] imageBytes = HttpRequest.post(apiUrl)
|
||||||
|
.body(JSON.toJSONString(params))
|
||||||
|
.timeout(15000)
|
||||||
|
.execute().bodyBytes();
|
||||||
|
|
||||||
|
// 判断是否返回图片(二进制)或错误(JSON)
|
||||||
|
if (imageBytes == null || imageBytes.length == 0) {
|
||||||
|
log.error("生成小程序码API返回空数据");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否返回JSON错误(微信API错误时会返回JSON)
|
||||||
|
if (imageBytes.length < 100 && new String(imageBytes).startsWith("{")) {
|
||||||
|
JSONObject errorResult = JSON.parseObject(new String(imageBytes));
|
||||||
|
Integer errCode = errorResult.getInteger("errcode");
|
||||||
|
String errMsg = errorResult.getString("errmsg");
|
||||||
|
|
||||||
|
log.error("生成小程序码API返回错误[{}:{}]", errCode, errMsg);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将图片字节数组转换为Base64字符串
|
||||||
|
String base64Image = cn.hutool.core.codec.Base64.encode(imageBytes);
|
||||||
|
// 添加Data URI前缀,使前端可以直接使用
|
||||||
|
return "data:image/png;base64," + base64Image;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("生成小程序码异常: {}", e.getMessage(), e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否是 token 相关的错误码,需要清理缓存
|
||||||
|
* 常见微信 API 错误码:
|
||||||
|
* - 40001: 获取access_token时AppSecret错误
|
||||||
|
* - 40013: appid无效
|
||||||
|
* - 40125: appsecret无效
|
||||||
|
* - 42001: access_token超时
|
||||||
|
* - 42002: refresh_token超时
|
||||||
|
* - 42003: code超时
|
||||||
|
* - 44002: post body太长
|
||||||
|
* - 44003: 图片太大
|
||||||
|
* - 41002: appid不正确
|
||||||
|
* - 41008: 缺少access_token参数
|
||||||
|
*/
|
||||||
|
private boolean isTokenRelatedError(Integer errCode, String errMsg) {
|
||||||
|
if (errCode == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// token 相关错误码
|
||||||
|
return errCode == 40001 // AppSecret错误
|
||||||
|
|| errCode == 40013 // appid无效
|
||||||
|
|| errCode == 40125 // appsecret无效
|
||||||
|
|| errCode == 42001 // access_token超时
|
||||||
|
|| errCode == 42002 // refresh_token超时
|
||||||
|
|| errCode == 42003 // code超时
|
||||||
|
|| errCode == 41002 // appid不正确
|
||||||
|
|| errCode == 41008 // 缺少access_token参数
|
||||||
|
|| errCode == 40014 // 不合法的access_token
|
||||||
|
|| errCode == 40097; // invalid page
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理 access_token 缓存
|
||||||
|
*/
|
||||||
|
private void clearAccessTokenCache(String accessTokenKey, Integer tenantId) {
|
||||||
|
try {
|
||||||
|
redisUtil.delete(accessTokenKey);
|
||||||
|
log.info("清理微信access_token缓存[{}], tenantId={}", accessTokenKey, tenantId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("清理access_token缓存失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public QrLoginStatusResponse checkQrLoginStatus(String token) {
|
public QrLoginStatusResponse checkQrLoginStatus(String token) {
|
||||||
if (StrUtil.isBlank(token)) {
|
if (StrUtil.isBlank(token)) {
|
||||||
return new QrLoginStatusResponse(QR_LOGIN_STATUS_EXPIRED, null, null, 0L, null);
|
return buildExpiredResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
String redisKey = QR_LOGIN_TOKEN_KEY + token;
|
String redisKey = QR_LOGIN_TOKEN_KEY + token;
|
||||||
QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class);
|
QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class);
|
||||||
|
|
||||||
if (qrLoginData == null) {
|
if (qrLoginData == null) {
|
||||||
return new QrLoginStatusResponse(QR_LOGIN_STATUS_EXPIRED, null, null, 0L, null);
|
return buildExpiredResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否过期
|
Date expireAt = parseExpireTime(qrLoginData.getExpireTime());
|
||||||
if (DateUtil.date().after(DateUtil.parseDateTime(qrLoginData.getExpireTime()))) {
|
if (expireAt == null || DateUtil.date().after(expireAt)) {
|
||||||
// 删除过期的token
|
|
||||||
redisUtil.delete(redisKey);
|
redisUtil.delete(redisKey);
|
||||||
return new QrLoginStatusResponse(QR_LOGIN_STATUS_EXPIRED, null, null, 0L, null);
|
return buildExpiredResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算剩余过期时间
|
long expiresIn = calculateExpiresIn(expireAt);
|
||||||
long expiresIn = (DateUtil.parseDateTime(qrLoginData.getExpireTime()).getTime() - DateUtil.date().getTime()) / 1000;
|
|
||||||
|
|
||||||
QrLoginStatusResponse response = new QrLoginStatusResponse();
|
if (QR_LOGIN_STATUS_CONFIRMED.equals(qrLoginData.getStatus())
|
||||||
response.setStatus(qrLoginData.getStatus());
|
&& StrUtil.isBlank(qrLoginData.getAccessToken())
|
||||||
response.setExpiresIn(expiresIn);
|
&& qrLoginData.getUserId() != null) {
|
||||||
response.setTenantId(qrLoginData.getTenantId());
|
try {
|
||||||
|
User user = userService.getAllByUserId(String.valueOf(qrLoginData.getUserId()));
|
||||||
// 如果已确认,返回token和用户信息
|
|
||||||
if (QR_LOGIN_STATUS_CONFIRMED.equals(qrLoginData.getStatus())) {
|
|
||||||
response.setAccessToken(qrLoginData.getAccessToken());
|
|
||||||
// 获取用户信息
|
|
||||||
if (qrLoginData.getUserId() != null) {
|
|
||||||
User user = userService.getAllByUserId("" + qrLoginData.getUserId());
|
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
// 清除敏感信息
|
qrLoginData.setUsername(user.getUsername());
|
||||||
user.setPassword(null);
|
if (StrUtil.isBlank(user.getPhone())) {
|
||||||
response.setUserInfo(user);
|
qrLoginData.setStatus(QR_LOGIN_STATUS_BIND_PHONE);
|
||||||
|
qrLoginData.setNeedBindPhone(true);
|
||||||
|
qrLoginData.setAccessToken(null);
|
||||||
|
qrLoginData.setMessage("请先绑定手机号完成登录");
|
||||||
|
} else {
|
||||||
|
qrLoginData.setStatus(QR_LOGIN_STATUS_CONFIRMED);
|
||||||
|
qrLoginData.setNeedBindPhone(false);
|
||||||
|
qrLoginData.setAccessToken(buildAccessToken(user));
|
||||||
|
qrLoginData.setMessage(StrUtil.blankToDefault(qrLoginData.getMessage(), "登录成功"));
|
||||||
|
}
|
||||||
|
long refreshedTtl = Math.max(expiresIn, 120L);
|
||||||
|
persistQrLoginData(redisKey, qrLoginData, refreshedTtl, true);
|
||||||
|
expiresIn = refreshedTtl;
|
||||||
}
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("补全扫码登录状态失败,token={}", token, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确认后删除token,防止重复使用
|
|
||||||
// redisUtil.delete(redisKey);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return buildStatusResponse(qrLoginData, expiresIn);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -150,47 +313,36 @@ public class QrLoginServiceImpl implements QrLoginService {
|
|||||||
|
|
||||||
String redisKey = QR_LOGIN_TOKEN_KEY + token;
|
String redisKey = QR_LOGIN_TOKEN_KEY + token;
|
||||||
QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class);
|
QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class);
|
||||||
|
|
||||||
if (qrLoginData == null) {
|
if (qrLoginData == null) {
|
||||||
throw new RuntimeException("扫码登录token不存在或已过期");
|
throw new RuntimeException("扫码登录token不存在或已过期");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否过期
|
Date expireAt = parseExpireTime(qrLoginData.getExpireTime());
|
||||||
if (DateUtil.date().after(DateUtil.parseDateTime(qrLoginData.getExpireTime()))) {
|
if (expireAt == null || DateUtil.date().after(expireAt)) {
|
||||||
redisUtil.delete(redisKey);
|
redisUtil.delete(redisKey);
|
||||||
throw new RuntimeException("扫码登录token已过期");
|
throw new RuntimeException("扫码登录token已过期");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取用户信息
|
|
||||||
User user = userService.getAllByUserId(String.valueOf(userId));
|
User user = userService.getAllByUserId(String.valueOf(userId));
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
throw new RuntimeException("用户不存在");
|
throw new RuntimeException("用户不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查用户状态
|
|
||||||
if (user.getStatus() != null && user.getStatus() != 0) {
|
if (user.getStatus() != null && user.getStatus() != 0) {
|
||||||
throw new RuntimeException("用户已被冻结");
|
throw new RuntimeException("用户已被冻结");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成JWT token
|
String accessToken = buildAccessToken(user);
|
||||||
JwtSubject jwtSubject = new JwtSubject(user.getUsername(), user.getTenantId());
|
|
||||||
String accessToken = JwtUtil.buildToken(jwtSubject, configProperties.getTokenExpireTime(), configProperties.getTokenKey());
|
|
||||||
|
|
||||||
// 更新扫码登录数据
|
|
||||||
qrLoginData.setStatus(QR_LOGIN_STATUS_CONFIRMED);
|
qrLoginData.setStatus(QR_LOGIN_STATUS_CONFIRMED);
|
||||||
qrLoginData.setUserId(userId);
|
qrLoginData.setUserId(userId);
|
||||||
qrLoginData.setUsername(user.getUsername());
|
qrLoginData.setUsername(user.getUsername());
|
||||||
qrLoginData.setAccessToken(accessToken);
|
qrLoginData.setAccessToken(accessToken);
|
||||||
qrLoginData.setTenantId(user.getTenantId());
|
qrLoginData.setTenantId(user.getTenantId());
|
||||||
// 更新Redis中的数据
|
qrLoginData.setNeedBindPhone(false);
|
||||||
redisUtil.set(redisKey, qrLoginData, 60L, TimeUnit.SECONDS); // 给前端60秒时间获取token
|
qrLoginData.setMessage("登录成功");
|
||||||
|
persistQrLoginData(redisKey, qrLoginData, 120L, true);
|
||||||
|
|
||||||
log.info("用户 {} 确认扫码登录,token: {}", user.getUsername(), token);
|
log.info("用户 {} 确认扫码登录,token: {}", user.getUsername(), token);
|
||||||
|
return buildStatusResponse(qrLoginData, 120L);
|
||||||
// 清除敏感信息
|
|
||||||
user.setPassword(null);
|
|
||||||
|
|
||||||
return new QrLoginStatusResponse(QR_LOGIN_STATUS_CONFIRMED, accessToken, user, 60L, user.getTenantId());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -201,25 +353,22 @@ public class QrLoginServiceImpl implements QrLoginService {
|
|||||||
|
|
||||||
String redisKey = QR_LOGIN_TOKEN_KEY + token;
|
String redisKey = QR_LOGIN_TOKEN_KEY + token;
|
||||||
QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class);
|
QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class);
|
||||||
|
|
||||||
if (qrLoginData == null) {
|
if (qrLoginData == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否过期
|
Date expireAt = parseExpireTime(qrLoginData.getExpireTime());
|
||||||
if (DateUtil.date().after(DateUtil.parseDateTime(qrLoginData.getExpireTime()))) {
|
if (expireAt == null || DateUtil.date().after(expireAt)) {
|
||||||
redisUtil.delete(redisKey);
|
redisUtil.delete(redisKey);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只有pending状态才能更新为scanned
|
|
||||||
if (QR_LOGIN_STATUS_PENDING.equals(qrLoginData.getStatus())) {
|
if (QR_LOGIN_STATUS_PENDING.equals(qrLoginData.getStatus())) {
|
||||||
qrLoginData.setStatus(QR_LOGIN_STATUS_SCANNED);
|
qrLoginData.setStatus(QR_LOGIN_STATUS_SCANNED);
|
||||||
|
qrLoginData.setMessage("已识别扫码,等待公众号回调");
|
||||||
// 计算剩余过期时间
|
long remainingSeconds = Math.max(1L,
|
||||||
long remainingSeconds = (DateUtil.parseDateTime(qrLoginData.getExpireTime()).getTime() - DateUtil.date().getTime()) / 1000;
|
(expireAt.getTime() - DateUtil.date().getTime()) / 1000);
|
||||||
redisUtil.set(redisKey, qrLoginData, remainingSeconds, TimeUnit.SECONDS);
|
redisUtil.set(redisKey, qrLoginData, remainingSeconds, TimeUnit.SECONDS);
|
||||||
|
|
||||||
log.info("扫码登录token {} 状态更新为已扫码", token);
|
log.info("扫码登录token {} 状态更新为已扫码", token);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -227,67 +376,270 @@ public class QrLoginServiceImpl implements QrLoginService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Override
|
||||||
* 生成微信小程序码
|
public QrLoginStatusResponse bindPhone(QrLoginBindPhoneRequest request) {
|
||||||
*/
|
if (request == null || StrUtil.isBlank(request.getToken()) || StrUtil.isBlank(request.getPhone()) || StrUtil.isBlank(request.getCode())) {
|
||||||
private String generateMiniprogramQrCode(String token, Integer tenantId) {
|
throw new RuntimeException("参数不能为空");
|
||||||
|
}
|
||||||
|
if (!CommonUtil.isValidPhoneNumber(request.getPhone())) {
|
||||||
|
throw new RuntimeException("请输入有效的手机号码");
|
||||||
|
}
|
||||||
|
|
||||||
|
String redisKey = QR_LOGIN_TOKEN_KEY + request.getToken();
|
||||||
|
QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class);
|
||||||
|
if (qrLoginData == null) {
|
||||||
|
throw new RuntimeException("二维码已过期,请刷新后重试");
|
||||||
|
}
|
||||||
|
Date expireAt = parseExpireTime(qrLoginData.getExpireTime());
|
||||||
|
if (expireAt == null || DateUtil.date().after(expireAt)) {
|
||||||
|
redisUtil.delete(redisKey);
|
||||||
|
throw new RuntimeException("二维码已过期,请刷新后重试");
|
||||||
|
}
|
||||||
|
if (!QR_LOGIN_STATUS_BIND_PHONE.equals(qrLoginData.getStatus()) && !Boolean.TRUE.equals(qrLoginData.getNeedBindPhone())) {
|
||||||
|
throw new RuntimeException("当前二维码无需绑定手机号");
|
||||||
|
}
|
||||||
|
if (qrLoginData.getUserId() == null) {
|
||||||
|
throw new RuntimeException("绑定账号不存在,请重新扫码");
|
||||||
|
}
|
||||||
|
|
||||||
|
String codeKey = "code:" + request.getPhone();
|
||||||
|
String smsCode = redisUtil.get(codeKey);
|
||||||
|
String devCode = redisUtil.get(CACHE_KEY_VERIFICATION_CODE_BY_DEV_SMS);
|
||||||
|
if (StrUtil.isBlank(smsCode) && StrUtil.isBlank(devCode)) {
|
||||||
|
throw new RuntimeException("验证码已过期,请重新获取");
|
||||||
|
}
|
||||||
|
if (!StrUtil.equals(request.getCode(), smsCode) && !StrUtil.equals(request.getCode(), devCode)) {
|
||||||
|
throw new RuntimeException("验证码不正确");
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = userService.getAllByUserId(String.valueOf(qrLoginData.getUserId()));
|
||||||
|
if (user == null) {
|
||||||
|
throw new RuntimeException("用户不存在");
|
||||||
|
}
|
||||||
|
if (user.getStatus() != null && user.getStatus() != 0) {
|
||||||
|
throw new RuntimeException("账号已被冻结");
|
||||||
|
}
|
||||||
|
|
||||||
|
User existed = userService.getByPhone(request.getPhone());
|
||||||
|
if (existed != null && !existed.getUserId().equals(user.getUserId())) {
|
||||||
|
throw new RuntimeException("该手机号已绑定其他账号");
|
||||||
|
}
|
||||||
|
|
||||||
|
user.setPhone(request.getPhone());
|
||||||
|
if (StrUtil.isBlank(user.getNickname()) || "微信公众号用户".equals(user.getNickname())) {
|
||||||
|
user.setNickname(DesensitizedUtil.mobilePhone(request.getPhone()));
|
||||||
|
}
|
||||||
|
if (StrUtil.isBlank(user.getUsername()) || user.getUsername().startsWith("wxoff_")) {
|
||||||
|
user.setUsername(request.getPhone());
|
||||||
|
}
|
||||||
|
userService.updateUser(user);
|
||||||
|
redisUtil.delete(codeKey);
|
||||||
|
|
||||||
|
// 绑定手机号成功后,通过MQ异步同步用户数据到 websopy
|
||||||
|
if (syncMessageProducer != null) {
|
||||||
|
User updatedUser = userService.getAllByUserId(String.valueOf(user.getUserId()));
|
||||||
|
if (updatedUser != null) {
|
||||||
|
syncMessageProducer.sendUserSyncMessage("websopy", "UPDATE", updatedUser);
|
||||||
|
log.info("扫码绑定手机号后发送MQ消息同步用户到websopy: userId={}, phone={}", user.getUserId(), user.getPhone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String accessToken = buildAccessToken(user);
|
||||||
|
qrLoginData.setStatus(QR_LOGIN_STATUS_CONFIRMED);
|
||||||
|
qrLoginData.setUserId(user.getUserId());
|
||||||
|
qrLoginData.setUsername(user.getUsername());
|
||||||
|
qrLoginData.setTenantId(user.getTenantId());
|
||||||
|
qrLoginData.setAccessToken(accessToken);
|
||||||
|
qrLoginData.setNeedBindPhone(false);
|
||||||
|
qrLoginData.setMessage("手机号绑定成功,正在登录");
|
||||||
|
persistQrLoginData(redisKey, qrLoginData, 120L, true);
|
||||||
|
|
||||||
|
return buildStatusResponse(qrLoginData, 120L);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildAccessToken(User user) {
|
||||||
|
JwtSubject jwtSubject = new JwtSubject(user.getUsername(), user.getTenantId());
|
||||||
|
return JwtUtil.buildToken(jwtSubject, configProperties.getTokenExpireTime(), configProperties.getTokenKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Date parseExpireTime(String expireTime) {
|
||||||
|
if (StrUtil.isBlank(expireTime)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// 使用公共的 WxService 获取 AccessToken
|
return DateUtil.parseDateTime(expireTime);
|
||||||
String accessToken = wxService.getAccessToken(tenantId);
|
|
||||||
if (StrUtil.isBlank(accessToken)) {
|
|
||||||
throw new RuntimeException("获取微信AccessToken失败");
|
|
||||||
}
|
|
||||||
|
|
||||||
String apiUrl = "https://api.weixin.qq.com/wxa/getwxacode?access_token=" + accessToken;
|
|
||||||
HashMap<String, Object> params = new HashMap<>();
|
|
||||||
params.put("path", "/pages/qr-login?token=" + token);
|
|
||||||
params.put("width", 430); // 二维码宽度,默认430px
|
|
||||||
|
|
||||||
// 调用微信API生成小程序码
|
|
||||||
byte[] qrCodeBytes = HttpRequest.post(apiUrl)
|
|
||||||
.body(JSON.toJSONString(params))
|
|
||||||
.execute().bodyBytes();
|
|
||||||
|
|
||||||
// 保存文件
|
|
||||||
String fileName = "qr-login-" + token + ".png";
|
|
||||||
String uploadPath = getUploadPath();
|
|
||||||
String filePath = uploadPath + "qrcode/" + fileName;
|
|
||||||
|
|
||||||
// 确保目录存在
|
|
||||||
File dir = new File(uploadPath + "qrcode/");
|
|
||||||
if (!dir.exists()) {
|
|
||||||
dir.mkdirs();
|
|
||||||
}
|
|
||||||
|
|
||||||
File file = FileUtil.writeBytes(qrCodeBytes, filePath);
|
|
||||||
if (file != null && file.exists()) {
|
|
||||||
// 返回可访问的URL
|
|
||||||
return configProperties.getFileServer() + "/qrcode/" + fileName;
|
|
||||||
} else {
|
|
||||||
throw new RuntimeException("保存小程序码文件失败");
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("生成微信小程序码失败: {}", e.getMessage(), e);
|
log.warn("扫码登录 expireTime 解析失败: {}", expireTime, e);
|
||||||
throw new RuntimeException("生成微信小程序码失败: " + e.getMessage());
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private long calculateExpiresIn(Date expireAt) {
|
||||||
|
if (expireAt == null) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
return Math.max(0L, (expireAt.getTime() - DateUtil.date().getTime()) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void persistQrLoginData(String redisKey, QrLoginData qrLoginData, long ttlSeconds, boolean refreshExpireTime) {
|
||||||
|
if (refreshExpireTime) {
|
||||||
|
qrLoginData.setExpireTime(DateUtil.formatDateTime(DateUtil.offsetSecond(DateUtil.date(), (int) ttlSeconds)));
|
||||||
|
}
|
||||||
|
redisUtil.set(redisKey, qrLoginData, ttlSeconds, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
private QrLoginStatusResponse buildExpiredResponse() {
|
||||||
* 获取文件上传路径
|
QrLoginStatusResponse response = new QrLoginStatusResponse(QR_LOGIN_STATUS_EXPIRED, null, null, 0L, null);
|
||||||
*/
|
response.setNeedBindPhone(false);
|
||||||
private String getUploadPath() {
|
response.setMessage("二维码已过期,请刷新后重试");
|
||||||
String uploadPath = configProperties.getUploadPath();
|
return response;
|
||||||
if (StrUtil.isBlank(uploadPath)) {
|
}
|
||||||
uploadPath = configProperties.getLocalUploadPath();
|
|
||||||
|
private QrLoginStatusResponse buildStatusResponse(QrLoginData qrLoginData, Long expiresIn) {
|
||||||
|
QrLoginStatusResponse response = new QrLoginStatusResponse();
|
||||||
|
response.setStatus(qrLoginData.getStatus());
|
||||||
|
response.setAccessToken(qrLoginData.getAccessToken());
|
||||||
|
response.setExpiresIn(expiresIn);
|
||||||
|
response.setTenantId(qrLoginData.getTenantId());
|
||||||
|
response.setNeedBindPhone(Boolean.TRUE.equals(qrLoginData.getNeedBindPhone())
|
||||||
|
|| QR_LOGIN_STATUS_BIND_PHONE.equals(qrLoginData.getStatus()));
|
||||||
|
response.setMessage(qrLoginData.getMessage());
|
||||||
|
|
||||||
|
// 设置下一步操作逻辑
|
||||||
|
if (QR_LOGIN_STATUS_BIND_PHONE.equals(qrLoginData.getStatus()) || Boolean.TRUE.equals(qrLoginData.getNeedBindPhone())) {
|
||||||
|
response.setNextAction("bind_phone");
|
||||||
|
response.setRedirectUrl(null);
|
||||||
|
} else if (QR_LOGIN_STATUS_CONFIRMED.equals(qrLoginData.getStatus()) && StrUtil.isNotBlank(qrLoginData.getAccessToken())) {
|
||||||
|
response.setNextAction("redirect");
|
||||||
|
response.setRedirectUrl("/console");
|
||||||
|
response.setSuccessMessage("登录成功,即将跳转到控制台");
|
||||||
|
} else {
|
||||||
|
response.setNextAction("wait");
|
||||||
}
|
}
|
||||||
if (StrUtil.isBlank(uploadPath)) {
|
|
||||||
uploadPath = "/tmp/uploads/";
|
if (qrLoginData.getUserId() != null) {
|
||||||
|
try {
|
||||||
|
User user = userService.getAllByUserId(String.valueOf(qrLoginData.getUserId()));
|
||||||
|
if (user != null) {
|
||||||
|
user.setPassword(null);
|
||||||
|
response.setUserInfo(user);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("构建扫码登录状态响应时查询用户失败,userId={}", qrLoginData.getUserId(), e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!uploadPath.endsWith("/")) {
|
return response;
|
||||||
uploadPath += "/";
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public WechatScanResponse wechatScanConfirm(WechatScanRequest request) {
|
||||||
|
String token = request.getToken();
|
||||||
|
if (StrUtil.isBlank(token)) {
|
||||||
|
return WechatScanResponse.notBound("二维码参数错误");
|
||||||
}
|
}
|
||||||
return uploadPath;
|
|
||||||
|
String redisKey = QR_LOGIN_TOKEN_KEY + token;
|
||||||
|
QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class);
|
||||||
|
if (qrLoginData == null) {
|
||||||
|
return WechatScanResponse.notBound("二维码已过期,请刷新重试");
|
||||||
|
}
|
||||||
|
Date expireAt = parseExpireTime(qrLoginData.getExpireTime());
|
||||||
|
if (expireAt == null || DateUtil.date().after(expireAt)) {
|
||||||
|
redisUtil.delete(redisKey);
|
||||||
|
return WechatScanResponse.notBound("二维码已过期,请刷新重试");
|
||||||
|
}
|
||||||
|
|
||||||
|
String unionId = request.getUnionId();
|
||||||
|
String openId = request.getOpenId();
|
||||||
|
Integer tenantId = qrLoginData.getTenantId();
|
||||||
|
|
||||||
|
if (StrUtil.isBlank(unionId) && StrUtil.isNotBlank(request.getCode())) {
|
||||||
|
try {
|
||||||
|
JSONObject userAccessToken = wxService.getOfficialUserAccessToken(request.getCode(), tenantId);
|
||||||
|
unionId = userAccessToken.getString("unionid");
|
||||||
|
openId = userAccessToken.getString("openid");
|
||||||
|
log.info("通过授权码获取到 unionId: {}, openId: {}", unionId, openId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("通过授权码获取用户信息失败: {}", e.getMessage());
|
||||||
|
return WechatScanResponse.notBound("微信授权失败,请重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StrUtil.isBlank(unionId) && StrUtil.isBlank(openId)) {
|
||||||
|
return WechatScanResponse.notBound("无法获取微信用户信息");
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = null;
|
||||||
|
if (StrUtil.isNotBlank(unionId)) {
|
||||||
|
user = userService.getOne(new LambdaQueryWrapper<User>()
|
||||||
|
.eq(User::getUnionid, unionId)
|
||||||
|
.eq(User::getDeleted, 0)
|
||||||
|
.last("limit 1"));
|
||||||
|
log.info("通过 unionId {} 查找用户: {}", unionId, user != null ? user.getUsername() : "未找到");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user == null && StrUtil.isNotBlank(openId)) {
|
||||||
|
user = userService.getOne(new LambdaQueryWrapper<User>()
|
||||||
|
.eq(User::getOpenid, openId)
|
||||||
|
.eq(User::getDeleted, 0)
|
||||||
|
.last("limit 1"));
|
||||||
|
log.info("通过 openId {} 查找用户: {}", openId, user != null ? user.getUsername() : "未找到");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user == null && (StrUtil.isNotBlank(unionId) || StrUtil.isNotBlank(openId))) {
|
||||||
|
try {
|
||||||
|
LambdaQueryWrapper<UserOauth> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
if (StrUtil.isNotBlank(unionId)) {
|
||||||
|
wrapper.eq(UserOauth::getUnionid, unionId);
|
||||||
|
} else {
|
||||||
|
wrapper.eq(UserOauth::getOauthId, openId);
|
||||||
|
}
|
||||||
|
wrapper.eq(UserOauth::getDeleted, 0);
|
||||||
|
|
||||||
|
UserOauth userOauth = null;
|
||||||
|
if (userOauthService != null) {
|
||||||
|
userOauth = userOauthService.getOne(wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userOauth != null && userOauth.getUserId() != null) {
|
||||||
|
user = userService.getAllByUserId(String.valueOf(userOauth.getUserId()));
|
||||||
|
log.info("通过 UserOauth 查找到用户: {}", user != null ? user.getUsername() : "未找到");
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("通过 UserOauth 查找用户失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user == null) {
|
||||||
|
return WechatScanResponse.notBound("该微信未绑定平台账号,请先在平台注册并绑定微信");
|
||||||
|
}
|
||||||
|
if (user.getStatus() != null && user.getStatus() != 0) {
|
||||||
|
return WechatScanResponse.notBound("账号已被冻结");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StrUtil.isBlank(user.getPhone())) {
|
||||||
|
qrLoginData.setStatus(QR_LOGIN_STATUS_BIND_PHONE);
|
||||||
|
qrLoginData.setUserId(user.getUserId());
|
||||||
|
qrLoginData.setUsername(user.getUsername());
|
||||||
|
qrLoginData.setTenantId(user.getTenantId());
|
||||||
|
qrLoginData.setAccessToken(null);
|
||||||
|
qrLoginData.setNeedBindPhone(true);
|
||||||
|
qrLoginData.setMessage("请先绑定手机号完成登录");
|
||||||
|
persistQrLoginData(redisKey, qrLoginData, 120L, true);
|
||||||
|
return WechatScanResponse.needBind("请先绑定手机号完成登录");
|
||||||
|
}
|
||||||
|
|
||||||
|
String accessToken = buildAccessToken(user);
|
||||||
|
qrLoginData.setStatus(QR_LOGIN_STATUS_CONFIRMED);
|
||||||
|
qrLoginData.setUserId(user.getUserId());
|
||||||
|
qrLoginData.setUsername(user.getUsername());
|
||||||
|
qrLoginData.setAccessToken(accessToken);
|
||||||
|
qrLoginData.setTenantId(user.getTenantId());
|
||||||
|
qrLoginData.setNeedBindPhone(false);
|
||||||
|
qrLoginData.setMessage("登录成功");
|
||||||
|
persistQrLoginData(redisKey, qrLoginData, 120L, true);
|
||||||
|
|
||||||
|
user.setPassword(null);
|
||||||
|
return WechatScanResponse.success(accessToken, user, user.getTenantId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
package com.gxwebsoft.common.core;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 系统常量
|
|
||||||
* Created by WebSoft on 2019-10-29 15:55
|
|
||||||
*/
|
|
||||||
public class Constants {
|
|
||||||
/**
|
|
||||||
* 默认成功码
|
|
||||||
*/
|
|
||||||
public static final int RESULT_OK_CODE = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 默认失败码
|
|
||||||
*/
|
|
||||||
public static final int RESULT_ERROR_CODE = 1;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 默认成功信息
|
|
||||||
*/
|
|
||||||
public static final String RESULT_OK_MSG = "操作成功";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 默认失败信息
|
|
||||||
*/
|
|
||||||
public static final String RESULT_ERROR_MSG = "操作失败";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 无权限错误码
|
|
||||||
*/
|
|
||||||
public static final int UNAUTHORIZED_CODE = 403;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 无权限提示信息
|
|
||||||
*/
|
|
||||||
public static final String UNAUTHORIZED_MSG = "没有访问权限";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 未认证错误码
|
|
||||||
*/
|
|
||||||
public static final int UNAUTHENTICATED_CODE = 401;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 未认证提示信息
|
|
||||||
*/
|
|
||||||
public static final String UNAUTHENTICATED_MSG = "请先登录";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 登录过期错误码
|
|
||||||
*/
|
|
||||||
public static final int TOKEN_EXPIRED_CODE = 401;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 登录过期提示信息
|
|
||||||
*/
|
|
||||||
public static final String TOKEN_EXPIRED_MSG = "登录已过期";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 非法token错误码
|
|
||||||
*/
|
|
||||||
public static final int BAD_CREDENTIALS_CODE = 401;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 非法token提示信息
|
|
||||||
*/
|
|
||||||
public static final String BAD_CREDENTIALS_MSG = "请退出重新登录";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 表示升序的值
|
|
||||||
*/
|
|
||||||
public static final String ORDER_ASC_VALUE = "asc";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 表示降序的值
|
|
||||||
*/
|
|
||||||
public static final String ORDER_DESC_VALUE = "desc";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* token通过header传递的名称
|
|
||||||
*/
|
|
||||||
public static final String TOKEN_HEADER_NAME = "Authorization";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* token通过参数传递的名称
|
|
||||||
*/
|
|
||||||
public static final String TOKEN_PARAM_NAME = "access_token";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* token认证类型
|
|
||||||
*/
|
|
||||||
public static final String TOKEN_TYPE = "Bearer";
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -92,6 +92,21 @@ public class ConfigProperties {
|
|||||||
*/
|
*/
|
||||||
private String serverUrl;
|
private String serverUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* websopy 服务地址(用于同步用户数据)
|
||||||
|
*/
|
||||||
|
private String websopyUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内部服务调用密钥(调用 websopy 等内部接口时使用)
|
||||||
|
*/
|
||||||
|
private String internalKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信扫码H5页面访问地址(用于微信扫码登录跳转)
|
||||||
|
*/
|
||||||
|
private String wechatScanUrl;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 阿里云存储 OSS
|
* 阿里云存储 OSS
|
||||||
* Endpoint
|
* Endpoint
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.gxwebsoft.common.core.config;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MQTT配置属性
|
||||||
|
*
|
||||||
|
* @author 科技小王子
|
||||||
|
* @since 2025-07-02
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "mqtt")
|
||||||
|
public class MqttProperties {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否启用MQTT服务
|
||||||
|
*/
|
||||||
|
private boolean enabled = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MQTT服务器地址
|
||||||
|
*/
|
||||||
|
private String host = "tcp://127.0.0.1:1883";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户名
|
||||||
|
*/
|
||||||
|
private String username = "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 密码
|
||||||
|
*/
|
||||||
|
private String password = "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户端ID前缀
|
||||||
|
*/
|
||||||
|
private String clientIdPrefix = "mqtt_client_";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅主题
|
||||||
|
*/
|
||||||
|
private String topic = "/SW_GPS/#";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QoS等级
|
||||||
|
*/
|
||||||
|
private int qos = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接超时时间(秒)
|
||||||
|
*/
|
||||||
|
private int connectionTimeout = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 心跳间隔(秒)
|
||||||
|
*/
|
||||||
|
private int keepAliveInterval = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否自动重连
|
||||||
|
*/
|
||||||
|
private boolean autoReconnect = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否清除会话
|
||||||
|
*/
|
||||||
|
private boolean cleanSession = false;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
|
|||||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
|
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
|
||||||
import com.gxwebsoft.common.core.utils.RedisUtil;
|
import com.gxwebsoft.common.core.utils.RedisUtil;
|
||||||
|
import com.gxwebsoft.platform.openapi.context.OpenTenantContext;
|
||||||
import com.gxwebsoft.common.system.entity.User;
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
import net.sf.jsqlparser.expression.Expression;
|
import net.sf.jsqlparser.expression.Expression;
|
||||||
import net.sf.jsqlparser.expression.LongValue;
|
import net.sf.jsqlparser.expression.LongValue;
|
||||||
@@ -40,6 +41,12 @@ public class MybatisPlusConfig {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Expression getTenantId() {
|
public Expression getTenantId() {
|
||||||
|
// 0 开放平台链路:租户只来自令牌,优先级最高
|
||||||
|
// 必须放在最前面,否则第三方可以伪造 tenantId 请求头越过数据隔离
|
||||||
|
Integer openTenantId = OpenTenantContext.getTenantId();
|
||||||
|
if (openTenantId != null) {
|
||||||
|
return new LongValue(openTenantId);
|
||||||
|
}
|
||||||
String tenantId;
|
String tenantId;
|
||||||
// 从请求头拿ID
|
// 从请求头拿ID
|
||||||
tenantId = request.getHeader("tenantId");
|
tenantId = request.getHeader("tenantId");
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ public class RedisConstants {
|
|||||||
public static final String SMS_CODE_KEY = "sms";
|
public static final String SMS_CODE_KEY = "sms";
|
||||||
// 验证码过期时间
|
// 验证码过期时间
|
||||||
public static final Long SMS_CODE_TTL = 5L;
|
public static final Long SMS_CODE_TTL = 5L;
|
||||||
|
// 邮箱验证码Key
|
||||||
|
public static final String EMAIL_CODE_KEY = "emailCode";
|
||||||
|
// 邮箱验证码过期时间(分钟)
|
||||||
|
public static final Long EMAIL_CODE_TTL = 5L;
|
||||||
// 微信凭证access-token
|
// 微信凭证access-token
|
||||||
public static final String ACCESS_TOKEN_KEY = "access-token";
|
public static final String ACCESS_TOKEN_KEY = "access-token";
|
||||||
// 空值防止击穿数据库
|
// 空值防止击穿数据库
|
||||||
@@ -33,6 +37,7 @@ public class RedisConstants {
|
|||||||
public static final String QR_LOGIN_STATUS_PENDING = "pending"; // 等待扫码
|
public static final String QR_LOGIN_STATUS_PENDING = "pending"; // 等待扫码
|
||||||
public static final String QR_LOGIN_STATUS_SCANNED = "scanned"; // 已扫码
|
public static final String QR_LOGIN_STATUS_SCANNED = "scanned"; // 已扫码
|
||||||
public static final String QR_LOGIN_STATUS_CONFIRMED = "confirmed"; // 已确认
|
public static final String QR_LOGIN_STATUS_CONFIRMED = "confirmed"; // 已确认
|
||||||
|
public static final String QR_LOGIN_STATUS_BIND_PHONE = "bind_phone"; // 待绑定手机号
|
||||||
public static final String QR_LOGIN_STATUS_EXPIRED = "expired"; // 已过期
|
public static final String QR_LOGIN_STATUS_EXPIRED = "expired"; // 已过期
|
||||||
|
|
||||||
// 哗啦啦key
|
// 哗啦啦key
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.gxwebsoft.common.core.context;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 租户上下文管理器
|
||||||
|
*
|
||||||
|
* 用于在特定场景下临时禁用租户隔离
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
* @since 2025-01-26
|
||||||
|
*/
|
||||||
|
public class TenantContext {
|
||||||
|
|
||||||
|
private static final ThreadLocal<Boolean> IGNORE_TENANT = new ThreadLocal<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置忽略租户隔离
|
||||||
|
*/
|
||||||
|
public static void setIgnoreTenant(boolean ignore) {
|
||||||
|
IGNORE_TENANT.set(ignore);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否忽略租户隔离
|
||||||
|
*/
|
||||||
|
public static boolean isIgnoreTenant() {
|
||||||
|
Boolean ignore = IGNORE_TENANT.get();
|
||||||
|
return ignore != null && ignore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除租户上下文
|
||||||
|
*/
|
||||||
|
public static void clear() {
|
||||||
|
IGNORE_TENANT.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在忽略租户隔离的上下文中执行操作
|
||||||
|
*
|
||||||
|
* @param runnable 要执行的操作
|
||||||
|
*/
|
||||||
|
public static void runIgnoreTenant(Runnable runnable) {
|
||||||
|
boolean originalIgnore = isIgnoreTenant();
|
||||||
|
try {
|
||||||
|
setIgnoreTenant(true);
|
||||||
|
runnable.run();
|
||||||
|
} finally {
|
||||||
|
setIgnoreTenant(originalIgnore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在忽略租户隔离的上下文中执行操作并返回结果
|
||||||
|
*
|
||||||
|
* @param supplier 要执行的操作
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
public static <T> T callIgnoreTenant(java.util.function.Supplier<T> supplier) {
|
||||||
|
boolean originalIgnore = isIgnoreTenant();
|
||||||
|
try {
|
||||||
|
setIgnoreTenant(true);
|
||||||
|
return supplier.get();
|
||||||
|
} finally {
|
||||||
|
setIgnoreTenant(originalIgnore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package com.gxwebsoft.common.core.exception;
|
|
||||||
|
|
||||||
import com.gxwebsoft.common.core.Constants;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 自定义业务异常
|
|
||||||
*
|
|
||||||
* @author WebSoft
|
|
||||||
* @since 2018-02-22 11:29:28
|
|
||||||
*/
|
|
||||||
public class BusinessException extends RuntimeException {
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
private Integer code;
|
|
||||||
|
|
||||||
public BusinessException() {
|
|
||||||
this(Constants.RESULT_ERROR_MSG);
|
|
||||||
}
|
|
||||||
|
|
||||||
public BusinessException(String message) {
|
|
||||||
this(Constants.RESULT_ERROR_CODE, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
public BusinessException(Integer code, String message) {
|
|
||||||
super(message);
|
|
||||||
this.code = code;
|
|
||||||
}
|
|
||||||
|
|
||||||
public BusinessException(Integer code, String message, Throwable cause) {
|
|
||||||
super(message, cause);
|
|
||||||
this.code = code;
|
|
||||||
}
|
|
||||||
|
|
||||||
public BusinessException(Integer code, String message, Throwable cause,
|
|
||||||
boolean enableSuppression, boolean writableStackTrace) {
|
|
||||||
super(message, cause, enableSuppression, writableStackTrace);
|
|
||||||
this.code = code;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Integer getCode() {
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setCode(Integer code) {
|
|
||||||
this.code = code;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -47,6 +47,19 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
@Resource
|
@Resource
|
||||||
private LoginRecordService loginRecordService;
|
private LoginRecordService loginRecordService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放平台接口走独立的安全链与 RS256 验签,不能被本过滤器用内部 HS256 密钥解析。
|
||||||
|
*
|
||||||
|
* <p>否则请求会在这里抛出
|
||||||
|
* {@code UnsupportedJwtException: ... may not be used to verify RS256 signatures}
|
||||||
|
* 并被包装成「请退出重新登录」。</p>
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||||
|
String uri = request.getRequestURI();
|
||||||
|
return uri != null && uri.startsWith("/api/open/");
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||||
throws ServletException, IOException {
|
throws ServletException, IOException {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.gxwebsoft.common.core.security;
|
|||||||
|
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
|
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
@@ -20,6 +21,7 @@ import javax.annotation.Resource;
|
|||||||
* @since 2020-03-23 18:04:52
|
* @since 2020-03-23 18:04:52
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
|
@Order(2)
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@EnableGlobalMethodSecurity(prePostEnabled = true)
|
@EnableGlobalMethodSecurity(prePostEnabled = true)
|
||||||
public class SecurityConfig extends WebSecurityConfigurerAdapter {
|
public class SecurityConfig extends WebSecurityConfigurerAdapter {
|
||||||
@@ -41,11 +43,13 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
|
|||||||
"/api/login",
|
"/api/login",
|
||||||
"/api/qr-login/**",
|
"/api/qr-login/**",
|
||||||
"/api/loginByUserId",
|
"/api/loginByUserId",
|
||||||
|
"/api/loginByEmail",
|
||||||
"/api/register",
|
"/api/register",
|
||||||
"/api/superAdminRegister",
|
"/api/superAdminRegister",
|
||||||
"/api/findAccountByPhone",
|
"/api/findAccountByPhone",
|
||||||
"/api/resetPassword",
|
"/api/resetPassword",
|
||||||
"/api/checkPhoneRegistered",
|
"/api/checkPhoneRegistered",
|
||||||
|
"/api/checkEmailRegistered",
|
||||||
"/api/existence",
|
"/api/existence",
|
||||||
"/api/oss/upload",
|
"/api/oss/upload",
|
||||||
"/druid/**",
|
"/druid/**",
|
||||||
@@ -53,7 +57,13 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
|
|||||||
"/webjars/**",
|
"/webjars/**",
|
||||||
"/hxz/v1/**",
|
"/hxz/v1/**",
|
||||||
"/api/sendSmsCaptcha",
|
"/api/sendSmsCaptcha",
|
||||||
|
"/api/sendSmsCaptchaByAdmin",
|
||||||
|
"/api/sendEmailCaptcha",
|
||||||
|
"/api/verifyEmailCaptcha",
|
||||||
"/api/loginBySms",
|
"/api/loginBySms",
|
||||||
|
"/api/loginBySuperAdminSms",
|
||||||
|
"/api/loginBySelectTenant",
|
||||||
|
"/api/loginByDeveloperSms",
|
||||||
"/api/system/user/regByPhone",
|
"/api/system/user/regByPhone",
|
||||||
"/api/parseToken/*",
|
"/api/parseToken/*",
|
||||||
"/api/login-alipay/*",
|
"/api/login-alipay/*",
|
||||||
@@ -86,7 +96,8 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
|
|||||||
"/api/system/setting/getByKey/**",
|
"/api/system/setting/getByKey/**",
|
||||||
"/api/system/setting/updateByKey/**",
|
"/api/system/setting/updateByKey/**",
|
||||||
"/lvQ4EoivKJ.txt",
|
"/lvQ4EoivKJ.txt",
|
||||||
"/api/wechat-cert-test"
|
"/api/wechat-cert-test",
|
||||||
|
"/MP_verify_joj96VBHPtL9YROj.txt"
|
||||||
)
|
)
|
||||||
.permitAll()
|
.permitAll()
|
||||||
.anyRequest()
|
.anyRequest()
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import java.io.File;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
|
|
||||||
import java.security.cert.CertificateFactory;
|
import java.security.cert.CertificateFactory;
|
||||||
import java.security.cert.X509Certificate;
|
import java.security.cert.X509Certificate;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
@@ -135,7 +134,7 @@ public class CertificateService {
|
|||||||
log.warn("无法获取微信支付证书绝对路径: {}", e.getMessage());
|
log.warn("无法获取微信支付证书绝对路径: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
java.io.File file = new java.io.File(certPath);
|
File file = new File(certPath);
|
||||||
String absolutePath = file.getAbsolutePath();
|
String absolutePath = file.getAbsolutePath();
|
||||||
log.info("微信支付证书路径模式: FILESYSTEM");
|
log.info("微信支付证书路径模式: FILESYSTEM");
|
||||||
log.info("微信支付证书完整绝对路径: {}", absolutePath);
|
log.info("微信支付证书完整绝对路径: {}", absolutePath);
|
||||||
@@ -171,7 +170,7 @@ public class CertificateService {
|
|||||||
log.warn("无法获取支付宝证书绝对路径: {}", e.getMessage());
|
log.warn("无法获取支付宝证书绝对路径: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
java.io.File file = new java.io.File(certPath);
|
File file = new File(certPath);
|
||||||
String absolutePath = file.getAbsolutePath();
|
String absolutePath = file.getAbsolutePath();
|
||||||
log.info("支付宝证书路径模式: FILESYSTEM");
|
log.info("支付宝证书路径模式: FILESYSTEM");
|
||||||
log.info("支付宝证书完整绝对路径: {}", absolutePath);
|
log.info("支付宝证书完整绝对路径: {}", absolutePath);
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package com.gxwebsoft.common.core.service;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.ObjectUtil;
|
||||||
|
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||||
|
import com.gxwebsoft.common.core.utils.RedisUtil;
|
||||||
|
import com.gxwebsoft.common.system.entity.Payment;
|
||||||
|
import com.gxwebsoft.common.system.param.PaymentParam;
|
||||||
|
import com.gxwebsoft.common.system.service.PaymentService;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付配置缓存服务
|
||||||
|
* 统一管理支付配置的缓存读取,支持 Payment:1* 格式
|
||||||
|
*
|
||||||
|
* @author 科技小王子
|
||||||
|
* @since 2025-07-27
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class PaymentCacheService {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RedisUtil redisUtil;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PaymentService paymentService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据支付类型获取支付配置
|
||||||
|
* 优先从 Payment:1{payType} 格式的缓存读取
|
||||||
|
*
|
||||||
|
* @param payType 支付类型 (0=微信支付, 1=支付宝, 2=其他)
|
||||||
|
* @param tenantId 租户ID (用于兜底查询)
|
||||||
|
* @return Payment 支付配置
|
||||||
|
*/
|
||||||
|
public Payment getPaymentConfig(Integer payType, Integer tenantId) {
|
||||||
|
// 1. 优先使用 Payment:1{payType} 格式的缓存键
|
||||||
|
String primaryKey = "Payment:1:" + tenantId;
|
||||||
|
Payment payment = redisUtil.get(primaryKey, Payment.class);
|
||||||
|
|
||||||
|
if (ObjectUtil.isNotEmpty(payment)) {
|
||||||
|
log.debug("从缓存获取支付配置成功: {}", primaryKey);
|
||||||
|
return payment;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 如果 Payment:1* 格式不存在,尝试原有格式
|
||||||
|
String fallbackKey = "Payment:" + payType + ":" + tenantId;
|
||||||
|
payment = redisUtil.get(fallbackKey, Payment.class);
|
||||||
|
|
||||||
|
if (ObjectUtil.isNotEmpty(payment)) {
|
||||||
|
log.debug("从兜底缓存获取支付配置成功: {}", fallbackKey);
|
||||||
|
// 将查询结果缓存到 Payment:1* 格式
|
||||||
|
redisUtil.set(primaryKey, payment);
|
||||||
|
return payment;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 最后从数据库查询
|
||||||
|
log.debug("从数据库查询支付配置, payType: {}, tenantId: {}", payType, tenantId);
|
||||||
|
PaymentParam paymentParam = new PaymentParam();
|
||||||
|
paymentParam.setType(payType);
|
||||||
|
paymentParam.setTenantId(tenantId); // 设置租户ID进行过滤
|
||||||
|
List<Payment> payments = paymentService.listRel(paymentParam);
|
||||||
|
|
||||||
|
if (payments.isEmpty()) {
|
||||||
|
throw new BusinessException("请完成支付配置,支付类型: " + payType);
|
||||||
|
}
|
||||||
|
|
||||||
|
Payment dbPayment = payments.get(0);
|
||||||
|
|
||||||
|
// 清理时间字段,避免序列化问题
|
||||||
|
Payment cachePayment = cleanPaymentForCache(dbPayment);
|
||||||
|
|
||||||
|
// 将查询结果缓存到 Payment:1* 格式
|
||||||
|
redisUtil.set(primaryKey, cachePayment);
|
||||||
|
log.debug("支付配置已缓存到: {}", primaryKey);
|
||||||
|
|
||||||
|
return dbPayment; // 返回原始对象,不影响业务逻辑
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存支付配置
|
||||||
|
* 同时缓存到 Payment:1{payType} 和原有格式
|
||||||
|
*
|
||||||
|
* @param payment 支付配置
|
||||||
|
* @param tenantId 租户ID
|
||||||
|
*/
|
||||||
|
public void cachePaymentConfig(Payment payment, Integer tenantId) {
|
||||||
|
// 缓存到 Payment:1* 格式
|
||||||
|
String primaryKey = "Payment:1" + payment.getCode();
|
||||||
|
redisUtil.set(primaryKey, payment);
|
||||||
|
log.debug("支付配置已缓存到: {}", primaryKey);
|
||||||
|
|
||||||
|
// 兼容原有格式
|
||||||
|
String legacyKey = "Payment:" + payment.getCode() + ":" + tenantId;
|
||||||
|
redisUtil.set(legacyKey, payment);
|
||||||
|
log.debug("支付配置已缓存到兼容格式: {}", legacyKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除支付配置缓存
|
||||||
|
* 同时删除 Payment:1{payType} 和原有格式
|
||||||
|
*
|
||||||
|
* @param paymentCode 支付代码 (可以是String或Integer)
|
||||||
|
* @param tenantId 租户ID
|
||||||
|
*/
|
||||||
|
public void removePaymentConfig(String paymentCode, Integer tenantId) {
|
||||||
|
// 删除 Payment:1* 格式缓存
|
||||||
|
String primaryKey = "Payment:1" + paymentCode;
|
||||||
|
redisUtil.delete(primaryKey);
|
||||||
|
log.debug("已删除支付配置缓存: {}", primaryKey);
|
||||||
|
|
||||||
|
// 删除原有格式缓存
|
||||||
|
String legacyKey = "Payment:" + paymentCode + ":" + tenantId;
|
||||||
|
redisUtil.delete(legacyKey);
|
||||||
|
log.debug("已删除兼容格式缓存: {}", legacyKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取微信支付配置 (payType = 0)
|
||||||
|
*/
|
||||||
|
public Payment getWechatPayConfig(Integer tenantId) {
|
||||||
|
return getPaymentConfig(0, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取支付宝配置 (payType = 1)
|
||||||
|
*/
|
||||||
|
public Payment getAlipayConfig(Integer tenantId) {
|
||||||
|
return getPaymentConfig(1, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理Payment对象用于缓存
|
||||||
|
* 移除可能导致序列化问题的时间字段
|
||||||
|
*/
|
||||||
|
private Payment cleanPaymentForCache(Payment original) {
|
||||||
|
if (original == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Payment cleaned = new Payment();
|
||||||
|
// 复制所有业务相关字段
|
||||||
|
cleaned.setId(original.getId());
|
||||||
|
cleaned.setName(original.getName());
|
||||||
|
cleaned.setType(original.getType());
|
||||||
|
cleaned.setCode(original.getCode());
|
||||||
|
cleaned.setImage(original.getImage());
|
||||||
|
cleaned.setWechatType(original.getWechatType());
|
||||||
|
cleaned.setAppId(original.getAppId());
|
||||||
|
cleaned.setMchId(original.getMchId());
|
||||||
|
cleaned.setApiKey(original.getApiKey());
|
||||||
|
cleaned.setApiclientCert(original.getApiclientCert());
|
||||||
|
cleaned.setApiclientKey(original.getApiclientKey());
|
||||||
|
cleaned.setPubKey(original.getPubKey());
|
||||||
|
cleaned.setPubKeyId(original.getPubKeyId());
|
||||||
|
cleaned.setMerchantSerialNumber(original.getMerchantSerialNumber());
|
||||||
|
cleaned.setNotifyUrl(original.getNotifyUrl());
|
||||||
|
cleaned.setComments(original.getComments());
|
||||||
|
cleaned.setSortNumber(original.getSortNumber());
|
||||||
|
cleaned.setStatus(original.getStatus());
|
||||||
|
cleaned.setDeleted(original.getDeleted());
|
||||||
|
cleaned.setTenantId(original.getTenantId());
|
||||||
|
|
||||||
|
// 不设置时间字段,避免序列化问题
|
||||||
|
// cleaned.setCreateTime(null);
|
||||||
|
// cleaned.setUpdateTime(null);
|
||||||
|
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package com.gxwebsoft.common.core.utils;
|
||||||
|
import cn.hutool.core.codec.Base64;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.crypto.Mac;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.io.*;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.net.URLConnection;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class AliYunSender {
|
||||||
|
/*
|
||||||
|
* 计算MD5+BASE64
|
||||||
|
*/
|
||||||
|
public static String MD5Base64(String s) {
|
||||||
|
if (s == null)
|
||||||
|
return null;
|
||||||
|
String encodeStr = "";
|
||||||
|
byte[] utfBytes = s.getBytes();
|
||||||
|
MessageDigest mdTemp;
|
||||||
|
try {
|
||||||
|
mdTemp = MessageDigest.getInstance("MD5");
|
||||||
|
mdTemp.update(utfBytes);
|
||||||
|
byte[] md5Bytes = mdTemp.digest();
|
||||||
|
encodeStr = Base64.encode(md5Bytes);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new Error("Failed to generate MD5 : " + e.getMessage());
|
||||||
|
}
|
||||||
|
return encodeStr;
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* 计算 HMAC-SHA1
|
||||||
|
*/
|
||||||
|
public static String HMACSha1(String data, String key) {
|
||||||
|
String result;
|
||||||
|
try {
|
||||||
|
SecretKeySpec signingKey = new SecretKeySpec(key.getBytes(), "HmacSHA1");
|
||||||
|
Mac mac = Mac.getInstance("HmacSHA1");
|
||||||
|
mac.init(signingKey);
|
||||||
|
byte[] rawHmac = mac.doFinal(data.getBytes());
|
||||||
|
result = Base64.encode(rawHmac);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new Error("Failed to generate HMAC : " + e.getMessage());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* 获取时间
|
||||||
|
*/
|
||||||
|
public static String toGMTString(Date date) {
|
||||||
|
SimpleDateFormat df = new SimpleDateFormat("E, dd MMM yyyy HH:mm:ss z", Locale.UK);
|
||||||
|
df.setTimeZone(new java.util.SimpleTimeZone(0, "GMT"));
|
||||||
|
return df.format(date);
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* 发送POST请求
|
||||||
|
*/
|
||||||
|
public static String sendPost(String url, String body, String ak_id, String ak_secret) {
|
||||||
|
PrintWriter out = null;
|
||||||
|
BufferedReader in = null;
|
||||||
|
String result = "";
|
||||||
|
try {
|
||||||
|
URL realUrl = new URL(url);
|
||||||
|
/*
|
||||||
|
* http header 参数
|
||||||
|
*/
|
||||||
|
String method = "POST";
|
||||||
|
String accept = "application/json";
|
||||||
|
String content_type = "application/json;chrset=utf-8";
|
||||||
|
String path = realUrl.getFile();
|
||||||
|
String date = toGMTString(new Date());
|
||||||
|
String host = realUrl.getHost();
|
||||||
|
// 1.对body做MD5+BASE64加密
|
||||||
|
String bodyMd5 = MD5Base64(body);
|
||||||
|
String uuid = UUID.randomUUID().toString();
|
||||||
|
String stringToSign = method + "\n" + accept + "\n" + bodyMd5 + "\n" + content_type + "\n" + date + "\n"
|
||||||
|
+ "x-acs-signature-method:HMAC-SHA1\n"
|
||||||
|
+ "x-acs-signature-nonce:" + uuid + "\n"
|
||||||
|
+ "x-acs-version:2019-01-02\n"
|
||||||
|
+ path;
|
||||||
|
// 2.计算 HMAC-SHA1
|
||||||
|
String signature = HMACSha1(stringToSign, ak_secret);
|
||||||
|
// 3.得到 authorization header
|
||||||
|
String authHeader = "acs " + ak_id + ":" + signature;
|
||||||
|
// 打开和URL之间的连接
|
||||||
|
URLConnection conn = realUrl.openConnection();
|
||||||
|
// 设置通用的请求属性
|
||||||
|
conn.setRequestProperty("Accept", accept);
|
||||||
|
conn.setRequestProperty("Content-Type", content_type);
|
||||||
|
conn.setRequestProperty("Content-MD5", bodyMd5);
|
||||||
|
conn.setRequestProperty("Date", date);
|
||||||
|
conn.setRequestProperty("Host", host);
|
||||||
|
conn.setRequestProperty("Authorization", authHeader);
|
||||||
|
conn.setRequestProperty("x-acs-signature-nonce", uuid);
|
||||||
|
conn.setRequestProperty("x-acs-signature-method", "HMAC-SHA1");
|
||||||
|
conn.setRequestProperty("x-acs-version", "2019-01-02"); // 版本可选
|
||||||
|
// 发送POST请求必须设置如下两行
|
||||||
|
conn.setDoOutput(true);
|
||||||
|
conn.setDoInput(true);
|
||||||
|
// 获取URLConnection对象对应的输出流
|
||||||
|
out = new PrintWriter(conn.getOutputStream());
|
||||||
|
// 发送请求参数
|
||||||
|
out.print(body);
|
||||||
|
// flush输出流的缓冲
|
||||||
|
out.flush();
|
||||||
|
// 定义BufferedReader输入流来读取URL的响应
|
||||||
|
InputStream is;
|
||||||
|
HttpURLConnection httpconn = (HttpURLConnection) conn;
|
||||||
|
if (httpconn.getResponseCode() == 200) {
|
||||||
|
is = httpconn.getInputStream();
|
||||||
|
} else {
|
||||||
|
is = httpconn.getErrorStream();
|
||||||
|
}
|
||||||
|
in = new BufferedReader(new InputStreamReader(is));
|
||||||
|
String line;
|
||||||
|
while ((line = in.readLine()) != null) {
|
||||||
|
result += line;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println("发送 POST 请求出现异常!" + e);
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
// 使用finally块来关闭输出流、输入流
|
||||||
|
finally {
|
||||||
|
try {
|
||||||
|
if (out != null) {
|
||||||
|
out.close();
|
||||||
|
}
|
||||||
|
if (in != null) {
|
||||||
|
in.close();
|
||||||
|
}
|
||||||
|
} catch (IOException ex) {
|
||||||
|
ex.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package com.gxwebsoft.common.core.utils;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.core.config.CertificateProperties;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import javax.annotation.PostConstruct;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 证书加载工具类
|
||||||
|
* 支持多种证书加载方式,适配Docker容器化部署
|
||||||
|
*
|
||||||
|
* @author 科技小王子
|
||||||
|
* @since 2025-01-26
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class CertificateLoader {
|
||||||
|
|
||||||
|
private final CertificateProperties certConfig;
|
||||||
|
|
||||||
|
public CertificateLoader(CertificateProperties certConfig) {
|
||||||
|
this.certConfig = certConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
log.info("证书加载器初始化,加载模式:{}", certConfig.getLoadMode());
|
||||||
|
if (certConfig.getLoadMode() == CertificateProperties.LoadMode.VOLUME) {
|
||||||
|
log.info("Docker挂载卷证书路径:{}", certConfig.getCertRootPath());
|
||||||
|
validateCertDirectory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证证书目录是否存在
|
||||||
|
*/
|
||||||
|
private void validateCertDirectory() {
|
||||||
|
File certDir = new File(certConfig.getCertRootPath());
|
||||||
|
if (!certDir.exists()) {
|
||||||
|
log.warn("证书目录不存在:{},将尝试创建", certConfig.getCertRootPath());
|
||||||
|
if (!certDir.mkdirs()) {
|
||||||
|
log.error("无法创建证书目录:{}", certConfig.getCertRootPath());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.info("证书目录验证成功:{}", certConfig.getCertRootPath());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载证书文件路径
|
||||||
|
*
|
||||||
|
* @param certPath 证书路径(可能是相对路径、绝对路径或classpath路径)
|
||||||
|
* @return 实际的证书文件路径
|
||||||
|
*/
|
||||||
|
public String loadCertificatePath(String certPath) {
|
||||||
|
if (!StringUtils.hasText(certPath)) {
|
||||||
|
throw new IllegalArgumentException("证书路径不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (certConfig.getLoadMode()) {
|
||||||
|
case CLASSPATH:
|
||||||
|
return loadFromClasspath(certPath);
|
||||||
|
case VOLUME:
|
||||||
|
return loadFromVolume(certPath);
|
||||||
|
case FILESYSTEM:
|
||||||
|
default:
|
||||||
|
return loadFromFileSystem(certPath);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("加载证书失败,路径:{}", certPath, e);
|
||||||
|
throw new RuntimeException("证书加载失败:" + certPath, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从classpath加载证书
|
||||||
|
*/
|
||||||
|
private String loadFromClasspath(String certPath) throws IOException {
|
||||||
|
String resourcePath = certPath.startsWith("classpath:") ?
|
||||||
|
certPath.substring("classpath:".length()) : certPath;
|
||||||
|
|
||||||
|
ClassPathResource resource = new ClassPathResource(resourcePath);
|
||||||
|
if (!resource.exists()) {
|
||||||
|
throw new IOException("Classpath中找不到证书文件:" + resourcePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将classpath中的文件复制到临时目录
|
||||||
|
Path tempFile = Files.createTempFile("cert_", ".pem");
|
||||||
|
try (InputStream inputStream = resource.getInputStream()) {
|
||||||
|
Files.copy(inputStream, tempFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
|
||||||
|
String tempPath = tempFile.toAbsolutePath().toString();
|
||||||
|
log.debug("从classpath加载证书:{} -> {}", resourcePath, tempPath);
|
||||||
|
return tempPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从Docker挂载卷加载证书
|
||||||
|
*/
|
||||||
|
private String loadFromVolume(String certPath) {
|
||||||
|
log.debug("尝试从Docker挂载卷加载证书:{}", certPath);
|
||||||
|
|
||||||
|
// 如果是完整路径,直接使用
|
||||||
|
if (certPath.startsWith("/") || certPath.contains(":")) {
|
||||||
|
File file = new File(certPath);
|
||||||
|
log.debug("检查完整路径文件是否存在:{}", certPath);
|
||||||
|
if (file.exists()) {
|
||||||
|
log.debug("使用完整路径加载证书:{}", certPath);
|
||||||
|
return certPath;
|
||||||
|
} else {
|
||||||
|
log.error("完整路径文件不存在:{}", certPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则拼接挂载卷路径
|
||||||
|
String fullPath = Paths.get(certConfig.getCertRootPath(), certPath).toString();
|
||||||
|
File file = new File(fullPath);
|
||||||
|
if (!file.exists()) {
|
||||||
|
throw new RuntimeException("Docker挂载卷中找不到证书文件:" + fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("从Docker挂载卷加载证书:{}", fullPath);
|
||||||
|
return fullPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文件系统加载证书
|
||||||
|
*/
|
||||||
|
private String loadFromFileSystem(String certPath) {
|
||||||
|
File file = new File(certPath);
|
||||||
|
if (!file.exists()) {
|
||||||
|
throw new RuntimeException("文件系统中找不到证书文件:" + certPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("从文件系统加载证书:{}", certPath);
|
||||||
|
return certPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查证书文件是否存在
|
||||||
|
*
|
||||||
|
* @param certPath 证书路径
|
||||||
|
* @return 是否存在
|
||||||
|
*/
|
||||||
|
public boolean certificateExists(String certPath) {
|
||||||
|
try {
|
||||||
|
switch (certConfig.getLoadMode()) {
|
||||||
|
case CLASSPATH:
|
||||||
|
String resourcePath = certPath.startsWith("classpath:") ?
|
||||||
|
certPath.substring("classpath:".length()) : certPath;
|
||||||
|
ClassPathResource resource = new ClassPathResource(resourcePath);
|
||||||
|
return resource.exists();
|
||||||
|
case VOLUME:
|
||||||
|
String fullPath = certPath.startsWith("/") ? certPath :
|
||||||
|
Paths.get(certConfig.getCertRootPath(), certPath).toString();
|
||||||
|
return new File(fullPath).exists();
|
||||||
|
case FILESYSTEM:
|
||||||
|
default:
|
||||||
|
return new File(certPath).exists();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("检查证书文件存在性时出错:{}", certPath, e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取证书文件的输入流
|
||||||
|
*
|
||||||
|
* @param certPath 证书路径
|
||||||
|
* @return 输入流
|
||||||
|
*/
|
||||||
|
public InputStream getCertificateInputStream(String certPath) throws IOException {
|
||||||
|
switch (certConfig.getLoadMode()) {
|
||||||
|
case CLASSPATH:
|
||||||
|
String resourcePath = certPath.startsWith("classpath:") ?
|
||||||
|
certPath.substring("classpath:".length()) : certPath;
|
||||||
|
ClassPathResource resource = new ClassPathResource(resourcePath);
|
||||||
|
return resource.getInputStream();
|
||||||
|
case VOLUME:
|
||||||
|
case FILESYSTEM:
|
||||||
|
default:
|
||||||
|
String actualPath = loadCertificatePath(certPath);
|
||||||
|
return Files.newInputStream(Paths.get(actualPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出证书目录中的所有文件
|
||||||
|
*
|
||||||
|
* @return 证书文件列表
|
||||||
|
*/
|
||||||
|
public String[] listCertificateFiles() {
|
||||||
|
try {
|
||||||
|
switch (certConfig.getLoadMode()) {
|
||||||
|
case VOLUME:
|
||||||
|
File certDir = new File(certConfig.getCertRootPath());
|
||||||
|
if (certDir.exists() && certDir.isDirectory()) {
|
||||||
|
return certDir.list();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case CLASSPATH:
|
||||||
|
// classpath模式下不支持列出文件
|
||||||
|
log.warn("Classpath模式下不支持列出证书文件");
|
||||||
|
break;
|
||||||
|
case FILESYSTEM:
|
||||||
|
default:
|
||||||
|
// 文件系统模式下证书可能分散在不同目录,不支持统一列出
|
||||||
|
log.warn("文件系统模式下不支持列出证书文件");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("列出证书文件时出错", e);
|
||||||
|
}
|
||||||
|
return new String[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import cn.hutool.core.util.ObjectUtil;
|
|||||||
import cn.hutool.core.util.RandomUtil;
|
import cn.hutool.core.util.RandomUtil;
|
||||||
import com.gxwebsoft.common.core.Constants;
|
import com.gxwebsoft.common.core.Constants;
|
||||||
import com.gxwebsoft.common.core.web.ApiResult;
|
import com.gxwebsoft.common.core.web.ApiResult;
|
||||||
|
import com.gxwebsoft.common.core.utils.JSONUtil;
|
||||||
import com.gxwebsoft.common.system.entity.Role;
|
import com.gxwebsoft.common.system.entity.Role;
|
||||||
|
|
||||||
import javax.servlet.http.HttpServletResponse;
|
import javax.servlet.http.HttpServletResponse;
|
||||||
@@ -289,4 +290,20 @@ public class CommonUtil {
|
|||||||
return pattern.matcher(phoneNumber).matches();
|
return pattern.matcher(phoneNumber).matches();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验邮箱格式是否有效
|
||||||
|
*
|
||||||
|
* @param email 要验证的邮箱字符串
|
||||||
|
* @return 如果字符串是有效的邮箱地址,则返回true;否则返回false
|
||||||
|
*/
|
||||||
|
public static boolean isValidEmail(String email) {
|
||||||
|
if (email == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 邮箱格式正则:本地部分@域名.顶级域
|
||||||
|
String regex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$";
|
||||||
|
Pattern pattern = Pattern.compile(regex);
|
||||||
|
return pattern.matcher(email).matches();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package com.gxwebsoft.common.core.utils;
|
||||||
|
|
||||||
|
import io.minio.BucketExistsArgs;
|
||||||
|
import io.minio.GetBucketPolicyArgs;
|
||||||
|
import io.minio.MakeBucketArgs;
|
||||||
|
import io.minio.MinioClient;
|
||||||
|
import io.minio.SetBucketPolicyArgs;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MinIO 对象存储工具类
|
||||||
|
*
|
||||||
|
* <p>说明:aliyun-sdk-oss 只实现了阿里云自家的 OSS 签名(OSSV1/V2/V4Signer),
|
||||||
|
* 不含 AWS4-HMAC-SHA256,因此无法对接只接受 AWS4 的 MinIO。
|
||||||
|
* 所有 MinIO 操作统一走官方 minio-java SDK。
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
* @since 2026-08-07
|
||||||
|
*/
|
||||||
|
public class MinioUtil {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MinIO 默认 region。
|
||||||
|
* 必须显式指定,否则 minio-java 会先发 {@code GET /{bucket}?location=} 做 region 自动探测,
|
||||||
|
* 该探测请求在 MinIO + 反向代理场景下容易触发 SignatureDoesNotMatch。
|
||||||
|
*/
|
||||||
|
public static final String DEFAULT_REGION = "us-east-1";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已确保「存在 + 已配置匿名只读策略」的 bucket 缓存(endpoint|bucket)。
|
||||||
|
* 进程内缓存,避免每次上传都多发两次 HTTP 请求。
|
||||||
|
*/
|
||||||
|
private static final Set<String> READY_BUCKETS = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
private MinioUtil() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化 endpoint:去掉结尾斜杠,避免 minio-java 拼接出双斜杠导致签名不匹配
|
||||||
|
*/
|
||||||
|
public static String normalizeEndpoint(String endpoint) {
|
||||||
|
if (endpoint == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String result = endpoint.trim();
|
||||||
|
while (result.endsWith("/")) {
|
||||||
|
result = result.substring(0, result.length() - 1);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 MinioClient(统一带上 region,跳过 getBucketLocation 探测)
|
||||||
|
*
|
||||||
|
* @param endpoint S3 API 地址,如 https://minio.websoft.top
|
||||||
|
* @param accessKeyId AK
|
||||||
|
* @param accessKeySecret SK
|
||||||
|
*/
|
||||||
|
public static MinioClient buildClient(String endpoint, String accessKeyId, String accessKeySecret) {
|
||||||
|
return MinioClient.builder()
|
||||||
|
.endpoint(normalizeEndpoint(endpoint))
|
||||||
|
.credentials(accessKeyId, accessKeySecret)
|
||||||
|
.region(DEFAULT_REGION)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 bucket 的匿名只读策略。
|
||||||
|
* 只放开读取对象,<b>故意不给 s3:ListBucket</b>,避免匿名用户列出桶内全部文件清单。
|
||||||
|
*/
|
||||||
|
public static String buildPublicReadPolicy(String bucketName) {
|
||||||
|
return "{"
|
||||||
|
+ "\"Version\":\"2012-10-17\","
|
||||||
|
+ "\"Statement\":["
|
||||||
|
+ "{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":[\"*\"]},"
|
||||||
|
+ "\"Action\":[\"s3:GetBucketLocation\"],"
|
||||||
|
+ "\"Resource\":[\"arn:aws:s3:::" + bucketName + "\"]},"
|
||||||
|
+ "{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":[\"*\"]},"
|
||||||
|
+ "\"Action\":[\"s3:GetObject\"],"
|
||||||
|
+ "\"Resource\":[\"arn:aws:s3:::" + bucketName + "/*\"]}"
|
||||||
|
+ "]}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保 bucket 可用:
|
||||||
|
* <ol>
|
||||||
|
* <li>bucket 不存在 → 自动创建,并套上匿名只读策略</li>
|
||||||
|
* <li>bucket 已存在但没有任何策略 → 补上匿名只读策略(兼容历史手动建的裸桶)</li>
|
||||||
|
* <li>bucket 已有自定义策略 → 不覆盖,尊重人工配置</li>
|
||||||
|
* </ol>
|
||||||
|
* 结果会缓存在进程内,同一个 endpoint+bucket 只检查一次。
|
||||||
|
*
|
||||||
|
* <p>本方法不会抛异常:即使当前 AK 没有建桶/改策略权限,也只打日志,
|
||||||
|
* 后续的上传动作照常执行(真正失败会由上传自身报错)。
|
||||||
|
*
|
||||||
|
* @return true 表示 bucket 已就绪
|
||||||
|
*/
|
||||||
|
public static boolean ensureBucketReady(MinioClient client, String endpoint, String bucketName) {
|
||||||
|
if (client == null || bucketName == null || bucketName.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String cacheKey = normalizeEndpoint(endpoint) + "|" + bucketName;
|
||||||
|
if (READY_BUCKETS.contains(cacheKey)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
boolean exists = client.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
|
||||||
|
if (!exists) {
|
||||||
|
client.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
|
||||||
|
client.setBucketPolicy(SetBucketPolicyArgs.builder()
|
||||||
|
.bucket(bucketName)
|
||||||
|
.config(buildPublicReadPolicy(bucketName))
|
||||||
|
.build());
|
||||||
|
System.out.println("[MinIO] 自动创建 bucket 并配置匿名只读策略: " + bucketName);
|
||||||
|
} else {
|
||||||
|
// minio-java 在无策略时返回空串而非抛错
|
||||||
|
String policy = client.getBucketPolicy(GetBucketPolicyArgs.builder().bucket(bucketName).build());
|
||||||
|
if (policy == null || policy.trim().isEmpty()) {
|
||||||
|
client.setBucketPolicy(SetBucketPolicyArgs.builder()
|
||||||
|
.bucket(bucketName)
|
||||||
|
.config(buildPublicReadPolicy(bucketName))
|
||||||
|
.build());
|
||||||
|
System.out.println("[MinIO] 已为存量 bucket 补充匿名只读策略: " + bucketName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
READY_BUCKETS.add(cacheKey);
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 不阻断上传:权限不足或网络异常时仅告警
|
||||||
|
System.out.println("[MinIO] ensureBucketReady 失败(不影响上传), bucket=" + bucketName
|
||||||
|
+ ", reason=" + e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动失效缓存(例如后台修改了存储配置后调用)
|
||||||
|
*/
|
||||||
|
public static void evictBucketCache(String endpoint, String bucketName) {
|
||||||
|
READY_BUCKETS.remove(normalizeEndpoint(endpoint) + "|" + bucketName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空全部缓存
|
||||||
|
*/
|
||||||
|
public static void clearBucketCache() {
|
||||||
|
READY_BUCKETS.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,10 @@ import cn.hutool.extra.qrcode.QrConfig;
|
|||||||
|
|
||||||
import javax.imageio.ImageIO;
|
import javax.imageio.ImageIO;
|
||||||
import java.awt.image.BufferedImage;
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
|
import java.util.Base64;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
|
||||||
import static com.gxwebsoft.common.core.constants.QRCodeConstants.*;
|
import static com.gxwebsoft.common.core.constants.QRCodeConstants.*;
|
||||||
@@ -77,4 +79,17 @@ public class MyQrCodeUtil {
|
|||||||
QrCodeUtil.generate(content, config, FileUtil.file(filePath));
|
QrCodeUtil.generate(content, config, FileUtil.file(filePath));
|
||||||
return qrcodeUrl;
|
return qrcodeUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成二维码并返回 base64(data:image/png;base64,...),用于前端直接展示,无需写文件
|
||||||
|
* @param content 二维码内容(如微信支付 code_url)
|
||||||
|
* @return base64 图片字符串
|
||||||
|
*/
|
||||||
|
public static String generateBase64(String content) throws IOException {
|
||||||
|
QrConfig config = new QrConfig(300, 300);
|
||||||
|
config.setMargin(1);
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
QrCodeUtil.generate(content, config, "PNG", out);
|
||||||
|
return "data:image/png;base64," + Base64.getEncoder().encodeToString(out.toByteArray());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import java.util.HashMap;
|
|||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class RequestUtil {
|
public class RequestUtil {
|
||||||
private static final String SERVER_HOST = "https://server.gxwebsoft.com/api";
|
private static final String SERVER_HOST = "https://server.websoft.top/api";
|
||||||
private static final String MODULES_HOST = "https://modules.gxwebsoft.com/api";
|
private static final String MODULES_HOST = "https://modules.gxwebsoft.com/api";
|
||||||
private static String ACCESS_TOKEN;
|
private static String ACCESS_TOKEN;
|
||||||
private static String TENANT_ID;
|
private static String TENANT_ID;
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
package com.gxwebsoft.common.core.utils;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.core.config.CertificateProperties;
|
||||||
|
import com.gxwebsoft.common.system.entity.Payment;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.security.cert.CertificateFactory;
|
||||||
|
import java.security.cert.X509Certificate;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信支付证书诊断工具
|
||||||
|
* 专门用于诊断和解决证书相关问题
|
||||||
|
*
|
||||||
|
* @author 科技小王子
|
||||||
|
* @since 2025-07-29
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class WechatPayCertificateDiagnostic {
|
||||||
|
|
||||||
|
private final CertificateProperties certConfig;
|
||||||
|
private final CertificateLoader certificateLoader;
|
||||||
|
|
||||||
|
public WechatPayCertificateDiagnostic(CertificateProperties certConfig, CertificateLoader certificateLoader) {
|
||||||
|
this.certConfig = certConfig;
|
||||||
|
this.certificateLoader = certificateLoader;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全面诊断微信支付证书配置
|
||||||
|
*
|
||||||
|
* @param payment 支付配置
|
||||||
|
* @param tenantId 租户ID
|
||||||
|
* @param environment 环境(dev/prod)
|
||||||
|
* @return 诊断结果
|
||||||
|
*/
|
||||||
|
public DiagnosticResult diagnoseCertificateConfig(Payment payment, Integer tenantId, String environment) {
|
||||||
|
DiagnosticResult result = new DiagnosticResult();
|
||||||
|
|
||||||
|
log.info("=== 开始微信支付证书诊断 ===");
|
||||||
|
log.info("租户ID: {}, 环境: {}", tenantId, environment);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 检查基本配置
|
||||||
|
checkBasicConfig(payment, result);
|
||||||
|
|
||||||
|
// 2. 检查证书文件
|
||||||
|
checkCertificateFiles(payment, tenantId, environment, result);
|
||||||
|
|
||||||
|
// 3. 检查证书内容
|
||||||
|
validateCertificateContent(payment, tenantId, environment, result);
|
||||||
|
|
||||||
|
// 4. 生成建议
|
||||||
|
generateRecommendations(result);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
result.addError("诊断过程中发生异常: " + e.getMessage());
|
||||||
|
log.error("证书诊断异常", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("=== 证书诊断完成 ===");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查基本配置
|
||||||
|
*/
|
||||||
|
private void checkBasicConfig(Payment payment, DiagnosticResult result) {
|
||||||
|
if (payment == null) {
|
||||||
|
result.addError("支付配置为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment.getMchId() == null || payment.getMchId().trim().isEmpty()) {
|
||||||
|
result.addError("商户号未配置");
|
||||||
|
} else {
|
||||||
|
result.addInfo("商户号: " + payment.getMchId());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment.getAppId() == null || payment.getAppId().trim().isEmpty()) {
|
||||||
|
result.addError("应用ID未配置");
|
||||||
|
} else {
|
||||||
|
result.addInfo("应用ID: " + payment.getAppId());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment.getMerchantSerialNumber() == null || payment.getMerchantSerialNumber().trim().isEmpty()) {
|
||||||
|
result.addError("商户证书序列号未配置");
|
||||||
|
} else {
|
||||||
|
result.addInfo("商户证书序列号: " + payment.getMerchantSerialNumber());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment.getApiKey() == null || payment.getApiKey().trim().isEmpty()) {
|
||||||
|
result.addWarning("数据库中APIv3密钥未配置,将使用配置文件默认值");
|
||||||
|
} else {
|
||||||
|
result.addInfo("APIv3密钥: 已配置(" + payment.getApiKey().length() + "位)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查证书文件
|
||||||
|
*/
|
||||||
|
private void checkCertificateFiles(Payment payment, Integer tenantId, String environment, DiagnosticResult result) {
|
||||||
|
if ("dev".equals(environment)) {
|
||||||
|
// 开发环境证书检查
|
||||||
|
String tenantCertPath = "dev/wechat/" + tenantId;
|
||||||
|
String privateKeyPath = tenantCertPath + "/" + certConfig.getWechatPay().getDev().getPrivateKeyFile();
|
||||||
|
String apiclientCertPath = tenantCertPath + "/" + certConfig.getWechatPay().getDev().getApiclientCertFile();
|
||||||
|
|
||||||
|
// 检查私钥文件
|
||||||
|
if (certificateLoader.certificateExists(privateKeyPath)) {
|
||||||
|
result.addInfo("✅ 私钥文件存在: " + privateKeyPath);
|
||||||
|
try {
|
||||||
|
String privateKeyFile = certificateLoader.loadCertificatePath(privateKeyPath);
|
||||||
|
result.addInfo("私钥文件路径: " + privateKeyFile);
|
||||||
|
} catch (Exception e) {
|
||||||
|
result.addError("私钥文件加载失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.addError("❌ 私钥文件不存在: " + privateKeyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查商户证书文件
|
||||||
|
if (certificateLoader.certificateExists(apiclientCertPath)) {
|
||||||
|
result.addInfo("✅ 商户证书文件存在: " + apiclientCertPath);
|
||||||
|
} else {
|
||||||
|
result.addWarning("⚠️ 商户证书文件不存在: " + apiclientCertPath + " (自动证书配置不需要此文件)");
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// 生产环境证书检查
|
||||||
|
if (payment.getApiclientKey() != null) {
|
||||||
|
result.addInfo("私钥文件配置: " + payment.getApiclientKey());
|
||||||
|
} else {
|
||||||
|
result.addError("生产环境私钥文件路径未配置");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment.getApiclientCert() != null) {
|
||||||
|
result.addInfo("商户证书文件配置: " + payment.getApiclientCert());
|
||||||
|
} else {
|
||||||
|
result.addWarning("生产环境商户证书文件路径未配置 (自动证书配置不需要此文件)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证证书内容
|
||||||
|
*/
|
||||||
|
private void validateCertificateContent(Payment payment, Integer tenantId, String environment, DiagnosticResult result) {
|
||||||
|
try {
|
||||||
|
if ("dev".equals(environment)) {
|
||||||
|
String tenantCertPath = "dev/wechat/" + tenantId;
|
||||||
|
String apiclientCertPath = tenantCertPath + "/" + certConfig.getWechatPay().getDev().getApiclientCertFile();
|
||||||
|
|
||||||
|
if (certificateLoader.certificateExists(apiclientCertPath)) {
|
||||||
|
validateX509Certificate(apiclientCertPath, payment.getMerchantSerialNumber(), result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
result.addWarning("证书内容验证失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证X509证书
|
||||||
|
*/
|
||||||
|
private void validateX509Certificate(String certPath, String expectedSerialNumber, DiagnosticResult result) {
|
||||||
|
try {
|
||||||
|
String actualCertPath = certificateLoader.loadCertificatePath(certPath);
|
||||||
|
|
||||||
|
try (InputStream inputStream = new FileInputStream(new File(actualCertPath))) {
|
||||||
|
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||||
|
X509Certificate cert = (X509Certificate) cf.generateCertificate(inputStream);
|
||||||
|
|
||||||
|
if (cert != null) {
|
||||||
|
String actualSerialNumber = cert.getSerialNumber().toString(16).toUpperCase();
|
||||||
|
result.addInfo("证书序列号: " + actualSerialNumber);
|
||||||
|
result.addInfo("证书有效期: " + cert.getNotBefore() + " 至 " + cert.getNotAfter());
|
||||||
|
result.addInfo("证书主体: " + cert.getSubjectX500Principal().toString());
|
||||||
|
|
||||||
|
// 检查序列号是否匹配
|
||||||
|
if (expectedSerialNumber != null && !expectedSerialNumber.equalsIgnoreCase(actualSerialNumber)) {
|
||||||
|
result.addError("证书序列号不匹配! 配置: " + expectedSerialNumber + ", 实际: " + actualSerialNumber);
|
||||||
|
} else {
|
||||||
|
result.addInfo("✅ 证书序列号匹配");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查证书是否过期
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (now < cert.getNotBefore().getTime()) {
|
||||||
|
result.addError("证书尚未生效");
|
||||||
|
} else if (now > cert.getNotAfter().getTime()) {
|
||||||
|
result.addError("证书已过期");
|
||||||
|
} else {
|
||||||
|
result.addInfo("✅ 证书在有效期内");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.addError("无法解析证书文件");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
result.addError("证书验证失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成建议
|
||||||
|
*/
|
||||||
|
private void generateRecommendations(DiagnosticResult result) {
|
||||||
|
if (result.hasErrors()) {
|
||||||
|
result.addRecommendation("🔧 修复建议:");
|
||||||
|
|
||||||
|
String errorText = result.getErrors();
|
||||||
|
if (errorText.contains("商户号")) {
|
||||||
|
result.addRecommendation("1. 请在支付配置中设置正确的商户号");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorText.contains("序列号")) {
|
||||||
|
result.addRecommendation("2. 请检查商户证书序列号是否正确,可在微信商户平台查看");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorText.contains("证书文件")) {
|
||||||
|
result.addRecommendation("3. 请确保证书文件已正确放置在指定目录");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorText.contains("过期")) {
|
||||||
|
result.addRecommendation("4. 请更新过期的证书文件");
|
||||||
|
}
|
||||||
|
|
||||||
|
result.addRecommendation("5. 建议使用RSAAutoCertificateConfig自动证书配置,可避免手动管理证书");
|
||||||
|
result.addRecommendation("6. 确保在微信商户平台开启API安全功能并申请使用微信支付公钥");
|
||||||
|
} else {
|
||||||
|
result.addRecommendation("✅ 证书配置正常,建议使用自动证书配置以获得最佳体验");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊断结果类
|
||||||
|
*/
|
||||||
|
public static class DiagnosticResult {
|
||||||
|
private final StringBuilder errors = new StringBuilder();
|
||||||
|
private final StringBuilder warnings = new StringBuilder();
|
||||||
|
private final StringBuilder info = new StringBuilder();
|
||||||
|
private final StringBuilder recommendations = new StringBuilder();
|
||||||
|
|
||||||
|
public void addError(String error) {
|
||||||
|
if (errors.length() > 0) errors.append("\n");
|
||||||
|
errors.append(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addWarning(String warning) {
|
||||||
|
if (warnings.length() > 0) warnings.append("\n");
|
||||||
|
warnings.append(warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addInfo(String information) {
|
||||||
|
if (info.length() > 0) info.append("\n");
|
||||||
|
info.append(information);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addRecommendation(String recommendation) {
|
||||||
|
if (recommendations.length() > 0) recommendations.append("\n");
|
||||||
|
recommendations.append(recommendation);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasErrors() {
|
||||||
|
return errors.length() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getErrors() {
|
||||||
|
return errors.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getWarnings() {
|
||||||
|
return warnings.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getInfo() {
|
||||||
|
return info.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRecommendations() {
|
||||||
|
return recommendations.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFullReport() {
|
||||||
|
StringBuilder report = new StringBuilder();
|
||||||
|
report.append("=== 微信支付证书诊断报告 ===\n\n");
|
||||||
|
|
||||||
|
if (info.length() > 0) {
|
||||||
|
report.append("📋 基本信息:\n").append(info).append("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warnings.length() > 0) {
|
||||||
|
report.append("⚠️ 警告:\n").append(warnings).append("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length() > 0) {
|
||||||
|
report.append("❌ 错误:\n").append(errors).append("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recommendations.length() > 0) {
|
||||||
|
report.append("💡 建议:\n").append(recommendations).append("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
report.append("=== 诊断报告结束 ===");
|
||||||
|
return report.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package com.gxwebsoft.common.core.utils;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.core.config.CertificateProperties;
|
||||||
|
import com.gxwebsoft.common.system.entity.Payment;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信支付配置验证工具
|
||||||
|
*
|
||||||
|
* @author 科技小王子
|
||||||
|
* @since 2025-07-27
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class WechatPayConfigValidator {
|
||||||
|
|
||||||
|
private final CertificateProperties certConfig;
|
||||||
|
private final CertificateLoader certificateLoader;
|
||||||
|
|
||||||
|
@Value("${spring.profiles.active}")
|
||||||
|
private String activeProfile;
|
||||||
|
|
||||||
|
public WechatPayConfigValidator(CertificateProperties certConfig, CertificateLoader certificateLoader) {
|
||||||
|
this.certConfig = certConfig;
|
||||||
|
this.certificateLoader = certificateLoader;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证微信支付配置
|
||||||
|
*
|
||||||
|
* @param payment 支付配置
|
||||||
|
* @param tenantId 租户ID
|
||||||
|
* @return 验证结果
|
||||||
|
*/
|
||||||
|
public ValidationResult validateWechatPayConfig(Payment payment, Integer tenantId) {
|
||||||
|
ValidationResult result = new ValidationResult();
|
||||||
|
|
||||||
|
log.info("开始验证微信支付配置 - 租户ID: {}", tenantId);
|
||||||
|
|
||||||
|
// 1. 验证基本配置
|
||||||
|
if (payment == null) {
|
||||||
|
result.addError("支付配置为空");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!StringUtils.hasText(payment.getMchId())) {
|
||||||
|
result.addError("商户号未配置");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!StringUtils.hasText(payment.getAppId())) {
|
||||||
|
result.addError("应用ID未配置");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!StringUtils.hasText(payment.getMerchantSerialNumber())) {
|
||||||
|
result.addError("商户证书序列号未配置");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 验证 APIv3 密钥
|
||||||
|
String apiV3Key = getValidApiV3Key(payment);
|
||||||
|
if (!StringUtils.hasText(apiV3Key)) {
|
||||||
|
result.addError("APIv3密钥未配置");
|
||||||
|
} else {
|
||||||
|
validateApiV3Key(apiV3Key, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 验证证书文件
|
||||||
|
validateCertificateFiles(tenantId, result);
|
||||||
|
|
||||||
|
// 4. 记录验证结果
|
||||||
|
if (result.isValid()) {
|
||||||
|
log.info("✅ 微信支付配置验证通过 - 租户ID: {}", tenantId);
|
||||||
|
} else {
|
||||||
|
log.error("❌ 微信支付配置验证失败 - 租户ID: {}, 错误: {}", tenantId, result.getErrors());
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取有效的 APIv3 密钥
|
||||||
|
* 优先使用数据库配置,如果为空则使用配置文件默认值
|
||||||
|
*/
|
||||||
|
public String getValidApiV3Key(Payment payment) {
|
||||||
|
String apiV3Key = payment.getApiKey();
|
||||||
|
|
||||||
|
if (!StringUtils.hasText(apiV3Key)) {
|
||||||
|
apiV3Key = certConfig.getWechatPay().getDev().getApiV3Key();
|
||||||
|
log.warn("数据库中APIv3密钥为空,使用配置文件默认值");
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiV3Key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 APIv3 密钥格式
|
||||||
|
*/
|
||||||
|
private void validateApiV3Key(String apiV3Key, ValidationResult result) {
|
||||||
|
if (apiV3Key.length() != 32) {
|
||||||
|
result.addError("APIv3密钥长度错误,应为32位,实际为: " + apiV3Key.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiV3Key.matches("^[a-zA-Z0-9]+$")) {
|
||||||
|
result.addError("APIv3密钥格式错误,应仅包含字母和数字");
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("APIv3密钥验证 - 长度: {}, 格式: {}",
|
||||||
|
apiV3Key.length(),
|
||||||
|
apiV3Key.matches("^[a-zA-Z0-9]+$") ? "正确" : "错误");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证证书文件
|
||||||
|
*/
|
||||||
|
private void validateCertificateFiles(Integer tenantId, ValidationResult result) {
|
||||||
|
if ("dev".equals(activeProfile)) {
|
||||||
|
// 开发环境证书验证
|
||||||
|
String tenantCertPath = "dev/wechat/" + tenantId;
|
||||||
|
String privateKeyPath = tenantCertPath + "/" + certConfig.getWechatPay().getDev().getPrivateKeyFile();
|
||||||
|
|
||||||
|
if (!certificateLoader.certificateExists(privateKeyPath)) {
|
||||||
|
result.addError("证书文件不存在: " + privateKeyPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
certificateLoader.loadCertificatePath(privateKeyPath);
|
||||||
|
log.info("✅ 开发环境证书文件验证通过: {}", privateKeyPath);
|
||||||
|
} catch (Exception e) {
|
||||||
|
result.addError("证书文件加载失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 生产环境证书验证 - 跳过文件存在性检查,因为证书路径来自数据库
|
||||||
|
log.info("✅ 生产环境跳过证书文件存在性验证,使用数据库配置的证书路径");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证结果类
|
||||||
|
*/
|
||||||
|
public static class ValidationResult {
|
||||||
|
private boolean valid = true;
|
||||||
|
private StringBuilder errors = new StringBuilder();
|
||||||
|
|
||||||
|
public void addError(String error) {
|
||||||
|
this.valid = false;
|
||||||
|
if (errors.length() > 0) {
|
||||||
|
errors.append("; ");
|
||||||
|
}
|
||||||
|
errors.append(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isValid() {
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getErrors() {
|
||||||
|
return errors.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void logErrors() {
|
||||||
|
if (!valid) {
|
||||||
|
log.error("配置验证失败: {}", errors.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成配置诊断报告
|
||||||
|
*/
|
||||||
|
public String generateDiagnosticReport(Payment payment, Integer tenantId) {
|
||||||
|
StringBuilder report = new StringBuilder();
|
||||||
|
report.append("=== 微信支付配置诊断报告 ===\n");
|
||||||
|
report.append("租户ID: ").append(tenantId).append("\n");
|
||||||
|
|
||||||
|
if (payment != null) {
|
||||||
|
report.append("商户号: ").append(payment.getMchId()).append("\n");
|
||||||
|
report.append("应用ID: ").append(payment.getAppId()).append("\n");
|
||||||
|
report.append("商户证书序列号: ").append(payment.getMerchantSerialNumber()).append("\n");
|
||||||
|
|
||||||
|
String dbApiKey = payment.getApiKey();
|
||||||
|
String configApiKey = certConfig.getWechatPay().getDev().getApiV3Key();
|
||||||
|
|
||||||
|
report.append("数据库APIv3密钥: ").append(dbApiKey != null ? "已配置(" + dbApiKey.length() + "位)" : "未配置").append("\n");
|
||||||
|
report.append("配置文件APIv3密钥: ").append(configApiKey != null ? "已配置(" + configApiKey.length() + "位)" : "未配置").append("\n");
|
||||||
|
|
||||||
|
String finalApiKey = getValidApiV3Key(payment);
|
||||||
|
report.append("最终使用APIv3密钥: ").append(finalApiKey != null ? "已配置(" + finalApiKey.length() + "位)" : "未配置").append("\n");
|
||||||
|
|
||||||
|
} else {
|
||||||
|
report.append("❌ 支付配置为空\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 证书文件检查
|
||||||
|
report.append("当前环境: ").append(activeProfile).append("\n");
|
||||||
|
if ("dev".equals(activeProfile)) {
|
||||||
|
String tenantCertPath = "dev/wechat/" + tenantId;
|
||||||
|
String privateKeyPath = tenantCertPath + "/" + certConfig.getWechatPay().getDev().getPrivateKeyFile();
|
||||||
|
boolean certExists = certificateLoader.certificateExists(privateKeyPath);
|
||||||
|
|
||||||
|
report.append("开发环境证书文件路径: ").append(privateKeyPath).append("\n");
|
||||||
|
report.append("证书文件存在: ").append(certExists ? "是" : "否").append("\n");
|
||||||
|
} else {
|
||||||
|
report.append("生产环境证书路径: 从数据库配置获取\n");
|
||||||
|
if (payment != null) {
|
||||||
|
report.append("私钥文件: ").append(payment.getApiclientKey()).append("\n");
|
||||||
|
report.append("证书文件: ").append(payment.getApiclientCert()).append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidationResult validation = validateWechatPayConfig(payment, tenantId);
|
||||||
|
report.append("配置验证结果: ").append(validation.isValid() ? "通过" : "失败").append("\n");
|
||||||
|
if (!validation.isValid()) {
|
||||||
|
report.append("验证错误: ").append(validation.getErrors()).append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
report.append("=== 诊断报告结束 ===");
|
||||||
|
|
||||||
|
return report.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package com.gxwebsoft.common.core.utils;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.system.entity.Payment;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信支付配置诊断工具
|
||||||
|
* 用于排查微信支付签名验证失败等问题
|
||||||
|
*
|
||||||
|
* @author 科技小王子
|
||||||
|
* @since 2025-07-27
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class WechatPayDiagnostic {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊断微信支付配置
|
||||||
|
*
|
||||||
|
* @param payment 支付配置
|
||||||
|
* @param privateKeyPath 私钥路径
|
||||||
|
* @param environment 环境标识
|
||||||
|
*/
|
||||||
|
public void diagnosePaymentConfig(Payment payment, String privateKeyPath, String environment) {
|
||||||
|
log.info("=== 微信支付配置诊断开始 ===");
|
||||||
|
log.info("环境: {}", environment);
|
||||||
|
|
||||||
|
// 1. 检查支付配置基本信息
|
||||||
|
checkBasicConfig(payment);
|
||||||
|
|
||||||
|
// 2. 检查证书文件
|
||||||
|
checkCertificateFiles(payment, privateKeyPath, environment);
|
||||||
|
|
||||||
|
// 3. 检查配置完整性
|
||||||
|
checkConfigCompleteness(payment);
|
||||||
|
|
||||||
|
log.info("=== 微信支付配置诊断结束 ===");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查基本配置信息
|
||||||
|
*/
|
||||||
|
private void checkBasicConfig(Payment payment) {
|
||||||
|
log.info("--- 基本配置检查 ---");
|
||||||
|
|
||||||
|
if (payment == null) {
|
||||||
|
log.error("❌ 支付配置为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("支付配置ID: {}", payment.getId());
|
||||||
|
log.info("支付方式名称: {}", payment.getName());
|
||||||
|
log.info("支付类型: {}", payment.getType());
|
||||||
|
log.info("支付代码: {}", payment.getCode());
|
||||||
|
log.info("状态: {}", payment.getStatus());
|
||||||
|
|
||||||
|
// 检查关键字段
|
||||||
|
checkField("应用ID", payment.getAppId());
|
||||||
|
checkField("商户号", payment.getMchId());
|
||||||
|
checkField("商户证书序列号", payment.getMerchantSerialNumber());
|
||||||
|
checkField("API密钥", payment.getApiKey(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查证书文件
|
||||||
|
*/
|
||||||
|
private void checkCertificateFiles(Payment payment, String privateKeyPath, String environment) {
|
||||||
|
log.info("--- 证书文件检查 ---");
|
||||||
|
|
||||||
|
// 检查私钥文件
|
||||||
|
if (privateKeyPath != null) {
|
||||||
|
checkFileExists("私钥文件", privateKeyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生产环境检查证书文件
|
||||||
|
if (!"dev".equals(environment)) {
|
||||||
|
if (payment.getApiclientCert() != null) {
|
||||||
|
log.info("商户证书文件配置: {}", payment.getApiclientCert());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment.getPubKey() != null) {
|
||||||
|
log.info("公钥文件配置: {}", payment.getPubKey());
|
||||||
|
log.info("公钥ID: {}", payment.getPubKeyId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查配置完整性
|
||||||
|
*/
|
||||||
|
private void checkConfigCompleteness(Payment payment) {
|
||||||
|
log.info("--- 配置完整性检查 ---");
|
||||||
|
|
||||||
|
boolean isComplete = true;
|
||||||
|
|
||||||
|
if (isEmpty(payment.getMchId())) {
|
||||||
|
log.error("❌ 商户号未配置");
|
||||||
|
isComplete = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmpty(payment.getMerchantSerialNumber())) {
|
||||||
|
log.error("❌ 商户证书序列号未配置");
|
||||||
|
isComplete = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmpty(payment.getApiKey())) {
|
||||||
|
log.error("❌ API密钥未配置");
|
||||||
|
isComplete = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmpty(payment.getAppId())) {
|
||||||
|
log.error("❌ 应用ID未配置");
|
||||||
|
isComplete = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isComplete) {
|
||||||
|
log.info("✅ 配置完整性检查通过");
|
||||||
|
} else {
|
||||||
|
log.error("❌ 配置不完整,请补充缺失的配置项");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查字段是否为空
|
||||||
|
*/
|
||||||
|
private void checkField(String fieldName, String value) {
|
||||||
|
checkField(fieldName, value, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查字段是否为空
|
||||||
|
*/
|
||||||
|
private void checkField(String fieldName, String value, boolean isSensitive) {
|
||||||
|
if (isEmpty(value)) {
|
||||||
|
log.warn("⚠️ {}: 未配置", fieldName);
|
||||||
|
} else {
|
||||||
|
if (isSensitive) {
|
||||||
|
log.info("✅ {}: 已配置(长度:{})", fieldName, value.length());
|
||||||
|
} else {
|
||||||
|
log.info("✅ {}: {}", fieldName, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查文件是否存在
|
||||||
|
*/
|
||||||
|
private void checkFileExists(String fileName, String filePath) {
|
||||||
|
try {
|
||||||
|
File file = new File(filePath);
|
||||||
|
if (file.exists() && file.isFile()) {
|
||||||
|
log.info("✅ {}: 文件存在 - {}", fileName, filePath);
|
||||||
|
log.info(" 文件大小: {} bytes", file.length());
|
||||||
|
|
||||||
|
// 检查文件内容格式
|
||||||
|
if (filePath.endsWith(".pem")) {
|
||||||
|
checkPemFileFormat(fileName, filePath);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.error("❌ {}: 文件不存在 - {}", fileName, filePath);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("❌ {}: 检查文件时出错 - {} ({})", fileName, filePath, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查PEM文件格式
|
||||||
|
*/
|
||||||
|
private void checkPemFileFormat(String fileName, String filePath) {
|
||||||
|
try {
|
||||||
|
String content = Files.readString(Paths.get(filePath));
|
||||||
|
if (content.contains("-----BEGIN") && content.contains("-----END")) {
|
||||||
|
log.info("✅ {}: PEM格式正确", fileName);
|
||||||
|
} else {
|
||||||
|
log.warn("⚠️ {}: PEM格式可能有问题", fileName);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("⚠️ {}: 无法读取文件内容进行格式检查 ({})", fileName, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查字符串是否为空
|
||||||
|
*/
|
||||||
|
private boolean isEmpty(String str) {
|
||||||
|
return str == null || str.trim().isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成诊断报告
|
||||||
|
*/
|
||||||
|
public String generateDiagnosticReport(Payment payment, String environment) {
|
||||||
|
StringBuilder report = new StringBuilder();
|
||||||
|
report.append("🔍 微信支付配置诊断报告\n");
|
||||||
|
report.append("========================\n\n");
|
||||||
|
|
||||||
|
report.append("环境: ").append(environment).append("\n");
|
||||||
|
report.append("租户ID: ").append(payment != null ? payment.getTenantId() : "未知").append("\n");
|
||||||
|
report.append("商户号: ").append(payment != null ? payment.getMchId() : "未配置").append("\n");
|
||||||
|
report.append("应用ID: ").append(payment != null ? payment.getAppId() : "未配置").append("\n\n");
|
||||||
|
|
||||||
|
report.append("🚨 常见问题排查:\n");
|
||||||
|
report.append("1. 商户证书序列号是否正确\n");
|
||||||
|
report.append("2. APIv3密钥是否正确\n");
|
||||||
|
report.append("3. 私钥文件是否正确\n");
|
||||||
|
report.append("4. 微信支付平台证书是否过期\n");
|
||||||
|
report.append("5. 网络连接是否正常\n\n");
|
||||||
|
|
||||||
|
report.append("💡 建议解决方案:\n");
|
||||||
|
report.append("1. 使用自动证书配置(RSAAutoCertificateConfig)\n");
|
||||||
|
report.append("2. 在微信商户平台重新下载证书\n");
|
||||||
|
report.append("3. 检查商户平台API安全设置\n");
|
||||||
|
|
||||||
|
return report.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.gxwebsoft.common.core.utils;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信支付工具类
|
||||||
|
* 处理微信支付API的字段限制和格式要求
|
||||||
|
*
|
||||||
|
* @author 科技小王子
|
||||||
|
* @since 2025-01-11
|
||||||
|
*/
|
||||||
|
public class WechatPayUtils {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信支付description字段的最大字节数限制
|
||||||
|
*/
|
||||||
|
public static final int DESCRIPTION_MAX_BYTES = 127;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信支付attach字段的最大字节数限制
|
||||||
|
*/
|
||||||
|
public static final int ATTACH_MAX_BYTES = 127;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 截断字符串以确保字节数不超过指定限制
|
||||||
|
* 主要用于微信支付API的字段限制处理
|
||||||
|
*
|
||||||
|
* @param text 原始文本
|
||||||
|
* @param maxBytes 最大字节数
|
||||||
|
* @return 截断后的文本,确保UTF-8字符完整性
|
||||||
|
*/
|
||||||
|
public static String truncateToByteLimit(String text, int maxBytes) {
|
||||||
|
if (text == null || text.isEmpty()) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (bytes.length <= maxBytes) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 截断字节数组,但要确保不会截断UTF-8字符的中间
|
||||||
|
int truncateLength = maxBytes;
|
||||||
|
while (truncateLength > 0) {
|
||||||
|
byte[] truncated = new byte[truncateLength];
|
||||||
|
System.arraycopy(bytes, 0, truncated, 0, truncateLength);
|
||||||
|
|
||||||
|
try {
|
||||||
|
String result = new String(truncated, StandardCharsets.UTF_8);
|
||||||
|
// 检查是否有无效字符(被截断的UTF-8字符)
|
||||||
|
if (!result.contains("\uFFFD")) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 继续尝试更短的长度
|
||||||
|
}
|
||||||
|
truncateLength--;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""; // 如果无法安全截断,返回空字符串
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理微信支付商品描述字段
|
||||||
|
* 确保字节数不超过127字节
|
||||||
|
*
|
||||||
|
* @param description 商品描述
|
||||||
|
* @return 处理后的描述,符合微信支付要求
|
||||||
|
*/
|
||||||
|
public static String processDescription(String description) {
|
||||||
|
return truncateToByteLimit(description, DESCRIPTION_MAX_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理微信支付附加数据字段
|
||||||
|
* 确保字节数不超过127字节
|
||||||
|
*
|
||||||
|
* @param attach 附加数据
|
||||||
|
* @return 处理后的附加数据,符合微信支付要求
|
||||||
|
*/
|
||||||
|
public static String processAttach(String attach) {
|
||||||
|
return truncateToByteLimit(attach, ATTACH_MAX_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证字符串是否符合微信支付字段的字节限制
|
||||||
|
*
|
||||||
|
* @param text 待验证的文本
|
||||||
|
* @param maxBytes 最大字节数限制
|
||||||
|
* @return true如果符合限制,false如果超出限制
|
||||||
|
*/
|
||||||
|
public static boolean isWithinByteLimit(String text, int maxBytes) {
|
||||||
|
if (text == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return text.getBytes(StandardCharsets.UTF_8).length <= maxBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取字符串的UTF-8字节数
|
||||||
|
*
|
||||||
|
* @param text 文本
|
||||||
|
* @return 字节数
|
||||||
|
*/
|
||||||
|
public static int getByteLength(String text) {
|
||||||
|
if (text == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return text.getBytes(StandardCharsets.UTF_8).length;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,7 +72,7 @@ public class WxOfficialUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public String getCodeUrl() throws UnsupportedEncodingException {
|
public String getCodeUrl() throws UnsupportedEncodingException {
|
||||||
String encodedReturnUrl = URLEncoder.encode("https://server.gxwebsoft.com/api/open/wx-official/accessToken","UTF-8");
|
String encodedReturnUrl = URLEncoder.encode("https://server.websoft.top/api/open/wx-official/accessToken","UTF-8");
|
||||||
return "https://open.weixin.qq.com/connect/oauth2/authorize?appid="+ this.appId +"&redirect_uri=" + encodedReturnUrl + "&response_type=code&scope=snsapi_userinfo&state="+ this.tenantId +"#wechat_redirect";
|
return "https://open.weixin.qq.com/connect/oauth2/authorize?appid="+ this.appId +"&redirect_uri=" + encodedReturnUrl + "&response_type=code&scope=snsapi_userinfo&state="+ this.tenantId +"#wechat_redirect";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
package com.gxwebsoft.common.core.web;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
|
|
||||||
import java.io.Serializable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 返回结果
|
|
||||||
*
|
|
||||||
* @author WebSoft
|
|
||||||
* @since 2017-06-10 10:10:50
|
|
||||||
*/
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
|
||||||
public class ApiResult<T> implements Serializable {
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
@Schema(description = "状态码")
|
|
||||||
private Integer code;
|
|
||||||
|
|
||||||
@Schema(description = "状态信息")
|
|
||||||
private String message;
|
|
||||||
|
|
||||||
@Schema(description = "返回数据")
|
|
||||||
private T data;
|
|
||||||
|
|
||||||
@Schema(description = "错误信息")
|
|
||||||
private String error;
|
|
||||||
|
|
||||||
public ApiResult() {}
|
|
||||||
|
|
||||||
public ApiResult(Integer code) {
|
|
||||||
this(code, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ApiResult(Integer code, String message) {
|
|
||||||
this(code, message, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ApiResult(Integer code, String message, T data) {
|
|
||||||
this(code, message, data, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ApiResult(Integer code, String message, T data, String error) {
|
|
||||||
setCode(code);
|
|
||||||
setMessage(message);
|
|
||||||
setData(data);
|
|
||||||
setError(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Integer getCode() {
|
|
||||||
return this.code;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ApiResult<T> setCode(Integer code) {
|
|
||||||
this.code = code;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getMessage() {
|
|
||||||
return this.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ApiResult<T> setMessage(String message) {
|
|
||||||
this.message = message;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public T getData() {
|
|
||||||
return this.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ApiResult<T> setData(T data) {
|
|
||||||
this.data = data;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getError() {
|
|
||||||
return this.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ApiResult<T> setError(String error) {
|
|
||||||
this.error = error;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
package com.gxwebsoft.common.core.web;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
|
|
||||||
import java.io.Serializable;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 分页查询返回结果
|
|
||||||
*
|
|
||||||
* @author WebSoft
|
|
||||||
* @since 2017-06-10 10:10:02
|
|
||||||
*/
|
|
||||||
public class PageResult<T> implements Serializable {
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
@Schema(description = "当前页数据")
|
|
||||||
private List<T> list;
|
|
||||||
|
|
||||||
@Schema(description = "总数量")
|
|
||||||
private Long count;
|
|
||||||
|
|
||||||
public PageResult() {
|
|
||||||
}
|
|
||||||
|
|
||||||
public PageResult(List<T> list) {
|
|
||||||
this(list, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public PageResult(List<T> list, Long count) {
|
|
||||||
setList(list);
|
|
||||||
setCount(count);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<T> getList() {
|
|
||||||
return this.list;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setList(List<T> list) {
|
|
||||||
this.list = list;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Long getCount() {
|
|
||||||
return this.count;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setCount(Long count) {
|
|
||||||
this.count = count;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package com.gxwebsoft.common.mq.config;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.amqp.core.DirectExchange;
|
||||||
|
import org.springframework.amqp.core.TopicExchange;
|
||||||
|
import org.springframework.amqp.core.Queue;
|
||||||
|
import org.springframework.amqp.core.QueueBuilder;
|
||||||
|
import org.springframework.amqp.core.Binding;
|
||||||
|
import org.springframework.amqp.core.BindingBuilder;
|
||||||
|
import org.springframework.amqp.core.AcknowledgeMode;
|
||||||
|
import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory;
|
||||||
|
import org.springframework.amqp.rabbit.connection.CachingConnectionFactory;
|
||||||
|
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
|
||||||
|
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||||
|
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
|
||||||
|
import org.springframework.amqp.support.converter.MessageConverter;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RabbitMQ 配置类
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@ConditionalOnProperty(name = "sync.mq.enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
public class RabbitMQConfig {
|
||||||
|
|
||||||
|
// ==================== 常量定义 ====================
|
||||||
|
public static final String SYNC_EXCHANGE = "sync.topic.exchange";
|
||||||
|
public static final String SYNC_QUEUE = "sync.queue";
|
||||||
|
public static final String SYNC_ROUTING_KEY = "sync.message";
|
||||||
|
|
||||||
|
// 死信队列
|
||||||
|
public static final String DLX_EXCHANGE = "sync.dlx.exchange";
|
||||||
|
public static final String DLQ_QUEUE = "sync.dlq";
|
||||||
|
public static final String DLQ_ROUTING_KEY = "sync.dlq";
|
||||||
|
|
||||||
|
@Value("${spring.rabbitmq.host:localhost}")
|
||||||
|
private String host;
|
||||||
|
|
||||||
|
@Value("${spring.rabbitmq.port:5672}")
|
||||||
|
private int port;
|
||||||
|
|
||||||
|
@Value("${spring.rabbitmq.username:guest}")
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@Value("${spring.rabbitmq.password:guest}")
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@Value("${spring.rabbitmq.virtual-host:/}")
|
||||||
|
private String virtualHost;
|
||||||
|
|
||||||
|
// ==================== Connection Factory ====================
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ConnectionFactory connectionFactory() {
|
||||||
|
CachingConnectionFactory connectionFactory = new CachingConnectionFactory();
|
||||||
|
connectionFactory.setHost(host);
|
||||||
|
connectionFactory.setPort(port);
|
||||||
|
connectionFactory.setUsername(username);
|
||||||
|
connectionFactory.setPassword(password);
|
||||||
|
connectionFactory.setVirtualHost(virtualHost);
|
||||||
|
// 开启publisher-confirm确认模式
|
||||||
|
connectionFactory.setPublisherConfirmType(CachingConnectionFactory.ConfirmType.CORRELATED);
|
||||||
|
// 开启publisher-return确认模式
|
||||||
|
connectionFactory.setPublisherReturns(true);
|
||||||
|
return connectionFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Message Converter ====================
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public MessageConverter messageConverter(ObjectMapper objectMapper) {
|
||||||
|
return new Jackson2JsonMessageConverter(objectMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== RabbitTemplate ====================
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory, MessageConverter messageConverter) {
|
||||||
|
RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory);
|
||||||
|
rabbitTemplate.setMessageConverter(messageConverter);
|
||||||
|
// 设置Mandatory为true,才能触发ReturnCallback
|
||||||
|
rabbitTemplate.setMandatory(true);
|
||||||
|
return rabbitTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(
|
||||||
|
ConnectionFactory connectionFactory, MessageConverter messageConverter) {
|
||||||
|
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
|
||||||
|
factory.setConnectionFactory(connectionFactory);
|
||||||
|
factory.setMessageConverter(messageConverter);
|
||||||
|
// 设置并发数
|
||||||
|
factory.setConcurrentConsumers(1);
|
||||||
|
factory.setMaxConcurrentConsumers(5);
|
||||||
|
// 设置手动ack
|
||||||
|
factory.setAcknowledgeMode(AcknowledgeMode.MANUAL);
|
||||||
|
// 预取数量
|
||||||
|
factory.setPrefetchCount(10);
|
||||||
|
return factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 交换机 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户同步 Topic Exchange
|
||||||
|
* 使用 Topic 类型,支持按 targetSystem 路由到不同队列
|
||||||
|
* routing key 格式: user.sync.{targetSystem}
|
||||||
|
* 各子系统可以绑定自己的队列来消费消息
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public TopicExchange syncExchange() {
|
||||||
|
return new TopicExchange(SYNC_EXCHANGE, true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public DirectExchange deadLetterExchange() {
|
||||||
|
return new DirectExchange(DLX_EXCHANGE, true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 队列 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注意:core 系统只负责发送消息,不消费消息
|
||||||
|
* 各子系统(websopy等)需要在自己的系统中配置消费者和队列
|
||||||
|
*
|
||||||
|
* 如果 core 系统也需要消费某些消息,可以在这里添加对应的队列
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Queue deadLetterQueue() {
|
||||||
|
return QueueBuilder.durable(DLQ_QUEUE).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 绑定 ====================
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Binding dlqBinding() {
|
||||||
|
return BindingBuilder.bind(deadLetterQueue())
|
||||||
|
.to(deadLetterExchange())
|
||||||
|
.with(DLQ_ROUTING_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.gxwebsoft.common.mq.message;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一消息实体 - 用于各模块间的数据同步
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class SyncMessage implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息唯一ID
|
||||||
|
*/
|
||||||
|
private String messageId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息类型:USER_SYNC, TENANT_SYNC, etc.
|
||||||
|
*/
|
||||||
|
private String messageType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 事件类型:CREATE, UPDATE, DELETE
|
||||||
|
*/
|
||||||
|
private String eventType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 目标系统标识
|
||||||
|
*/
|
||||||
|
private String targetSystem;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务数据(Map格式,便于序列化)
|
||||||
|
*/
|
||||||
|
private Map<String, Object> data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息重试次数
|
||||||
|
*/
|
||||||
|
private Integer retryCount;
|
||||||
|
|
||||||
|
public SyncMessage() {
|
||||||
|
this.createTime = LocalDateTime.now();
|
||||||
|
this.retryCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SyncMessage(String messageType, String eventType, String targetSystem, Map<String, Object> data) {
|
||||||
|
this();
|
||||||
|
this.messageId = java.util.UUID.randomUUID().toString().replace("-", "");
|
||||||
|
this.messageType = messageType;
|
||||||
|
this.eventType = eventType;
|
||||||
|
this.targetSystem = targetSystem;
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建用户同步消息
|
||||||
|
*/
|
||||||
|
public static SyncMessage userCreate(String targetSystem, Map<String, Object> userData) {
|
||||||
|
return new SyncMessage("USER_SYNC", "CREATE", targetSystem, userData);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SyncMessage userUpdate(String targetSystem, Map<String, Object> userData) {
|
||||||
|
return new SyncMessage("USER_SYNC", "UPDATE", targetSystem, userData);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SyncMessage userDelete(String targetSystem, Map<String, Object> userData) {
|
||||||
|
return new SyncMessage("USER_SYNC", "DELETE", targetSystem, userData);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.gxwebsoft.common.mq.producer;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.mq.message.SyncMessage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息生产者接口 - 预留抽象层,便于将来切换MQ实现(如从RabbitMQ迁移到RocketMQ)
|
||||||
|
*/
|
||||||
|
public interface SyncMessageProducer {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送同步消息
|
||||||
|
*
|
||||||
|
* @param message 消息体
|
||||||
|
*/
|
||||||
|
void sendSyncMessage(SyncMessage message);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送同步消息(带回调)
|
||||||
|
*
|
||||||
|
* @param message 消息体
|
||||||
|
* @param callback 发送回调
|
||||||
|
*/
|
||||||
|
void sendSyncMessage(SyncMessage message, SendCallback callback);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送用户同步消息
|
||||||
|
*
|
||||||
|
* @param targetSystem 目标系统
|
||||||
|
* @param eventType 事件类型:CREATE, UPDATE, DELETE
|
||||||
|
* @param userData 用户数据
|
||||||
|
*/
|
||||||
|
void sendUserSyncMessage(String targetSystem, String eventType, Object userData);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送回调接口
|
||||||
|
*/
|
||||||
|
interface SendCallback {
|
||||||
|
void onSuccess(String messageId);
|
||||||
|
void onFailure(String messageId, Throwable throwable);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package com.gxwebsoft.common.mq.producer.impl;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.gxwebsoft.common.mq.config.RabbitMQConfig;
|
||||||
|
import com.gxwebsoft.common.mq.message.SyncMessage;
|
||||||
|
import com.gxwebsoft.common.mq.producer.SyncMessageProducer;
|
||||||
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.amqp.core.Message;
|
||||||
|
import org.springframework.amqp.core.MessageProperties;
|
||||||
|
import org.springframework.amqp.rabbit.connection.CorrelationData;
|
||||||
|
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||||
|
import org.springframework.amqp.support.converter.MessageConverter;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RabbitMQ 消息生产者实现
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "sync.mq.enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
public class RabbitMQSyncProducer implements SyncMessageProducer, RabbitTemplate.ConfirmCallback, RabbitTemplate.ReturnCallback {
|
||||||
|
|
||||||
|
private final RabbitTemplate rabbitTemplate;
|
||||||
|
private final MessageConverter messageConverter;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public RabbitMQSyncProducer(RabbitTemplate rabbitTemplate, MessageConverter messageConverter, ObjectMapper objectMapper) {
|
||||||
|
this.rabbitTemplate = rabbitTemplate;
|
||||||
|
this.messageConverter = messageConverter;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
// 设置确认回调
|
||||||
|
this.rabbitTemplate.setConfirmCallback(this);
|
||||||
|
this.rabbitTemplate.setReturnCallback(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendSyncMessage(SyncMessage message) {
|
||||||
|
sendSyncMessage(message, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendSyncMessage(SyncMessage message, SendCallback callback) {
|
||||||
|
try {
|
||||||
|
log.info("发送MQ消息: messageId={}, type={}, event={}, target={}",
|
||||||
|
message.getMessageId(), message.getMessageType(),
|
||||||
|
message.getEventType(), message.getTargetSystem());
|
||||||
|
|
||||||
|
CorrelationData correlationData = new CorrelationData(message.getMessageId());
|
||||||
|
|
||||||
|
if (callback != null) {
|
||||||
|
correlationData.getFuture().addCallback(
|
||||||
|
result -> {
|
||||||
|
if (result.isAck()) {
|
||||||
|
callback.onSuccess(message.getMessageId());
|
||||||
|
} else {
|
||||||
|
callback.onFailure(message.getMessageId(),
|
||||||
|
new RuntimeException("消息发送未被确认"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ex -> callback.onFailure(message.getMessageId(), ex)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 targetSystem 作为 routing key
|
||||||
|
// 格式: user.sync.{targetSystem}
|
||||||
|
// 各子系统绑定队列时使用 pattern: user.sync.{systemName}
|
||||||
|
String routingKey = buildRoutingKey(message.getTargetSystem());
|
||||||
|
|
||||||
|
rabbitTemplate.convertAndSend(
|
||||||
|
RabbitMQConfig.SYNC_EXCHANGE,
|
||||||
|
routingKey,
|
||||||
|
message,
|
||||||
|
correlationData
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("发送MQ消息失败: messageId={}, error={}", message.getMessageId(), e.getMessage(), e);
|
||||||
|
if (callback != null) {
|
||||||
|
callback.onFailure(message.getMessageId(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 routing key
|
||||||
|
* 格式: user.sync.{targetSystem}
|
||||||
|
*/
|
||||||
|
private String buildRoutingKey(String targetSystem) {
|
||||||
|
if (targetSystem == null || targetSystem.isEmpty()) {
|
||||||
|
return "user.sync.all";
|
||||||
|
}
|
||||||
|
return "user.sync." + targetSystem.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendUserSyncMessage(String targetSystem, String eventType, Object userData) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> dataMap;
|
||||||
|
if (userData instanceof Map) {
|
||||||
|
dataMap = (Map<String, Object>) userData;
|
||||||
|
} else {
|
||||||
|
// 转换为Map
|
||||||
|
dataMap = objectMapper.convertValue(userData, Map.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// User 实体把 password / payPassword 标记为 WRITE_ONLY(不再出现在任何 HTTP 响应里),
|
||||||
|
// 但用户同步到 websopy 的消息历来包含这两个字段。这里显式补回,保证本次安全修复
|
||||||
|
// 不会悄悄改变对端收到的报文;是否保留由 websopy 侧确认后另行决定。
|
||||||
|
if (userData instanceof User) {
|
||||||
|
User user = (User) userData;
|
||||||
|
if (user.getPassword() != null) {
|
||||||
|
dataMap.put("password", user.getPassword());
|
||||||
|
}
|
||||||
|
if (user.getPayPassword() != null) {
|
||||||
|
dataMap.put("payPassword", user.getPayPassword());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncMessage message = new SyncMessage("USER_SYNC", eventType, targetSystem, dataMap);
|
||||||
|
sendSyncMessage(message);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("发送用户同步消息失败: targetSystem={}, eventType={}, error={}",
|
||||||
|
targetSystem, eventType, e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认回调 - Broker确认收到消息
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void confirm(CorrelationData correlationData, boolean ack, String cause) {
|
||||||
|
String messageId = correlationData.getId();
|
||||||
|
if (ack) {
|
||||||
|
log.debug("消息确认成功: messageId={}", messageId);
|
||||||
|
} else {
|
||||||
|
log.warn("消息确认失败: messageId={}, cause={}", messageId, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return回调 - 消息无法路由时回调
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void returnedMessage(Message message, int replyCode, String replyText,
|
||||||
|
String exchange, String routingKey) {
|
||||||
|
log.error("消息无法路由: exchange={}, routingKey={}, replyCode={}, replyText={}",
|
||||||
|
exchange, routingKey, replyCode, replyText);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import com.aliyun.oss.ClientException;
|
|||||||
import com.aliyun.oss.OSS;
|
import com.aliyun.oss.OSS;
|
||||||
import com.aliyun.oss.OSSClientBuilder;
|
import com.aliyun.oss.OSSClientBuilder;
|
||||||
import com.aliyun.oss.OSSException;
|
import com.aliyun.oss.OSSException;
|
||||||
|
import com.aliyun.oss.ClientBuilderConfiguration;
|
||||||
|
import com.aliyun.oss.common.comm.SignVersion;
|
||||||
import com.aliyun.oss.common.auth.CredentialsProvider;
|
import com.aliyun.oss.common.auth.CredentialsProvider;
|
||||||
import com.aliyun.oss.common.auth.DefaultCredentialProvider;
|
import com.aliyun.oss.common.auth.DefaultCredentialProvider;
|
||||||
import com.aliyun.oss.common.utils.BinaryUtil;
|
import com.aliyun.oss.common.utils.BinaryUtil;
|
||||||
@@ -22,6 +24,7 @@ import com.aliyuncs.profile.IClientProfile;
|
|||||||
import com.gxwebsoft.common.core.annotation.OperationLog;
|
import com.gxwebsoft.common.core.annotation.OperationLog;
|
||||||
import com.gxwebsoft.common.core.config.ConfigProperties;
|
import com.gxwebsoft.common.core.config.ConfigProperties;
|
||||||
import com.gxwebsoft.common.core.utils.FileServerUtil;
|
import com.gxwebsoft.common.core.utils.FileServerUtil;
|
||||||
|
import com.gxwebsoft.common.core.utils.MinioUtil;
|
||||||
import com.gxwebsoft.common.core.utils.RedisUtil;
|
import com.gxwebsoft.common.core.utils.RedisUtil;
|
||||||
import com.gxwebsoft.common.core.web.ApiResult;
|
import com.gxwebsoft.common.core.web.ApiResult;
|
||||||
import com.gxwebsoft.common.core.web.BaseController;
|
import com.gxwebsoft.common.core.web.BaseController;
|
||||||
@@ -35,6 +38,8 @@ 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.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import io.minio.MinioClient;
|
||||||
|
import io.minio.UploadObjectArgs;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
@@ -96,6 +101,10 @@ public class AliOssController extends BaseController {
|
|||||||
String bucketName = settingInfo.getString("bucketName");
|
String bucketName = settingInfo.getString("bucketName");
|
||||||
String accessKeyId = settingInfo.getString("accessKeyId");
|
String accessKeyId = settingInfo.getString("accessKeyId");
|
||||||
String accessKeySecret = settingInfo.getString("accessKeySecret");
|
String accessKeySecret = settingInfo.getString("accessKeySecret");
|
||||||
|
String uploadMethod = settingInfo.getString("uploadMethod");
|
||||||
|
|
||||||
|
// 规范化 endpoint:去掉结尾斜杠,避免 minio-java 拼接出双斜杠导致签名不匹配
|
||||||
|
endpoint = MinioUtil.normalizeEndpoint(endpoint);
|
||||||
|
|
||||||
// 判断是否登录
|
// 判断是否登录
|
||||||
String authorization = getAuthorization();
|
String authorization = getAuthorization();
|
||||||
@@ -112,9 +121,21 @@ public class AliOssController extends BaseController {
|
|||||||
|
|
||||||
// 上传文件结果
|
// 上传文件结果
|
||||||
FileRecord result;
|
FileRecord result;
|
||||||
CredentialsProvider credentialsProvider = new DefaultCredentialProvider(accessKeyId, accessKeySecret);
|
boolean isMinio = "minio".equals(uploadMethod);
|
||||||
// 创建OSSClient实例。
|
// MinIO 用官方 minio-java SDK(原生 AWS4-HMAC-SHA256 + path-style);
|
||||||
OSS ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);
|
// aliyun OSS SDK 无 AWS4 签名器,对接不了 MinIO,因此 minio 分支独立使用 MinioClient。
|
||||||
|
OSS ossClient = null;
|
||||||
|
MinioClient minioClient = null;
|
||||||
|
if (isMinio) {
|
||||||
|
// 显式指定 region=us-east-1,跳过 minio-java 的 getBucketLocation 自动探测
|
||||||
|
// (该探测 GET 在 MinIO + 反代下会触发 SignatureDoesNotMatch)
|
||||||
|
minioClient = MinioUtil.buildClient(endpoint, accessKeyId, accessKeySecret);
|
||||||
|
// bucket 不存在则自动创建,并套上匿名只读策略(存量裸桶也会自动补策略)
|
||||||
|
MinioUtil.ensureBucketReady(minioClient, endpoint, bucketName);
|
||||||
|
} else {
|
||||||
|
CredentialsProvider credentialsProvider = new DefaultCredentialProvider(accessKeyId, accessKeySecret);
|
||||||
|
ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
@@ -123,16 +144,18 @@ public class AliOssController extends BaseController {
|
|||||||
String path = upload.getAbsolutePath().replace("\\", "/").substring(dir.length());
|
String path = upload.getAbsolutePath().replace("\\", "/").substring(dir.length());
|
||||||
String originalName = file.getOriginalFilename();
|
String originalName = file.getOriginalFilename();
|
||||||
|
|
||||||
// 创建PutObjectRequest对象。
|
// 上传文件到对象存储。
|
||||||
PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, path, upload);
|
if (isMinio) {
|
||||||
// 如果需要上传时设置存储类型和访问权限,请参考以下示例代码。
|
// minio-java:path 此时无前导 "/",作为对象名(如 2026/08/07/xxx.png),默认 region=us-east-1
|
||||||
// ObjectMetadata metadata = new ObjectMetadata();
|
minioClient.uploadObject(UploadObjectArgs.builder()
|
||||||
// metadata.setHeader(OSSHeaders.OSS_STORAGE_CLASS, StorageClass.Standard.toString());
|
.bucket(bucketName)
|
||||||
// metadata.setObjectAcl(CannedAccessControlList.Private);
|
.object(path)
|
||||||
// putObjectRequest.setMetadata(metadata);
|
.filename(upload.getAbsolutePath())
|
||||||
|
.build());
|
||||||
// 上传文件。
|
} else {
|
||||||
PutObjectResult ossResult = ossClient.putObject(putObjectRequest);
|
PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, path, upload);
|
||||||
|
ossClient.putObject(putObjectRequest);
|
||||||
|
}
|
||||||
|
|
||||||
// 保存记录并返回
|
// 保存记录并返回
|
||||||
result = new FileRecord();
|
result = new FileRecord();
|
||||||
@@ -155,11 +178,39 @@ public class AliOssController extends BaseController {
|
|||||||
result.setName(StrUtil.isBlank(originalName) ? upload.getName() : originalName);
|
result.setName(StrUtil.isBlank(originalName) ? upload.getName() : originalName);
|
||||||
result.setLength(upload.length());
|
result.setLength(upload.length());
|
||||||
result.setPath(bucketDomain + path);
|
result.setPath(bucketDomain + path);
|
||||||
result.setThumbnail(bucketDomain + path + "?x-oss-process=image/resize,m_fixed,w_100,h_100/quality,Q_90");
|
|
||||||
result.setUrl(bucketDomain + path + "?x-oss-process=image/resize,w_750/quality,Q_90");
|
|
||||||
result.setDownloadUrl(bucketDomain + path);
|
result.setDownloadUrl(bucketDomain + path);
|
||||||
|
|
||||||
|
// 获取文件类型
|
||||||
String contentType = FileServerUtil.getContentType(upload);
|
String contentType = FileServerUtil.getContentType(upload);
|
||||||
result.setContentType(contentType);
|
result.setContentType(contentType);
|
||||||
|
|
||||||
|
// 根据文件类型设置缩略图和预览图
|
||||||
|
// MinIO 不支持阿里云 x-oss-process 图片处理参数,统一返回原图 URL
|
||||||
|
isMinio = "minio".equals(uploadMethod);
|
||||||
|
if (FileServerUtil.isImage(contentType)) {
|
||||||
|
if (isMinio) {
|
||||||
|
result.setThumbnail(bucketDomain + path);
|
||||||
|
result.setUrl(bucketDomain + path);
|
||||||
|
} else {
|
||||||
|
// 图片:生成缩略图和压缩预览图
|
||||||
|
result.setThumbnail(bucketDomain + path + "?x-oss-process=image/resize,m_fixed,w_100,h_100/quality,Q_90");
|
||||||
|
result.setUrl(bucketDomain + path + "?x-oss-process=image/resize,w_750/quality,Q_90");
|
||||||
|
}
|
||||||
|
} else if (contentType != null && contentType.startsWith("video/")) {
|
||||||
|
if (isMinio) {
|
||||||
|
result.setThumbnail(bucketDomain + path);
|
||||||
|
result.setUrl(bucketDomain + path);
|
||||||
|
} else {
|
||||||
|
// 视频:使用OSS视频截帧生成缩略图(取首帧)
|
||||||
|
result.setThumbnail(bucketDomain + path + "?x-oss-process=video/snapshot,t_0,f_jpg,w_100,h_100,m_fast");
|
||||||
|
result.setUrl(bucketDomain + path);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 非图片/视频:直接使用原始URL
|
||||||
|
result.setThumbnail(bucketDomain + path);
|
||||||
|
result.setUrl(bucketDomain + path);
|
||||||
|
}
|
||||||
|
|
||||||
result.setTenantId(Integer.valueOf(tenantId));
|
result.setTenantId(Integer.valueOf(tenantId));
|
||||||
upload.delete();
|
upload.delete();
|
||||||
fileRecordService.save(result);
|
fileRecordService.save(result);
|
||||||
@@ -186,10 +237,15 @@ public class AliOssController extends BaseController {
|
|||||||
+ "a serious internal problem while trying to communicate with OSS, "
|
+ "a serious internal problem while trying to communicate with OSS, "
|
||||||
+ "such as not being able to access the network.");
|
+ "such as not being able to access the network.");
|
||||||
System.out.println("Error Message:" + ce.getMessage());
|
System.out.println("Error Message:" + ce.getMessage());
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 兼容 minio-java 抛出的异常(MinioException / IOException 等)
|
||||||
|
System.out.println("Caught an upload Exception: " + e.getMessage());
|
||||||
|
e.printStackTrace();
|
||||||
} finally {
|
} finally {
|
||||||
if (ossClient != null) {
|
if (ossClient != null) {
|
||||||
ossClient.shutdown();
|
ossClient.shutdown();
|
||||||
}
|
}
|
||||||
|
// MinioClient 内部复用 HttpClient 连接池,无需显式关闭
|
||||||
}
|
}
|
||||||
return fail("上传失败", null);
|
return fail("上传失败", null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,11 +61,11 @@ public class DictDataController extends BaseController {
|
|||||||
.eq(DictData::getDictDataName, dictData.getDictDataName())) > 0) {
|
.eq(DictData::getDictDataName, dictData.getDictDataName())) > 0) {
|
||||||
return fail("字典数据名称已存在");
|
return fail("字典数据名称已存在");
|
||||||
}
|
}
|
||||||
if (dictDataService.count(new LambdaQueryWrapper<DictData>()
|
// if (dictDataService.count(new LambdaQueryWrapper<DictData>()
|
||||||
.eq(DictData::getDictId, dictData.getDictId())
|
// .eq(DictData::getDictId, dictData.getDictId())
|
||||||
.eq(DictData::getDictDataCode, dictData.getDictDataCode())) > 0) {
|
// .eq(DictData::getDictDataCode, dictData.getDictDataCode())) > 0) {
|
||||||
return fail("字典数据标识已存在");
|
// return fail("字典数据标识已存在");
|
||||||
}
|
// }
|
||||||
// 自动添加字典
|
// 自动添加字典
|
||||||
final int count = dictService.count(new LambdaQueryWrapper<Dict>().eq(Dict::getDictCode, dictData.getDictCode()));
|
final int count = dictService.count(new LambdaQueryWrapper<Dict>().eq(Dict::getDictCode, dictData.getDictCode()));
|
||||||
if (dictData.getDictCode() != null && count == 0) {
|
if (dictData.getDictCode() != null && count == 0) {
|
||||||
@@ -95,12 +95,12 @@ public class DictDataController extends BaseController {
|
|||||||
.ne(DictData::getDictDataId, dictData.getDictDataId())) > 0) {
|
.ne(DictData::getDictDataId, dictData.getDictDataId())) > 0) {
|
||||||
return fail("字典数据名称已存在");
|
return fail("字典数据名称已存在");
|
||||||
}
|
}
|
||||||
if (dictDataService.count(new LambdaQueryWrapper<DictData>()
|
// if (dictDataService.count(new LambdaQueryWrapper<DictData>()
|
||||||
.eq(DictData::getDictId, dictData.getDictId())
|
// .eq(DictData::getDictId, dictData.getDictId())
|
||||||
.eq(DictData::getDictDataCode, dictData.getDictDataCode())
|
// .eq(DictData::getDictDataCode, dictData.getDictDataCode())
|
||||||
.ne(DictData::getDictDataId, dictData.getDictDataId())) > 0) {
|
// .ne(DictData::getDictDataId, dictData.getDictDataId())) > 0) {
|
||||||
return fail("字典数据标识已存在");
|
// return fail("字典数据标识已存在");
|
||||||
}
|
// }
|
||||||
if (dictDataService.updateById(dictData)) {
|
if (dictDataService.updateById(dictData)) {
|
||||||
return success("修改成功");
|
return success("修改成功");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,9 +29,11 @@ public class EmailTestController extends BaseController {
|
|||||||
public ApiResult<?> testRegisterSuccessEmail(@RequestParam String email) {
|
public ApiResult<?> testRegisterSuccessEmail(@RequestParam String email) {
|
||||||
try {
|
try {
|
||||||
emailTemplateUtil.sendRegisterSuccessEmail(
|
emailTemplateUtil.sendRegisterSuccessEmail(
|
||||||
"测试用户",
|
"广西河马科技有限公司",
|
||||||
"13800138000",
|
"https://site-10654.sitelnk.cn",
|
||||||
"TestPassword123",
|
"https://site.websoft.top",
|
||||||
|
email,
|
||||||
|
"F!huhtL3fqD8",
|
||||||
email,
|
email,
|
||||||
getTenantId()
|
getTenantId()
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -226,6 +226,7 @@ public class MenuController extends BaseController {
|
|||||||
}
|
}
|
||||||
companyService.updateById(company);
|
companyService.updateById(company);
|
||||||
loginUser.setInstalled(true);
|
loginUser.setInstalled(true);
|
||||||
|
loginUser.setIsSuperAdmin(true);
|
||||||
userService.updateById(loginUser);
|
userService.updateById(loginUser);
|
||||||
return success("安装成功");
|
return success("安装成功");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import com.gxwebsoft.common.system.param.OrderParam;
|
|||||||
import com.gxwebsoft.common.system.service.MenuService;
|
import com.gxwebsoft.common.system.service.MenuService;
|
||||||
import com.gxwebsoft.common.system.service.OrderGoodsService;
|
import com.gxwebsoft.common.system.service.OrderGoodsService;
|
||||||
import com.gxwebsoft.common.system.service.OrderService;
|
import com.gxwebsoft.common.system.service.OrderService;
|
||||||
|
import com.gxwebsoft.common.system.service.UserService;
|
||||||
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.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
@@ -47,6 +48,18 @@ public class OrderController extends BaseController {
|
|||||||
private MenuService menuService;
|
private MenuService menuService;
|
||||||
@Resource
|
@Resource
|
||||||
private MenuMapper menuMapper;
|
private MenuMapper menuMapper;
|
||||||
|
@Resource
|
||||||
|
private UserService userService;
|
||||||
|
|
||||||
|
/** 检查用户审核状态:auditStatus==null(老用户)或1(已通过)返回true,否则false */
|
||||||
|
private boolean checkUserAuditStatus(User loginUser) {
|
||||||
|
if (loginUser == null) return false;
|
||||||
|
User user = userService.getByIdRel(loginUser.getUserId());
|
||||||
|
if (user == null) return false;
|
||||||
|
Integer auditStatus = user.getAuditStatus();
|
||||||
|
// auditStatus == null 表示老用户(功能上线前注册的),默认放行
|
||||||
|
return auditStatus == null || auditStatus == 1;
|
||||||
|
}
|
||||||
|
|
||||||
@Operation(summary = "分页查询订单")
|
@Operation(summary = "分页查询订单")
|
||||||
@GetMapping("/page")
|
@GetMapping("/page")
|
||||||
@@ -94,6 +107,9 @@ public class OrderController extends BaseController {
|
|||||||
if(loginUser == null){
|
if(loginUser == null){
|
||||||
return fail("请先登录");
|
return fail("请先登录");
|
||||||
}
|
}
|
||||||
|
if (!checkUserAuditStatus(loginUser)) {
|
||||||
|
return fail("您的账号尚未通过审核,暂时无法下单");
|
||||||
|
}
|
||||||
if (ObjectUtil.isEmpty(order.getType())) {
|
if (ObjectUtil.isEmpty(order.getType())) {
|
||||||
return fail("订单类型不能为空");
|
return fail("订单类型不能为空");
|
||||||
}
|
}
|
||||||
@@ -184,6 +200,9 @@ public class OrderController extends BaseController {
|
|||||||
// 记录当前登录用户id
|
// 记录当前登录用户id
|
||||||
User loginUser = getLoginUser();
|
User loginUser = getLoginUser();
|
||||||
if (loginUser != null) {
|
if (loginUser != null) {
|
||||||
|
if (!checkUserAuditStatus(loginUser)) {
|
||||||
|
return fail("您的账号尚未通过审核,暂时无法下单");
|
||||||
|
}
|
||||||
// 封装订单数据
|
// 封装订单数据
|
||||||
final Order order = new Order();
|
final Order order = new Order();
|
||||||
order.setType(cart.getType());
|
order.setType(cart.getType());
|
||||||
|
|||||||
@@ -111,6 +111,10 @@ public class SettingController extends BaseController {
|
|||||||
// 更新系统设置信息到缓存 key = ""
|
// 更新系统设置信息到缓存 key = ""
|
||||||
String key = setting.getSettingKey().concat(":").concat(loginUser.getTenantId().toString());
|
String key = setting.getSettingKey().concat(":").concat(loginUser.getTenantId().toString());
|
||||||
redisUtil.set(key, JSON.parseObject(setting.getContent()));
|
redisUtil.set(key, JSON.parseObject(setting.getContent()));
|
||||||
|
// 上传配置变更后需同步清除上传缓存,避免 /api/oss/upload 继续命中旧配置
|
||||||
|
if ("upload".equals(setting.getSettingKey())) {
|
||||||
|
redisUtil.delete("Upload:" + loginUser.getTenantId());
|
||||||
|
}
|
||||||
// 更新租户信息
|
// 更新租户信息
|
||||||
if (setting.getSettingKey().equals("setting")) {
|
if (setting.getSettingKey().equals("setting")) {
|
||||||
final String content = setting.getContent();
|
final String content = setting.getContent();
|
||||||
@@ -198,8 +202,9 @@ public class SettingController extends BaseController {
|
|||||||
if(loginUser == null){
|
if(loginUser == null){
|
||||||
return fail("请先登录");
|
return fail("请先登录");
|
||||||
}
|
}
|
||||||
if(!loginUser.getIsSuperAdmin()){
|
// getIsSuperAdmin() is a Boolean and may be null; avoid NPE from auto-unboxing.
|
||||||
return fail("权限不足");
|
if(!"superAdmin".equals(loginUser.getUsername())){
|
||||||
|
return fail("权限不足!");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为Setting对象
|
// 转换为Setting对象
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.gxwebsoft.common.system.controller;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
|
import com.gxwebsoft.common.core.Constants;
|
||||||
import com.gxwebsoft.common.core.config.ConfigProperties;
|
import com.gxwebsoft.common.core.config.ConfigProperties;
|
||||||
import com.gxwebsoft.common.core.exception.BusinessException;
|
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||||
import com.gxwebsoft.common.core.utils.CommonUtil;
|
import com.gxwebsoft.common.core.utils.CommonUtil;
|
||||||
@@ -17,6 +18,7 @@ import com.gxwebsoft.common.core.web.BatchParam;
|
|||||||
import com.gxwebsoft.common.core.annotation.OperationLog;
|
import com.gxwebsoft.common.core.annotation.OperationLog;
|
||||||
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.beans.factory.annotation.Value;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.util.CollectionUtils;
|
import org.springframework.util.CollectionUtils;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@@ -24,6 +26,7 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -49,11 +52,36 @@ public class TenantController extends BaseController {
|
|||||||
private RedisUtil redisUtil;
|
private RedisUtil redisUtil;
|
||||||
@Resource
|
@Resource
|
||||||
private UserService userService;
|
private UserService userService;
|
||||||
|
// cms权限参考租户, 默认取平台租户
|
||||||
|
@Value("${websoft.cms.ref-tenant-id:10257}")
|
||||||
|
private Integer cmsRefTenantId;
|
||||||
|
|
||||||
@Operation(summary = "分页查询租户")
|
@Operation(summary = "分页查询租户")
|
||||||
@GetMapping("/page")
|
@GetMapping("/page")
|
||||||
public ApiResult<PageResult<Tenant>> page(TenantParam param) {
|
public ApiResult<PageResult<Tenant>> page(TenantParam param) {
|
||||||
return success(tenantService.pageRel(param));
|
// 安全修复:SecurityConfig 对所有 GET 放行,本方法原先在未登录且未指定 userId 时
|
||||||
|
// 会走「无过滤」分支返回全部租户(含租户名称、编码、手机号)。这里补上登录校验。
|
||||||
|
// 登录页的多租户选择走登录响应里的 tenants 列表,不依赖本接口,因此不影响登录流程。
|
||||||
|
final User loginUser = getLoginUser();
|
||||||
|
if (loginUser == null) {
|
||||||
|
return new ApiResult<>(Constants.UNAUTHENTICATED_CODE, Constants.UNAUTHENTICATED_MSG);
|
||||||
|
}
|
||||||
|
// 如果传了 all=true,查询全部租户;否则自动用当前登录用户的 userId
|
||||||
|
if (param.getAll() == null || !param.getAll()) {
|
||||||
|
if (param.getUserId() == null && loginUser.getUserId() != null) {
|
||||||
|
param.setUserId(loginUser.getUserId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PageResult<Tenant> result = tenantService.pageRel(param);
|
||||||
|
// 如果传入 mask=false,设置不脱敏
|
||||||
|
if (param.getMask() != null && !param.getMask()) {
|
||||||
|
if (result.getList() != null) {
|
||||||
|
for (Tenant tenant : result.getList()) {
|
||||||
|
tenant.setPhoneMasked(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return success(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreAuthorize("hasAuthority('sys:tenant:list')")
|
@PreAuthorize("hasAuthority('sys:tenant:list')")
|
||||||
@@ -64,7 +92,6 @@ public class TenantController extends BaseController {
|
|||||||
return success(tenantService.listRel(param));
|
return success(tenantService.listRel(param));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreAuthorize("hasAuthority('sys:tenant:list')")
|
|
||||||
@Operation(summary = "根据id查询租户")
|
@Operation(summary = "根据id查询租户")
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public ApiResult<Tenant> get(@PathVariable("id") Integer id) {
|
public ApiResult<Tenant> get(@PathVariable("id") Integer id) {
|
||||||
@@ -198,6 +225,40 @@ public class TenantController extends BaseController {
|
|||||||
return success(menus);
|
return success(menus);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OperationLog
|
||||||
|
@Operation(summary = "授予当前租户cms建站权限")
|
||||||
|
@PostMapping("/grantCmsPermission")
|
||||||
|
public ApiResult<?> grantCmsPermission(@RequestBody(required = false) Map<String, Object> body) {
|
||||||
|
final Integer curTenant = getTenantId();
|
||||||
|
if (curTenant == null) {
|
||||||
|
return fail("获取当前租户失败, 请重新登录");
|
||||||
|
}
|
||||||
|
// 参考租户优先级: 请求参数 > 企业当前使用的租户模板 > 配置项websoft.cms.ref-tenant-id
|
||||||
|
Integer refTenant = null;
|
||||||
|
final Object refTenantId = body == null ? null : body.get("refTenantId");
|
||||||
|
if (refTenantId instanceof Number) {
|
||||||
|
refTenant = ((Number) refTenantId).intValue();
|
||||||
|
} else if (refTenantId != null && refTenantId.toString().matches("\\d+")) {
|
||||||
|
refTenant = Integer.valueOf(refTenantId.toString());
|
||||||
|
}
|
||||||
|
if (refTenant == null) {
|
||||||
|
final Company company = getCompany();
|
||||||
|
if (company != null && company.getPlanId() != null) {
|
||||||
|
refTenant = company.getPlanId();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (refTenant == null) {
|
||||||
|
refTenant = cmsRefTenantId;
|
||||||
|
}
|
||||||
|
if (refTenant == null) {
|
||||||
|
return fail("未找到参考租户, 请配置websoft.cms.ref-tenant-id");
|
||||||
|
}
|
||||||
|
if (refTenant.equals(curTenant)) {
|
||||||
|
return fail("参考租户不能与当前租户相同");
|
||||||
|
}
|
||||||
|
return tenantService.grantCmsPermission(refTenant, curTenant);
|
||||||
|
}
|
||||||
|
|
||||||
@Operation(summary = "创建租户")
|
@Operation(summary = "创建租户")
|
||||||
@PostMapping("/saveByPhone")
|
@PostMapping("/saveByPhone")
|
||||||
public ApiResult<?> saveByPhone(@RequestBody Tenant tenant) {
|
public ApiResult<?> saveByPhone(@RequestBody Tenant tenant) {
|
||||||
|
|||||||
+107
-1
@@ -4,17 +4,25 @@ import cn.hutool.core.util.IdUtil;
|
|||||||
import com.alibaba.fastjson.JSONArray;
|
import com.alibaba.fastjson.JSONArray;
|
||||||
import com.alibaba.fastjson.JSONObject;
|
import com.alibaba.fastjson.JSONObject;
|
||||||
import com.gxwebsoft.common.core.Constants;
|
import com.gxwebsoft.common.core.Constants;
|
||||||
|
import com.gxwebsoft.common.core.utils.MyQrCodeUtil;
|
||||||
import com.gxwebsoft.common.core.web.ApiResult;
|
import com.gxwebsoft.common.core.web.ApiResult;
|
||||||
import com.gxwebsoft.common.core.web.BaseController;
|
import com.gxwebsoft.common.core.web.BaseController;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import com.gxwebsoft.common.system.entity.Order;
|
import com.gxwebsoft.common.system.entity.Order;
|
||||||
|
import com.gxwebsoft.common.system.entity.TenantPackage;
|
||||||
|
import com.gxwebsoft.common.system.entity.TenantSubscriptionOrder;
|
||||||
import com.gxwebsoft.common.system.entity.User;
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
import com.gxwebsoft.common.system.param.SubscriptionOrderParam;
|
import com.gxwebsoft.common.system.param.SubscriptionOrderParam;
|
||||||
import com.gxwebsoft.common.system.result.SubscriptionOrderCreateResult;
|
import com.gxwebsoft.common.system.result.SubscriptionOrderCreateResult;
|
||||||
import com.gxwebsoft.common.system.result.SubscriptionOrderPayResult;
|
import com.gxwebsoft.common.system.result.SubscriptionOrderPayResult;
|
||||||
import com.gxwebsoft.common.system.result.SubscriptionPriceResult;
|
import com.gxwebsoft.common.system.result.SubscriptionPriceResult;
|
||||||
import com.gxwebsoft.common.system.service.SettingService;
|
import com.gxwebsoft.common.system.service.SettingService;
|
||||||
|
import com.gxwebsoft.common.system.service.TenantPackageService;
|
||||||
|
import com.gxwebsoft.common.system.service.TenantSubscriptionOrderService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
@@ -29,6 +37,7 @@ import java.util.List;
|
|||||||
/**
|
/**
|
||||||
* 订阅订单接口
|
* 订阅订单接口
|
||||||
*/
|
*/
|
||||||
|
@Slf4j
|
||||||
@Tag(name = "订阅订单")
|
@Tag(name = "订阅订单")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/system/subscription-order")
|
@RequestMapping("/api/system/subscription-order")
|
||||||
@@ -38,6 +47,10 @@ public class TenantSubscriptionOrderController extends BaseController {
|
|||||||
private SettingService settingService;
|
private SettingService settingService;
|
||||||
@Resource
|
@Resource
|
||||||
private WxNativePayController wxNativePayController;
|
private WxNativePayController wxNativePayController;
|
||||||
|
@Resource
|
||||||
|
private TenantSubscriptionOrderService subscriptionOrderService;
|
||||||
|
@Resource
|
||||||
|
private TenantPackageService tenantPackageService;
|
||||||
|
|
||||||
@Operation(summary = "计算订阅订单价格")
|
@Operation(summary = "计算订阅订单价格")
|
||||||
@PostMapping("/calculate-price")
|
@PostMapping("/calculate-price")
|
||||||
@@ -88,7 +101,6 @@ public class TenantSubscriptionOrderController extends BaseController {
|
|||||||
|
|
||||||
JSONObject config = loadSubscriptionConfig();
|
JSONObject config = loadSubscriptionConfig();
|
||||||
final SubscriptionPriceResult price = buildPriceResult(param, config);
|
final SubscriptionPriceResult price = buildPriceResult(param, config);
|
||||||
price.setPayPrice(new BigDecimal("0.01"));
|
|
||||||
if (price.getPayPrice() == null || price.getPayPrice().compareTo(BigDecimal.ZERO) <= 0) {
|
if (price.getPayPrice() == null || price.getPayPrice().compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
return fail("支付金额必须大于0", null);
|
return fail("支付金额必须大于0", null);
|
||||||
}
|
}
|
||||||
@@ -114,6 +126,100 @@ public class TenantSubscriptionOrderController extends BaseController {
|
|||||||
return success(result);
|
return success(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建官网开通订单并直接生成微信Native支付二维码。
|
||||||
|
* 用于 /register 最后一步「支付开通」:建真实订单(TenantSubscriptionOrder)→ 调 Native 预下单
|
||||||
|
* (沿用已落库的 orderNo,保证回调查询一致)→ 将 code_url 编码为 base64 二维码返回。
|
||||||
|
*/
|
||||||
|
@Operation(summary = "创建开通订单并生成微信Native支付二维码")
|
||||||
|
@PostMapping("/create-and-pay")
|
||||||
|
public ApiResult<SubscriptionOrderPayResult> createAndPay(@RequestBody SubscriptionOrderParam param) {
|
||||||
|
final User loginUser = getLoginUser();
|
||||||
|
if (loginUser == null) {
|
||||||
|
return fail("请先登录", null);
|
||||||
|
}
|
||||||
|
Integer packageId = param.getPackageId();
|
||||||
|
if (packageId == null) {
|
||||||
|
// 默认基础版(version=10)
|
||||||
|
TenantPackage basic = tenantPackageService.getByVersion(10);
|
||||||
|
if (basic == null) {
|
||||||
|
return fail("未找到基础版套餐,请联系管理员配置", null);
|
||||||
|
}
|
||||||
|
packageId = basic.getPackageId();
|
||||||
|
}
|
||||||
|
Integer payType = param.getPayType() != null ? param.getPayType() : 12; // 默认年付
|
||||||
|
|
||||||
|
// 1) 落库真实订单(状态 0 待支付)
|
||||||
|
TenantSubscriptionOrder order;
|
||||||
|
try {
|
||||||
|
order = subscriptionOrderService.createOrder(packageId, payType, loginUser.getTenantId(), loginUser.getUserId());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return fail(e.getMessage(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 用已落库的订单号生成支付二维码(关键:不能重新生成随机号)
|
||||||
|
Order payOrder = new Order();
|
||||||
|
payOrder.setOrderNo(order.getOrderNo());
|
||||||
|
payOrder.setPayPrice(order.getActualPrice());
|
||||||
|
payOrder.setTotalPrice(order.getActualPrice());
|
||||||
|
payOrder.setComments("官网基础版开通-" + order.getOrderNo());
|
||||||
|
payOrder.setPayType(payType);
|
||||||
|
payOrder.setUserId(loginUser.getUserId());
|
||||||
|
payOrder.setTenantId(loginUser.getTenantId());
|
||||||
|
|
||||||
|
ApiResult<?> payResp = wxNativePayController.getCodeUrl(payOrder);
|
||||||
|
if (payResp.getCode() == null || !payResp.getCode().equals(Constants.RESULT_OK_CODE)) {
|
||||||
|
return fail(payResp.getMessage(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
String codeUrl = String.valueOf(payResp.getData());
|
||||||
|
String qrImage = null;
|
||||||
|
try {
|
||||||
|
qrImage = MyQrCodeUtil.generateBase64(codeUrl);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("生成支付二维码图片失败,前端将改用 codeUrl 渲染: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
SubscriptionOrderPayResult result = new SubscriptionOrderPayResult();
|
||||||
|
result.setOrderNo(order.getOrderNo());
|
||||||
|
result.setCodeUrl(codeUrl);
|
||||||
|
result.setQrImage(qrImage);
|
||||||
|
result.setOrderStatus(order.getOrderStatus());
|
||||||
|
result.setPrice(buildPriceFromOrder(order));
|
||||||
|
return success(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询订单支付状态(前端支付页轮询用)。
|
||||||
|
* 状态由微信回调(wx-native-pay/notify)在服务端验签解密后翻转:0待支付→1已支付→2已激活。
|
||||||
|
*/
|
||||||
|
@Operation(summary = "查询开通订单支付状态")
|
||||||
|
@GetMapping("/status/{orderNo}")
|
||||||
|
public ApiResult<SubscriptionOrderPayResult> getOrderStatus(@PathVariable("orderNo") String orderNo) {
|
||||||
|
TenantSubscriptionOrder order = subscriptionOrderService.getByOrderNo(orderNo);
|
||||||
|
if (order == null) {
|
||||||
|
return fail("订单不存在", null);
|
||||||
|
}
|
||||||
|
SubscriptionOrderPayResult result = new SubscriptionOrderPayResult();
|
||||||
|
result.setOrderNo(order.getOrderNo());
|
||||||
|
result.setOrderStatus(order.getOrderStatus());
|
||||||
|
result.setPrice(buildPriceFromOrder(order));
|
||||||
|
return success(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由已落库订单构造前端所需的价格结果
|
||||||
|
*/
|
||||||
|
private SubscriptionPriceResult buildPriceFromOrder(TenantSubscriptionOrder order) {
|
||||||
|
SubscriptionPriceResult price = new SubscriptionPriceResult();
|
||||||
|
price.setPackageId(order.getPackageId());
|
||||||
|
price.setOriginalPrice(order.getOriginalPrice() == null ? null : order.getOriginalPrice().setScale(2, RoundingMode.HALF_UP));
|
||||||
|
price.setTotalPrice(order.getOriginalPrice() == null ? null : order.getOriginalPrice().setScale(2, RoundingMode.HALF_UP));
|
||||||
|
price.setPayPrice(order.getActualPrice() == null ? null : order.getActualPrice().setScale(2, RoundingMode.HALF_UP));
|
||||||
|
price.setDiscountAmount(order.getDiscountPrice() == null ? null : order.getDiscountPrice().setScale(2, RoundingMode.HALF_UP));
|
||||||
|
return price;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从配置表读取订阅套餐配置(尝试多种key以兼容历史数据)
|
* 从配置表读取订阅套餐配置(尝试多种key以兼容历史数据)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ public class UserController extends BaseController {
|
|||||||
private ConfigProperties configProperties;
|
private ConfigProperties configProperties;
|
||||||
@Resource
|
@Resource
|
||||||
private LoginRecordService loginRecordService;
|
private LoginRecordService loginRecordService;
|
||||||
|
@Resource
|
||||||
|
private UserSyncService userSyncService;
|
||||||
|
|
||||||
@PreAuthorize("hasAuthority('sys:auth:user')")
|
@PreAuthorize("hasAuthority('sys:auth:user')")
|
||||||
@Operation(summary = "分页查询用户")
|
@Operation(summary = "分页查询用户")
|
||||||
@@ -142,6 +144,8 @@ public class UserController extends BaseController {
|
|||||||
return fail("该手机号码已存在");
|
return fail("该手机号码已存在");
|
||||||
}
|
}
|
||||||
if (userService.saveUser(user)) {
|
if (userService.saveUser(user)) {
|
||||||
|
// 同步到 websopy
|
||||||
|
userSyncService.syncUserToWebsopy(user);
|
||||||
return success("添加成功", user.getUserId());
|
return success("添加成功", user.getUserId());
|
||||||
}
|
}
|
||||||
return fail("添加失败");
|
return fail("添加失败");
|
||||||
@@ -159,6 +163,8 @@ public class UserController extends BaseController {
|
|||||||
return fail("该手机号码已存在");
|
return fail("该手机号码已存在");
|
||||||
}
|
}
|
||||||
if (userService.saveUser(user)) {
|
if (userService.saveUser(user)) {
|
||||||
|
// 同步到 websopy
|
||||||
|
userSyncService.syncUserToWebsopy(user);
|
||||||
return success("添加成功", user.getUserId());
|
return success("添加成功", user.getUserId());
|
||||||
}
|
}
|
||||||
return fail("添加失败");
|
return fail("添加失败");
|
||||||
@@ -181,6 +187,8 @@ public class UserController extends BaseController {
|
|||||||
userList.removeIf(d -> phoneCollect.containsKey(d.getPhone()));
|
userList.removeIf(d -> phoneCollect.containsKey(d.getPhone()));
|
||||||
|
|
||||||
if (userService.saveBatch(userList)) {
|
if (userService.saveBatch(userList)) {
|
||||||
|
// 同步到 websopy(异步处理,不阻塞响应)
|
||||||
|
userList.forEach(user -> userSyncService.syncUserToWebsopy(user));
|
||||||
return success("添加成功");
|
return success("添加成功");
|
||||||
}
|
}
|
||||||
return fail("添加失败");
|
return fail("添加失败");
|
||||||
@@ -201,6 +209,8 @@ public class UserController extends BaseController {
|
|||||||
userParam.setLimit(500L);
|
userParam.setLimit(500L);
|
||||||
final PageResult<User> result = userService.pageRel(userParam);
|
final PageResult<User> result = userService.pageRel(userParam);
|
||||||
final Set<Integer> collect = result.getList().stream().map(User::getUserId).collect(Collectors.toSet());
|
final Set<Integer> collect = result.getList().stream().map(User::getUserId).collect(Collectors.toSet());
|
||||||
|
// 同步到 websopy(异步处理,不阻塞响应)
|
||||||
|
userList.forEach(user -> userSyncService.syncUserToWebsopy(user));
|
||||||
return success("添加成功", collect);
|
return success("添加成功", collect);
|
||||||
}
|
}
|
||||||
return fail("添加失败");
|
return fail("添加失败");
|
||||||
@@ -294,6 +304,49 @@ public class UserController extends BaseController {
|
|||||||
return fail("修改失败");
|
return fail("修改失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('sys:auth:user')")
|
||||||
|
@Operation(summary = "查询用户审核信息")
|
||||||
|
@GetMapping("/audit/{userId}")
|
||||||
|
public ApiResult<Map<String, Object>> getAuditInfo(@PathVariable("userId") Integer userId) {
|
||||||
|
User user = userService.getByIdRel(userId);
|
||||||
|
if (user == null) {
|
||||||
|
return fail("用户不存在",null);
|
||||||
|
}
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("userId", user.getUserId());
|
||||||
|
result.put("auditStatus", user.getAuditStatus());
|
||||||
|
result.put("rejectReason", user.getRejectReason());
|
||||||
|
return success(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('sys:user:update')")
|
||||||
|
@OperationLog
|
||||||
|
@Operation(summary = "审核用户注册(通过/拒绝)")
|
||||||
|
@PutMapping("/audit")
|
||||||
|
public ApiResult<?> auditUser(@RequestBody User user) {
|
||||||
|
if (user.getUserId() == null || user.getAuditStatus() == null
|
||||||
|
|| !Arrays.asList(0, 1, 2).contains(user.getAuditStatus())) {
|
||||||
|
return fail("参数不正确");
|
||||||
|
}
|
||||||
|
User update = new User();
|
||||||
|
update.setUserId(user.getUserId());
|
||||||
|
update.setAuditStatus(user.getAuditStatus());
|
||||||
|
// 拒绝时必须填写驳回原因
|
||||||
|
if (user.getAuditStatus() == 2) {
|
||||||
|
if (StrUtil.isBlank(user.getRejectReason())) {
|
||||||
|
return fail("驳回时请填写原因");
|
||||||
|
}
|
||||||
|
update.setRejectReason(user.getRejectReason());
|
||||||
|
} else {
|
||||||
|
// 通过或重新设为待审核时清除驳回原因
|
||||||
|
update.setRejectReason(null);
|
||||||
|
}
|
||||||
|
if (userService.updateById(update)) {
|
||||||
|
return success(user.getAuditStatus() == 1 ? "审核通过" : "已拒绝");
|
||||||
|
}
|
||||||
|
return fail("操作失败");
|
||||||
|
}
|
||||||
|
|
||||||
@PreAuthorize("hasAuthority('sys:user:update')")
|
@PreAuthorize("hasAuthority('sys:user:update')")
|
||||||
@OperationLog
|
@OperationLog
|
||||||
@Operation(summary = "修改推荐状态")
|
@Operation(summary = "修改推荐状态")
|
||||||
@@ -311,6 +364,20 @@ public class UserController extends BaseController {
|
|||||||
return fail("修改失败");
|
return fail("修改失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('sys:userRole:save')")
|
||||||
|
@OperationLog
|
||||||
|
@Operation(summary = "设为开发者(审核通过时标记,不新增角色)")
|
||||||
|
@PutMapping("/developer/{userId}")
|
||||||
|
public ApiResult<?> setDeveloper(@PathVariable("userId") Integer userId) {
|
||||||
|
if (userId == null) {
|
||||||
|
return fail("参数不正确");
|
||||||
|
}
|
||||||
|
if (userService.markAsDeveloper(userId)) {
|
||||||
|
return success("已设为开发者");
|
||||||
|
}
|
||||||
|
return fail("操作失败");
|
||||||
|
}
|
||||||
|
|
||||||
@PreAuthorize("hasAuthority('sys:user:update')")
|
@PreAuthorize("hasAuthority('sys:user:update')")
|
||||||
@OperationLog
|
@OperationLog
|
||||||
@Operation(summary = "批量修改用户状态")
|
@Operation(summary = "批量修改用户状态")
|
||||||
@@ -511,6 +578,7 @@ public class UserController extends BaseController {
|
|||||||
// 保存新用户(逐个保存以处理角色关系)
|
// 保存新用户(逐个保存以处理角色关系)
|
||||||
int successCount = 0;
|
int successCount = 0;
|
||||||
List<String> failedUsers = new ArrayList<>();
|
List<String> failedUsers = new ArrayList<>();
|
||||||
|
List<User> successUsers = new ArrayList<>();
|
||||||
|
|
||||||
for (User user : users) {
|
for (User user : users) {
|
||||||
try {
|
try {
|
||||||
@@ -524,6 +592,7 @@ public class UserController extends BaseController {
|
|||||||
userRoleService.saveBatch(user.getUserId(), roleIds);
|
userRoleService.saveBatch(user.getUserId(), roleIds);
|
||||||
}
|
}
|
||||||
successCount++;
|
successCount++;
|
||||||
|
successUsers.add(user);
|
||||||
} else {
|
} else {
|
||||||
failedUsers.add(user.getUsername());
|
failedUsers.add(user.getUsername());
|
||||||
}
|
}
|
||||||
@@ -534,6 +603,11 @@ public class UserController extends BaseController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 同步成功导入的用户到 websopy
|
||||||
|
for (User successUser : successUsers) {
|
||||||
|
userSyncService.syncUserToWebsopy(successUser);
|
||||||
|
}
|
||||||
|
|
||||||
// 构建返回消息
|
// 构建返回消息
|
||||||
StringBuilder message = new StringBuilder();
|
StringBuilder message = new StringBuilder();
|
||||||
if (successCount > 0) {
|
if (successCount > 0) {
|
||||||
|
|||||||
@@ -63,9 +63,9 @@ public class UserRoleController extends BaseController {
|
|||||||
@Operation(summary = "添加用户角色")
|
@Operation(summary = "添加用户角色")
|
||||||
@PostMapping()
|
@PostMapping()
|
||||||
public ApiResult<?> save(@RequestBody UserRole userRole) {
|
public ApiResult<?> save(@RequestBody UserRole userRole) {
|
||||||
// 记录当前登录用户id
|
// 记录当前登录用户id(请求体未指定 userId 时,回退到当前登录用户)
|
||||||
User loginUser = getLoginUser();
|
User loginUser = getLoginUser();
|
||||||
if (loginUser != null) {
|
if (userRole.getUserId() == null && loginUser != null) {
|
||||||
userRole.setUserId(loginUser.getUserId());
|
userRole.setUserId(loginUser.getUserId());
|
||||||
}
|
}
|
||||||
if (userRoleService.save(userRole)) {
|
if (userRoleService.save(userRole)) {
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ public class UserVerifyController extends BaseController {
|
|||||||
if (userVerify.getType().equals(1)) {
|
if (userVerify.getType().equals(1)) {
|
||||||
byUserId.setRealName(userVerify.getName());
|
byUserId.setRealName(userVerify.getName());
|
||||||
}
|
}
|
||||||
|
// 设置管理员id
|
||||||
|
userVerify.setAdminId(loginUser.getUserId());
|
||||||
}
|
}
|
||||||
userService.updateById(byUserId);
|
userService.updateById(byUserId);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.gxwebsoft.common.system.controller;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@Tag(name = "安全")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/MP_verify_joj96VBHPtL9YROj.txt")
|
||||||
|
public class VerifyTxt2 {
|
||||||
|
|
||||||
|
@Operation(summary = "域名所有权验证")
|
||||||
|
@GetMapping()
|
||||||
|
public String verify(){
|
||||||
|
return "joj96VBHPtL9YROj";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -34,7 +35,9 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
@@ -65,6 +68,10 @@ public class WxLoginController extends BaseController {
|
|||||||
private ConfigProperties config;
|
private ConfigProperties config;
|
||||||
@Resource
|
@Resource
|
||||||
private UserRefereeService userRefereeService;
|
private UserRefereeService userRefereeService;
|
||||||
|
@Resource
|
||||||
|
private UserSyncService userSyncService;
|
||||||
|
@Resource
|
||||||
|
private AppConfigService appConfigService;
|
||||||
|
|
||||||
public WxLoginController(StringRedisTemplate redisTemplate) {
|
public WxLoginController(StringRedisTemplate redisTemplate) {
|
||||||
this.redisTemplate = redisTemplate;
|
this.redisTemplate = redisTemplate;
|
||||||
@@ -129,11 +136,23 @@ public class WxLoginController extends BaseController {
|
|||||||
if (openid == null) {
|
if (openid == null) {
|
||||||
return fail("获取openid失败", null);
|
return fail("获取openid失败", null);
|
||||||
}
|
}
|
||||||
|
// 关键:把 openid/unionid 设置到 userParam,否则后续查库/注册都拿不到 openid
|
||||||
|
userParam.setOpenid(openid);
|
||||||
|
if (StrUtil.isNotBlank(unionid)) {
|
||||||
|
userParam.setUnionid(unionid);
|
||||||
|
}
|
||||||
// 2.通过openid查询用户是否已存在
|
// 2.通过openid查询用户是否已存在
|
||||||
User user = userService.getByOauthId(userParam);
|
User user = userService.getByOauthId(userParam);
|
||||||
// 3.存在则签发token并返回登录成功,不存在则注册新用户
|
// 3.存在则签发token并返回登录成功,不存在则注册新用户
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
user = addUser(userParam);
|
user = addUser(userParam);
|
||||||
|
} else if (StrUtil.isBlank(user.getOpenid())) {
|
||||||
|
// 老用户(比如手机号注册)没有 openid,补上
|
||||||
|
user.setOpenid(openid);
|
||||||
|
if (StrUtil.isNotBlank(unionid)) {
|
||||||
|
user.setUnionid(unionid);
|
||||||
|
}
|
||||||
|
userService.updateById(user);
|
||||||
}
|
}
|
||||||
// 4.签发token
|
// 4.签发token
|
||||||
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
|
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
|
||||||
@@ -194,6 +213,7 @@ public class WxLoginController extends BaseController {
|
|||||||
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
|
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
|
||||||
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
|
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
|
||||||
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
|
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));
|
return success("登录成功", new LoginResult(access_token, user));
|
||||||
}
|
}
|
||||||
@@ -250,7 +270,7 @@ public class WxLoginController extends BaseController {
|
|||||||
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
|
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
|
||||||
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
|
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
|
||||||
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_REGISTER, null, user.getTenantId(), request);
|
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));
|
return success("注册并登录成功", new LoginResult(access_token, user));
|
||||||
|
|
||||||
} catch (BusinessException e) {
|
} catch (BusinessException e) {
|
||||||
@@ -262,6 +282,7 @@ public class WxLoginController extends BaseController {
|
|||||||
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
|
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
|
||||||
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
|
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
|
||||||
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
|
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
|
||||||
|
activateShopUserMemberByPhone(phone);
|
||||||
return success("登录成功", new LoginResult(access_token, user));
|
return success("登录成功", new LoginResult(access_token, user));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -323,6 +344,7 @@ public class WxLoginController extends BaseController {
|
|||||||
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
|
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
|
||||||
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
|
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
|
||||||
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
|
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
|
||||||
|
activateShopUserMemberByPhone(phone);
|
||||||
|
|
||||||
return success("登录成功", new LoginResult(access_token, user));
|
return success("登录成功", new LoginResult(access_token, user));
|
||||||
}
|
}
|
||||||
@@ -396,6 +418,7 @@ public class WxLoginController extends BaseController {
|
|||||||
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
|
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
|
||||||
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
|
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
|
||||||
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_REGISTER, null, user.getTenantId(), request);
|
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_REGISTER, null, user.getTenantId(), request);
|
||||||
|
activateShopUserMemberByPhone(phone);
|
||||||
|
|
||||||
return success("注册并登录成功", new LoginResult(access_token, user));
|
return success("注册并登录成功", new LoginResult(access_token, user));
|
||||||
|
|
||||||
@@ -408,6 +431,7 @@ public class WxLoginController extends BaseController {
|
|||||||
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
|
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
|
||||||
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
|
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
|
||||||
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
|
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
|
||||||
|
activateShopUserMemberByPhone(phone);
|
||||||
return success("登录成功", new LoginResult(access_token, user));
|
return success("登录成功", new LoginResult(access_token, user));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -415,6 +439,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 = "微信授权手机号码并更新")
|
@Operation(summary = "微信授权手机号码并更新")
|
||||||
@Transactional(rollbackFor = {Exception.class})
|
@Transactional(rollbackFor = {Exception.class})
|
||||||
@PostMapping("/updatePhoneByMpWx")
|
@PostMapping("/updatePhoneByMpWx")
|
||||||
@@ -431,6 +475,36 @@ public class WxLoginController extends BaseController {
|
|||||||
return fail("更新失败");
|
return fail("更新失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 优先读取 sys_setting (mp-weixin),不存在或异常时回退到 db_websopy.app_config(category=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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 新用户注册
|
* 新用户注册
|
||||||
*/
|
*/
|
||||||
@@ -445,6 +519,8 @@ public class WxLoginController extends BaseController {
|
|||||||
User addUser = new User();
|
User addUser = new User();
|
||||||
// 注册用户
|
// 注册用户
|
||||||
addUser.setStatus(0);
|
addUser.setStatus(0);
|
||||||
|
// 默认审核通过;前端传 auditStatus=0 时走审核流程,传其他值则按前端值
|
||||||
|
addUser.setAuditStatus(userParam.getAuditStatus() != null ? userParam.getAuditStatus() : 1);
|
||||||
addUser.setUsername(createUsername("wx_"));
|
addUser.setUsername(createUsername("wx_"));
|
||||||
addUser.setNickname("微信用户");
|
addUser.setNickname("微信用户");
|
||||||
addUser.setPlatform(MP_WEIXIN);
|
addUser.setPlatform(MP_WEIXIN);
|
||||||
@@ -473,6 +549,8 @@ public class WxLoginController extends BaseController {
|
|||||||
userRole.setTenantId(addUser.getTenantId());
|
userRole.setTenantId(addUser.getTenantId());
|
||||||
userRole.setRoleId(addUser.getRoleId());
|
userRole.setRoleId(addUser.getRoleId());
|
||||||
userRoleService.save(userRole);
|
userRoleService.save(userRole);
|
||||||
|
// 同步到 websopy
|
||||||
|
userSyncService.syncUserToWebsopy(addUser);
|
||||||
}
|
}
|
||||||
// 绑定关系
|
// 绑定关系
|
||||||
if (userParam.getSceneType() != null && userParam.getSceneType().equals("save_referee") && userParam.getRefereeId() != null && userParam.getRefereeId() != 0) {
|
if (userParam.getSceneType() != null && userParam.getSceneType().equals("save_referee") && userParam.getRefereeId() != null && userParam.getRefereeId() != 0) {
|
||||||
@@ -490,8 +568,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请求
|
||||||
@@ -598,16 +676,29 @@ public class WxLoginController extends BaseController {
|
|||||||
JSONObject phoneInfo = JSON.parseObject(json.getString("phone_info"));
|
JSONObject phoneInfo = JSON.parseObject(json.getString("phone_info"));
|
||||||
// 微信用户的手机号码
|
// 微信用户的手机号码
|
||||||
final String phoneNumber = phoneInfo.getString("phoneNumber");
|
final String phoneNumber = phoneInfo.getString("phoneNumber");
|
||||||
// 验证手机号码
|
|
||||||
// if (userParam.getNotVerifyPhone() == null && !Validator.isMobile(phoneNumber)) {
|
|
||||||
// String key = ACCESS_TOKEN_KEY.concat(":").concat(getTenantId().toString());
|
|
||||||
// redisTemplate.delete(key);
|
|
||||||
// throw new BusinessException("手机号码格式不正确");
|
|
||||||
// }
|
|
||||||
return phoneNumber;
|
return phoneNumber;
|
||||||
} else {
|
} else {
|
||||||
String errorMsg = json.getString("errmsg");
|
String errorMsg = json.getString("errmsg");
|
||||||
|
Integer errCodeInt = null;
|
||||||
|
if (errcode instanceof Integer) {
|
||||||
|
errCodeInt = (Integer) errcode;
|
||||||
|
} else if (errcode instanceof Long) {
|
||||||
|
errCodeInt = ((Long) errcode).intValue();
|
||||||
|
} else if (errcode instanceof String) {
|
||||||
|
try {
|
||||||
|
errCodeInt = Integer.parseInt((String) errcode);
|
||||||
|
} catch (NumberFormatException ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
System.err.println("微信获取手机号失败: errcode=" + errcode + ", errmsg=" + errorMsg);
|
System.err.println("微信获取手机号失败: errcode=" + errcode + ", errmsg=" + errorMsg);
|
||||||
|
|
||||||
|
// 判断是否是 token 相关错误,如果是则清理缓存
|
||||||
|
if (isTokenRelatedError(errCodeInt, errorMsg)) {
|
||||||
|
String key = ACCESS_TOKEN_KEY.concat(":").concat(tenantId != null ? tenantId.toString() : getTenantId().toString());
|
||||||
|
redisTemplate.delete(key);
|
||||||
|
System.err.println("已清理access_token缓存,key=" + key);
|
||||||
|
}
|
||||||
|
|
||||||
throw new BusinessException("获取手机号失败:" + errorMsg);
|
throw new BusinessException("获取手机号失败:" + errorMsg);
|
||||||
}
|
}
|
||||||
} catch (BusinessException be) {
|
} catch (BusinessException be) {
|
||||||
@@ -623,6 +714,25 @@ public class WxLoginController extends BaseController {
|
|||||||
throw new BusinessException("获取手机号失败,请检查参数");
|
throw new BusinessException("获取手机号失败,请检查参数");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否是 token 相关的错误码,需要清理缓存
|
||||||
|
*/
|
||||||
|
private boolean isTokenRelatedError(Integer errCode, String errMsg) {
|
||||||
|
if (errCode == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// token 相关错误码
|
||||||
|
return errCode == 40001 // AppSecret错误
|
||||||
|
|| errCode == 40013 // appid无效
|
||||||
|
|| errCode == 40125 // appsecret无效
|
||||||
|
|| errCode == 42001 // access_token超时
|
||||||
|
|| errCode == 42002 // refresh_token超时
|
||||||
|
|| errCode == 42003 // code超时
|
||||||
|
|| errCode == 41002 // appid不正确
|
||||||
|
|| errCode == 41008 // 缺少access_token参数
|
||||||
|
|| errCode == 40014; // 不合法的access_token
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成随机账号
|
* 生成随机账号
|
||||||
*
|
*
|
||||||
@@ -653,8 +763,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("请先配置小程序");
|
||||||
}
|
}
|
||||||
@@ -772,7 +882,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);
|
||||||
@@ -799,7 +909,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);
|
||||||
@@ -857,48 +967,59 @@ public class WxLoginController extends BaseController {
|
|||||||
return fail("获取失败", null);
|
return fail("获取失败", null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "获取微信小程序码-订单核销码-数量极多的业务场景")
|
@Operation(summary = "获取微信小程序码-数量极多的业务场景(直接返回图片字节,支持自定义页面与场景值)")
|
||||||
@GetMapping("/getOrderQRCodeUnlimited/{orderNo}")
|
@GetMapping("/getOrderQRCodeUnlimited/{orderNo}")
|
||||||
public ApiResult<?> getOrderQRCodeUnlimited(@PathVariable("orderNo") String orderNo) {
|
public void getOrderQRCodeUnlimited(@PathVariable("orderNo") String orderNo,
|
||||||
|
@RequestParam(required = false) String page,
|
||||||
|
@RequestParam(required = false) String scene,
|
||||||
|
HttpServletResponse response) throws IOException {
|
||||||
final User loginUser = getLoginUser();
|
final User loginUser = getLoginUser();
|
||||||
if(loginUser == null){
|
if(loginUser == null){
|
||||||
return fail("请先登录");
|
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "请先登录");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
String apiUrl = "https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=" + getAccessToken(loginUser.getTenantId());
|
String apiUrl = "https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=" + getAccessToken(loginUser.getTenantId());
|
||||||
final HashMap<String, Object> map = new HashMap<>();
|
final HashMap<String, Object> map = new HashMap<>();
|
||||||
map.put("scene", "orderNo=".concat(orderNo));
|
// scene 优先级:显式 scene > 自定义页面用原始 orderNo > 兼容原订单核销(orderNo=xxx)
|
||||||
map.put("page", "package/admin/order-scan");
|
String sceneValue;
|
||||||
|
if (StrUtil.isNotBlank(scene)) {
|
||||||
|
sceneValue = scene;
|
||||||
|
} else if (StrUtil.isNotBlank(page)) {
|
||||||
|
// 自定义页面场景:小程序端通过 options.scene 直接读取业务值(如活动/订单号)
|
||||||
|
sceneValue = orderNo;
|
||||||
|
} else {
|
||||||
|
// 兼容原订单核销场景:scene=orderNo=xxx,默认跳转 package/admin/order-scan
|
||||||
|
sceneValue = "orderNo=".concat(orderNo);
|
||||||
|
}
|
||||||
|
map.put("scene", sceneValue);
|
||||||
|
// page 优先用入参,否则回退兼容原订单核销页面
|
||||||
|
map.put("page", StrUtil.isNotBlank(page) ? page : "package/admin/order-scan");
|
||||||
map.put("env_version", "trial");
|
map.put("env_version", "trial");
|
||||||
// 获取图片 Buffer
|
// 获取图片 Buffer
|
||||||
byte[] qrCode = HttpRequest.post(apiUrl)
|
byte[] qrCode = HttpRequest.post(apiUrl)
|
||||||
.body(JSON.toJSONString(map))
|
.body(JSON.toJSONString(map))
|
||||||
.execute().bodyBytes();
|
.execute().bodyBytes();
|
||||||
System.out.println("qrCode = " + qrCode);
|
|
||||||
|
|
||||||
// 保存的文件名称
|
// 直接将小程序码图片字节写回响应,不依赖 file.websoft.top 文件服务器
|
||||||
final String fileName = CommonUtil.randomUUID8().concat(".png");
|
response.setContentType("image/png");
|
||||||
// 保存路径
|
response.setHeader("Cache-Control", "no-cache");
|
||||||
String filePath = getUploadDir().concat("qrcode/") + fileName;
|
response.getOutputStream().write(qrCode);
|
||||||
File file = FileUtil.writeBytes(qrCode, filePath);
|
response.getOutputStream().flush();
|
||||||
if (file != null) {
|
|
||||||
return success(config.getFileServer().concat("/qrcode/").concat(fileName));
|
|
||||||
}
|
|
||||||
return fail("获取失败", null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@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_config(category=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
|
||||||
|
|||||||
@@ -12,15 +12,22 @@ import com.gxwebsoft.common.core.web.ApiResult;
|
|||||||
import com.gxwebsoft.common.core.web.BaseController;
|
import com.gxwebsoft.common.core.web.BaseController;
|
||||||
import com.gxwebsoft.common.system.entity.Order;
|
import com.gxwebsoft.common.system.entity.Order;
|
||||||
import com.gxwebsoft.common.system.entity.Payment;
|
import com.gxwebsoft.common.system.entity.Payment;
|
||||||
|
import com.gxwebsoft.common.system.entity.TenantSubscriptionOrder;
|
||||||
import com.gxwebsoft.common.system.param.SettingParam;
|
import com.gxwebsoft.common.system.param.SettingParam;
|
||||||
import com.gxwebsoft.common.system.service.OrderService;
|
import com.gxwebsoft.common.system.service.OrderService;
|
||||||
import com.gxwebsoft.common.system.service.SettingService;
|
import com.gxwebsoft.common.system.service.SettingService;
|
||||||
|
import com.gxwebsoft.common.system.service.TenantSubscriptionOrderService;
|
||||||
import com.wechat.pay.java.core.Config;
|
import com.wechat.pay.java.core.Config;
|
||||||
import com.wechat.pay.java.core.RSAAutoCertificateConfig;
|
import com.wechat.pay.java.core.RSAAutoCertificateConfig;
|
||||||
|
import com.wechat.pay.java.core.notification.NotificationConfig;
|
||||||
|
import com.wechat.pay.java.core.notification.NotificationParser;
|
||||||
|
import com.wechat.pay.java.core.notification.RSANotificationConfig;
|
||||||
|
import com.wechat.pay.java.service.partnerpayments.jsapi.model.Transaction;
|
||||||
import com.wechat.pay.java.service.payments.nativepay.NativePayService;
|
import com.wechat.pay.java.service.payments.nativepay.NativePayService;
|
||||||
import com.wechat.pay.java.service.payments.nativepay.model.Amount;
|
import com.wechat.pay.java.service.payments.nativepay.model.Amount;
|
||||||
import com.wechat.pay.java.service.payments.nativepay.model.PrepayRequest;
|
import com.wechat.pay.java.service.payments.nativepay.model.PrepayRequest;
|
||||||
import com.wechat.pay.java.service.payments.nativepay.model.PrepayResponse;
|
import com.wechat.pay.java.service.payments.nativepay.model.PrepayResponse;
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
@@ -62,6 +69,8 @@ public class WxNativePayController extends BaseController {
|
|||||||
private CertificateService certificateService;
|
private CertificateService certificateService;
|
||||||
@Resource
|
@Resource
|
||||||
private CertificateProperties certificateProperties;
|
private CertificateProperties certificateProperties;
|
||||||
|
@Resource
|
||||||
|
private TenantSubscriptionOrderService subscriptionOrderService;
|
||||||
|
|
||||||
|
|
||||||
@Operation(summary = "生成付款码")
|
@Operation(summary = "生成付款码")
|
||||||
@@ -92,7 +101,10 @@ public class WxNativePayController extends BaseController {
|
|||||||
PrepayRequest request = new PrepayRequest();
|
PrepayRequest request = new PrepayRequest();
|
||||||
// 计算金额
|
// 计算金额
|
||||||
order.setMoney(new BigDecimal(order.getPayPrice().toString()));
|
order.setMoney(new BigDecimal(order.getPayPrice().toString()));
|
||||||
order.setOrderNo(CommonUtil.createOrderNo());
|
// 若调用方已指定订单号(如订阅订单),则保留以关联回调查询;否则生成随机号
|
||||||
|
if (order.getOrderNo() == null || order.getOrderNo().trim().isEmpty()) {
|
||||||
|
order.setOrderNo(CommonUtil.createOrderNo());
|
||||||
|
}
|
||||||
BigDecimal decimal = order.getMoney();
|
BigDecimal decimal = order.getMoney();
|
||||||
final BigDecimal multiply = decimal.multiply(new BigDecimal(100));
|
final BigDecimal multiply = decimal.multiply(new BigDecimal(100));
|
||||||
// 将 BigDecimal 转换为 Integer
|
// 将 BigDecimal 转换为 Integer
|
||||||
@@ -229,20 +241,67 @@ public class WxNativePayController extends BaseController {
|
|||||||
@Schema(description = "异步通知")
|
@Schema(description = "异步通知")
|
||||||
@PostMapping("/notify/{tenantId}")
|
@PostMapping("/notify/{tenantId}")
|
||||||
public String wxNotify(@RequestHeader Map<String, String> header, @RequestBody String body, @PathVariable("tenantId") Integer tenantId) {
|
public String wxNotify(@RequestHeader Map<String, String> header, @RequestBody String body, @PathVariable("tenantId") Integer tenantId) {
|
||||||
System.out.println("异步通知*************** = ");
|
log.info("微信Native支付异步通知, tenantId={}", tenantId);
|
||||||
System.out.println("request header = " + header);
|
try {
|
||||||
System.out.println("request body = " + body);
|
// 1) 获取支付配置用于验签/解密(优先 Native 支付配置,兜底会员支付配置,再兜底静态测试商户)
|
||||||
System.out.println("tenantId = " + tenantId);
|
Payment payment = redisUtil.get("Payment:wxPay:".concat(tenantId.toString()), Payment.class);
|
||||||
|
if (payment == null) {
|
||||||
|
payment = redisUtil.get("Payment:1:".concat(tenantId.toString()), Payment.class);
|
||||||
|
}
|
||||||
|
String apiV3Key;
|
||||||
|
String apiclientCert;
|
||||||
|
if (payment != null) {
|
||||||
|
apiV3Key = payment.getApiKey();
|
||||||
|
apiclientCert = config.getUploadPath().concat("/file").concat(payment.getApiclientCert());
|
||||||
|
} else {
|
||||||
|
apiV3Key = WxNativePayController.apiV3Key;
|
||||||
|
apiclientCert = config.getUploadPath().concat("/file/wxpay/apiclient_cert.pem");
|
||||||
|
}
|
||||||
|
|
||||||
// 推送微信官方支付结果(携带租户ID的POST请求)
|
com.wechat.pay.java.core.notification.RequestParam requestParam = new com.wechat.pay.java.core.notification.RequestParam.Builder()
|
||||||
// final String string = requestUtil.pushWxPayNotify(transaction, payment);
|
.serialNumber(header.get("wechatpay-serial"))
|
||||||
|
.nonce(header.get("wechatpay-nonce"))
|
||||||
|
.signature(header.get("wechatpay-signature"))
|
||||||
|
.timestamp(header.get("wechatpay-timestamp"))
|
||||||
|
.body(body)
|
||||||
|
.build();
|
||||||
|
|
||||||
// 获取支付配置信息用于解密
|
NotificationConfig notificationConfig = new RSANotificationConfig.Builder()
|
||||||
final SettingParam param = new SettingParam();
|
.apiV3Key(apiV3Key)
|
||||||
param.setSettingKey("payment");
|
.certificatesFromPath(apiclientCert)
|
||||||
param.setTenantId(tenantId);
|
.build();
|
||||||
final String uploadPath = config.getUploadPath(); // 服务器本地路径
|
|
||||||
|
|
||||||
return "fail";
|
NotificationParser parser = new NotificationParser(notificationConfig);
|
||||||
|
|
||||||
|
// 2) 验签 + 解密,拿到 Transaction
|
||||||
|
Transaction transaction = parser.parse(requestParam, Transaction.class);
|
||||||
|
if (transaction == null) {
|
||||||
|
log.warn("微信Native支付回调解析失败: transaction 为空");
|
||||||
|
return "fail";
|
||||||
|
}
|
||||||
|
if (transaction.getTradeState() != Transaction.TradeStateEnum.SUCCESS) {
|
||||||
|
log.info("微信Native支付回调:交易状态非成功, state={}", transaction.getTradeState());
|
||||||
|
return "SUCCESS";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 按 out_trade_no(=订阅订单号)翻订单状态:0待支付→1已支付→2已激活
|
||||||
|
String outTradeNo = transaction.getOutTradeNo();
|
||||||
|
TenantSubscriptionOrder order = subscriptionOrderService.getByOrderNo(outTradeNo);
|
||||||
|
if (order == null) {
|
||||||
|
log.warn("微信Native支付回调:订单不存在, outTradeNo={}", outTradeNo);
|
||||||
|
return "SUCCESS";
|
||||||
|
}
|
||||||
|
if (order.getOrderStatus() == 0) {
|
||||||
|
subscriptionOrderService.payOrder(outTradeNo, "wechat", transaction.getTransactionId());
|
||||||
|
subscriptionOrderService.activateOrder(outTradeNo);
|
||||||
|
log.info("订阅订单支付成功并激活, orderNo={}, tenantId={}", outTradeNo, tenantId);
|
||||||
|
} else {
|
||||||
|
log.info("订阅订单已处理(幂等),orderNo={}, status={}", outTradeNo, order.getOrderStatus());
|
||||||
|
}
|
||||||
|
return "SUCCESS";
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("微信Native支付回调处理失败: {}", e.getMessage(), e);
|
||||||
|
return "fail";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.gxwebsoft.common.system.controller;
|
package com.gxwebsoft.common.system.controller;
|
||||||
|
|
||||||
|
import cn.hutool.core.date.DateUtil;
|
||||||
import cn.hutool.core.util.RandomUtil;
|
import cn.hutool.core.util.RandomUtil;
|
||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
import cn.hutool.core.util.XmlUtil;
|
import cn.hutool.core.util.XmlUtil;
|
||||||
@@ -10,6 +11,11 @@ import com.alibaba.fastjson.JSON;
|
|||||||
import com.alibaba.fastjson.JSONObject;
|
import com.alibaba.fastjson.JSONObject;
|
||||||
import com.alipay.api.internal.util.file.IOUtils;
|
import com.alipay.api.internal.util.file.IOUtils;
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.qq.weixin.mp.aes.WXBizJsonMsgCrypt;
|
||||||
|
import com.gxwebsoft.auto.dto.QrLoginData;
|
||||||
|
import com.gxwebsoft.common.core.config.ConfigProperties;
|
||||||
|
import com.gxwebsoft.common.core.security.JwtSubject;
|
||||||
|
import com.gxwebsoft.common.core.security.JwtUtil;
|
||||||
import com.gxwebsoft.common.core.utils.CommonUtil;
|
import com.gxwebsoft.common.core.utils.CommonUtil;
|
||||||
import com.gxwebsoft.common.core.utils.JSONUtil;
|
import com.gxwebsoft.common.core.utils.JSONUtil;
|
||||||
import com.gxwebsoft.common.core.utils.RedisUtil;
|
import com.gxwebsoft.common.core.utils.RedisUtil;
|
||||||
@@ -19,13 +25,17 @@ import com.gxwebsoft.common.system.entity.*;
|
|||||||
import com.gxwebsoft.common.system.param.RoleParam;
|
import com.gxwebsoft.common.system.param.RoleParam;
|
||||||
import com.gxwebsoft.common.system.param.UserParam;
|
import com.gxwebsoft.common.system.param.UserParam;
|
||||||
import com.gxwebsoft.common.system.service.RoleService;
|
import com.gxwebsoft.common.system.service.RoleService;
|
||||||
|
import com.gxwebsoft.common.system.service.SettingService;
|
||||||
import com.gxwebsoft.common.system.service.UserOauthService;
|
import com.gxwebsoft.common.system.service.UserOauthService;
|
||||||
import com.gxwebsoft.common.system.service.UserRoleService;
|
import com.gxwebsoft.common.system.service.UserRoleService;
|
||||||
import com.gxwebsoft.common.system.service.UserService;
|
import com.gxwebsoft.common.system.service.UserService;
|
||||||
|
import com.gxwebsoft.common.system.service.UserSyncService;
|
||||||
|
import com.gxwebsoft.common.system.service.WxService;
|
||||||
import com.gxwebsoft.common.system.vo.WxOfficialButton;
|
import com.gxwebsoft.common.system.vo.WxOfficialButton;
|
||||||
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 lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.util.CollectionUtils;
|
||||||
import org.apache.http.HttpEntity;
|
import org.apache.http.HttpEntity;
|
||||||
import org.apache.http.client.methods.CloseableHttpResponse;
|
import org.apache.http.client.methods.CloseableHttpResponse;
|
||||||
import org.apache.http.client.methods.HttpPost;
|
import org.apache.http.client.methods.HttpPost;
|
||||||
@@ -62,6 +72,15 @@ public class WxOfficialController extends BaseController {
|
|||||||
private static final String miniAppid = "wx541db955e7a62709";
|
private static final String miniAppid = "wx541db955e7a62709";
|
||||||
// 创建公众号菜单
|
// 创建公众号菜单
|
||||||
private static final String MENU_CREATE_URL = "https://api.weixin.qq.com/cgi-bin/menu/create?access_token=";
|
private static final String MENU_CREATE_URL = "https://api.weixin.qq.com/cgi-bin/menu/create?access_token=";
|
||||||
|
// 生成二维码接口
|
||||||
|
private static final String QRCODE_CREATE_URL = "https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=";
|
||||||
|
// 查看二维码接口
|
||||||
|
private static final String QRCODE_SHOW_URL = "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=";
|
||||||
|
|
||||||
|
// 微信服务器配置(从配置文件读取或使用默认值)
|
||||||
|
private static final String TOKEN = "gxwebsoft";
|
||||||
|
private static final String ENCODING_AES_KEY = "ARve4au5GF2fE2cT13xpaHhuqS2yjE34gpVe8IZwd4C";
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private UserService userService;
|
private UserService userService;
|
||||||
@Resource
|
@Resource
|
||||||
@@ -69,9 +88,17 @@ public class WxOfficialController extends BaseController {
|
|||||||
@Resource
|
@Resource
|
||||||
private UserRoleService userRoleService;
|
private UserRoleService userRoleService;
|
||||||
@Resource
|
@Resource
|
||||||
|
private UserSyncService userSyncService;
|
||||||
|
@Resource
|
||||||
private UserOauthService userOauthService;
|
private UserOauthService userOauthService;
|
||||||
@Resource
|
@Resource
|
||||||
|
private SettingService settingService;
|
||||||
|
@Resource
|
||||||
|
private WxService wxService;
|
||||||
|
@Resource
|
||||||
private RedisUtil redisUtil;
|
private RedisUtil redisUtil;
|
||||||
|
@Resource
|
||||||
|
private ConfigProperties configProperties;
|
||||||
|
|
||||||
@Operation(summary = "验证微信服务器")
|
@Operation(summary = "验证微信服务器")
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
@@ -81,7 +108,7 @@ public class WxOfficialController extends BaseController {
|
|||||||
if (tenantId == null) {
|
if (tenantId == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
String token = "gxwebsoft";
|
String token = getOfficialToken();
|
||||||
String[] array = new String[]{token, timestamp, nonce};
|
String[] array = new String[]{token, timestamp, nonce};
|
||||||
// 将token、timestamp、nonce三个参数进行字典序排序
|
// 将token、timestamp、nonce三个参数进行字典序排序
|
||||||
Arrays.sort(array);
|
Arrays.sort(array);
|
||||||
@@ -97,90 +124,309 @@ public class WxOfficialController extends BaseController {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "接收微信的xml消息")
|
@Operation(summary = "接收微信的消息推送")
|
||||||
@Transactional(rollbackFor = {Exception.class})
|
@Transactional(rollbackFor = {Exception.class})
|
||||||
@RequestMapping("/{id}")
|
@PostMapping("/{id}")
|
||||||
@ResponseBody
|
@ResponseBody
|
||||||
public String receiveMessages(HttpServletRequest request, @PathVariable("id") Integer tenantId) throws IOException {
|
public String receiveMessages(HttpServletRequest request, @PathVariable("id") Integer tenantId,
|
||||||
|
@RequestParam(required = false) String msg_signature,
|
||||||
|
@RequestParam(required = false) String timestamp,
|
||||||
|
@RequestParam(required = false) String nonce) throws Exception {
|
||||||
|
System.out.println("========== 接收微信消息 ==========");
|
||||||
System.out.println("tenantId = " + tenantId);
|
System.out.println("tenantId = " + tenantId);
|
||||||
Integer userId = 0; // 用户ID
|
System.out.println("msg_signature = " + msg_signature);
|
||||||
|
|
||||||
// 从请求中获取XML数据
|
// 从请求中获取XML数据
|
||||||
String xmlData = IOUtils.toString(request.getInputStream(), "UTF-8");
|
String xmlData = IOUtils.toString(request.getInputStream(), "UTF-8");
|
||||||
System.out.println("xmlData = " + xmlData);
|
System.out.println("原始xmlData = " + xmlData);
|
||||||
|
|
||||||
|
// 如果有加密参数,进行解密
|
||||||
|
if (StrUtil.isNotBlank(msg_signature) && StrUtil.isNotBlank(xmlData) && xmlData.contains("Encrypt")) {
|
||||||
|
try {
|
||||||
|
Document encryptedDocument = XmlUtil.parseXml(xmlData);
|
||||||
|
Element encryptedRoot = XmlUtil.getRootElement(encryptedDocument);
|
||||||
|
Element encryptElement = XmlUtil.getElement(encryptedRoot, "Encrypt");
|
||||||
|
String encryptedMessage = encryptElement != null ? encryptElement.getTextContent() : "";
|
||||||
|
if (StrUtil.isBlank(encryptedMessage)) {
|
||||||
|
log.error("消息解密失败: 未从XML报文中提取到Encrypt节点");
|
||||||
|
return "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
WXBizJsonMsgCrypt crypt = new WXBizJsonMsgCrypt(getOfficialToken(), getOfficialEncodingAESKey(), getOfficialAppId(tenantId));
|
||||||
|
xmlData = crypt.DecryptXmlMsg(msg_signature, timestamp, nonce, encryptedMessage);
|
||||||
|
System.out.println("解密后xmlData = " + xmlData);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("消息解密失败: {}", e.getMessage());
|
||||||
|
return "error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 解析XML数据
|
// 解析XML数据
|
||||||
Document document = XmlUtil.parseXml(xmlData);
|
Document document = XmlUtil.parseXml(xmlData);
|
||||||
Element rootElement = XmlUtil.getRootElement(document);
|
Element rootElement = XmlUtil.getRootElement(document);
|
||||||
|
|
||||||
|
// 获取消息类型
|
||||||
|
Element msgTypeElement = XmlUtil.getElement(rootElement, "MsgType");
|
||||||
|
String msgType = msgTypeElement != null ? msgTypeElement.getTextContent() : "";
|
||||||
|
System.out.println("msgType = " + msgType);
|
||||||
|
|
||||||
|
// 获取事件类型(如果是事件消息)
|
||||||
|
Element eventElement = XmlUtil.getElement(rootElement, "Event");
|
||||||
|
String event = eventElement != null ? eventElement.getTextContent() : "";
|
||||||
|
System.out.println("event = " + event);
|
||||||
|
|
||||||
|
// 获取事件KEY(用于判断是否是扫码事件)
|
||||||
|
Element eventKeyElement = XmlUtil.getElement(rootElement, "EventKey");
|
||||||
|
String eventKey = eventKeyElement != null ? eventKeyElement.getTextContent() : "";
|
||||||
|
System.out.println("eventKey = " + eventKey);
|
||||||
|
|
||||||
|
// 获取用户openid
|
||||||
Element FromUserName = XmlUtil.getElement(rootElement, "FromUserName");
|
Element FromUserName = XmlUtil.getElement(rootElement, "FromUserName");
|
||||||
String openId = FromUserName.getTextContent();
|
String openId = FromUserName != null ? FromUserName.getTextContent() : "";
|
||||||
System.out.println("openId = " + openId);
|
System.out.println("openId = " + openId);
|
||||||
if (StrUtil.isNotBlank(openId)) {
|
|
||||||
// 获取用户基本信息(UnionID机制)
|
// 获取 ticket(扫码事件专用)
|
||||||
final String userStr = HttpUtil.get("https://api.weixin.qq.com/cgi-bin/user/info?access_token=" + getAccessToken() + "&openid=" + openId + "&lang=zh_CN");
|
Element ticketElement = XmlUtil.getElement(rootElement, "Ticket");
|
||||||
// 保存第三方用户信息表shop_user_oauth
|
String ticket = ticketElement != null ? ticketElement.getTextContent() : "";
|
||||||
final JSONObject jsonObject = JSONObject.parseObject(userStr);
|
System.out.println("ticket = " + ticket);
|
||||||
final String unionid = jsonObject.getString("unionid");
|
|
||||||
final String subscribe = jsonObject.getString("subscribe");
|
// 处理扫码关注事件
|
||||||
System.out.println("unionid = " + unionid);
|
if ("event".equals(msgType) && ("subscribe".equals(event) || "SCAN".equals(event))) {
|
||||||
sendTemplateMessage(openId);
|
System.out.println("========== 处理扫码关注事件 ==========");
|
||||||
// 关注操作
|
|
||||||
if (subscribe != null && subscribe.equals("1")) {
|
// 获取扫码的 token(从 EventKey 中提取,格式:qrscene_xxx 或直接是 xxx)
|
||||||
final int count = userOauthService.count(new LambdaQueryWrapper<UserOauth>().eq(UserOauth::getOauthType, MP_OFFICIAL).eq(UserOauth::getUnionid, unionid).eq(UserOauth::getTenantId, tenantId));
|
String token = "";
|
||||||
System.out.println("count = " + count);
|
if (StrUtil.isNotBlank(eventKey)) {
|
||||||
if (count == 0) {
|
if (eventKey.startsWith("qrscene_")) {
|
||||||
// 其他平台是否有注册过
|
token = eventKey.substring(8); // 去掉 qrscene_ 前缀
|
||||||
final List<UserOauth> list = userOauthService.list(new LambdaQueryWrapper<UserOauth>().eq(UserOauth::getUnionid, unionid).eq(UserOauth::getDeleted, 0));
|
} else {
|
||||||
final int size = list.size();
|
token = eventKey;
|
||||||
// 新用户注册
|
|
||||||
if (size == 0) {
|
|
||||||
User user = new User();
|
|
||||||
user.setStatus(0);
|
|
||||||
user.setUsername("wxoff_".concat(RandomUtil.randomString(12)));
|
|
||||||
user.setStatus(0);
|
|
||||||
user.setNickname("微信公众号用户");
|
|
||||||
user.setPlatform(MP_OFFICIAL);
|
|
||||||
user.setGradeId(1);
|
|
||||||
user.setPassword(userService.encodePassword(CommonUtil.randomUUID16()));
|
|
||||||
user.setTenantId(tenantId);
|
|
||||||
user.setRecommend(0);
|
|
||||||
final RoleParam roleParam = new RoleParam();
|
|
||||||
roleParam.setTenantId(tenantId);
|
|
||||||
roleParam.setRoleCode("guest");
|
|
||||||
Role role = roleService.getByRoleCode(roleParam);
|
|
||||||
user.setRoleId(role.getRoleId());
|
|
||||||
if (userService.saveUser(user)) {
|
|
||||||
userId = user.getUserId();
|
|
||||||
// 添加用户角色
|
|
||||||
final UserRole userRole = new UserRole();
|
|
||||||
userRole.setUserId(user.getUserId());
|
|
||||||
userRole.setTenantId(user.getTenantId());
|
|
||||||
userRole.setRoleId(user.getRoleId());
|
|
||||||
userRoleService.save(userRole);
|
|
||||||
}
|
|
||||||
System.out.println("新微信公众号用户 = " + userId);
|
|
||||||
}
|
|
||||||
// 更新
|
|
||||||
if (!CollectionUtils.isEmpty(list)) {
|
|
||||||
for (UserOauth item : list) {
|
|
||||||
if (item.getUserId() != null) {
|
|
||||||
userId = item.getUserId();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
System.out.println("其他平台有注册过 = " + userId);
|
|
||||||
}
|
|
||||||
// 保存第三方用户记录
|
|
||||||
final UserOauth userOauth = new UserOauth();
|
|
||||||
userOauth.setOauthType(MP_OFFICIAL);
|
|
||||||
userOauth.setUnionid(unionid);
|
|
||||||
userOauth.setOauthId(openId);
|
|
||||||
userOauth.setUserId(userId);
|
|
||||||
userOauth.setTenantId(tenantId);
|
|
||||||
boolean save = userOauthService.save(userOauth);
|
|
||||||
System.out.println("关注微信公众号 = " + save);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
System.out.println("扫码登录token = " + token);
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
if (StrUtil.isNotBlank(openId)) {
|
||||||
|
// 获取用户基本信息(UnionID机制)
|
||||||
|
final String userStr = HttpUtil.get("https://api.weixin.qq.com/cgi-bin/user/info?access_token=" + getAccessToken(tenantId) + "&openid=" + openId + "&lang=zh_CN");
|
||||||
|
final JSONObject jsonObject = JSONObject.parseObject(userStr);
|
||||||
|
final String unionid = jsonObject.getString("unionid");
|
||||||
|
final String subscribe = jsonObject.getString("subscribe");
|
||||||
|
System.out.println("unionid = " + unionid);
|
||||||
|
System.out.println("subscribe = " + subscribe);
|
||||||
|
|
||||||
|
Integer userId = processWxUser(tenantId, openId, unionid, subscribe);
|
||||||
|
|
||||||
|
// 如果有关联的扫码登录token,完成登录
|
||||||
|
if (StrUtil.isNotBlank(token) && userId != null && userId > 0) {
|
||||||
|
completeQrLogin(token, userId, tenantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回 success 表示处理成功
|
||||||
|
return "success";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理微信用户(关注/注册/登录)
|
||||||
|
*/
|
||||||
|
private Integer processWxUser(Integer tenantId, String openId, String unionid, String subscribe) {
|
||||||
|
Integer userId = 0;
|
||||||
|
|
||||||
|
// 关注操作
|
||||||
|
if (subscribe != null && subscribe.equals("1")) {
|
||||||
|
final int count = userOauthService.count(new LambdaQueryWrapper<UserOauth>()
|
||||||
|
.eq(UserOauth::getOauthType, MP_OFFICIAL)
|
||||||
|
.eq(UserOauth::getUnionid, unionid)
|
||||||
|
.eq(UserOauth::getTenantId, tenantId));
|
||||||
|
System.out.println("已绑定用户数量 = " + count);
|
||||||
|
|
||||||
|
if (count == 0) {
|
||||||
|
// 检查其他平台是否有注册过
|
||||||
|
final List<UserOauth> list = userOauthService.list(
|
||||||
|
new LambdaQueryWrapper<UserOauth>()
|
||||||
|
.eq(UserOauth::getUnionid, unionid)
|
||||||
|
.eq(UserOauth::getDeleted, 0));
|
||||||
|
final int size = list.size();
|
||||||
|
|
||||||
|
// 新用户注册
|
||||||
|
if (size == 0) {
|
||||||
|
User user = new User();
|
||||||
|
user.setStatus(0);
|
||||||
|
user.setUsername("wxoff_".concat(RandomUtil.randomString(12)));
|
||||||
|
user.setNickname("微信公众号用户");
|
||||||
|
user.setPlatform(MP_OFFICIAL);
|
||||||
|
user.setGradeId(1);
|
||||||
|
user.setPassword(userService.encodePassword(CommonUtil.randomUUID16()));
|
||||||
|
user.setTenantId(tenantId);
|
||||||
|
user.setRecommend(0);
|
||||||
|
final RoleParam roleParam = new RoleParam();
|
||||||
|
roleParam.setTenantId(tenantId);
|
||||||
|
roleParam.setRoleCode("user");
|
||||||
|
Role role = roleService.getByRoleCode(roleParam);
|
||||||
|
user.setRoleId(role.getRoleId());
|
||||||
|
if (userService.saveUser(user)) {
|
||||||
|
userId = user.getUserId();
|
||||||
|
// 添加用户角色
|
||||||
|
final UserRole userRole = new UserRole();
|
||||||
|
userRole.setUserId(user.getUserId());
|
||||||
|
userRole.setTenantId(user.getTenantId());
|
||||||
|
userRole.setRoleId(user.getRoleId());
|
||||||
|
userRoleService.save(userRole);
|
||||||
|
// 注意:不立即同步到 websopy,等绑定手机号后再同步
|
||||||
|
}
|
||||||
|
System.out.println("新微信公众号用户 userId = " + userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新
|
||||||
|
if (!CollectionUtils.isEmpty(list)) {
|
||||||
|
for (UserOauth item : list) {
|
||||||
|
if (item.getUserId() != null) {
|
||||||
|
userId = item.getUserId();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
System.out.println("其他平台有注册过 userId = " + userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存第三方用户记录
|
||||||
|
final UserOauth userOauth = new UserOauth();
|
||||||
|
userOauth.setOauthType(MP_OFFICIAL);
|
||||||
|
userOauth.setUnionid(unionid);
|
||||||
|
userOauth.setOauthId(openId);
|
||||||
|
userOauth.setUserId(userId);
|
||||||
|
userOauth.setTenantId(tenantId);
|
||||||
|
boolean save = userOauthService.save(userOauth);
|
||||||
|
System.out.println("关注微信公众号保存结果 = " + save);
|
||||||
|
} else {
|
||||||
|
// 已绑定用户,获取userId
|
||||||
|
UserOauth existingUser = userOauthService.getOne(new LambdaQueryWrapper<UserOauth>()
|
||||||
|
.eq(UserOauth::getOauthType, MP_OFFICIAL)
|
||||||
|
.eq(UserOauth::getUnionid, unionid)
|
||||||
|
.eq(UserOauth::getTenantId, tenantId));
|
||||||
|
if (existingUser != null) {
|
||||||
|
userId = existingUser.getUserId();
|
||||||
|
System.out.println("已存在绑定用户 userId = " + userId);
|
||||||
|
// 即使已存在记录,也确保oauth记录存在(防止数据不一致)
|
||||||
|
boolean oauthExists = userOauthService.count(new LambdaQueryWrapper<UserOauth>()
|
||||||
|
.eq(UserOauth::getOauthType, MP_OFFICIAL)
|
||||||
|
.eq(UserOauth::getUnionid, unionid)
|
||||||
|
.eq(UserOauth::getTenantId, tenantId)
|
||||||
|
.eq(UserOauth::getOauthId, openId)) > 0;
|
||||||
|
if (!oauthExists) {
|
||||||
|
// 创建oauth记录
|
||||||
|
final UserOauth userOauth = new UserOauth();
|
||||||
|
userOauth.setOauthType(MP_OFFICIAL);
|
||||||
|
userOauth.setUnionid(unionid);
|
||||||
|
userOauth.setOauthId(openId);
|
||||||
|
userOauth.setUserId(userId);
|
||||||
|
userOauth.setTenantId(tenantId);
|
||||||
|
boolean save = userOauthService.save(userOauth);
|
||||||
|
System.out.println("补充创建oauth记录,结果 = " + save);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
System.out.println("警告:count=1但未找到对应的绑定记录,unionid=" + unionid + ", tenantId=" + tenantId);
|
||||||
|
// 这种情况可能是数据不一致,尝试创建新的oauth记录
|
||||||
|
userId = findOrCreateUserForOauth(tenantId, openId, unionid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完成扫码登录
|
||||||
|
*/
|
||||||
|
private void completeQrLogin(String token, Integer userId, Integer tenantId) {
|
||||||
|
try {
|
||||||
|
System.out.println("开始完成扫码登录: token=" + token + ", userId=" + userId + ", tenantId=" + tenantId);
|
||||||
|
String redisKey = "qr-login:token:" + token;
|
||||||
|
QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class);
|
||||||
|
if (qrLoginData == null) {
|
||||||
|
qrLoginData = new QrLoginData();
|
||||||
|
qrLoginData.setToken(token);
|
||||||
|
qrLoginData.setCreateTime(DateUtil.formatDateTime(DateUtil.date()));
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = userService.getAllByUserId(String.valueOf(userId));
|
||||||
|
if (user == null) {
|
||||||
|
log.warn("扫码登录完成时未找到用户,token={}, userId={}", token, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 检查并确保用户有合适的角色
|
||||||
|
ensureUserHasAppropriateRole(user, tenantId);
|
||||||
|
|
||||||
|
long ttlSeconds = 120L;
|
||||||
|
qrLoginData.setToken(token);
|
||||||
|
qrLoginData.setUserId(userId);
|
||||||
|
qrLoginData.setUsername(user.getUsername());
|
||||||
|
qrLoginData.setTenantId(user.getTenantId() != null ? user.getTenantId() : tenantId);
|
||||||
|
qrLoginData.setExpireTime(DateUtil.formatDateTime(DateUtil.offsetSecond(DateUtil.date(), (int) ttlSeconds)));
|
||||||
|
|
||||||
|
if (StrUtil.isBlank(user.getPhone())) {
|
||||||
|
qrLoginData.setStatus("bind_phone");
|
||||||
|
qrLoginData.setNeedBindPhone(true);
|
||||||
|
qrLoginData.setAccessToken(null);
|
||||||
|
qrLoginData.setMessage("请先绑定手机号完成登录");
|
||||||
|
} else {
|
||||||
|
qrLoginData.setStatus("confirmed");
|
||||||
|
qrLoginData.setNeedBindPhone(false);
|
||||||
|
qrLoginData.setAccessToken(buildAccessToken(user));
|
||||||
|
qrLoginData.setMessage("登录成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
redisUtil.set(redisKey, qrLoginData, ttlSeconds, TimeUnit.SECONDS);
|
||||||
|
log.info("扫码登录状态已更新,token={}, userId={}, status={}, needBindPhone={}, message={}, ttlSeconds={}",
|
||||||
|
token, userId, qrLoginData.getStatus(), qrLoginData.getNeedBindPhone(),
|
||||||
|
qrLoginData.getMessage(), ttlSeconds);
|
||||||
|
System.out.println("扫码登录完成,token=" + token + ", userId=" + userId +
|
||||||
|
", status=" + qrLoginData.getStatus() + ", message=" + qrLoginData.getMessage());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("完成扫码登录失败", e);
|
||||||
|
System.out.println("完成扫码登录异常: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildAccessToken(User user) {
|
||||||
|
JwtSubject jwtSubject = new JwtSubject(user.getUsername(), user.getTenantId());
|
||||||
|
return JwtUtil.buildToken(jwtSubject, configProperties.getTokenExpireTime(), configProperties.getTokenKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "生成微信扫码登录二维码")
|
||||||
|
@GetMapping("/qrcode/{token}")
|
||||||
|
public ApiResult<?> generateQrCode(@PathVariable("token") String token) {
|
||||||
|
try {
|
||||||
|
// 生成带参数的二维码,scene 为 token
|
||||||
|
String url = QRCODE_CREATE_URL + getAccessToken();
|
||||||
|
|
||||||
|
// 创建临时二维码(有效期7天),scene_str 最大32个可见字符
|
||||||
|
JSONObject params = new JSONObject();
|
||||||
|
params.put("action_info", new JSONObject().put("scene", new JSONObject().put("scene_str", token)));
|
||||||
|
params.put("action_name", "QR_STR_SCENE");
|
||||||
|
params.put("expire_seconds", 604800); // 7天有效期
|
||||||
|
|
||||||
|
String result = HttpRequest.post(url)
|
||||||
|
.body(params.toJSONString())
|
||||||
|
.timeout(10000)
|
||||||
|
.execute().body();
|
||||||
|
|
||||||
|
System.out.println("生成二维码结果: " + result);
|
||||||
|
|
||||||
|
JSONObject jsonResult = JSONObject.parseObject(result);
|
||||||
|
if (jsonResult.containsKey("ticket")) {
|
||||||
|
String ticket = jsonResult.getString("ticket");
|
||||||
|
String qrCodeUrl = QRCODE_SHOW_URL + java.net.URLEncoder.encode(ticket, "UTF-8");
|
||||||
|
|
||||||
|
return success(qrCodeUrl);
|
||||||
|
} else {
|
||||||
|
return fail("生成二维码失败: " + result);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("生成二维码异常: {}", e.getMessage());
|
||||||
|
return fail("生成二维码异常: " + e.getMessage());
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendTemplateMessage(String openId) {
|
private void sendTemplateMessage(String openId) {
|
||||||
@@ -268,30 +514,65 @@ public class WxOfficialController extends BaseController {
|
|||||||
|
|
||||||
// 调用接口凭证
|
// 调用接口凭证
|
||||||
private String getAccessToken() {
|
private String getAccessToken() {
|
||||||
|
return getAccessToken(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getAccessToken(Integer tenantId) {
|
||||||
|
try {
|
||||||
|
return wxService.getOfficialAccessToken(tenantId);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("从系统设置获取公众号access_token失败,回退到兼容逻辑: {}", ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
String key = MP_OFFICIAL.concat(":access_token:5");
|
String key = MP_OFFICIAL.concat(":access_token:5");
|
||||||
// 从缓存获取access_token
|
|
||||||
String value = redisUtil.get(key);
|
String value = redisUtil.get(key);
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
// 解析access_token
|
|
||||||
JSONObject response = JSON.parseObject(value);
|
JSONObject response = JSON.parseObject(value);
|
||||||
return response.getString("access_token");
|
return response.getString("access_token");
|
||||||
}
|
}
|
||||||
// 微信获取凭证接口https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET
|
|
||||||
String apiUrl = "https://api.weixin.qq.com/cgi-bin/token";
|
String apiUrl = "https://api.weixin.qq.com/cgi-bin/token";
|
||||||
// 组装url参数
|
|
||||||
String url = apiUrl.concat("?grant_type=client_credential").concat("&appid=").concat(appid).concat("&secret=").concat(secret);
|
String url = apiUrl.concat("?grant_type=client_credential").concat("&appid=").concat(appid).concat("&secret=").concat(secret);
|
||||||
// 执行get请求
|
|
||||||
String result = HttpUtil.get(url);
|
String result = HttpUtil.get(url);
|
||||||
// 解析access_token
|
|
||||||
JSONObject response = JSON.parseObject(result);
|
JSONObject response = JSON.parseObject(result);
|
||||||
if (response.getString("access_token") != null) {
|
if (response.getString("access_token") != null) {
|
||||||
// 存入缓存
|
|
||||||
redisUtil.set(key, result, 7000L, TimeUnit.SECONDS);
|
redisUtil.set(key, result, 7000L, TimeUnit.SECONDS);
|
||||||
return response.getString("access_token");
|
return response.getString("access_token");
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String getOfficialToken() {
|
||||||
|
try {
|
||||||
|
JSONObject config = settingService.getBySettingKey("wx-official");
|
||||||
|
String token = config.getString("token");
|
||||||
|
return StrUtil.isNotBlank(token) ? token : TOKEN;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("读取公众号token配置失败,回退到硬编码值: {}", ex.getMessage());
|
||||||
|
return TOKEN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getOfficialEncodingAESKey() {
|
||||||
|
try {
|
||||||
|
JSONObject config = settingService.getBySettingKey("wx-official");
|
||||||
|
String encodingAESKey = config.getString("encodingAESKey");
|
||||||
|
return StrUtil.isNotBlank(encodingAESKey) ? encodingAESKey : ENCODING_AES_KEY;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("读取公众号EncodingAESKey配置失败,回退到硬编码值: {}", ex.getMessage());
|
||||||
|
return ENCODING_AES_KEY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getOfficialAppId(Integer tenantId) {
|
||||||
|
try {
|
||||||
|
return wxService.getOfficialAppId(tenantId);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("读取公众号AppId配置失败,回退到硬编码值: {}", ex.getMessage());
|
||||||
|
return appid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Operation(summary = "创建公众号菜单接口")
|
@Operation(summary = "创建公众号菜单接口")
|
||||||
@PostMapping("/createMenu")
|
@PostMapping("/createMenu")
|
||||||
public ApiResult<?> createMenu() {
|
public ApiResult<?> createMenu() {
|
||||||
@@ -346,4 +627,168 @@ public class WxOfficialController extends BaseController {
|
|||||||
return success(getAccessToken());
|
return success(getAccessToken());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找或创建用户用于oauth绑定
|
||||||
|
*/
|
||||||
|
private Integer findOrCreateUserForOauth(Integer tenantId, String openId, String unionid) {
|
||||||
|
Integer userId = 0;
|
||||||
|
// 首先尝试通过unionid查找任何平台的现有用户
|
||||||
|
final List<UserOauth> list = userOauthService.list(
|
||||||
|
new LambdaQueryWrapper<UserOauth>()
|
||||||
|
.eq(UserOauth::getUnionid, unionid)
|
||||||
|
.eq(UserOauth::getDeleted, 0));
|
||||||
|
|
||||||
|
if (!CollectionUtils.isEmpty(list)) {
|
||||||
|
for (UserOauth item : list) {
|
||||||
|
if (item.getUserId() != null) {
|
||||||
|
userId = item.getUserId();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
System.out.println("数据不一致:通过unionid找到其他平台的用户 userId = " + userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没找到用户,创建一个新用户
|
||||||
|
if (userId == 0) {
|
||||||
|
User user = new User();
|
||||||
|
user.setStatus(0);
|
||||||
|
user.setUsername("wxoff_".concat(RandomUtil.randomString(12)));
|
||||||
|
user.setNickname("微信公众号用户");
|
||||||
|
user.setPlatform(MP_OFFICIAL);
|
||||||
|
user.setGradeId(1);
|
||||||
|
user.setPassword(userService.encodePassword(CommonUtil.randomUUID16()));
|
||||||
|
user.setTenantId(tenantId);
|
||||||
|
user.setRecommend(0);
|
||||||
|
|
||||||
|
// 尝试获取"user"角色,不行就用"guest",再不行用默认6
|
||||||
|
Role role = null;
|
||||||
|
try {
|
||||||
|
final RoleParam roleParam = new RoleParam();
|
||||||
|
roleParam.setTenantId(tenantId);
|
||||||
|
roleParam.setRoleCode("user");
|
||||||
|
role = roleService.getByRoleCode(roleParam);
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println("获取user角色失败,尝试guest角色");
|
||||||
|
try {
|
||||||
|
final RoleParam roleParam = new RoleParam();
|
||||||
|
roleParam.setTenantId(tenantId);
|
||||||
|
roleParam.setRoleCode("guest");
|
||||||
|
role = roleService.getByRoleCode(roleParam);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
System.out.println("获取guest角色也失败,使用默认角色ID 6");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user.setRoleId(role != null ? role.getRoleId() : 6);
|
||||||
|
|
||||||
|
if (userService.saveUser(user)) {
|
||||||
|
userId = user.getUserId();
|
||||||
|
// 添加用户角色
|
||||||
|
final UserRole userRole = new UserRole();
|
||||||
|
userRole.setUserId(user.getUserId());
|
||||||
|
userRole.setTenantId(user.getTenantId());
|
||||||
|
userRole.setRoleId(user.getRoleId());
|
||||||
|
userRoleService.save(userRole);
|
||||||
|
// 注意:不立即同步到 websopy,等绑定手机号后再同步
|
||||||
|
}
|
||||||
|
System.out.println("数据不一致:创建新用户 userId = " + userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建oauth记录
|
||||||
|
final UserOauth userOauth = new UserOauth();
|
||||||
|
userOauth.setOauthType(MP_OFFICIAL);
|
||||||
|
userOauth.setUnionid(unionid);
|
||||||
|
userOauth.setOauthId(openId);
|
||||||
|
userOauth.setUserId(userId);
|
||||||
|
userOauth.setTenantId(tenantId);
|
||||||
|
boolean save = userOauthService.save(userOauth);
|
||||||
|
System.out.println("创建oauth记录修复数据不一致,结果 = " + save);
|
||||||
|
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保用户有合适的角色
|
||||||
|
*/
|
||||||
|
private void ensureUserHasAppropriateRole(User user, Integer tenantId) {
|
||||||
|
try {
|
||||||
|
// 检查用户是否有有效的角色绑定
|
||||||
|
List<UserRole> userRoles = userRoleService.list(new LambdaQueryWrapper<UserRole>()
|
||||||
|
.eq(UserRole::getUserId, user.getUserId())
|
||||||
|
.eq(UserRole::getTenantId, tenantId));
|
||||||
|
|
||||||
|
if (CollectionUtils.isEmpty(userRoles)) {
|
||||||
|
System.out.println("用户 " + user.getUserId() + " 没有角色绑定,尝试分配角色");
|
||||||
|
|
||||||
|
// 获取合适的角色
|
||||||
|
Role role = null;
|
||||||
|
// 先尝试获取"user"角色
|
||||||
|
try {
|
||||||
|
final RoleParam roleParam = new RoleParam();
|
||||||
|
roleParam.setTenantId(tenantId);
|
||||||
|
roleParam.setRoleCode("user");
|
||||||
|
role = roleService.getByRoleCode(roleParam);
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println("获取user角色失败,尝试guest角色");
|
||||||
|
try {
|
||||||
|
final RoleParam roleParam = new RoleParam();
|
||||||
|
roleParam.setTenantId(tenantId);
|
||||||
|
roleParam.setRoleCode("guest");
|
||||||
|
role = roleService.getByRoleCode(roleParam);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
System.out.println("获取guest角色也失败,使用默认角色ID 6");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Integer roleId = role != null ? role.getRoleId() : 6;
|
||||||
|
|
||||||
|
// 创建用户角色绑定
|
||||||
|
final UserRole userRole = new UserRole();
|
||||||
|
userRole.setUserId(user.getUserId());
|
||||||
|
userRole.setTenantId(tenantId);
|
||||||
|
userRole.setRoleId(roleId);
|
||||||
|
userRoleService.save(userRole);
|
||||||
|
|
||||||
|
// 更新用户的roleId
|
||||||
|
user.setRoleId(roleId);
|
||||||
|
userService.updateUser(user);
|
||||||
|
|
||||||
|
System.out.println("为用户 " + user.getUserId() + " 分配了角色ID: " + roleId);
|
||||||
|
} else {
|
||||||
|
// 检查角色的有效性
|
||||||
|
boolean hasValidRole = false;
|
||||||
|
for (UserRole userRole : userRoles) {
|
||||||
|
if (userRole.getRoleId() != null && userRole.getRoleId() > 0) {
|
||||||
|
hasValidRole = true;
|
||||||
|
// 确保用户的roleId与绑定的角色一致
|
||||||
|
if (!userRole.getRoleId().equals(user.getRoleId())) {
|
||||||
|
user.setRoleId(userRole.getRoleId());
|
||||||
|
userService.updateUser(user);
|
||||||
|
System.out.println("更新用户 " + user.getUserId() + " 的角色ID为: " + userRole.getRoleId());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasValidRole) {
|
||||||
|
System.out.println("用户 " + user.getUserId() + " 的角色绑定无效,重新分配");
|
||||||
|
// 重新分配角色(简化逻辑,使用默认角色ID 6)
|
||||||
|
final UserRole userRole = new UserRole();
|
||||||
|
userRole.setUserId(user.getUserId());
|
||||||
|
userRole.setTenantId(tenantId);
|
||||||
|
userRole.setRoleId(6);
|
||||||
|
userRoleService.save(userRole);
|
||||||
|
|
||||||
|
user.setRoleId(6);
|
||||||
|
userService.updateUser(user);
|
||||||
|
|
||||||
|
System.out.println("重新为用户 " + user.getUserId() + " 分配了默认角色ID: 6");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println("确保用户角色时发生异常: " + e.getMessage());
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.IdType;
|
|||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -46,9 +47,11 @@ public class AccessKey implements Serializable {
|
|||||||
private Integer tenantId;
|
private Integer tenantId;
|
||||||
|
|
||||||
@Schema(description = "创建时间")
|
@Schema(description = "创建时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
@Schema(description = "修改时间")
|
@Schema(description = "修改时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date updateTime;
|
private Date updateTime;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.annotation.TableLogic;
|
|||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -47,9 +48,11 @@ public class AuthorizeCode implements Serializable {
|
|||||||
private Integer tenantId;
|
private Integer tenantId;
|
||||||
|
|
||||||
@Schema(description = "创建时间")
|
@Schema(description = "创建时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
@Schema(description = "修改时间")
|
@Schema(description = "修改时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date updateTime;
|
private Date updateTime;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.IdType;
|
|||||||
import com.baomidou.mybatisplus.annotation.TableField;
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -83,9 +84,11 @@ public class Cart implements Serializable {
|
|||||||
private Integer tenantId;
|
private Integer tenantId;
|
||||||
|
|
||||||
@Schema(description = "创建时间")
|
@Schema(description = "创建时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
@Schema(description = "修改时间")
|
@Schema(description = "修改时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date updateTime;
|
private Date updateTime;
|
||||||
|
|
||||||
@Schema(description = "商品描述")
|
@Schema(description = "商品描述")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.annotation.TableLogic;
|
|||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -56,9 +57,11 @@ public class ChatConversation implements Serializable {
|
|||||||
private Integer tenantId;
|
private Integer tenantId;
|
||||||
|
|
||||||
@Schema(description = "注册时间")
|
@Schema(description = "注册时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
@Schema(description = "修改时间")
|
@Schema(description = "修改时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date updateTime;
|
private Date updateTime;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import java.util.Date;
|
|||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -66,9 +67,11 @@ public class ChatMessage implements Serializable {
|
|||||||
private Integer tenantId;
|
private Integer tenantId;
|
||||||
|
|
||||||
@Schema(description = "注册时间")
|
@Schema(description = "注册时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
@Schema(description = "修改时间")
|
@Schema(description = "修改时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date updateTime;
|
private Date updateTime;
|
||||||
|
|
||||||
@Schema(description = "发送人昵称")
|
@Schema(description = "发送人昵称")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.gxwebsoft.common.system.entity;
|
|||||||
|
|
||||||
import cn.hutool.core.util.DesensitizedUtil;
|
import cn.hutool.core.util.DesensitizedUtil;
|
||||||
import com.baomidou.mybatisplus.annotation.*;
|
import com.baomidou.mybatisplus.annotation.*;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -100,9 +101,11 @@ public class Company implements Serializable {
|
|||||||
private String invoiceHeader;
|
private String invoiceHeader;
|
||||||
|
|
||||||
@Schema(description = "服务开始时间")
|
@Schema(description = "服务开始时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date startTime;
|
private Date startTime;
|
||||||
|
|
||||||
@Schema(description = "服务到期时间")
|
@Schema(description = "服务到期时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date expirationTime;
|
private Date expirationTime;
|
||||||
|
|
||||||
@Schema(description = "即将过期")
|
@Schema(description = "即将过期")
|
||||||
@@ -245,9 +248,11 @@ public class Company implements Serializable {
|
|||||||
private Integer deleted;
|
private Integer deleted;
|
||||||
|
|
||||||
@Schema(description = "创建时间")
|
@Schema(description = "创建时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
@Schema(description = "修改时间")
|
@Schema(description = "修改时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date updateTime;
|
private Date updateTime;
|
||||||
|
|
||||||
@Schema(description = "是否默认企业主体")
|
@Schema(description = "是否默认企业主体")
|
||||||
@@ -289,6 +294,10 @@ public class Company implements Serializable {
|
|||||||
@TableField(exist = false)
|
@TableField(exist = false)
|
||||||
private String password;
|
private String password;
|
||||||
|
|
||||||
|
@Schema(description = "应用代号(非数据库字段,注册时由前端传入,用于生成开通邮件中的域名,如 mp/shop/site)")
|
||||||
|
@TableField(exist = false)
|
||||||
|
private String appCode;
|
||||||
|
|
||||||
@Schema(description = "手机号(脱敏)")
|
@Schema(description = "手机号(脱敏)")
|
||||||
@TableField(exist = false)
|
@TableField(exist = false)
|
||||||
private String mobile;
|
private String mobile;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.gxwebsoft.common.system.entity;
|
|||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -54,6 +55,7 @@ public class CompanyComment implements Serializable {
|
|||||||
private Integer tenantId;
|
private Integer tenantId;
|
||||||
|
|
||||||
@Schema(description = "创建时间")
|
@Schema(description = "创建时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.gxwebsoft.common.system.entity;
|
|||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -37,6 +38,7 @@ public class CompanyContent implements Serializable {
|
|||||||
private Integer tenantId;
|
private Integer tenantId;
|
||||||
|
|
||||||
@Schema(description = "创建时间")
|
@Schema(description = "创建时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.gxwebsoft.common.system.entity;
|
|||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -59,6 +60,7 @@ public class CompanyGit implements Serializable {
|
|||||||
private Integer status;
|
private Integer status;
|
||||||
|
|
||||||
@Schema(description = "创建时间")
|
@Schema(description = "创建时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
@Schema(description = "租户id")
|
@Schema(description = "租户id")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.gxwebsoft.common.system.entity;
|
|||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -47,6 +48,7 @@ public class CompanyParameter implements Serializable {
|
|||||||
private Integer status;
|
private Integer status;
|
||||||
|
|
||||||
@Schema(description = "创建时间")
|
@Schema(description = "创建时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
@Schema(description = "租户id")
|
@Schema(description = "租户id")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.gxwebsoft.common.system.entity;
|
|||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -56,6 +57,7 @@ public class CompanyUrl implements Serializable {
|
|||||||
private Integer status;
|
private Integer status;
|
||||||
|
|
||||||
@Schema(description = "创建时间")
|
@Schema(description = "创建时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
@Schema(description = "租户id")
|
@Schema(description = "租户id")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import java.util.Date;
|
|||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -66,6 +67,7 @@ public class Components implements Serializable {
|
|||||||
private Integer tenantId;
|
private Integer tenantId;
|
||||||
|
|
||||||
@Schema(description = "创建时间")
|
@Schema(description = "创建时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.gxwebsoft.common.system.entity;
|
package com.gxwebsoft.common.system.entity;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.*;
|
import com.baomidou.mybatisplus.annotation.*;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -42,9 +43,11 @@ public class Dict implements Serializable {
|
|||||||
private Integer deleted;
|
private Integer deleted;
|
||||||
|
|
||||||
@Schema(description = "创建时间")
|
@Schema(description = "创建时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
@Schema(description = "修改时间")
|
@Schema(description = "修改时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date updateTime;
|
private Date updateTime;
|
||||||
|
|
||||||
@Schema(description = "租户id")
|
@Schema(description = "租户id")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.gxwebsoft.common.system.entity;
|
package com.gxwebsoft.common.system.entity;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.*;
|
import com.baomidou.mybatisplus.annotation.*;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -33,6 +34,9 @@ public class DictData implements Serializable {
|
|||||||
@Schema(description = "字典数据名称")
|
@Schema(description = "字典数据名称")
|
||||||
private String dictDataName;
|
private String dictDataName;
|
||||||
|
|
||||||
|
@Schema(description = "字段类型")
|
||||||
|
private String type;
|
||||||
|
|
||||||
@Schema(description = "字段名称")
|
@Schema(description = "字段名称")
|
||||||
@TableField(exist = false)
|
@TableField(exist = false)
|
||||||
private String text;
|
private String text;
|
||||||
@@ -62,9 +66,11 @@ public class DictData implements Serializable {
|
|||||||
private Integer deleted;
|
private Integer deleted;
|
||||||
|
|
||||||
@Schema(description = "创建时间")
|
@Schema(description = "创建时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
@Schema(description = "修改时间")
|
@Schema(description = "修改时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date updateTime;
|
private Date updateTime;
|
||||||
|
|
||||||
@Schema(description = "字典代码")
|
@Schema(description = "字典代码")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.annotation.TableLogic;
|
|||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -45,9 +46,11 @@ public class Dictionary implements Serializable {
|
|||||||
private Integer deleted;
|
private Integer deleted;
|
||||||
|
|
||||||
@Schema(description = "创建时间")
|
@Schema(description = "创建时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
@Schema(description = "修改时间")
|
@Schema(description = "修改时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date updateTime;
|
private Date updateTime;
|
||||||
|
|
||||||
@Schema(description = "租户id")
|
@Schema(description = "租户id")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.annotation.*;
|
|||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -51,9 +52,11 @@ public class DictionaryData implements Serializable {
|
|||||||
private Integer deleted;
|
private Integer deleted;
|
||||||
|
|
||||||
@Schema(description = "创建时间")
|
@Schema(description = "创建时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
@Schema(description = "修改时间")
|
@Schema(description = "修改时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date updateTime;
|
private Date updateTime;
|
||||||
|
|
||||||
@Schema(description = "字典代码")
|
@Schema(description = "字典代码")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.gxwebsoft.common.system.entity;
|
|||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -48,9 +49,11 @@ public class EmailRecord implements Serializable {
|
|||||||
private Integer tenantId;
|
private Integer tenantId;
|
||||||
|
|
||||||
@Schema(description = "创建时间")
|
@Schema(description = "创建时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
@Schema(description = "修改时间")
|
@Schema(description = "修改时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date updateTime;
|
private Date updateTime;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.annotation.TableLogic;
|
|||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -68,9 +69,11 @@ public class Environment implements Serializable {
|
|||||||
private Integer tenantId;
|
private Integer tenantId;
|
||||||
|
|
||||||
@Schema(description = "创建时间")
|
@Schema(description = "创建时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
@Schema(description = "修改时间")
|
@Schema(description = "修改时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date updateTime;
|
private Date updateTime;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.gxwebsoft.common.system.entity;
|
|||||||
|
|
||||||
import cn.hutool.core.util.DesensitizedUtil;
|
import cn.hutool.core.util.DesensitizedUtil;
|
||||||
import com.baomidou.mybatisplus.annotation.*;
|
import com.baomidou.mybatisplus.annotation.*;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -70,9 +71,11 @@ public class FileRecord implements Serializable {
|
|||||||
private Integer tenantId;
|
private Integer tenantId;
|
||||||
|
|
||||||
@Schema(description = "创建时间")
|
@Schema(description = "创建时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
@Schema(description = "修改时间")
|
@Schema(description = "修改时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date updateTime;
|
private Date updateTime;
|
||||||
|
|
||||||
@Schema(description = "文件访问地址")
|
@Schema(description = "文件访问地址")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.IdType;
|
|||||||
import com.baomidou.mybatisplus.annotation.TableField;
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -57,9 +58,11 @@ public class LoginRecord implements Serializable {
|
|||||||
private Integer tenantId;
|
private Integer tenantId;
|
||||||
|
|
||||||
@Schema(description = "操作时间")
|
@Schema(description = "操作时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
@Schema(description = "修改时间")
|
@Schema(description = "修改时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date updateTime;
|
private Date updateTime;
|
||||||
|
|
||||||
@Schema(description = "用户id")
|
@Schema(description = "用户id")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.gxwebsoft.common.system.entity;
|
package com.gxwebsoft.common.system.entity;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.*;
|
import com.baomidou.mybatisplus.annotation.*;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
@@ -80,9 +81,11 @@ public class Menu implements GrantedAuthority {
|
|||||||
private Integer tenantId;
|
private Integer tenantId;
|
||||||
|
|
||||||
@Schema(description = "创建时间")
|
@Schema(description = "创建时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
@Schema(description = "修改时间")
|
@Schema(description = "修改时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date updateTime;
|
private Date updateTime;
|
||||||
|
|
||||||
@Schema(description = "子菜单")
|
@Schema(description = "子菜单")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.gxwebsoft.common.system.entity;
|
package com.gxwebsoft.common.system.entity;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.*;
|
import com.baomidou.mybatisplus.annotation.*;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -113,6 +114,7 @@ public class Merchant implements Serializable {
|
|||||||
private Integer tenantId;
|
private Integer tenantId;
|
||||||
|
|
||||||
@Schema(description = "创建时间")
|
@Schema(description = "创建时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
@Schema(description = "默认商户管理员角色ID")
|
@Schema(description = "默认商户管理员角色ID")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.annotation.IdType;
|
|||||||
import com.baomidou.mybatisplus.annotation.TableField;
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -65,6 +66,7 @@ public class MerchantAccount implements Serializable {
|
|||||||
private Integer tenantId;
|
private Integer tenantId;
|
||||||
|
|
||||||
@Schema(description = "创建时间")
|
@Schema(description = "创建时间")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
@Schema(description = "商户名称")
|
@Schema(description = "商户名称")
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user