Compare commits
137 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 |
+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
|
||||||
|
|||||||
@@ -11,7 +11,40 @@
|
|||||||
"usedAt": 1775495439006,
|
"usedAt": 1775495439006,
|
||||||
"industryId": "all"
|
"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": 1775498241052
|
"lastUpdated": 1775868870779
|
||||||
}
|
}
|
||||||
@@ -123,3 +123,85 @@
|
|||||||
### 工作流程
|
### 工作流程
|
||||||
server-api (生产者) -> RabbitMQ -> websopy-java (消费者) -> AppUserCacheService
|
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,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。
|
||||||
@@ -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>
|
||||||
@@ -322,6 +371,13 @@
|
|||||||
<artifactId>spring-boot-starter-amqp</artifactId>
|
<artifactId>spring-boot-starter-amqp</artifactId>
|
||||||
</dependency>
|
</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 "验证完成"
|
||||||
@@ -26,9 +26,12 @@ 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;
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
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.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.alibaba.fastjson.JSONObject;
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
@@ -25,8 +25,6 @@ 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.io.File;
|
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
@@ -84,34 +82,176 @@ public class QrLoginServiceImpl implements QrLoginService {
|
|||||||
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);
|
||||||
response.setQrCodeContent("qr-login:" + token);
|
// 二维码内容:使用自定义协议,前端据此生成base64二维码
|
||||||
|
response.setQrCodeContent("websopy://login?token=" + token);
|
||||||
|
// 小程序路径(用于小程序扫码直接打开)
|
||||||
response.setMiniprogramPath("/pages/qr-login?token=" + token);
|
response.setMiniprogramPath("/pages/qr-login?token=" + token);
|
||||||
|
|
||||||
|
// 扫码跳转URL(前端生成二维码时使用此URL)
|
||||||
try {
|
try {
|
||||||
String miniprogramQrCodeUrl = generateMiniprogramQrCode(token, tenantId);
|
|
||||||
response.setMiniprogramQrCodeUrl(miniprogramQrCodeUrl);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("生成微信小程序码失败: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
String appId = wxService.getOfficialAppId(tenantId);
|
|
||||||
String baseUrl = configProperties.getWechatScanUrl();
|
String baseUrl = configProperties.getWechatScanUrl();
|
||||||
if (StrUtil.isBlank(baseUrl)) {
|
if (StrUtil.isBlank(baseUrl)) {
|
||||||
baseUrl = "https://websopy.websoft.top";
|
baseUrl = "https://websopy.websoft.top";
|
||||||
}
|
}
|
||||||
String wechatScanUrl = baseUrl + "/wx-scan?token=" + token;
|
String wechatScanUrl = baseUrl + "/wx-scan?token=" + token;
|
||||||
response.setWechatScanUrl(wechatScanUrl);
|
response.setWechatScanUrl(wechatScanUrl);
|
||||||
response.setWechatAppId(appId);
|
log.info("扫码跳转URL: {}", wechatScanUrl);
|
||||||
response.setWechatQrCodeUrl(generateOfficialQrCodeUrl(token, tenantId));
|
|
||||||
log.info("生成公众号扫码登录URL: {}", wechatScanUrl);
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("生成公众号扫码URL失败: {}", 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)) {
|
||||||
@@ -317,96 +457,6 @@ public class QrLoginServiceImpl implements QrLoginService {
|
|||||||
return buildStatusResponse(qrLoginData, 120L);
|
return buildStatusResponse(qrLoginData, 120L);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成微信小程序码
|
|
||||||
*/
|
|
||||||
private String generateMiniprogramQrCode(String token, Integer tenantId) {
|
|
||||||
try {
|
|
||||||
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);
|
|
||||||
|
|
||||||
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()) {
|
|
||||||
return configProperties.getFileServer() + "/qrcode/" + fileName;
|
|
||||||
}
|
|
||||||
throw new RuntimeException("保存小程序码文件失败");
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("生成微信小程序码失败: {}", e.getMessage(), e);
|
|
||||||
throw new RuntimeException("生成微信小程序码失败: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成公众号带参数二维码
|
|
||||||
*/
|
|
||||||
private String generateOfficialQrCodeUrl(String token, Integer tenantId) {
|
|
||||||
try {
|
|
||||||
String accessToken = wxService.getOfficialAccessToken(tenantId);
|
|
||||||
JSONObject scene = new JSONObject();
|
|
||||||
scene.put("scene_str", token);
|
|
||||||
JSONObject actionInfo = new JSONObject();
|
|
||||||
actionInfo.put("scene", scene);
|
|
||||||
JSONObject params = new JSONObject();
|
|
||||||
params.put("action_name", "QR_STR_SCENE");
|
|
||||||
params.put("expire_seconds", QR_LOGIN_TOKEN_TTL.intValue());
|
|
||||||
params.put("action_info", actionInfo);
|
|
||||||
|
|
||||||
String response = HttpRequest.post("https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=" + accessToken)
|
|
||||||
.body(params.toJSONString())
|
|
||||||
.timeout(10000)
|
|
||||||
.execute()
|
|
||||||
.body();
|
|
||||||
|
|
||||||
JSONObject result = JSON.parseObject(response);
|
|
||||||
String ticket = result.getString("ticket");
|
|
||||||
if (StrUtil.isBlank(ticket)) {
|
|
||||||
throw new RuntimeException("生成公众号二维码失败: " + response);
|
|
||||||
}
|
|
||||||
return "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket="
|
|
||||||
+ java.net.URLEncoder.encode(ticket, java.nio.charset.StandardCharsets.UTF_8);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("生成公众号二维码失败: {}", e.getMessage(), e);
|
|
||||||
throw new RuntimeException("生成公众号二维码失败: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取文件上传路径
|
|
||||||
*/
|
|
||||||
private String getUploadPath() {
|
|
||||||
String uploadPath = configProperties.getUploadPath();
|
|
||||||
if (StrUtil.isBlank(uploadPath)) {
|
|
||||||
uploadPath = configProperties.getLocalUploadPath();
|
|
||||||
}
|
|
||||||
if (StrUtil.isBlank(uploadPath)) {
|
|
||||||
uploadPath = "/tmp/uploads/";
|
|
||||||
}
|
|
||||||
if (!uploadPath.endsWith("/")) {
|
|
||||||
uploadPath += "/";
|
|
||||||
}
|
|
||||||
return uploadPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String buildAccessToken(User user) {
|
private String buildAccessToken(User user) {
|
||||||
JwtSubject jwtSubject = new JwtSubject(user.getUsername(), user.getTenantId());
|
JwtSubject jwtSubject = new JwtSubject(user.getUsername(), user.getTenantId());
|
||||||
return JwtUtil.buildToken(jwtSubject, configProperties.getTokenExpireTime(), configProperties.getTokenKey());
|
return JwtUtil.buildToken(jwtSubject, configProperties.getTokenExpireTime(), configProperties.getTokenKey());
|
||||||
|
|||||||
@@ -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";
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -97,6 +97,11 @@ public class ConfigProperties {
|
|||||||
*/
|
*/
|
||||||
private String websopyUrl;
|
private String websopyUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内部服务调用密钥(调用 websopy 等内部接口时使用)
|
||||||
|
*/
|
||||||
|
private String internalKey;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 微信扫码H5页面访问地址(用于微信扫码登录跳转)
|
* 微信扫码H5页面访问地址(用于微信扫码登录跳转)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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";
|
||||||
// 空值防止击穿数据库
|
// 空值防止击穿数据库
|
||||||
|
|||||||
@@ -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/*",
|
||||||
|
|||||||
@@ -290,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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
package com.gxwebsoft.common.mq.config;
|
package com.gxwebsoft.common.mq.config;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
import org.springframework.amqp.core.DirectExchange;
|
||||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
import org.springframework.amqp.core.TopicExchange;
|
||||||
import org.springframework.amqp.core.*;
|
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.config.SimpleRabbitListenerContainerFactory;
|
||||||
import org.springframework.amqp.rabbit.connection.CachingConnectionFactory;
|
import org.springframework.amqp.rabbit.connection.CachingConnectionFactory;
|
||||||
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
|
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
|
||||||
@@ -23,7 +27,7 @@ import org.springframework.context.annotation.Configuration;
|
|||||||
public class RabbitMQConfig {
|
public class RabbitMQConfig {
|
||||||
|
|
||||||
// ==================== 常量定义 ====================
|
// ==================== 常量定义 ====================
|
||||||
public static final String SYNC_EXCHANGE = "sync.exchange";
|
public static final String SYNC_EXCHANGE = "sync.topic.exchange";
|
||||||
public static final String SYNC_QUEUE = "sync.queue";
|
public static final String SYNC_QUEUE = "sync.queue";
|
||||||
public static final String SYNC_ROUTING_KEY = "sync.message";
|
public static final String SYNC_ROUTING_KEY = "sync.message";
|
||||||
|
|
||||||
@@ -66,14 +70,6 @@ public class RabbitMQConfig {
|
|||||||
|
|
||||||
// ==================== Message Converter ====================
|
// ==================== Message Converter ====================
|
||||||
|
|
||||||
@Bean
|
|
||||||
public ObjectMapper objectMapper() {
|
|
||||||
ObjectMapper objectMapper = new ObjectMapper();
|
|
||||||
objectMapper.registerModule(new JavaTimeModule());
|
|
||||||
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
|
||||||
return objectMapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public MessageConverter messageConverter(ObjectMapper objectMapper) {
|
public MessageConverter messageConverter(ObjectMapper objectMapper) {
|
||||||
return new Jackson2JsonMessageConverter(objectMapper);
|
return new Jackson2JsonMessageConverter(objectMapper);
|
||||||
@@ -97,8 +93,8 @@ public class RabbitMQConfig {
|
|||||||
factory.setConnectionFactory(connectionFactory);
|
factory.setConnectionFactory(connectionFactory);
|
||||||
factory.setMessageConverter(messageConverter);
|
factory.setMessageConverter(messageConverter);
|
||||||
// 设置并发数
|
// 设置并发数
|
||||||
factory.setConcurrent(1);
|
factory.setConcurrentConsumers(1);
|
||||||
factory.setMaxConcurrent(5);
|
factory.setMaxConcurrentConsumers(5);
|
||||||
// 设置手动ack
|
// 设置手动ack
|
||||||
factory.setAcknowledgeMode(AcknowledgeMode.MANUAL);
|
factory.setAcknowledgeMode(AcknowledgeMode.MANUAL);
|
||||||
// 预取数量
|
// 预取数量
|
||||||
@@ -108,9 +104,15 @@ public class RabbitMQConfig {
|
|||||||
|
|
||||||
// ==================== 交换机 ====================
|
// ==================== 交换机 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户同步 Topic Exchange
|
||||||
|
* 使用 Topic 类型,支持按 targetSystem 路由到不同队列
|
||||||
|
* routing key 格式: user.sync.{targetSystem}
|
||||||
|
* 各子系统可以绑定自己的队列来消费消息
|
||||||
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public DirectExchange syncExchange() {
|
public TopicExchange syncExchange() {
|
||||||
return new DirectExchange(SYNC_EXCHANGE, true, false);
|
return new TopicExchange(SYNC_EXCHANGE, true, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@@ -120,13 +122,12 @@ public class RabbitMQConfig {
|
|||||||
|
|
||||||
// ==================== 队列 ====================
|
// ==================== 队列 ====================
|
||||||
|
|
||||||
@Bean
|
/**
|
||||||
public Queue syncQueue() {
|
* 注意:core 系统只负责发送消息,不消费消息
|
||||||
return QueueBuilder.durable(SYNC_QUEUE)
|
* 各子系统(websopy等)需要在自己的系统中配置消费者和队列
|
||||||
.withArgument("x-dead-letter-exchange", DLX_EXCHANGE)
|
*
|
||||||
.withArgument("x-dead-letter-routing-key", DLQ_ROUTING_KEY)
|
* 如果 core 系统也需要消费某些消息,可以在这里添加对应的队列
|
||||||
.build();
|
*/
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public Queue deadLetterQueue() {
|
public Queue deadLetterQueue() {
|
||||||
@@ -135,13 +136,6 @@ public class RabbitMQConfig {
|
|||||||
|
|
||||||
// ==================== 绑定 ====================
|
// ==================== 绑定 ====================
|
||||||
|
|
||||||
@Bean
|
|
||||||
public Binding syncBinding() {
|
|
||||||
return BindingBuilder.bind(syncQueue())
|
|
||||||
.to(syncExchange())
|
|
||||||
.with(SYNC_ROUTING_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public Binding dlqBinding() {
|
public Binding dlqBinding() {
|
||||||
return BindingBuilder.bind(deadLetterQueue())
|
return BindingBuilder.bind(deadLetterQueue())
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
package com.gxwebsoft.common.mq.consumer;
|
|
||||||
|
|
||||||
import com.gxwebsoft.common.mq.config.RabbitMQConfig;
|
|
||||||
import com.gxwebsoft.common.mq.message.SyncMessage;
|
|
||||||
import com.gxwebsoft.common.system.service.UserSyncService;
|
|
||||||
import com.rabbitmq.client.Channel;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.amqp.core.Message;
|
|
||||||
import org.springframework.amqp.rabbit.annotation.RabbitListener;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 用户同步消息消费者
|
|
||||||
*
|
|
||||||
* 负责监听用户同步消息,并将数据同步到目标系统(如websopy)
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Component
|
|
||||||
@ConditionalOnProperty(name = "sync.mq.enabled", havingValue = "true", matchIfMissing = true)
|
|
||||||
public class UserSyncConsumer {
|
|
||||||
|
|
||||||
@Autowired(required = false)
|
|
||||||
private UserSyncService userSyncService;
|
|
||||||
|
|
||||||
private static final int MAX_RETRY_COUNT = 3;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 监听用户同步消息
|
|
||||||
*/
|
|
||||||
@RabbitListener(queues = RabbitMQConfig.SYNC_QUEUE)
|
|
||||||
public void handleMessage(SyncMessage message, Channel channel, Message amqpMessage) {
|
|
||||||
long deliveryTag = amqpMessage.getMessageProperties().getDeliveryTag();
|
|
||||||
|
|
||||||
try {
|
|
||||||
log.info("收到MQ消息: messageId={}, type={}, event={}, target={}",
|
|
||||||
message.getMessageId(), message.getMessageType(),
|
|
||||||
message.getEventType(), message.getTargetSystem());
|
|
||||||
|
|
||||||
// 检查是否启用了同步服务
|
|
||||||
if (userSyncService == null) {
|
|
||||||
log.warn("UserSyncService 未启用,跳过消息处理: messageId={}", message.getMessageId());
|
|
||||||
channel.basicAck(deliveryTag, false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理消息
|
|
||||||
processMessage(message);
|
|
||||||
|
|
||||||
// 确认消息
|
|
||||||
channel.basicAck(deliveryTag, false);
|
|
||||||
log.info("消息处理成功: messageId={}", message.getMessageId());
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("消息处理失败: messageId={}, error={}", message.getMessageId(), e.getMessage(), e);
|
|
||||||
handleFailure(channel, amqpMessage, deliveryTag, message, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理消息
|
|
||||||
*/
|
|
||||||
private void processMessage(SyncMessage message) {
|
|
||||||
String messageType = message.getMessageType();
|
|
||||||
String eventType = message.getEventType();
|
|
||||||
String targetSystem = message.getTargetSystem();
|
|
||||||
Map<String, Object> data = message.getData();
|
|
||||||
|
|
||||||
// 判断消息类型并处理
|
|
||||||
if ("USER_SYNC".equals(messageType)) {
|
|
||||||
handleUserSync(targetSystem, eventType, data);
|
|
||||||
} else {
|
|
||||||
log.warn("未知的消息类型: messageType={}", messageType);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理用户同步
|
|
||||||
*/
|
|
||||||
private void handleUserSync(String targetSystem, String eventType, Map<String, Object> data) {
|
|
||||||
switch (eventType) {
|
|
||||||
case "CREATE":
|
|
||||||
case "UPDATE":
|
|
||||||
userSyncService.syncUserToWebsopy(data);
|
|
||||||
log.info("用户同步到{}成功: event={}", targetSystem, eventType);
|
|
||||||
break;
|
|
||||||
case "DELETE":
|
|
||||||
userSyncService.deleteUserFromWebsopy(data);
|
|
||||||
log.info("用户从{}删除成功: event={}", targetSystem, eventType);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
log.warn("未知的用户事件类型: eventType={}", eventType);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理失败消息
|
|
||||||
*/
|
|
||||||
private void handleFailure(Channel channel, Message amqpMessage, long deliveryTag,
|
|
||||||
SyncMessage message, Exception e) {
|
|
||||||
Integer retryCount = message.getRetryCount();
|
|
||||||
if (retryCount == null) {
|
|
||||||
retryCount = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (retryCount < MAX_RETRY_COUNT) {
|
|
||||||
// 重试:拒绝消息并重新入队
|
|
||||||
try {
|
|
||||||
log.warn("消息处理失败,准备重试: messageId={}, retryCount={}/{}",
|
|
||||||
message.getMessageId(), retryCount + 1, MAX_RETRY_COUNT);
|
|
||||||
// 增加重试次数
|
|
||||||
message.setRetryCount(retryCount + 1);
|
|
||||||
// 拒绝消息(requeue=true 会重新入队)
|
|
||||||
channel.basicNack(deliveryTag, false, true);
|
|
||||||
} catch (IOException ioException) {
|
|
||||||
log.error("消息拒绝失败: messageId={}", message.getMessageId(), ioException);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 超过重试次数,发送到死信队列
|
|
||||||
try {
|
|
||||||
log.error("消息处理失败次数超限,发送到死信队列: messageId={}, retryCount={}",
|
|
||||||
message.getMessageId(), retryCount);
|
|
||||||
// 拒绝消息(requeue=false 进入死信队列)
|
|
||||||
channel.basicNack(deliveryTag, false, false);
|
|
||||||
} catch (IOException ioException) {
|
|
||||||
log.error("消息拒绝失败: messageId={}", message.getMessageId(), ioException);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 监听死信队列消息(可选,用于告警或人工处理)
|
|
||||||
*/
|
|
||||||
@RabbitListener(queues = RabbitMQConfig.DLQ_QUEUE)
|
|
||||||
public void handleDeadLetter(SyncMessage message, Channel channel, Message amqpMessage) {
|
|
||||||
long deliveryTag = amqpMessage.getMessageProperties().getDeliveryTag();
|
|
||||||
try {
|
|
||||||
log.error("死信消息: messageId={}, type={}, event={}, target={}, retryCount={}",
|
|
||||||
message.getMessageId(), message.getMessageType(),
|
|
||||||
message.getEventType(), message.getTargetSystem(),
|
|
||||||
message.getRetryCount());
|
|
||||||
|
|
||||||
// TODO: 可以在这里添加告警逻辑,如发送邮件、钉钉通知等
|
|
||||||
|
|
||||||
channel.basicAck(deliveryTag, false);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("处理死信消息失败: messageId={}", message.getMessageId(), e);
|
|
||||||
try {
|
|
||||||
channel.basicAck(deliveryTag, false);
|
|
||||||
} catch (IOException ioException) {
|
|
||||||
log.error("确认死信消息失败", ioException);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
import com.gxwebsoft.common.mq.config.RabbitMQConfig;
|
import com.gxwebsoft.common.mq.config.RabbitMQConfig;
|
||||||
import com.gxwebsoft.common.mq.message.SyncMessage;
|
import com.gxwebsoft.common.mq.message.SyncMessage;
|
||||||
import com.gxwebsoft.common.mq.producer.SyncMessageProducer;
|
import com.gxwebsoft.common.mq.producer.SyncMessageProducer;
|
||||||
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.amqp.core.Message;
|
import org.springframework.amqp.core.Message;
|
||||||
import org.springframework.amqp.core.MessageProperties;
|
import org.springframework.amqp.core.MessageProperties;
|
||||||
@@ -66,9 +67,14 @@ public class RabbitMQSyncProducer implements SyncMessageProducer, RabbitTemplate
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 使用 targetSystem 作为 routing key
|
||||||
|
// 格式: user.sync.{targetSystem}
|
||||||
|
// 各子系统绑定队列时使用 pattern: user.sync.{systemName}
|
||||||
|
String routingKey = buildRoutingKey(message.getTargetSystem());
|
||||||
|
|
||||||
rabbitTemplate.convertAndSend(
|
rabbitTemplate.convertAndSend(
|
||||||
RabbitMQConfig.SYNC_EXCHANGE,
|
RabbitMQConfig.SYNC_EXCHANGE,
|
||||||
RabbitMQConfig.SYNC_ROUTING_KEY,
|
routingKey,
|
||||||
message,
|
message,
|
||||||
correlationData
|
correlationData
|
||||||
);
|
);
|
||||||
@@ -81,6 +87,17 @@ public class RabbitMQSyncProducer implements SyncMessageProducer, RabbitTemplate
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 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
|
@Override
|
||||||
public void sendUserSyncMessage(String targetSystem, String eventType, Object userData) {
|
public void sendUserSyncMessage(String targetSystem, String eventType, Object userData) {
|
||||||
try {
|
try {
|
||||||
@@ -92,6 +109,19 @@ public class RabbitMQSyncProducer implements SyncMessageProducer, RabbitTemplate
|
|||||||
dataMap = objectMapper.convertValue(userData, Map.class);
|
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);
|
SyncMessage message = new SyncMessage("USER_SYNC", eventType, targetSystem, dataMap);
|
||||||
sendSyncMessage(message);
|
sendSyncMessage(message);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|||||||
@@ -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,10 +29,12 @@ 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,
|
email,
|
||||||
|
"F!huhtL3fqD8",
|
||||||
|
email,
|
||||||
getTenantId()
|
getTenantId()
|
||||||
);
|
);
|
||||||
return success("注册成功邮件发送成功");
|
return success("注册成功邮件发送成功");
|
||||||
|
|||||||
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();
|
||||||
|
|||||||
@@ -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以兼容历史数据)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -304,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 = "修改推荐状态")
|
||||||
@@ -321,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 = "批量修改用户状态")
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -67,6 +70,8 @@ public class WxLoginController extends BaseController {
|
|||||||
private UserRefereeService userRefereeService;
|
private UserRefereeService userRefereeService;
|
||||||
@Resource
|
@Resource
|
||||||
private UserSyncService userSyncService;
|
private UserSyncService userSyncService;
|
||||||
|
@Resource
|
||||||
|
private AppConfigService appConfigService;
|
||||||
|
|
||||||
public WxLoginController(StringRedisTemplate redisTemplate) {
|
public WxLoginController(StringRedisTemplate redisTemplate) {
|
||||||
this.redisTemplate = redisTemplate;
|
this.redisTemplate = redisTemplate;
|
||||||
@@ -131,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);
|
||||||
@@ -196,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));
|
||||||
}
|
}
|
||||||
@@ -252,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) {
|
||||||
@@ -264,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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -325,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));
|
||||||
}
|
}
|
||||||
@@ -398,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));
|
||||||
|
|
||||||
@@ -410,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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -417,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")
|
||||||
@@ -433,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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 新用户注册
|
* 新用户注册
|
||||||
*/
|
*/
|
||||||
@@ -447,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);
|
||||||
@@ -494,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请求
|
||||||
@@ -602,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) {
|
||||||
@@ -627,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
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成随机账号
|
* 生成随机账号
|
||||||
*
|
*
|
||||||
@@ -657,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("请先配置小程序");
|
||||||
}
|
}
|
||||||
@@ -776,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);
|
||||||
@@ -803,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);
|
||||||
@@ -861,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";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -294,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;
|
||||||
|
|||||||
@@ -34,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;
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ public class Payment implements Serializable {
|
|||||||
@Schema(description = "备注")
|
@Schema(description = "备注")
|
||||||
private String comments;
|
private String comments;
|
||||||
|
|
||||||
|
@Schema(description = "子应用APPID")
|
||||||
|
private String subAppId;
|
||||||
|
|
||||||
|
@Schema(description = "子商户ID")
|
||||||
|
private String subMchId;
|
||||||
|
|
||||||
@Schema(description = "文章排序(数字越小越靠前)")
|
@Schema(description = "文章排序(数字越小越靠前)")
|
||||||
private Integer sortNumber;
|
private Integer sortNumber;
|
||||||
|
|
||||||
|
|||||||
@@ -96,10 +96,18 @@ public class Tenant implements Serializable {
|
|||||||
@TableField(exist = false)
|
@TableField(exist = false)
|
||||||
private Object date;
|
private Object date;
|
||||||
|
|
||||||
|
@Schema(description = "用户名")
|
||||||
|
@TableField(exist = false)
|
||||||
|
private String username;
|
||||||
|
|
||||||
@Schema(description = "手机号码")
|
@Schema(description = "手机号码")
|
||||||
@TableField(exist = false)
|
@TableField(exist = false)
|
||||||
private String phone;
|
private String phone;
|
||||||
|
|
||||||
|
@Schema(description = "超级管理员UID")
|
||||||
|
@TableField(exist = false)
|
||||||
|
private Integer ownerId;
|
||||||
|
|
||||||
@Schema(description = "管理地址")
|
@Schema(description = "管理地址")
|
||||||
@TableField(exist = false)
|
@TableField(exist = false)
|
||||||
private String adminUrl;
|
private String adminUrl;
|
||||||
@@ -112,7 +120,21 @@ public class Tenant implements Serializable {
|
|||||||
@TableField(exist = false)
|
@TableField(exist = false)
|
||||||
private String freeDomain;
|
private String freeDomain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否脱敏手机号,默认true脱敏
|
||||||
|
*/
|
||||||
|
@TableField(exist = false)
|
||||||
|
@Schema(description = "手机号是否脱敏,默认true")
|
||||||
|
private boolean phoneMasked = true;
|
||||||
|
|
||||||
public String getPhone(){
|
public String getPhone(){
|
||||||
return DesensitizedUtil.mobilePhone(this.phone);
|
if (phoneMasked) {
|
||||||
|
return DesensitizedUtil.mobilePhone(this.phone);
|
||||||
|
}
|
||||||
|
return this.phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPhoneMasked(boolean masked) {
|
||||||
|
this.phoneMasked = masked;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,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 com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
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;
|
||||||
@@ -40,7 +41,14 @@ public class User implements UserDetails {
|
|||||||
@Schema(description = "账号")
|
@Schema(description = "账号")
|
||||||
private String username;
|
private String username;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录密码(BCrypt 哈希)。
|
||||||
|
*
|
||||||
|
* <p>只写不读:序列化时永远不输出,避免任何接口把密码哈希返回给调用方;
|
||||||
|
* 反序列化不受影响,注册 / 改密 / 批量导入仍可正常写入。</p>
|
||||||
|
*/
|
||||||
@Schema(description = "密码")
|
@Schema(description = "密码")
|
||||||
|
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
|
||||||
private String password;
|
private String password;
|
||||||
|
|
||||||
@Schema(description = "昵称")
|
@Schema(description = "昵称")
|
||||||
@@ -61,6 +69,22 @@ public class User implements UserDetails {
|
|||||||
@Schema(description = "邮箱")
|
@Schema(description = "邮箱")
|
||||||
private String email;
|
private String email;
|
||||||
|
|
||||||
|
@Schema(description = "应用代号(非数据库字段,注册时由前端传入,用于生成开通邮件中的域名,如 mp/shop/site)")
|
||||||
|
@TableField(exist = false)
|
||||||
|
private String appCode;
|
||||||
|
|
||||||
|
@Schema(description = "邮箱验证码(非数据库字段,仅用于绑定/修改邮箱时校验)")
|
||||||
|
@TableField(exist = false)
|
||||||
|
private String emailCode;
|
||||||
|
|
||||||
|
@Schema(description = "短信验证码(非数据库字段,仅用于绑定/修改手机号时校验)")
|
||||||
|
@TableField(exist = false)
|
||||||
|
private String smsCode;
|
||||||
|
|
||||||
|
@Schema(description = "新手机号短信验证码(非数据库字段,仅用于换绑手机号时二次验证新号归属)")
|
||||||
|
@TableField(exist = false)
|
||||||
|
private String smsCodeNew;
|
||||||
|
|
||||||
@Schema(description = "资质")
|
@Schema(description = "资质")
|
||||||
private String aptitude;
|
private String aptitude;
|
||||||
|
|
||||||
@@ -70,7 +94,11 @@ public class User implements UserDetails {
|
|||||||
@Schema(description = "特长")
|
@Schema(description = "特长")
|
||||||
private String speciality;
|
private String speciality;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付密码(BCrypt 哈希),与登录密码同样只写不读。
|
||||||
|
*/
|
||||||
@Schema(description = "支付密码")
|
@Schema(description = "支付密码")
|
||||||
|
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
|
||||||
private String payPassword;
|
private String payPassword;
|
||||||
|
|
||||||
@Schema(description = "职务")
|
@Schema(description = "职务")
|
||||||
@@ -114,6 +142,9 @@ public class User implements UserDetails {
|
|||||||
@Schema(description = "街道地址")
|
@Schema(description = "街道地址")
|
||||||
private String address;
|
private String address;
|
||||||
|
|
||||||
|
@Schema(description = "店名")
|
||||||
|
private String shopName;
|
||||||
|
|
||||||
@Schema(description = "行业类型(父级)")
|
@Schema(description = "行业类型(父级)")
|
||||||
private String industryParent;
|
private String industryParent;
|
||||||
|
|
||||||
@@ -197,9 +228,11 @@ public class User implements UserDetails {
|
|||||||
private Integer isOrganizationAdmin;
|
private Integer isOrganizationAdmin;
|
||||||
|
|
||||||
@Schema(description = "是否超级管理员")
|
@Schema(description = "是否超级管理员")
|
||||||
@TableField(exist = false)
|
|
||||||
private Boolean isSuperAdmin;
|
private Boolean isSuperAdmin;
|
||||||
|
|
||||||
|
@Schema(description = "是否开发者")
|
||||||
|
private Boolean isDeveloper;
|
||||||
|
|
||||||
@Schema(description = "租户管理员ID")
|
@Schema(description = "租户管理员ID")
|
||||||
@TableField(exist = false)
|
@TableField(exist = false)
|
||||||
private Integer adminId;
|
private Integer adminId;
|
||||||
@@ -220,6 +253,12 @@ public class User implements UserDetails {
|
|||||||
@Schema(description = "专家角色")
|
@Schema(description = "专家角色")
|
||||||
private Integer expertType;
|
private Integer expertType;
|
||||||
|
|
||||||
|
@Schema(description = "审核状态: 0待审核, 1已通过, 2已拒绝")
|
||||||
|
private Integer auditStatus;
|
||||||
|
|
||||||
|
@Schema(description = "审核驳回原因")
|
||||||
|
private String rejectReason;
|
||||||
|
|
||||||
@Schema(description = "状态, 0正常, 1冻结")
|
@Schema(description = "状态, 0正常, 1冻结")
|
||||||
private Integer status;
|
private Integer status;
|
||||||
|
|
||||||
@@ -337,6 +376,10 @@ public class User implements UserDetails {
|
|||||||
@TableField(exist = false)
|
@TableField(exist = false)
|
||||||
private Boolean hasAdminsByPhone;
|
private Boolean hasAdminsByPhone;
|
||||||
|
|
||||||
|
@Schema(description = "是否已设置密码(未设置过密码的用户可免旧密码直接设置)")
|
||||||
|
@TableField(exist = false)
|
||||||
|
private Boolean hasPassword;
|
||||||
|
|
||||||
@Schema(description = "模板ID")
|
@Schema(description = "模板ID")
|
||||||
private Integer templateId;
|
private Integer templateId;
|
||||||
|
|
||||||
|
|||||||
@@ -36,4 +36,13 @@ public interface RoleMenuMapper extends BaseMapper<RoleMenu> {
|
|||||||
@InterceptorIgnore(tenantLine = "true")
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
List<Menu> listMenuByRoleIds(@Param("roleIds") List<Integer> roleIds, @Param("menuType") Integer menuType);
|
List<Menu> listMenuByRoleIds(@Param("roleIds") List<Integer> roleIds, @Param("menuType") Integer menuType);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跨租户查询角色菜单(忽略租户拦截器)
|
||||||
|
*
|
||||||
|
* @param tenantId 租户id
|
||||||
|
* @return List<RoleMenu>
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
List<RoleMenu> selectListAll(@Param("tenantId") Integer tenantId);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|||||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
import com.gxwebsoft.common.system.entity.Setting;
|
import com.gxwebsoft.common.system.entity.Setting;
|
||||||
import com.gxwebsoft.common.system.param.SettingParam;
|
import com.gxwebsoft.common.system.param.SettingParam;
|
||||||
|
import org.apache.ibatis.annotations.Insert;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -37,4 +39,20 @@ public interface SettingMapper extends BaseMapper<Setting> {
|
|||||||
|
|
||||||
@InterceptorIgnore(tenantLine = "true")
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
Setting getBySettingKeyIgnore(@Param("param") SettingParam param);
|
Setting getBySettingKeyIgnore(@Param("param") SettingParam param);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计指定租户下某 setting_key 的记录数(绕过多租户插件,自行按 tenant_id 过滤)。
|
||||||
|
* 用于初始化时幂等判断,避免被上下文租户覆盖。
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@Select("SELECT COUNT(1) FROM sys_setting WHERE setting_key = #{settingKey} AND tenant_id = #{tenantId} AND deleted = 0")
|
||||||
|
int countByKeyAndTenant(@Param("settingKey") String settingKey, @Param("tenantId") Integer tenantId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显式插入系统设置(绕过多租户插件,tenant_id 由实体携带,确保写入目标租户)。
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@Insert("INSERT INTO sys_setting (setting_key, content, sort_number, comments, tenant_id, create_time, update_time, deleted) "
|
||||||
|
+ "VALUES (#{settingKey}, #{content}, #{sortNumber}, #{comments}, #{tenantId}, #{createTime}, #{updateTime}, 0)")
|
||||||
|
int insertSetting(Setting setting);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,15 @@ public interface UserMapper extends BaseMapper<User> {
|
|||||||
@InterceptorIgnore(tenantLine = "true")
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
List<User> selectAccountsByPhone(@Param("phone") String phone);
|
List<User> selectAccountsByPhone(@Param("phone") String phone);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据邮箱查询所有账号(忽略租户隔离)
|
||||||
|
*
|
||||||
|
* @param email 邮箱
|
||||||
|
* @return List<User>
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
List<User> selectAccountsByEmail(@Param("email") String email);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据手机号统计账号数量(忽略租户隔离)
|
* 根据手机号统计账号数量(忽略租户隔离)
|
||||||
*
|
*
|
||||||
@@ -90,4 +99,52 @@ public interface UserMapper extends BaseMapper<User> {
|
|||||||
@InterceptorIgnore(tenantLine = "true")
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
Integer countByPhone(@Param("phone") String phone);
|
Integer countByPhone(@Param("phone") String phone);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据手机号查询最近登录的超级管理员账号(忽略租户隔离)
|
||||||
|
*
|
||||||
|
* @param phone 手机号
|
||||||
|
* @return User
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
User selectLastLoginSuperAdminByPhone(@Param("phone") String phone);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据手机号查询最近登录的开发者账号(忽略租户隔离)
|
||||||
|
*
|
||||||
|
* @param phone 手机号
|
||||||
|
* @return User
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
User selectLastLoginDeveloperByPhone(@Param("phone") String phone);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据手机号查询管理员账号(跨租户:超级管理员或普通管理员均可,忽略租户隔离)
|
||||||
|
*
|
||||||
|
* @param phone 手机号
|
||||||
|
* @return User
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
User selectAdminByPhoneCrossTenant(@Param("phone") String phone);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据手机号查询所有超级管理员账号(跨租户,忽略租户隔离)
|
||||||
|
* 用于多租户登录:手机号关联多个租户时返回列表供用户选择
|
||||||
|
*
|
||||||
|
* @param phone 手机号
|
||||||
|
* @return List<User>
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
List<User> selectSuperAdminsByPhone(@Param("phone") String phone);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据手机号和租户ID查询超级管理员账号(跨租户,忽略租户隔离)
|
||||||
|
* 用于多租户登录:用户选择租户后,查询该租户下的超级管理员
|
||||||
|
*
|
||||||
|
* @param phone 手机号
|
||||||
|
* @param tenantId 租户ID
|
||||||
|
* @return User
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
User selectSuperAdminByPhoneAndTenantId(@Param("phone") String phone, @Param("tenantId") Integer tenantId);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,4 +62,13 @@ public interface UserRoleMapper extends BaseMapper<UserRole> {
|
|||||||
*/
|
*/
|
||||||
List<UserRole> selectListRel(@Param("param") UserRoleParam param);
|
List<UserRole> selectListRel(@Param("param") UserRoleParam param);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跨租户查询用户角色(忽略租户拦截器)
|
||||||
|
*
|
||||||
|
* @param tenantId 租户id
|
||||||
|
* @return List<UserRole>
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
List<UserRole> selectListAll(@Param("tenantId") Integer tenantId);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,13 +59,19 @@
|
|||||||
<if test="param.contentType != null">
|
<if test="param.contentType != null">
|
||||||
AND a.content_type LIKE CONCAT('%', #{param.contentType}, '%')
|
AND a.content_type LIKE CONCAT('%', #{param.contentType}, '%')
|
||||||
</if>
|
</if>
|
||||||
<if test="param.keywords != null">
|
<if test="param.keywords != null and param.keywords != ''">
|
||||||
AND (
|
AND (
|
||||||
a.create_user_id = #{param.keywords}
|
a.`name` LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
OR b.name LIKE CONCAT('%', #{param.keywords}, '%')
|
OR a.path LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
OR b.comments LIKE CONCAT('%', #{param.keywords}, '%')
|
OR a.comments LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
)
|
OR b.username LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
</if>
|
OR b.nickname LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
|
OR b.real_name LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
|
<if test="param.keywords.matches('[0-9]+')">
|
||||||
|
OR a.create_user_id = #{param.keywords}
|
||||||
|
</if>
|
||||||
|
)
|
||||||
|
</if>
|
||||||
</where>
|
</where>
|
||||||
</sql>
|
</sql>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,12 @@
|
|||||||
FROM sys_order a
|
FROM sys_order a
|
||||||
LEFT JOIN sys_company b ON a.tenant_id = b.tenant_id
|
LEFT JOIN sys_company b ON a.tenant_id = b.tenant_id
|
||||||
<where>
|
<where>
|
||||||
|
<!-- 租户条件:sys_order 在 MybatisPlusConfig 的 ignoreTable 白名单里,
|
||||||
|
不受多租户插件管辖,必须在这里显式过滤。目前只有开放平台链路会设置该参数,
|
||||||
|
内部调用不传即保持原有行为。 -->
|
||||||
|
<if test="param.tenantId != null">
|
||||||
|
AND a.tenant_id = #{param.tenantId}
|
||||||
|
</if>
|
||||||
<if test="param.orderId != null">
|
<if test="param.orderId != null">
|
||||||
AND a.order_id = #{param.orderId}
|
AND a.order_id = #{param.orderId}
|
||||||
</if>
|
</if>
|
||||||
|
|||||||
@@ -39,4 +39,9 @@
|
|||||||
ORDER BY a.sort_number
|
ORDER BY a.sort_number
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- 跨租户查询角色菜单 -->
|
||||||
|
<select id="selectListAll" resultType="com.gxwebsoft.common.system.entity.RoleMenu">
|
||||||
|
SELECT * FROM sys_role_menu WHERE tenant_id = #{tenantId}
|
||||||
|
</select>
|
||||||
|
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@@ -4,9 +4,11 @@
|
|||||||
|
|
||||||
<!-- 关联查询sql -->
|
<!-- 关联查询sql -->
|
||||||
<sql id="selectSql">
|
<sql id="selectSql">
|
||||||
SELECT a.*,b.company_name,b.company_logo as logo,b.admin_url,b.domain,b.free_domain
|
SELECT a.*,b.company_name,b.company_logo as logo,b.admin_url,b.domain,b.free_domain,
|
||||||
|
u.phone,u.username,u.user_id as ownerId
|
||||||
FROM sys_tenant a
|
FROM sys_tenant a
|
||||||
LEFT JOIN sys_company b ON a.tenant_id = b.tenant_id
|
LEFT JOIN sys_company b ON a.tenant_id = b.tenant_id
|
||||||
|
LEFT JOIN gxwebsoft_core.sys_user u ON u.tenant_id = a.tenant_id AND u.is_super_admin = 1 AND u.deleted = 0
|
||||||
<where>
|
<where>
|
||||||
<if test="param.tenantId != null">
|
<if test="param.tenantId != null">
|
||||||
AND a.tenant_id = #{param.tenantId}
|
AND a.tenant_id = #{param.tenantId}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<!-- 关联查询sql -->
|
<!-- 关联查询sql -->
|
||||||
<sql id="selectSql">
|
<sql id="selectSql">
|
||||||
SELECT a.*,
|
SELECT a.*,
|
||||||
b.nickname,b.avatar,b.phone
|
b.nickname, b.avatar, b.phone
|
||||||
FROM sys_user_balance_log a
|
FROM sys_user_balance_log a
|
||||||
LEFT JOIN sys_user b ON a.user_id = b.user_id
|
LEFT JOIN sys_user b ON a.user_id = b.user_id
|
||||||
<where>
|
<where>
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
<if test="param.scene != null">
|
<if test="param.scene != null">
|
||||||
AND a.scene = #{param.scene}
|
AND a.scene = #{param.scene}
|
||||||
</if>
|
</if>
|
||||||
<if test="param.sceneMultiple != null">
|
<if test="param.sceneMultiple != null and param.sceneMultiple != ''">
|
||||||
AND a.scene IN
|
AND a.scene IN
|
||||||
<foreach collection="param.sceneMultiple.split(',')" item="item" separator="," open="(" close=")">
|
<foreach collection="param.sceneMultiple.split(',')" item="item" separator="," open="(" close=")">
|
||||||
#{item}
|
#{item}
|
||||||
@@ -30,16 +30,16 @@
|
|||||||
<if test="param.balance != null">
|
<if test="param.balance != null">
|
||||||
AND a.balance = #{param.balance}
|
AND a.balance = #{param.balance}
|
||||||
</if>
|
</if>
|
||||||
<if test="param.describe != null">
|
<if test="param.describe != null and param.describe != ''">
|
||||||
AND a.describe LIKE CONCAT('%', #{param.describe}, '%')
|
AND a.describe LIKE CONCAT('%', #{param.describe}, '%')
|
||||||
</if>
|
</if>
|
||||||
<if test="param.remark != null">
|
<if test="param.remark != null and param.remark != ''">
|
||||||
AND a.remark LIKE CONCAT('%', #{param.remark}, '%')
|
AND a.remark LIKE CONCAT('%', #{param.remark}, '%')
|
||||||
</if>
|
</if>
|
||||||
<if test="param.sortNumber != null">
|
<if test="param.sortNumber != null">
|
||||||
AND a.sort_number = #{param.sortNumber}
|
AND a.sort_number = #{param.sortNumber}
|
||||||
</if>
|
</if>
|
||||||
<if test="param.comments != null">
|
<if test="param.comments != null and param.comments != ''">
|
||||||
AND a.comments LIKE CONCAT('%', #{param.comments}, '%')
|
AND a.comments LIKE CONCAT('%', #{param.comments}, '%')
|
||||||
</if>
|
</if>
|
||||||
<if test="param.status != null">
|
<if test="param.status != null">
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
<if test="param.deleted == null">
|
<if test="param.deleted == null">
|
||||||
AND a.deleted = 0
|
AND a.deleted = 0
|
||||||
</if>
|
</if>
|
||||||
<if test="param.merchantCode != null">
|
<if test="param.merchantCode != null and param.merchantCode != ''">
|
||||||
AND a.merchant_code LIKE CONCAT('%', #{param.merchantCode}, '%')
|
AND a.merchant_code LIKE CONCAT('%', #{param.merchantCode}, '%')
|
||||||
</if>
|
</if>
|
||||||
<if test="param.createTimeStart != null">
|
<if test="param.createTimeStart != null">
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
<if test="param.createTimeEnd != null">
|
<if test="param.createTimeEnd != null">
|
||||||
AND a.create_time <= #{param.createTimeEnd}
|
AND a.create_time <= #{param.createTimeEnd}
|
||||||
</if>
|
</if>
|
||||||
<if test="param.keywords != null">
|
<if test="param.keywords != null and param.keywords != ''">
|
||||||
AND (
|
AND (
|
||||||
a.user_id = #{param.keywords}
|
a.user_id = #{param.keywords}
|
||||||
OR b.nickname LIKE CONCAT('%', #{param.keywords}, '%')
|
OR b.nickname LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
|
|||||||
@@ -110,6 +110,12 @@
|
|||||||
<if test="param.status != null">
|
<if test="param.status != null">
|
||||||
AND a.`status` = #{param.status}
|
AND a.`status` = #{param.status}
|
||||||
</if>
|
</if>
|
||||||
|
<if test="param.auditStatus != null">
|
||||||
|
AND a.`audit_status` = #{param.auditStatus}
|
||||||
|
</if>
|
||||||
|
<if test="param.auditPending != null and param.auditPending">
|
||||||
|
AND a.`audit_status` IN (0, 2)
|
||||||
|
</if>
|
||||||
<if test="param.createTimeStart != null">
|
<if test="param.createTimeStart != null">
|
||||||
AND a.create_time >= #{param.createTimeStart}
|
AND a.create_time >= #{param.createTimeStart}
|
||||||
</if>
|
</if>
|
||||||
@@ -125,6 +131,9 @@
|
|||||||
<if test="param.isAdmin != null">
|
<if test="param.isAdmin != null">
|
||||||
AND a.is_admin = #{param.isAdmin}
|
AND a.is_admin = #{param.isAdmin}
|
||||||
</if>
|
</if>
|
||||||
|
<if test="param.isDeveloper != null">
|
||||||
|
AND a.is_developer = #{param.isDeveloper}
|
||||||
|
</if>
|
||||||
<if test="param.templateId != null">
|
<if test="param.templateId != null">
|
||||||
AND a.template_id = #{param.templateId}
|
AND a.template_id = #{param.templateId}
|
||||||
</if>
|
</if>
|
||||||
@@ -316,18 +325,15 @@
|
|||||||
WHERE user_id = #{userId}
|
WHERE user_id = #{userId}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- 根据手机号码查询 -->
|
<!-- 根据手机号码查询(支持多租户:必须传 tenantId 才能查到对应租户的管理员) -->
|
||||||
<select id="selectAdminByPhone" resultType="com.gxwebsoft.common.system.entity.User">
|
<select id="selectAdminByPhone" resultType="com.gxwebsoft.common.system.entity.User">
|
||||||
SELECT a.*
|
SELECT a.*
|
||||||
FROM sys_user a
|
FROM sys_user a
|
||||||
<where>
|
<where>
|
||||||
AND a.deleted = 0
|
AND a.deleted = 0
|
||||||
AND a.phone = #{param.phone}
|
AND a.phone = #{param.phone}
|
||||||
AND a.template_id = #{param.templateId}
|
|
||||||
AND (a.username = 'superAdmin' OR a.username = 'admin' OR a.is_admin = 1)
|
AND (a.username = 'superAdmin' OR a.username = 'admin' OR a.is_admin = 1)
|
||||||
<if test="param.tenantId">
|
AND a.tenant_id = #{param.tenantId}
|
||||||
AND a.tenant_id = #{param.tenantId}
|
|
||||||
</if>
|
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
</where>
|
</where>
|
||||||
</select>
|
</select>
|
||||||
@@ -365,6 +371,8 @@
|
|||||||
a.tenant_id,
|
a.tenant_id,
|
||||||
a.username,
|
a.username,
|
||||||
a.avatar,
|
a.avatar,
|
||||||
|
a.status,
|
||||||
|
a.password,
|
||||||
a.create_time,
|
a.create_time,
|
||||||
t.tenant_name
|
t.tenant_name
|
||||||
FROM sys_user a
|
FROM sys_user a
|
||||||
@@ -374,6 +382,24 @@
|
|||||||
ORDER BY a.create_time DESC
|
ORDER BY a.create_time DESC
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- 根据邮箱查询所有账号(忽略租户隔离,用于跨租户邮箱登录) -->
|
||||||
|
<select id="selectAccountsByEmail" resultType="com.gxwebsoft.common.system.entity.User">
|
||||||
|
SELECT a.user_id,
|
||||||
|
a.tenant_id,
|
||||||
|
a.username,
|
||||||
|
a.nickname,
|
||||||
|
a.avatar,
|
||||||
|
a.status,
|
||||||
|
a.password,
|
||||||
|
a.create_time,
|
||||||
|
t.tenant_name
|
||||||
|
FROM sys_user a
|
||||||
|
LEFT JOIN sys_tenant t ON a.tenant_id = t.tenant_id
|
||||||
|
WHERE a.deleted = 0
|
||||||
|
AND a.email = #{email}
|
||||||
|
ORDER BY a.create_time DESC
|
||||||
|
</select>
|
||||||
|
|
||||||
<!-- 根据手机号统计账号数量(忽略租户隔离) -->
|
<!-- 根据手机号统计账号数量(忽略租户隔离) -->
|
||||||
<select id="countByPhone" resultType="java.lang.Integer">
|
<select id="countByPhone" resultType="java.lang.Integer">
|
||||||
SELECT COUNT(1)
|
SELECT COUNT(1)
|
||||||
@@ -382,4 +408,93 @@
|
|||||||
AND phone = #{phone}
|
AND phone = #{phone}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- 根据手机号查询最近登录的超级管理员账号(忽略租户隔离) -->
|
||||||
|
<select id="selectLastLoginSuperAdminByPhone" resultType="com.gxwebsoft.common.system.entity.User">
|
||||||
|
SELECT u.*
|
||||||
|
FROM sys_user u
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT tenant_id,
|
||||||
|
username,
|
||||||
|
MAX(create_time) AS last_login_time
|
||||||
|
FROM sys_login_record
|
||||||
|
WHERE login_type = 0
|
||||||
|
GROUP BY tenant_id, username
|
||||||
|
) lr ON lr.tenant_id = u.tenant_id
|
||||||
|
AND (lr.username = u.username OR lr.username = u.phone)
|
||||||
|
WHERE u.deleted = 0
|
||||||
|
AND u.status = 0
|
||||||
|
AND u.phone = #{phone}
|
||||||
|
AND u.is_super_admin = 1
|
||||||
|
ORDER BY lr.last_login_time DESC,
|
||||||
|
u.update_time DESC,
|
||||||
|
u.create_time DESC,
|
||||||
|
u.user_id DESC
|
||||||
|
LIMIT 1
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 根据手机号查询最近登录的开发者账号(忽略租户隔离) -->
|
||||||
|
<select id="selectLastLoginDeveloperByPhone" resultType="com.gxwebsoft.common.system.entity.User">
|
||||||
|
SELECT u.*
|
||||||
|
FROM sys_user u
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT tenant_id,
|
||||||
|
username,
|
||||||
|
MAX(create_time) AS last_login_time
|
||||||
|
FROM sys_login_record
|
||||||
|
WHERE login_type = 0
|
||||||
|
GROUP BY tenant_id, username
|
||||||
|
) lr ON lr.tenant_id = u.tenant_id
|
||||||
|
AND (lr.username = u.username OR lr.username = u.phone)
|
||||||
|
WHERE u.deleted = 0
|
||||||
|
AND u.status = 0
|
||||||
|
AND u.phone = #{phone}
|
||||||
|
AND u.is_developer = 1
|
||||||
|
ORDER BY lr.last_login_time DESC,
|
||||||
|
u.update_time DESC,
|
||||||
|
u.create_time DESC,
|
||||||
|
u.user_id DESC
|
||||||
|
LIMIT 1
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 根据手机号查询管理员账号(跨租户:超级管理员或普通管理员均可,忽略租户隔离) -->
|
||||||
|
<select id="selectAdminByPhoneCrossTenant" resultType="com.gxwebsoft.common.system.entity.User">
|
||||||
|
SELECT u.*
|
||||||
|
FROM sys_user u
|
||||||
|
WHERE u.deleted = 0
|
||||||
|
AND u.phone = #{phone}
|
||||||
|
AND (u.is_super_admin = 1 OR u.is_admin = 1)
|
||||||
|
ORDER BY u.update_time DESC,
|
||||||
|
u.user_id DESC
|
||||||
|
LIMIT 1
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 根据手机号查询所有超级管理员账号(跨租户,忽略租户隔离,用于多租户登录选择) -->
|
||||||
|
<select id="selectSuperAdminsByPhone" resultType="com.gxwebsoft.common.system.entity.User">
|
||||||
|
SELECT u.*,
|
||||||
|
t.tenant_name
|
||||||
|
FROM sys_user u
|
||||||
|
LEFT JOIN sys_tenant t ON u.tenant_id = t.tenant_id
|
||||||
|
WHERE u.deleted = 0
|
||||||
|
AND u.status = 0
|
||||||
|
AND u.phone = #{phone}
|
||||||
|
AND (u.is_super_admin = 1 OR u.is_admin = 1)
|
||||||
|
ORDER BY u.update_time DESC,
|
||||||
|
u.create_time DESC,
|
||||||
|
u.user_id DESC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 根据手机号和租户ID查询超级管理员账号(跨租户,忽略租户隔离,用于选择租户后登录) -->
|
||||||
|
<select id="selectSuperAdminByPhoneAndTenantId" resultType="com.gxwebsoft.common.system.entity.User">
|
||||||
|
SELECT u.*,
|
||||||
|
t.tenant_name
|
||||||
|
FROM sys_user u
|
||||||
|
LEFT JOIN sys_tenant t ON u.tenant_id = t.tenant_id
|
||||||
|
WHERE u.deleted = 0
|
||||||
|
AND u.status = 0
|
||||||
|
AND u.phone = #{phone}
|
||||||
|
AND u.is_super_admin = 1
|
||||||
|
AND u.tenant_id = #{tenantId}
|
||||||
|
LIMIT 1
|
||||||
|
</select>
|
||||||
|
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@@ -64,4 +64,9 @@
|
|||||||
<include refid="selectSql"></include>
|
<include refid="selectSql"></include>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- 跨租户查询用户角色 -->
|
||||||
|
<select id="selectListAll" resultType="com.gxwebsoft.common.system.entity.UserRole">
|
||||||
|
SELECT * FROM sys_user_role WHERE tenant_id = #{tenantId}
|
||||||
|
</select>
|
||||||
|
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.gxwebsoft.common.system.param;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送邮箱验证码参数
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
* @since 2026-07-20
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
@Schema(description = "发送邮箱验证码参数")
|
||||||
|
public class EmailCaptchaParam implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Schema(description = "邮箱")
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Schema(description = "邮箱验证码(校验时使用)")
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
@Schema(description = "租户ID")
|
||||||
|
private String tenantId;
|
||||||
|
|
||||||
|
@Schema(description = "场景")
|
||||||
|
private String scene;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -28,6 +28,9 @@ public class LoginParam implements Serializable {
|
|||||||
@Schema(description = "手机号码")
|
@Schema(description = "手机号码")
|
||||||
private String phone;
|
private String phone;
|
||||||
|
|
||||||
|
@Schema(description = "邮箱")
|
||||||
|
private String email;
|
||||||
|
|
||||||
@Schema(description = "短信验证码")
|
@Schema(description = "短信验证码")
|
||||||
private String code;
|
private String code;
|
||||||
|
|
||||||
@@ -55,4 +58,7 @@ public class LoginParam implements Serializable {
|
|||||||
@Schema(description = "租户id")
|
@Schema(description = "租户id")
|
||||||
private Integer tenantId;
|
private Integer tenantId;
|
||||||
|
|
||||||
|
@Schema(description = "审核状态: 0待审核, 1已通过, 2已拒绝(前端传值决定注册是否走审核流程)")
|
||||||
|
private Integer auditStatus;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,20 +21,22 @@ import java.io.Serializable;
|
|||||||
public class ResetPasswordParam implements Serializable {
|
public class ResetPasswordParam implements Serializable {
|
||||||
private static final long serialVersionUID = 1L;
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
@NotBlank(message = "手机号不能为空")
|
@Schema(description = "手机号码(手机找回时必填)")
|
||||||
@Schema(description = "手机号码", required = true)
|
|
||||||
private String phone;
|
private String phone;
|
||||||
|
|
||||||
@NotBlank(message = "短信验证码不能为空")
|
@Schema(description = "短信验证码(手机找回时必填)")
|
||||||
@Schema(description = "短信验证码", required = true)
|
|
||||||
private String smsCode;
|
private String smsCode;
|
||||||
|
|
||||||
@NotBlank(message = "用户ID不能为空")
|
@Schema(description = "邮箱(邮箱找回时必填)")
|
||||||
@Schema(description = "用户ID", required = true)
|
private String email;
|
||||||
|
|
||||||
|
@Schema(description = "邮箱验证码(邮箱找回时必填)")
|
||||||
|
private String emailCode;
|
||||||
|
|
||||||
|
@Schema(description = "用户ID(未登录忘记密码场景可不传,后端按手机号查用户)")
|
||||||
private String userId;
|
private String userId;
|
||||||
|
|
||||||
@NotNull(message = "租户ID不能为空")
|
@Schema(description = "租户ID(未登录忘记密码场景可不传)")
|
||||||
@Schema(description = "租户ID", required = true)
|
|
||||||
private Integer tenantId;
|
private Integer tenantId;
|
||||||
|
|
||||||
@NotBlank(message = "新密码不能为空")
|
@NotBlank(message = "新密码不能为空")
|
||||||
@@ -47,7 +49,6 @@ public class ResetPasswordParam implements Serializable {
|
|||||||
@Schema(description = "确认密码", required = true)
|
@Schema(description = "确认密码", required = true)
|
||||||
private String confirmPassword;
|
private String confirmPassword;
|
||||||
|
|
||||||
@NotNull(message = "模板ID不能为空")
|
@Schema(description = "短信模板ID(可选)")
|
||||||
@Schema(description = "短信模板ID", required = true)
|
|
||||||
private Integer templateId;
|
private Integer templateId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.gxwebsoft.common.system.param;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 短信验证码重置支付密码参数
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
* @since 2026-08-03
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
@Schema(description = "短信验证码重置支付密码参数")
|
||||||
|
public class ResetPayPasswordParam implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Schema(description = "手机号码")
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
@Schema(description = "短信验证码")
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
@Schema(description = "新的支付密码(4位数字,明文透传,后端加密落库)")
|
||||||
|
private String payPassword;
|
||||||
|
}
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
package com.gxwebsoft.common.system.param;
|
package com.gxwebsoft.common.system.param;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import com.gxwebsoft.common.core.annotation.QueryField;
|
import com.gxwebsoft.common.core.annotation.QueryField;
|
||||||
import com.gxwebsoft.common.core.annotation.QueryType;
|
import com.gxwebsoft.common.core.annotation.QueryType;
|
||||||
import com.gxwebsoft.common.core.web.BaseParam;
|
import com.gxwebsoft.common.core.web.BaseParam;
|
||||||
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;
|
||||||
import lombok.EqualsAndHashCode;
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
@@ -52,4 +49,11 @@ public class TenantParam extends BaseParam {
|
|||||||
@QueryField(type = QueryType.EQ)
|
@QueryField(type = QueryType.EQ)
|
||||||
private Integer tenantId;
|
private Integer tenantId;
|
||||||
|
|
||||||
|
@Schema(description = "手机号是否脱敏,默认true")
|
||||||
|
@QueryField(type = QueryType.EQ)
|
||||||
|
private Boolean mask;
|
||||||
|
|
||||||
|
@Schema(description = "查询全部租户,true时忽略userId条件")
|
||||||
|
private Boolean all;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,14 @@ public class UserParam extends BaseParam {
|
|||||||
@QueryField(type = QueryType.EQ)
|
@QueryField(type = QueryType.EQ)
|
||||||
private Integer status;
|
private Integer status;
|
||||||
|
|
||||||
|
@Schema(description = "审核状态: 0待审核, 1已通过, 2已拒绝(前端传值决定注册是否走审核流程)")
|
||||||
|
@QueryField(type = QueryType.EQ)
|
||||||
|
private Integer auditStatus;
|
||||||
|
|
||||||
|
@Schema(description = "是否查询待处理审核用户(待审核0/被驳回2)")
|
||||||
|
@TableField(exist = false)
|
||||||
|
private Boolean auditPending;
|
||||||
|
|
||||||
@Schema(description = "是否删除, 0否, 1是")
|
@Schema(description = "是否删除, 0否, 1是")
|
||||||
@TableLogic
|
@TableLogic
|
||||||
private Integer deleted;
|
private Integer deleted;
|
||||||
@@ -225,6 +233,10 @@ public class UserParam extends BaseParam {
|
|||||||
@TableField(exist = false)
|
@TableField(exist = false)
|
||||||
private Boolean isAdmin;
|
private Boolean isAdmin;
|
||||||
|
|
||||||
|
@Schema(description = "是否开发者")
|
||||||
|
@TableField(exist = false)
|
||||||
|
private Boolean isDeveloper;
|
||||||
|
|
||||||
@Schema(description = "是否企业管理员")
|
@Schema(description = "是否企业管理员")
|
||||||
private Integer isOrganizationAdmin;
|
private Integer isOrganizationAdmin;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.gxwebsoft.common.system.result;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查邮箱返回结果
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
* @since 2026-08-06
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "检查邮箱返回结果")
|
||||||
|
public class CheckEmailResult implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Schema(description = "是否已注册")
|
||||||
|
private Boolean isRegistered;
|
||||||
|
|
||||||
|
@Schema(description = "账号数量")
|
||||||
|
private Integer accountCount;
|
||||||
|
}
|
||||||
@@ -2,12 +2,11 @@ package com.gxwebsoft.common.system.result;
|
|||||||
|
|
||||||
import com.gxwebsoft.common.system.entity.User;
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
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.AllArgsConstructor;
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录返回结果
|
* 登录返回结果
|
||||||
@@ -17,7 +16,6 @@ import java.io.Serializable;
|
|||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
|
||||||
@Schema(description = "登录返回结果")
|
@Schema(description = "登录返回结果")
|
||||||
public class LoginResult implements Serializable {
|
public class LoginResult implements Serializable {
|
||||||
private static final long serialVersionUID = 1L;
|
private static final long serialVersionUID = 1L;
|
||||||
@@ -28,4 +26,16 @@ public class LoginResult implements Serializable {
|
|||||||
@Schema(description = "用户信息")
|
@Schema(description = "用户信息")
|
||||||
private User user;
|
private User user;
|
||||||
|
|
||||||
|
@Schema(description = "租户列表(多租户场景下返回,此时不返回access_token)")
|
||||||
|
private List<TenantOption> tenants;
|
||||||
|
|
||||||
|
public LoginResult(String access_token, User user) {
|
||||||
|
this.access_token = access_token;
|
||||||
|
this.user = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LoginResult(List<TenantOption> tenants) {
|
||||||
|
this.tenants = tenants;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ public class SubscriptionOrderPayResult {
|
|||||||
@Schema(description = "支付二维码链接")
|
@Schema(description = "支付二维码链接")
|
||||||
private String codeUrl;
|
private String codeUrl;
|
||||||
|
|
||||||
|
@Schema(description = "支付二维码图片(base64),前端可直接展示")
|
||||||
|
private String qrImage;
|
||||||
|
|
||||||
|
@Schema(description = "订单状态 0待支付 1已支付 2已激活 3已取消")
|
||||||
|
private Integer orderStatus;
|
||||||
|
|
||||||
@Schema(description = "价格信息")
|
@Schema(description = "价格信息")
|
||||||
private SubscriptionPriceResult price;
|
private SubscriptionPriceResult price;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.gxwebsoft.common.system.result;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 租户选项(多租户登录时返回,供前端展示选择列表)
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(description = "租户选项")
|
||||||
|
public class TenantOption implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Schema(description = "租户ID")
|
||||||
|
private Integer tenantId;
|
||||||
|
|
||||||
|
@Schema(description = "租户名称")
|
||||||
|
private String tenantName;
|
||||||
|
|
||||||
|
@Schema(description = "用户ID")
|
||||||
|
private Integer userId;
|
||||||
|
|
||||||
|
@Schema(description = "用户名")
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@Schema(description = "昵称")
|
||||||
|
private String nickname;
|
||||||
|
|
||||||
|
@Schema(description = "角色名称")
|
||||||
|
private String roleName;
|
||||||
|
|
||||||
|
@Schema(description = "头像")
|
||||||
|
private String avatar;
|
||||||
|
|
||||||
|
public TenantOption() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public TenantOption(Integer tenantId, String tenantName, Integer userId,
|
||||||
|
String username, String nickname, String roleName, String avatar) {
|
||||||
|
this.tenantId = tenantId;
|
||||||
|
this.tenantName = tenantName;
|
||||||
|
this.userId = userId;
|
||||||
|
this.username = username;
|
||||||
|
this.nickname = nickname;
|
||||||
|
this.roleName = roleName;
|
||||||
|
this.avatar = avatar;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.gxwebsoft.common.system.service;
|
package com.gxwebsoft.common.system.service;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.extension.service.IService;
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
import com.gxwebsoft.common.core.web.ApiResult;
|
||||||
import com.gxwebsoft.common.core.web.PageResult;
|
import com.gxwebsoft.common.core.web.PageResult;
|
||||||
import com.gxwebsoft.common.system.entity.Company;
|
import com.gxwebsoft.common.system.entity.Company;
|
||||||
import com.gxwebsoft.common.system.entity.Tenant;
|
import com.gxwebsoft.common.system.entity.Tenant;
|
||||||
@@ -45,4 +46,14 @@ public interface TenantService extends IService<Tenant> {
|
|||||||
boolean destructionAll(Integer tenantId);
|
boolean destructionAll(Integer tenantId);
|
||||||
|
|
||||||
Tenant getByCodeRel(String code);
|
Tenant getByCodeRel(String code);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 授予当前租户cms建站权限
|
||||||
|
* 从参考租户复制cms:*菜单到当前租户, 并绑定到当前租户的超级管理员角色
|
||||||
|
*
|
||||||
|
* @param refTenant 参考租户id(已配置好cms菜单的租户)
|
||||||
|
* @param curTenant 当前租户id
|
||||||
|
* @return ApiResult<?>
|
||||||
|
*/
|
||||||
|
ApiResult<?> grantCmsPermission(Integer refTenant, Integer curTenant);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ public interface UserService extends IService<User>, UserDetailsService {
|
|||||||
*/
|
*/
|
||||||
User getByUsername(String username, Integer tenantId);
|
User getByUsername(String username, Integer tenantId);
|
||||||
|
|
||||||
|
User getByUsernamePhone(String username, Integer tenantId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 添加用户
|
* 添加用户
|
||||||
*
|
*
|
||||||
@@ -74,6 +76,14 @@ public interface UserService extends IService<User>, UserDetailsService {
|
|||||||
*/
|
*/
|
||||||
boolean updateUser(User user);
|
boolean updateUser(User user);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将用户标记为开发者(审核通过时调用,直接更新用户标记位,不新增角色)
|
||||||
|
*
|
||||||
|
* @param userId 用户id
|
||||||
|
* @return boolean
|
||||||
|
*/
|
||||||
|
boolean markAsDeveloper(Integer userId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 比较用户密码
|
* 比较用户密码
|
||||||
*
|
*
|
||||||
@@ -126,6 +136,49 @@ public interface UserService extends IService<User>, UserDetailsService {
|
|||||||
|
|
||||||
List<User> getAdminsByPhone(LoginParam param);
|
List<User> getAdminsByPhone(LoginParam param);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据手机号查询最近登录的超级管理员账号(忽略租户隔离)
|
||||||
|
*
|
||||||
|
* @param phone 手机号
|
||||||
|
* @return 用户信息
|
||||||
|
*/
|
||||||
|
User getLastLoginSuperAdminByPhone(String phone);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据手机号查询最近登录的开发者账号(忽略租户隔离)
|
||||||
|
*
|
||||||
|
* @param phone 手机号
|
||||||
|
* @return 用户信息
|
||||||
|
*/
|
||||||
|
User getLastLoginDeveloperByPhone(String phone);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据手机号查询管理员账号(跨租户:超级管理员或普通管理员均可,忽略租户隔离)
|
||||||
|
*
|
||||||
|
* @param phone 手机号
|
||||||
|
* @return 用户信息
|
||||||
|
*/
|
||||||
|
User getByPhoneAndAdmin(String phone);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据手机号查询所有超级管理员账号(跨租户,忽略租户隔离)
|
||||||
|
* 用于多租户登录:手机号关联多个租户时返回列表供用户选择
|
||||||
|
*
|
||||||
|
* @param phone 手机号
|
||||||
|
* @return List<User>
|
||||||
|
*/
|
||||||
|
List<User> getSuperAdminsByPhone(String phone);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据手机号和租户ID查询超级管理员账号(跨租户,忽略租户隔离)
|
||||||
|
* 用于多租户登录:用户选择租户后,查询该租户下的超级管理员
|
||||||
|
*
|
||||||
|
* @param phone 手机号
|
||||||
|
* @param tenantId 租户ID
|
||||||
|
* @return User
|
||||||
|
*/
|
||||||
|
User getSuperAdminByPhoneAndTenantId(String phone, Integer tenantId);
|
||||||
|
|
||||||
List<User> pageAll(UserParam param);
|
List<User> pageAll(UserParam param);
|
||||||
|
|
||||||
User getByUserId(String userId);
|
User getByUserId(String userId);
|
||||||
@@ -138,6 +191,14 @@ public interface UserService extends IService<User>, UserDetailsService {
|
|||||||
*/
|
*/
|
||||||
List<User> findAccountsByPhone(String phone);
|
List<User> findAccountsByPhone(String phone);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据邮箱查询所有账号(忽略租户隔离,用于跨租户登录)
|
||||||
|
*
|
||||||
|
* @param email 邮箱
|
||||||
|
* @return List<User>
|
||||||
|
*/
|
||||||
|
List<User> findAccountsByEmail(String email);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据手机号统计账号数量
|
* 根据手机号统计账号数量
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,302 +1,42 @@
|
|||||||
package com.gxwebsoft.common.system.service;
|
package com.gxwebsoft.common.system.service;
|
||||||
|
|
||||||
import com.gxwebsoft.common.core.config.ConfigProperties;
|
|
||||||
import com.gxwebsoft.common.core.utils.HttpUtils;
|
|
||||||
import com.gxwebsoft.common.system.entity.User;
|
|
||||||
import com.alibaba.fastjson.JSON;
|
|
||||||
import com.alibaba.fastjson.JSONObject;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.http.HttpResponse;
|
|
||||||
import org.apache.http.util.EntityUtils;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户同步服务(同步到 websopy)
|
* 用户同步服务
|
||||||
|
*
|
||||||
|
* 注意:此服务已废弃,用户同步现在通过 MQ 实现。
|
||||||
|
* 各子系统(websopy等)需要在自己的系统中消费 MQ 消息进行同步。
|
||||||
|
*
|
||||||
|
* 保留此类是为了兼容可能存在的旧代码引用,所有方法已改为空实现。
|
||||||
*
|
*
|
||||||
* @author WebSoft
|
* @author WebSoft
|
||||||
* @since 2026-04-04
|
* @since 2026-04-04
|
||||||
|
* @deprecated 请使用 MQ 消息进行用户同步
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
|
@Deprecated
|
||||||
public class UserSyncService {
|
public class UserSyncService {
|
||||||
|
|
||||||
/**
|
|
||||||
* 转义JSON字符串中的特殊字符
|
|
||||||
*/
|
|
||||||
private String escapeJson(String str) {
|
|
||||||
if (str == null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return str.replace("\\", "\\\\")
|
|
||||||
.replace("\"", "\\\"")
|
|
||||||
.replace("\b", "\\b")
|
|
||||||
.replace("\f", "\\f")
|
|
||||||
.replace("\n", "\\n")
|
|
||||||
.replace("\r", "\\r")
|
|
||||||
.replace("\t", "\\t");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Resource
|
|
||||||
private ConfigProperties configProperties;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取 websopy 基础 URL
|
|
||||||
*/
|
|
||||||
private String getWebsopyBaseUrl() {
|
|
||||||
return configProperties.getWebsopyUrl();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同步单个用户到 websopy
|
* 同步单个用户到 websopy
|
||||||
*
|
*
|
||||||
* @param user 用户信息
|
* @deprecated 已废弃,用户同步现在通过 MQ 自动触发
|
||||||
*/
|
*/
|
||||||
public void syncUserToWebsopy(User user) {
|
@Deprecated
|
||||||
String websopyBaseUrl = getWebsopyBaseUrl();
|
public void syncUserToWebsopy(Object user) {
|
||||||
if (websopyBaseUrl == null || websopyBaseUrl.isEmpty()) {
|
log.debug("UserSyncService.syncUserToWebsopy 已废弃,用户同步通过 MQ 自动触发");
|
||||||
log.warn("websopyUrl 未配置,跳过用户同步: userId={}", user.getUserId());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user == null || user.getUserId() == null) {
|
|
||||||
log.warn("用户信息为空,跳过同步");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 构建请求体 - 使用手动JSON构建确保字段正确
|
|
||||||
Integer tenantIdValue = user.getTenantId() != null ? user.getTenantId() : 0;
|
|
||||||
|
|
||||||
// 构建JSON字符串,确保tenant_id字段存在且不为null
|
|
||||||
StringBuilder jsonBuilder = new StringBuilder();
|
|
||||||
jsonBuilder.append("{");
|
|
||||||
jsonBuilder.append("\"userId\":").append(user.getUserId()).append(",");
|
|
||||||
jsonBuilder.append("\"username\":\"").append(escapeJson(user.getUsername())).append("\",");
|
|
||||||
jsonBuilder.append("\"nickname\":\"").append(escapeJson(user.getNickname())).append("\",");
|
|
||||||
jsonBuilder.append("\"avatar\":\"").append(escapeJson(user.getAvatar())).append("\",");
|
|
||||||
jsonBuilder.append("\"phone\":\"").append(escapeJson(user.getPhone())).append("\",");
|
|
||||||
jsonBuilder.append("\"status\":").append(user.getStatus()).append(",");
|
|
||||||
jsonBuilder.append("\"updateTime\":\"").append(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))).append("\",");
|
|
||||||
jsonBuilder.append("\"tenantId\":").append(tenantIdValue).append(","); // 驼峰格式
|
|
||||||
jsonBuilder.append("\"tenant_id\":").append(tenantIdValue); // 下划线格式
|
|
||||||
jsonBuilder.append("}");
|
|
||||||
|
|
||||||
String url = websopyBaseUrl + "/api/app/user-sync/single";
|
|
||||||
String body = jsonBuilder.toString();
|
|
||||||
|
|
||||||
log.info("同步用户到 websopy: userId={}, username={}, nickname={}, phone={}, tenantId={}, url={}",
|
|
||||||
user.getUserId(), user.getUsername(), user.getNickname(), user.getPhone(), user.getTenantId(), url);
|
|
||||||
log.info("同步用户请求体JSON: {}", body); // 改为info级别以便查看
|
|
||||||
// 额外日志:tenantId 值检查
|
|
||||||
log.debug("tenantId检查 - 原始值: {}, 转换后值: {}",
|
|
||||||
user.getTenantId(), tenantIdValue);
|
|
||||||
|
|
||||||
// 发送 HTTP POST 请求
|
|
||||||
Map<String, String> headers = new HashMap<>();
|
|
||||||
headers.put("Content-Type", "application/json");
|
|
||||||
|
|
||||||
HttpResponse response = HttpUtils.doPost(url, "", "POST", headers, null, body);
|
|
||||||
String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
|
|
||||||
|
|
||||||
log.debug("websopy 响应: {}", responseBody);
|
|
||||||
|
|
||||||
// 解析响应
|
|
||||||
JSONObject result = JSON.parseObject(responseBody);
|
|
||||||
if (result != null && result.getIntValue("code") == 0) {
|
|
||||||
log.info("用户同步成功: userId={}", user.getUserId());
|
|
||||||
} else {
|
|
||||||
String message = result != null ? result.getString("message") : "未知错误";
|
|
||||||
log.error("用户同步失败: userId={}, message={}", user.getUserId(), message);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("用户同步异常: userId={}, error={}", user.getUserId(), e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 同步用户到 websopy(Map格式,用于MQ消息处理)
|
|
||||||
*
|
|
||||||
* @param data 用户信息Map
|
|
||||||
*/
|
|
||||||
public void syncUserToWebsopy(Map<String, Object> data) {
|
|
||||||
String websopyBaseUrl = getWebsopyBaseUrl();
|
|
||||||
if (websopyBaseUrl == null || websopyBaseUrl.isEmpty()) {
|
|
||||||
log.warn("websopyUrl 未配置,跳过用户同步");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data == null || data.isEmpty()) {
|
|
||||||
log.warn("用户数据为空,跳过同步");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
Integer userId = getIntValue(data, "userId");
|
|
||||||
if (userId == null) {
|
|
||||||
log.warn("用户数据中缺少userId,跳过同步");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Integer tenantIdValue = getIntValue(data, "tenantId");
|
|
||||||
if (tenantIdValue == null) {
|
|
||||||
tenantIdValue = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建JSON字符串
|
|
||||||
StringBuilder jsonBuilder = new StringBuilder();
|
|
||||||
jsonBuilder.append("{");
|
|
||||||
jsonBuilder.append("\"userId\":").append(userId).append(",");
|
|
||||||
jsonBuilder.append("\"username\":\"").append(escapeJson(getStringValue(data, "username"))).append("\",");
|
|
||||||
jsonBuilder.append("\"nickname\":\"").append(escapeJson(getStringValue(data, "nickname"))).append("\",");
|
|
||||||
jsonBuilder.append("\"avatar\":\"").append(escapeJson(getStringValue(data, "avatar"))).append("\",");
|
|
||||||
jsonBuilder.append("\"phone\":\"").append(escapeJson(getStringValue(data, "phone"))).append("\",");
|
|
||||||
jsonBuilder.append("\"status\":").append(getIntValue(data, "status", 1)).append(",");
|
|
||||||
jsonBuilder.append("\"updateTime\":\"").append(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))).append("\",");
|
|
||||||
jsonBuilder.append("\"tenantId\":").append(tenantIdValue).append(",");
|
|
||||||
jsonBuilder.append("\"tenant_id\":").append(tenantIdValue);
|
|
||||||
jsonBuilder.append("}");
|
|
||||||
|
|
||||||
String url = websopyBaseUrl + "/api/app/user-sync/single";
|
|
||||||
String body = jsonBuilder.toString();
|
|
||||||
|
|
||||||
log.info("MQ同步用户到 websopy: userId={}, body={}", userId, body);
|
|
||||||
|
|
||||||
Map<String, String> headers = new HashMap<>();
|
|
||||||
headers.put("Content-Type", "application/json");
|
|
||||||
|
|
||||||
HttpResponse response = HttpUtils.doPost(url, "", "POST", headers, null, body);
|
|
||||||
String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
|
|
||||||
|
|
||||||
JSONObject result = JSON.parseObject(responseBody);
|
|
||||||
if (result != null && result.getIntValue("code") == 0) {
|
|
||||||
log.info("MQ用户同步成功: userId={}", userId);
|
|
||||||
} else {
|
|
||||||
String message = result != null ? result.getString("message") : "未知错误";
|
|
||||||
log.error("MQ用户同步失败: userId={}, message={}", userId, message);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("MQ用户同步异常: error={}", e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从 websopy 删除用户
|
|
||||||
*
|
|
||||||
* @param data 包含userId的数据
|
|
||||||
*/
|
|
||||||
public void deleteUserFromWebsopy(Map<String, Object> data) {
|
|
||||||
String websopyBaseUrl = getWebsopyBaseUrl();
|
|
||||||
if (websopyBaseUrl == null || websopyBaseUrl.isEmpty()) {
|
|
||||||
log.warn("websopyUrl 未配置,跳过删除用户");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data == null) {
|
|
||||||
log.warn("删除用户数据为空");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
Integer userId = getIntValue(data, "userId");
|
|
||||||
if (userId == null) {
|
|
||||||
log.warn("删除用户数据中缺少userId");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String url = websopyBaseUrl + "/api/app/user-sync/delete/" + userId;
|
|
||||||
|
|
||||||
log.info("从 websopy 删除用户: userId={}", userId);
|
|
||||||
|
|
||||||
Map<String, String> headers = new HashMap<>();
|
|
||||||
headers.put("Content-Type", "application/json");
|
|
||||||
|
|
||||||
HttpResponse response = HttpUtils.doPost(url, "", "POST", headers, null, "");
|
|
||||||
String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
|
|
||||||
|
|
||||||
JSONObject result = JSON.parseObject(responseBody);
|
|
||||||
if (result != null && result.getIntValue("code") == 0) {
|
|
||||||
log.info("MQ删除用户成功: userId={}", userId);
|
|
||||||
} else {
|
|
||||||
String message = result != null ? result.getString("message") : "未知错误";
|
|
||||||
log.error("MQ删除用户失败: userId={}, message={}", userId, message);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("MQ删除用户异常: error={}", e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String getStringValue(Map<String, Object> data, String key) {
|
|
||||||
Object value = data.get(key);
|
|
||||||
return value != null ? String.valueOf(value) : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
private Integer getIntValue(Map<String, Object> data, String key) {
|
|
||||||
return getIntValue(data, key, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Integer getIntValue(Map<String, Object> data, String key, Integer defaultValue) {
|
|
||||||
Object value = data.get(key);
|
|
||||||
if (value == null) {
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
if (value instanceof Integer) {
|
|
||||||
return (Integer) value;
|
|
||||||
}
|
|
||||||
if (value instanceof Number) {
|
|
||||||
return ((Number) value).intValue();
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return Integer.parseInt(String.valueOf(value));
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 刷新 websopy 端的用户缓存
|
* 刷新 websopy 端的用户缓存
|
||||||
* 只传 userId,websopy 端会通过 API 回查获取完整信息
|
*
|
||||||
*
|
* @deprecated 已废弃
|
||||||
* @param userId 用户ID
|
|
||||||
*/
|
*/
|
||||||
|
@Deprecated
|
||||||
public void refreshUserCache(Integer userId) {
|
public void refreshUserCache(Integer userId) {
|
||||||
String websopyBaseUrl = getWebsopyBaseUrl();
|
log.debug("UserSyncService.refreshUserCache 已废弃");
|
||||||
if (websopyBaseUrl == null || websopyBaseUrl.isEmpty()) {
|
|
||||||
log.warn("websopyUrl 未配置,跳过刷新缓存: userId={}", userId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userId == null) {
|
|
||||||
log.warn("userId 为空,跳过刷新");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
String url = websopyBaseUrl + "/api/app/user-sync/refresh/" + userId;
|
|
||||||
|
|
||||||
log.info("刷新用户缓存: userId={}, url={}", userId, url);
|
|
||||||
|
|
||||||
Map<String, String> headers = new HashMap<>();
|
|
||||||
headers.put("Content-Type", "application/json");
|
|
||||||
|
|
||||||
HttpResponse response = HttpUtils.doPost(url, "", "POST", headers, null, "");
|
|
||||||
String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
|
|
||||||
|
|
||||||
JSONObject result = JSON.parseObject(responseBody);
|
|
||||||
if (result != null && result.getIntValue("code") == 0) {
|
|
||||||
log.info("刷新缓存成功: userId={}", userId);
|
|
||||||
} else {
|
|
||||||
String message = result != null ? result.getString("message") : "未知错误";
|
|
||||||
log.error("刷新缓存失败: userId={}, message={}", userId, message);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("刷新缓存异常: userId={}, error={}", userId, e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,61 @@ public class WxService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制刷新微信AccessToken(先删除缓存,再重新获取)
|
||||||
|
* 用于当 token 过期或失效后,需要强制获取新 token 的场景
|
||||||
|
*
|
||||||
|
* @param tenantId 租户ID,为null时使用默认值
|
||||||
|
* @return access_token
|
||||||
|
*/
|
||||||
|
public String getAccessTokenForcibly(Integer tenantId) {
|
||||||
|
if (tenantId == null) {
|
||||||
|
tenantId = 10048;
|
||||||
|
}
|
||||||
|
|
||||||
|
String key = ACCESS_TOKEN_KEY + ":" + tenantId;
|
||||||
|
|
||||||
|
// 先删除缓存
|
||||||
|
redisTemplate.delete(key);
|
||||||
|
log.info("强制刷新access_token,已删除缓存: {}", key);
|
||||||
|
|
||||||
|
// 直接从微信API获取新token(不再检查缓存)
|
||||||
|
try {
|
||||||
|
JSONObject setting = settingService.getBySettingKey("mp-weixin");
|
||||||
|
if (setting == null) {
|
||||||
|
throw new RuntimeException("请先配置微信小程序");
|
||||||
|
}
|
||||||
|
|
||||||
|
String appId = setting.getString("appId");
|
||||||
|
String appSecret = setting.getString("appSecret");
|
||||||
|
if (StrUtil.isBlank(appId) || StrUtil.isBlank(appSecret)) {
|
||||||
|
throw new RuntimeException("微信小程序配置不完整");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用微信API获取AccessToken
|
||||||
|
String apiUrl = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid="
|
||||||
|
+ appId + "&secret=" + appSecret;
|
||||||
|
String response = HttpRequest.get(apiUrl).execute().body();
|
||||||
|
|
||||||
|
JSONObject result = JSON.parseObject(response);
|
||||||
|
String accessToken = result.getString("access_token");
|
||||||
|
if (StrUtil.isNotBlank(accessToken)) {
|
||||||
|
// 存入缓存
|
||||||
|
JSONObject tokenData = new JSONObject();
|
||||||
|
tokenData.put("access_token", accessToken);
|
||||||
|
tokenData.put("expires_in", result.get("expires_in"));
|
||||||
|
redisTemplate.opsForValue().set(key, tokenData.toJSONString(), 7000L, TimeUnit.SECONDS);
|
||||||
|
log.info("强制刷新access_token成功: {}", accessToken);
|
||||||
|
return accessToken;
|
||||||
|
} else {
|
||||||
|
throw new RuntimeException("获取AccessToken失败: " + response);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("强制刷新微信AccessToken失败: {}", e.getMessage(), e);
|
||||||
|
throw new RuntimeException("获取微信AccessToken失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取微信公众号 AppID
|
* 获取微信公众号 AppID
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ 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.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
import com.gxwebsoft.common.core.utils.CommonUtil;
|
import com.gxwebsoft.common.core.utils.CommonUtil;
|
||||||
|
import com.gxwebsoft.common.core.utils.MinioUtil;
|
||||||
import com.gxwebsoft.common.core.web.PageParam;
|
import com.gxwebsoft.common.core.web.PageParam;
|
||||||
import com.gxwebsoft.common.core.web.PageResult;
|
import com.gxwebsoft.common.core.web.PageResult;
|
||||||
import com.gxwebsoft.common.system.entity.Company;
|
import com.gxwebsoft.common.system.entity.Company;
|
||||||
@@ -20,6 +23,8 @@ import com.gxwebsoft.common.system.param.FileRecordParam;
|
|||||||
import com.gxwebsoft.common.system.service.CompanyService;
|
import com.gxwebsoft.common.system.service.CompanyService;
|
||||||
import com.gxwebsoft.common.system.service.FileRecordService;
|
import com.gxwebsoft.common.system.service.FileRecordService;
|
||||||
import com.gxwebsoft.common.system.service.SettingService;
|
import com.gxwebsoft.common.system.service.SettingService;
|
||||||
|
import io.minio.MinioClient;
|
||||||
|
import io.minio.RemoveObjectArgs;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -93,13 +98,32 @@ public class FileRecordServiceImpl extends ServiceImpl<FileRecordMapper, FileRec
|
|||||||
String bucketName = uploadConfig.getString("bucketName");
|
String bucketName = uploadConfig.getString("bucketName");
|
||||||
String accessKeyId = uploadConfig.getString("accessKeyId");
|
String accessKeyId = uploadConfig.getString("accessKeyId");
|
||||||
String accessKeySecret = uploadConfig.getString("accessKeySecret");
|
String accessKeySecret = uploadConfig.getString("accessKeySecret");
|
||||||
CredentialsProvider credentialsProvider = new DefaultCredentialProvider(accessKeyId, accessKeySecret);
|
String uploadMethod = uploadConfig.getString("uploadMethod");
|
||||||
OSS ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);
|
// 规范化 endpoint:去掉结尾斜杠,避免 minio-java 拼接出双斜杠导致签名不匹配
|
||||||
|
endpoint = MinioUtil.normalizeEndpoint(endpoint);
|
||||||
|
boolean isMinio = "minio".equals(uploadMethod);
|
||||||
|
OSS ossClient = null;
|
||||||
|
MinioClient minioClient = null;
|
||||||
|
if (isMinio) {
|
||||||
|
// MinIO 用官方 minio-java SDK(原生 AWS4 + path-style),aliyun OSS SDK 无法对接 MinIO
|
||||||
|
// 显式指定 region=us-east-1,跳过 getBucketLocation 自动探测(该探测 GET 会触发 SignatureDoesNotMatch)
|
||||||
|
minioClient = MinioUtil.buildClient(endpoint, accessKeyId, accessKeySecret);
|
||||||
|
} else {
|
||||||
|
CredentialsProvider credentialsProvider = new DefaultCredentialProvider(accessKeyId, accessKeySecret);
|
||||||
|
ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);
|
||||||
|
}
|
||||||
for (FileRecord fileRecord : fileRecords) {
|
for (FileRecord fileRecord : fileRecords) {
|
||||||
fileRecord.setPath(StrUtil.replace(fileRecord.getPath(), bucketDomain.concat("/"), ""));
|
fileRecord.setPath(StrUtil.replace(fileRecord.getPath(), bucketDomain.concat("/"), ""));
|
||||||
try {
|
try {
|
||||||
// 删除远程文件
|
// 删除远程文件
|
||||||
ossClient.deleteObject(bucketName, fileRecord.getPath());
|
if (isMinio) {
|
||||||
|
minioClient.removeObject(RemoveObjectArgs.builder()
|
||||||
|
.bucket(bucketName)
|
||||||
|
.object(fileRecord.getPath())
|
||||||
|
.build());
|
||||||
|
} else {
|
||||||
|
ossClient.deleteObject(bucketName, fileRecord.getPath());
|
||||||
|
}
|
||||||
// 释放空间大小
|
// 释放空间大小
|
||||||
if (fileRecord.getCompanyId() > 0) {
|
if (fileRecord.getCompanyId() > 0) {
|
||||||
Company company = companyService.getById(fileRecord.getCompanyId());
|
Company company = companyService.getById(fileRecord.getCompanyId());
|
||||||
|
|||||||
@@ -182,7 +182,15 @@ public class SettingServiceImpl extends ServiceImpl<SettingMapper, Setting> impl
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean updateByKey(Setting setting) {
|
public boolean updateByKey(Setting setting) {
|
||||||
return update(setting, new QueryWrapper<Setting>().eq("setting_key", setting.getSettingKey()));
|
boolean ok = update(setting, new QueryWrapper<Setting>().eq("setting_key", setting.getSettingKey()));
|
||||||
|
// 上传配置变更后必须清缓存,否则 /api/oss/upload 仍读到旧的 Upload:{tenantId} 缓存
|
||||||
|
if (ok && "upload".equals(setting.getSettingKey())) {
|
||||||
|
Integer tenantId = setting.getTenantId();
|
||||||
|
if (tenantId != null) {
|
||||||
|
redisUtil.delete("Upload:" + tenantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,21 +2,43 @@ package com.gxwebsoft.common.system.service.impl;
|
|||||||
|
|
||||||
import cn.hutool.core.date.DateUtil;
|
import cn.hutool.core.date.DateUtil;
|
||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
|
import com.gxwebsoft.common.core.Constants;
|
||||||
import com.gxwebsoft.common.core.utils.CommonUtil;
|
import com.gxwebsoft.common.core.utils.CommonUtil;
|
||||||
import com.gxwebsoft.common.core.utils.DomainUtil;
|
import com.gxwebsoft.common.core.utils.DomainUtil;
|
||||||
|
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.system.entity.*;
|
import com.gxwebsoft.common.system.entity.*;
|
||||||
|
import com.gxwebsoft.common.system.mapper.MenuMapper;
|
||||||
|
import com.gxwebsoft.common.system.mapper.RoleMapper;
|
||||||
|
import com.gxwebsoft.common.system.mapper.RoleMenuMapper;
|
||||||
|
import com.gxwebsoft.common.system.mapper.SettingMapper;
|
||||||
|
import com.gxwebsoft.common.system.mapper.UserRoleMapper;
|
||||||
import com.gxwebsoft.common.system.mapper.TenantMapper;
|
import com.gxwebsoft.common.system.mapper.TenantMapper;
|
||||||
import com.gxwebsoft.common.system.param.MenuParam;
|
import com.gxwebsoft.common.system.param.MenuParam;
|
||||||
|
import com.gxwebsoft.common.system.param.RoleParam;
|
||||||
import com.gxwebsoft.common.system.service.*;
|
import com.gxwebsoft.common.system.service.*;
|
||||||
import com.gxwebsoft.common.system.param.TenantParam;
|
import com.gxwebsoft.common.system.param.TenantParam;
|
||||||
|
import com.gxwebsoft.common.core.web.ApiResult;
|
||||||
import com.gxwebsoft.common.core.web.PageParam;
|
import com.gxwebsoft.common.core.web.PageParam;
|
||||||
import com.gxwebsoft.common.core.web.PageResult;
|
import com.gxwebsoft.common.core.web.PageResult;
|
||||||
|
import com.alibaba.fastjson.JSONObject;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.util.CollectionUtils;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 租户Service实现
|
* 租户Service实现
|
||||||
@@ -24,14 +46,24 @@ import java.util.List;
|
|||||||
* @author 科技小王子
|
* @author 科技小王子
|
||||||
* @since 2023-07-17 17:49:53
|
* @since 2023-07-17 17:49:53
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> implements TenantService {
|
public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> implements TenantService {
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private CompanyService companyService;
|
private CompanyService companyService;
|
||||||
@Resource
|
@Resource
|
||||||
private MenuService menuService;
|
private MenuService menuService;
|
||||||
@Resource
|
@Resource
|
||||||
|
private MenuMapper menuMapper;
|
||||||
|
@Resource
|
||||||
|
private RoleMapper roleMapper;
|
||||||
|
@Resource
|
||||||
|
private RoleMenuMapper roleMenuMapper;
|
||||||
|
@Resource
|
||||||
|
private UserRoleMapper userRoleMapper;
|
||||||
|
@Resource
|
||||||
|
private RoleMenuService roleMenuService;
|
||||||
|
@Resource
|
||||||
private RoleService roleService;
|
private RoleService roleService;
|
||||||
@Resource
|
@Resource
|
||||||
private UserRoleService userRoleService;
|
private UserRoleService userRoleService;
|
||||||
@@ -45,6 +77,8 @@ public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> impleme
|
|||||||
private UserService userService;
|
private UserService userService;
|
||||||
@Resource
|
@Resource
|
||||||
private RedisUtil redisUtil;
|
private RedisUtil redisUtil;
|
||||||
|
@Resource
|
||||||
|
private SettingMapper settingMapper;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public PageResult<Tenant> pageRel(TenantParam param) {
|
public PageResult<Tenant> pageRel(TenantParam param) {
|
||||||
@@ -108,15 +142,17 @@ public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> impleme
|
|||||||
superAdmin.setUsername("superAdmin");
|
superAdmin.setUsername("superAdmin");
|
||||||
superAdmin.setNickname(company.getShortName());
|
superAdmin.setNickname(company.getShortName());
|
||||||
superAdmin.setPhone(company.getPhone());
|
superAdmin.setPhone(company.getPhone());
|
||||||
|
superAdmin.setIsSuperAdmin(true);
|
||||||
|
superAdmin.setIsAdmin(true);
|
||||||
superAdmin.setEmail(company.getEmail());
|
superAdmin.setEmail(company.getEmail());
|
||||||
superAdmin.setTemplateId(company.getTemplateId());
|
superAdmin.setTemplateId(company.getTemplateId());
|
||||||
superAdmin.setIsAdmin(true);
|
|
||||||
superAdmin.setRealName(company.getBusinessEntity());
|
superAdmin.setRealName(company.getBusinessEntity());
|
||||||
superAdmin.setPassword(userService.encodePassword("$2a$10$iMsEmh.rPlzwy/SVe6KW3.62vlwqMJpibhCF9jYN.fMqxdqymzMzu"));
|
superAdmin.setPassword(userService.encodePassword("$2a$10$iMsEmh.rPlzwy/SVe6KW3.62vlwqMJpibhCF9jYN.fMqxdqymzMzu"));
|
||||||
if (company.getPassword() != null) {
|
if (company.getPassword() != null) {
|
||||||
superAdmin.setPassword(userService.encodePassword(company.getPassword()));
|
superAdmin.setPassword(userService.encodePassword(company.getPassword()));
|
||||||
}
|
}
|
||||||
superAdmin.setTenantId(company.getTid());
|
superAdmin.setTenantId(company.getTid());
|
||||||
|
superAdmin.setInstalled(true);
|
||||||
if(company.getTemplateId() != null){
|
if(company.getTemplateId() != null){
|
||||||
superAdmin.setTemplateId(company.getTemplateId());
|
superAdmin.setTemplateId(company.getTemplateId());
|
||||||
}
|
}
|
||||||
@@ -586,21 +622,49 @@ public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> impleme
|
|||||||
// 添加菜单ID到超级管理员所属角色ID
|
// 添加菜单ID到超级管理员所属角色ID
|
||||||
if (resultMenu) {
|
if (resultMenu) {
|
||||||
saveRedis(company);
|
saveRedis(company);
|
||||||
|
// 初始化默认 MinIO 云存储配置(oss-{tenantId} 桶 + 自动建桶/策略),失败不阻断开通
|
||||||
|
try {
|
||||||
|
initDefaultUploadSetting(company.getTid());
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println("[Tenant] 初始化默认上传配置失败(不影响开通): " + e.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
// 发送邮件通知
|
// 发送邮件通知(按应用代号区分域名:前端注册时传入 appCode,未知默认 site)
|
||||||
String title = "恭喜!您的应用已创建成功";
|
// mp -> https://mp-{tid}.shoplnk.cn 后台 https://mp.websoft.top
|
||||||
String appUrl = "\r\n应用地址:" + DomainUtil.getSiteUrl(company.getTid().toString());
|
// shop -> https://shop-{tid}.shoplnk.cn 后台 https://shop.websoft.top
|
||||||
String appName = "\r\n应用名称:" + company.getShortName();
|
// site -> https://site-{tid}.sitelnk.cn 后台 https://site.websoft.top
|
||||||
String adminUrl = "\r\n后台管理:" + DomainUtil.getAdminUrl(company.getTid().toString());
|
final Map<String, String[]> appDomainMap = new HashMap<>(3);
|
||||||
String account = "\r\n账号:admin";
|
appDomainMap.put("mp", new String[]{"mp", "shoplnk.cn", "mp.websoft.top"});
|
||||||
String password = "\r\n密码:" + company.getPassword();
|
appDomainMap.put("shop", new String[]{"shop", "shoplnk.cn", "shop.websoft.top"});
|
||||||
String content = title + appUrl + appName + adminUrl + account + password;
|
appDomainMap.put("site", new String[]{"site", "sitelnk.cn", "site.websoft.top"});
|
||||||
// 发送邮件通知
|
final String appCode = StrUtil.isBlank(company.getAppCode()) ? "site" : company.getAppCode();
|
||||||
|
final String[] domainCfg = appDomainMap.getOrDefault(appCode, appDomainMap.get("site"));
|
||||||
|
final String sitePrefix = domainCfg[0];
|
||||||
|
final String siteDomain = domainCfg[1];
|
||||||
|
final String adminHost = domainCfg[2];
|
||||||
|
final String siteUrl = "https://" + sitePrefix + "-" + company.getTid() + "." + siteDomain;
|
||||||
|
final String adminUrl = "https://" + adminHost;
|
||||||
|
final String title = "您的企业官网已开通成功";
|
||||||
|
// 发送 HTML 邮件通知(模板风格与验证码邮件统一)
|
||||||
if (company.getEmail() != null) {
|
if (company.getEmail() != null) {
|
||||||
emailRecordService.sendEmail(title, content, company.getEmail(), company.getTid());
|
final Map<String, Object> data = new HashMap<>(8);
|
||||||
|
data.put("siteName", company.getShortName());
|
||||||
|
data.put("siteUrl", siteUrl);
|
||||||
|
data.put("adminUrl", adminUrl);
|
||||||
|
data.put("account", company.getEmail());
|
||||||
|
data.put("password", company.getPassword());
|
||||||
|
data.put("sendTime", DateUtil.now());
|
||||||
|
try {
|
||||||
|
emailRecordService.sendHtmlEmail(title, "register-success.html", data, new String[]{company.getEmail()});
|
||||||
|
} catch (Exception e) {
|
||||||
|
// HTML 邮件发送失败时降级为纯文本
|
||||||
|
String plain = "恭喜!您的企业官网「" + company.getShortName() + "」已开通成功\r\n官网地址:" + siteUrl
|
||||||
|
+ "\r\n后台地址:" + adminUrl + "\r\n登录账号:" + company.getEmail() + "\r\n登录密码:" + company.getPassword();
|
||||||
|
emailRecordService.sendEmail(title, plain, company.getEmail(), company.getTid());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return company;
|
return company;
|
||||||
}
|
}
|
||||||
@@ -616,6 +680,52 @@ public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> impleme
|
|||||||
redisUtil.set(key, tenant);
|
redisUtil.set(key, tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为新开通的租户写入默认 MinIO 上传配置,并自动建桶 + 匿名只读策略。
|
||||||
|
* 幂等:若已存在 upload 配置则跳过。
|
||||||
|
*/
|
||||||
|
private void initDefaultUploadSetting(Integer tenantId) {
|
||||||
|
// 绕过多租户插件,按目标租户精确计数,避免被上下文租户覆盖
|
||||||
|
int exists = settingMapper.countByKeyAndTenant("upload", tenantId);
|
||||||
|
if (exists > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final String bucketName = "oss-" + tenantId;
|
||||||
|
final String endpoint = "https://minio.sitelnk.cn";
|
||||||
|
final String accessKeyId = "ZB1RK0ARI4XPF0CWA2FF";
|
||||||
|
final String accessKeySecret = "QPnFBGLmXxto1lBEiaTyXgf4E4H46e8TOR4sYyiY";
|
||||||
|
|
||||||
|
JSONObject content = new JSONObject();
|
||||||
|
content.put("settingKey", "upload");
|
||||||
|
content.put("uploadMethod", "minio");
|
||||||
|
content.put("bucketName", bucketName);
|
||||||
|
content.put("bucketEndpoint", endpoint);
|
||||||
|
content.put("accessKeyId", accessKeyId);
|
||||||
|
content.put("accessKeySecret", accessKeySecret);
|
||||||
|
content.put("bucketDomain", endpoint + "/" + bucketName);
|
||||||
|
|
||||||
|
Setting setting = new Setting();
|
||||||
|
setting.setSettingKey("upload");
|
||||||
|
setting.setContent(content.toJSONString());
|
||||||
|
setting.setSortNumber(0);
|
||||||
|
setting.setComments("默认MinIO存储");
|
||||||
|
setting.setTenantId(tenantId);
|
||||||
|
setting.setDeleted(0);
|
||||||
|
setting.setCreateTime(new Date());
|
||||||
|
setting.setUpdateTime(new Date());
|
||||||
|
settingMapper.insertSetting(setting);
|
||||||
|
|
||||||
|
// 预热上传配置缓存,避免新租户首次读取命中空缓存
|
||||||
|
redisUtil.set("Upload:" + tenantId, content);
|
||||||
|
|
||||||
|
// 自动建桶 + 匿名只读策略(失败不阻断开通)
|
||||||
|
try {
|
||||||
|
MinioUtil.ensureBucketReady(MinioUtil.buildClient(endpoint, accessKeyId, accessKeySecret), endpoint, bucketName);
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println("[Tenant] 初始化时自动建桶失败(忽略): " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean destructionAll(Integer tenantId){
|
public boolean destructionAll(Integer tenantId){
|
||||||
if (baseMapper.destructionAll(tenantId)) {
|
if (baseMapper.destructionAll(tenantId)) {
|
||||||
@@ -631,4 +741,220 @@ public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> impleme
|
|||||||
return param.getOne(baseMapper.selectListRel(param));
|
return param.getOne(baseMapper.selectListRel(param));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = {Exception.class})
|
||||||
|
public ApiResult<?> grantCmsPermission(Integer refTenant, Integer curTenant) {
|
||||||
|
if (refTenant == null || curTenant == null) {
|
||||||
|
return new ApiResult<>(Constants.RESULT_ERROR_CODE, "参考租户或当前租户为空");
|
||||||
|
}
|
||||||
|
if (refTenant.equals(curTenant)) {
|
||||||
|
return new ApiResult<>(Constants.RESULT_ERROR_CODE, "参考租户不能与当前租户相同");
|
||||||
|
}
|
||||||
|
final Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("refTenantId", refTenant);
|
||||||
|
data.put("tenantId", curTenant);
|
||||||
|
|
||||||
|
// ===== 1. 克隆角色 sys_role(按 role_code 去重,superAdmin/admin/user 已存在则复用)=====
|
||||||
|
final RoleParam refRoleParam = new RoleParam();
|
||||||
|
refRoleParam.setTenantId(refTenant);
|
||||||
|
final List<Role> refRoles = roleMapper.selectListAll(refRoleParam);
|
||||||
|
if (CollectionUtils.isEmpty(refRoles)) {
|
||||||
|
return new ApiResult<>(Constants.RESULT_ERROR_CODE, "参考租户[" + refTenant + "]没有可复制的角色");
|
||||||
|
}
|
||||||
|
final RoleParam curRoleParam = new RoleParam();
|
||||||
|
curRoleParam.setTenantId(curTenant);
|
||||||
|
// role_code -> Role(当前租户已存在的角色)
|
||||||
|
final Map<String, Role> curRoleByCode = roleMapper.selectListAll(curRoleParam).stream()
|
||||||
|
.collect(Collectors.toMap(Role::getRoleCode, r -> r, (a, b) -> a));
|
||||||
|
// oldRoleId -> newRoleId 映射
|
||||||
|
final Map<Integer, Integer> roleIdMapping = new HashMap<>();
|
||||||
|
// 待新增角色(按 role_code 去重)
|
||||||
|
final Map<String, Role> toCreateByCode = new LinkedHashMap<>();
|
||||||
|
for (Role r : refRoles) {
|
||||||
|
final Role exist = curRoleByCode.get(r.getRoleCode());
|
||||||
|
if (exist != null) {
|
||||||
|
roleIdMapping.put(r.getRoleId(), exist.getRoleId());
|
||||||
|
} else if (!toCreateByCode.containsKey(r.getRoleCode())) {
|
||||||
|
final Role copy = new Role();
|
||||||
|
copy.setRoleCode(r.getRoleCode());
|
||||||
|
copy.setRoleName(r.getRoleName());
|
||||||
|
copy.setComments(r.getComments());
|
||||||
|
copy.setSortNumber(r.getSortNumber());
|
||||||
|
copy.setTenantId(curTenant);
|
||||||
|
toCreateByCode.put(r.getRoleCode(), copy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int roleCreated = 0;
|
||||||
|
if (!toCreateByCode.isEmpty()) {
|
||||||
|
roleService.saveBatch(new ArrayList<>(toCreateByCode.values()));
|
||||||
|
// 重新读取当前租户角色,回填 oldRoleId -> newRoleId 映射
|
||||||
|
final Map<String, Role> refreshed = roleMapper.selectListAll(curRoleParam).stream()
|
||||||
|
.collect(Collectors.toMap(Role::getRoleCode, r -> r, (a, b) -> a));
|
||||||
|
for (Role r : refRoles) {
|
||||||
|
final Role nr = refreshed.get(r.getRoleCode());
|
||||||
|
if (nr != null) {
|
||||||
|
roleIdMapping.put(r.getRoleId(), nr.getRoleId());
|
||||||
|
curRoleByCode.putIfAbsent(r.getRoleCode(), nr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roleCreated = toCreateByCode.size();
|
||||||
|
}
|
||||||
|
// 当前租户全部角色:roleId -> Role(含新克隆的),供后续 role_code 对齐使用
|
||||||
|
final Map<Integer, Role> newRoleById = new HashMap<>();
|
||||||
|
curRoleByCode.values().forEach(r -> newRoleById.put(r.getRoleId(), r));
|
||||||
|
|
||||||
|
// ===== 2. 克隆菜单 sys_menu(按层级复制,父先于子,标题/权限去重)=====
|
||||||
|
final MenuParam refMenuParam = new MenuParam();
|
||||||
|
refMenuParam.setTenantId(refTenant);
|
||||||
|
refMenuParam.setDeleted(0);
|
||||||
|
final List<Menu> refMenus = menuMapper.getMenuByClone(refMenuParam);
|
||||||
|
if (CollectionUtils.isEmpty(refMenus)) {
|
||||||
|
return new ApiResult<>(Constants.RESULT_ERROR_CODE, "参考租户[" + refTenant + "]没有可复制的菜单");
|
||||||
|
}
|
||||||
|
final Map<Integer, Menu> refMenuMap = new HashMap<>();
|
||||||
|
for (Menu menu : refMenus) {
|
||||||
|
refMenuMap.put(menu.getMenuId(), menu);
|
||||||
|
}
|
||||||
|
final MenuParam curMenuParam = new MenuParam();
|
||||||
|
curMenuParam.setTenantId(curTenant);
|
||||||
|
curMenuParam.setDeleted(0);
|
||||||
|
final Map<String, Integer> curMenuKeys = new HashMap<>();
|
||||||
|
for (Menu menu : menuMapper.getMenuByClone(curMenuParam)) {
|
||||||
|
curMenuKeys.putIfAbsent(getMenuKey(menu), menu.getMenuId());
|
||||||
|
}
|
||||||
|
final List<Menu> sources = refMenus.stream()
|
||||||
|
.sorted(Comparator.comparingInt((Menu d) -> getMenuDepth(d, refMenuMap)))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
final Map<Integer, Integer> menuIdMapping = new HashMap<>();
|
||||||
|
int menuCount = 0;
|
||||||
|
for (Menu source : sources) {
|
||||||
|
final Integer existsMenuId = curMenuKeys.get(getMenuKey(source));
|
||||||
|
if (existsMenuId != null) {
|
||||||
|
menuIdMapping.put(source.getMenuId(), existsMenuId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final Menu menu = new Menu();
|
||||||
|
menu.setParentId(source.getParentId() == null ? 0 : menuIdMapping.getOrDefault(source.getParentId(), 0));
|
||||||
|
menu.setTitle(source.getTitle());
|
||||||
|
menu.setPath(source.getPath());
|
||||||
|
menu.setComponent(source.getComponent());
|
||||||
|
menu.setModules(source.getModules());
|
||||||
|
menu.setModulesUrl(source.getModulesUrl());
|
||||||
|
menu.setMenuType(source.getMenuType());
|
||||||
|
menu.setSortNumber(source.getSortNumber());
|
||||||
|
menu.setAuthority(source.getAuthority());
|
||||||
|
menu.setIcon(source.getIcon());
|
||||||
|
menu.setHide(source.getHide());
|
||||||
|
menu.setMeta(source.getMeta());
|
||||||
|
menu.setTenantId(curTenant);
|
||||||
|
menuService.save(menu);
|
||||||
|
menuIdMapping.put(source.getMenuId(), menu.getMenuId());
|
||||||
|
curMenuKeys.putIfAbsent(getMenuKey(menu), menu.getMenuId());
|
||||||
|
menuCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 3. 克隆角色菜单 sys_role_menu(roleId/menuId 用映射转换,去重)=====
|
||||||
|
final Set<String> curRmKeys = roleMenuMapper.selectListAll(curTenant).stream()
|
||||||
|
.map(rm -> rm.getRoleId() + "_" + rm.getMenuId())
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
final List<RoleMenu> rmsToSave = new ArrayList<>();
|
||||||
|
for (RoleMenu rm : roleMenuMapper.selectListAll(refTenant)) {
|
||||||
|
final Integer newRoleId = roleIdMapping.get(rm.getRoleId());
|
||||||
|
final Integer newMenuId = menuIdMapping.get(rm.getMenuId());
|
||||||
|
if (newRoleId == null || newMenuId == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final String key = newRoleId + "_" + newMenuId;
|
||||||
|
if (curRmKeys.contains(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final RoleMenu copy = new RoleMenu();
|
||||||
|
copy.setRoleId(newRoleId);
|
||||||
|
copy.setMenuId(newMenuId);
|
||||||
|
copy.setTenantId(curTenant);
|
||||||
|
rmsToSave.add(copy);
|
||||||
|
curRmKeys.add(key);
|
||||||
|
}
|
||||||
|
int roleMenuCreated = 0;
|
||||||
|
if (!rmsToSave.isEmpty()) {
|
||||||
|
roleMenuService.saveBatch(rmsToSave);
|
||||||
|
roleMenuCreated = rmsToSave.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 4. 克隆用户角色 sys_user_role(按 role_code 对齐当前租户用户,去重)=====
|
||||||
|
final List<UserRole> curUserRoles = userRoleMapper.selectListAll(curTenant);
|
||||||
|
final Set<String> curUrKeys = curUserRoles.stream()
|
||||||
|
.map(ur -> ur.getUserId() + "_" + ur.getRoleId())
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
// 当前租户 userId -> 其 role_code 集合
|
||||||
|
final Map<Integer, Set<String>> userRoleCodes = new HashMap<>();
|
||||||
|
for (UserRole ur : curUserRoles) {
|
||||||
|
final Role role = newRoleById.get(ur.getRoleId());
|
||||||
|
if (role == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
userRoleCodes.computeIfAbsent(ur.getUserId(), k -> new HashSet<>()).add(role.getRoleCode());
|
||||||
|
}
|
||||||
|
final List<UserRole> ursToSave = new ArrayList<>();
|
||||||
|
for (UserRole ur : userRoleMapper.selectListAll(refTenant)) {
|
||||||
|
final Integer newRoleId = roleIdMapping.get(ur.getRoleId());
|
||||||
|
if (newRoleId == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final Role newRole = newRoleById.get(newRoleId);
|
||||||
|
if (newRole == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final String roleCode = newRole.getRoleCode();
|
||||||
|
// 找当前租户中 role_code 相同的用户,建立绑定
|
||||||
|
for (Map.Entry<Integer, Set<String>> e : userRoleCodes.entrySet()) {
|
||||||
|
if (!e.getValue().contains(roleCode)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final String key = e.getKey() + "_" + newRoleId;
|
||||||
|
if (curUrKeys.contains(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final UserRole copy = new UserRole();
|
||||||
|
copy.setUserId(e.getKey());
|
||||||
|
copy.setRoleId(newRoleId);
|
||||||
|
copy.setTenantId(curTenant);
|
||||||
|
ursToSave.add(copy);
|
||||||
|
curUrKeys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int userRoleCreated = 0;
|
||||||
|
if (!ursToSave.isEmpty()) {
|
||||||
|
userRoleService.saveBatch(ursToSave);
|
||||||
|
userRoleCreated = ursToSave.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
data.put("roleTotal", refRoles.size());
|
||||||
|
data.put("roleCreated", roleCreated);
|
||||||
|
data.put("menuTotal", menuIdMapping.size());
|
||||||
|
data.put("menuCreated", menuCount);
|
||||||
|
data.put("roleMenuCreated", roleMenuCreated);
|
||||||
|
data.put("userRoleCreated", userRoleCreated);
|
||||||
|
return new ApiResult<>(Constants.RESULT_OK_CODE, "授权成功", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 菜单去重标识, 优先使用权限标识, 目录类菜单权限标识为空时按标题+路由匹配
|
||||||
|
private String getMenuKey(Menu menu) {
|
||||||
|
if (StrUtil.isNotBlank(menu.getAuthority())) {
|
||||||
|
return "authority:" + menu.getAuthority();
|
||||||
|
}
|
||||||
|
return "menu:" + StrUtil.nullToEmpty(menu.getTitle()) + "@" + StrUtil.nullToEmpty(menu.getPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 菜单层级, 用于保证父菜单先于子菜单插入
|
||||||
|
private int getMenuDepth(Menu menu, Map<Integer, Menu> menuMap) {
|
||||||
|
int depth = 0;
|
||||||
|
Menu current = menu;
|
||||||
|
while (current != null && current.getParentId() != null && current.getParentId() != 0 && depth < 20) {
|
||||||
|
current = menuMap.get(current.getParentId());
|
||||||
|
depth++;
|
||||||
|
}
|
||||||
|
return depth;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,12 @@ import com.gxwebsoft.common.core.web.PageResult;
|
|||||||
import com.gxwebsoft.common.system.entity.*;
|
import com.gxwebsoft.common.system.entity.*;
|
||||||
import com.gxwebsoft.common.system.mapper.UserMapper;
|
import com.gxwebsoft.common.system.mapper.UserMapper;
|
||||||
import com.gxwebsoft.common.system.param.LoginParam;
|
import com.gxwebsoft.common.system.param.LoginParam;
|
||||||
|
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.mq.producer.SyncMessageProducer;
|
||||||
import com.gxwebsoft.common.system.service.*;
|
import com.gxwebsoft.common.system.service.*;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.security.core.userdetails.UserDetails;
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
@@ -23,6 +27,7 @@ import org.springframework.transaction.annotation.Isolation;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -34,6 +39,7 @@ import static com.gxwebsoft.common.core.constants.PlatformConstants.WEB;
|
|||||||
* @author WebSoft
|
* @author WebSoft
|
||||||
* @since 2018-12-24 16:10:14
|
* @since 2018-12-24 16:10:14
|
||||||
*/
|
*/
|
||||||
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
|
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
|
||||||
@Resource
|
@Resource
|
||||||
@@ -50,6 +56,11 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
|||||||
private OrganizationService organizationService;
|
private OrganizationService organizationService;
|
||||||
@Resource
|
@Resource
|
||||||
private UserRefereeService userRefereeService;
|
private UserRefereeService userRefereeService;
|
||||||
|
@Resource
|
||||||
|
private TenantService tenantService;
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
private SyncMessageProducer syncMessageProducer;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public PageResult<User> pageRel(UserParam param) {
|
public PageResult<User> pageRel(UserParam param) {
|
||||||
@@ -78,6 +89,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
|||||||
param.setUserId(userId);
|
param.setUserId(userId);
|
||||||
User user = param.getOne(baseMapper.selectListRel(param));
|
User user = param.getOne(baseMapper.selectListRel(param));
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
|
user.setHasPassword(StrUtil.isNotBlank(user.getPassword()));
|
||||||
user.setPassword(null);
|
user.setPassword(null);
|
||||||
user.setRoles(userRoleService.listByUserId(user.getUserId()));
|
user.setRoles(userRoleService.listByUserId(user.getUserId()));
|
||||||
user.setAuthorities(roleMenuService.listMenuByUserId(user.getUserId(), null));
|
user.setAuthorities(roleMenuService.listMenuByUserId(user.getUserId(), null));
|
||||||
@@ -103,6 +115,28 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public User getByUsernamePhone(String username, Integer tenantId) {
|
||||||
|
if (StrUtil.isBlank(username)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
User user = getOne(new LambdaQueryWrapper<User>()
|
||||||
|
.and(wrapper -> wrapper
|
||||||
|
.eq(User::getUsername, username)
|
||||||
|
.or()
|
||||||
|
.eq(User::getPhone, username))
|
||||||
|
.eq(tenantId != null, User::getTenantId, tenantId)
|
||||||
|
.eq(User::getDeleted, 0)
|
||||||
|
.orderByDesc(User::getUserId)
|
||||||
|
.last("limit 1")
|
||||||
|
);
|
||||||
|
if (user != null) {
|
||||||
|
user.setRoles(userRoleService.listByUserId(user.getUserId()));
|
||||||
|
user.setAuthorities(roleMenuService.listMenuByUserId(user.getUserId(), null));
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||||
return getByUsername(username);
|
return getByUsername(username);
|
||||||
@@ -130,6 +164,14 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
|||||||
throw new BusinessException("用户角色添加失败");
|
throw new BusinessException("用户角色添加失败");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 用户创建成功后,通过MQ异步同步用户数据到 websopy
|
||||||
|
if (result && syncMessageProducer != null) {
|
||||||
|
User savedUser = getAllByUserId(String.valueOf(user.getUserId()));
|
||||||
|
if (savedUser != null) {
|
||||||
|
syncMessageProducer.sendUserSyncMessage("websopy", "CREATE", savedUser);
|
||||||
|
log.info("用户创建后发送MQ消息同步到websopy: userId={}, phone={}", user.getUserId(), user.getPhone());
|
||||||
|
}
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +205,57 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
|||||||
throw new BusinessException("用户角色添加失败");
|
throw new BusinessException("用户角色添加失败");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 用户更新成功后,通过MQ异步同步用户数据到 websopy
|
||||||
|
if (result && syncMessageProducer != null) {
|
||||||
|
User updatedUser = getAllByUserId(String.valueOf(user.getUserId()));
|
||||||
|
if (updatedUser != null) {
|
||||||
|
syncMessageProducer.sendUserSyncMessage("websopy", "UPDATE", updatedUser);
|
||||||
|
log.info("用户更新后发送MQ消息同步到websopy: userId={}, phone={}", user.getUserId(), user.getPhone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public boolean markAsDeveloper(Integer userId) {
|
||||||
|
if (userId == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
User user = new User();
|
||||||
|
user.setUserId(userId);
|
||||||
|
user.setIsDeveloper(true);
|
||||||
|
boolean result = updateById(user);
|
||||||
|
if (result) {
|
||||||
|
// 替换用户角色为 developer:先按用户所在租户找到 developer 角色,删除该用户现有角色后
|
||||||
|
// 只绑定 developer,保证用户最终只有「开发者」单一角色(不与原角色并存)
|
||||||
|
try {
|
||||||
|
User dbUser = getById(userId);
|
||||||
|
Integer tenantId = dbUser != null ? dbUser.getTenantId() : null;
|
||||||
|
RoleParam roleParam = new RoleParam();
|
||||||
|
roleParam.setRoleCode("developer");
|
||||||
|
if (tenantId != null) {
|
||||||
|
roleParam.setTenantId(tenantId);
|
||||||
|
}
|
||||||
|
Role developerRole = roleService.getByRoleCode(roleParam);
|
||||||
|
if (developerRole != null && developerRole.getRoleId() != null) {
|
||||||
|
userRoleService.remove(new LambdaUpdateWrapper<UserRole>().eq(UserRole::getUserId, userId));
|
||||||
|
userRoleService.saveBatch(userId, Collections.singletonList(developerRole.getRoleId()));
|
||||||
|
log.info("用户设为开发者并替换角色为 developer: userId={}, roleId={}", userId, developerRole.getRoleId());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 角色替换失败不影响 is_developer 标记位落库
|
||||||
|
log.warn("替换用户角色为 developer 失败(不影响 is_developer 标记): userId={}", userId, e);
|
||||||
|
}
|
||||||
|
// 标记开发者后,通过MQ异步同步用户数据到 websopy
|
||||||
|
if (syncMessageProducer != null) {
|
||||||
|
User updatedUser = getAllByUserId(String.valueOf(userId));
|
||||||
|
if (updatedUser != null) {
|
||||||
|
syncMessageProducer.sendUserSyncMessage("websopy", "UPDATE", updatedUser);
|
||||||
|
log.info("用户设为开发者后同步到websopy: userId={}", userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +325,8 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
|||||||
User addUser = new User();
|
User addUser = new User();
|
||||||
// 注册用户
|
// 注册用户
|
||||||
addUser.setStatus(0);
|
addUser.setStatus(0);
|
||||||
|
// 默认审核通过;前端传 auditStatus=0 时走审核流程
|
||||||
|
addUser.setAuditStatus(userParam.getAuditStatus() != null ? userParam.getAuditStatus() : 1);
|
||||||
if(userParam.getUsername() != null){
|
if(userParam.getUsername() != null){
|
||||||
addUser.setUsername(userParam.getUsername());
|
addUser.setUsername(userParam.getUsername());
|
||||||
}
|
}
|
||||||
@@ -299,6 +394,10 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
|||||||
role = roleService.getOne(roleQw, false);
|
role = roleService.getOne(roleQw, false);
|
||||||
// If the default "user" role is missing (fresh DB / incomplete init), create it to avoid empty roles.
|
// If the default "user" role is missing (fresh DB / incomplete init), create it to avoid empty roles.
|
||||||
if (role == null && addUser.getTenantId() != null && "user".equals(roleCode)) {
|
if (role == null && addUser.getTenantId() != null && "user".equals(roleCode)) {
|
||||||
|
// 校验租户是否存在,否则插入 sys_role 会因外键约束失败(tenant_id 引用 sys_tenant)
|
||||||
|
if (tenantService.getById(addUser.getTenantId()) == null) {
|
||||||
|
throw new BusinessException("租户不存在(tenantId=" + addUser.getTenantId() + "),无法创建默认角色");
|
||||||
|
}
|
||||||
Role defaultRole = new Role();
|
Role defaultRole = new Role();
|
||||||
defaultRole.setRoleName("注册用户");
|
defaultRole.setRoleName("注册用户");
|
||||||
defaultRole.setRoleCode("user");
|
defaultRole.setRoleCode("user");
|
||||||
@@ -323,6 +422,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
|||||||
// Ensure caller (e.g. register / invite register) gets non-empty roles/authorities in response.
|
// Ensure caller (e.g. register / invite register) gets non-empty roles/authorities in response.
|
||||||
addUser.setRoles(userRoleService.listByUserId(addUser.getUserId()));
|
addUser.setRoles(userRoleService.listByUserId(addUser.getUserId()));
|
||||||
addUser.setAuthorities(roleMenuService.listMenuByUserId(addUser.getUserId(), null));
|
addUser.setAuthorities(roleMenuService.listMenuByUserId(addUser.getUserId(), null));
|
||||||
|
// addUser内部调用saveUser,saveUser已发送MQ消息,这里不需要重复发送
|
||||||
return addUser;
|
return addUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,6 +446,37 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
|||||||
return baseMapper.selectListAllRel(userParam);
|
return baseMapper.selectListAllRel(userParam);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public User getLastLoginSuperAdminByPhone(String phone) {
|
||||||
|
return baseMapper.selectLastLoginSuperAdminByPhone(phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public User getLastLoginDeveloperByPhone(String phone) {
|
||||||
|
return baseMapper.selectLastLoginDeveloperByPhone(phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public User getByPhoneAndAdmin(String phone) {
|
||||||
|
return baseMapper.selectAdminByPhoneCrossTenant(phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<User> getSuperAdminsByPhone(String phone) {
|
||||||
|
if (StrUtil.isBlank(phone)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return baseMapper.selectSuperAdminsByPhone(phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public User getSuperAdminByPhoneAndTenantId(String phone, Integer tenantId) {
|
||||||
|
if (StrUtil.isBlank(phone) || tenantId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return baseMapper.selectSuperAdminByPhoneAndTenantId(phone, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<User> pageAll(UserParam param) {
|
public List<User> pageAll(UserParam param) {
|
||||||
return baseMapper.pageRelAll(param);
|
return baseMapper.pageRelAll(param);
|
||||||
@@ -415,6 +546,14 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
|||||||
return baseMapper.selectAccountsByPhone(phone);
|
return baseMapper.selectAccountsByPhone(phone);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<User> findAccountsByEmail(String email) {
|
||||||
|
if (StrUtil.isBlank(email)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return baseMapper.selectAccountsByEmail(email);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Integer countAccountsByPhone(String phone) {
|
public Integer countAccountsByPhone(String phone) {
|
||||||
if (StrUtil.isBlank(phone)) {
|
if (StrUtil.isBlank(phone)) {
|
||||||
|
|||||||
@@ -30,22 +30,23 @@ public class EmailTemplateUtil {
|
|||||||
* @param email 邮箱
|
* @param email 邮箱
|
||||||
* @param tenantId 租户ID
|
* @param tenantId 租户ID
|
||||||
*/
|
*/
|
||||||
public void sendRegisterSuccessEmail(String username, String phone, String password, String email, Integer tenantId) {
|
public void sendRegisterSuccessEmail(String siteName, String siteUrl, String adminUrl, String account, String password, String email, Integer tenantId) {
|
||||||
try {
|
try {
|
||||||
String title = "恭喜!您的账号已注册成功";
|
String title = "您的企业官网已开通成功";
|
||||||
Map<String, Object> data = new HashMap<>();
|
Map<String, Object> data = new HashMap<>(8);
|
||||||
data.put("username", username);
|
data.put("siteName", siteName);
|
||||||
data.put("phone", phone);
|
data.put("siteUrl", siteUrl);
|
||||||
|
data.put("adminUrl", adminUrl);
|
||||||
|
data.put("account", account);
|
||||||
data.put("password", password);
|
data.put("password", password);
|
||||||
if (email != null && !email.trim().isEmpty()) {
|
data.put("sendTime", DateUtil.now());
|
||||||
data.put("email", email);
|
|
||||||
}
|
|
||||||
|
|
||||||
emailRecordService.sendHtmlEmail(title, "register-success.html", data, new String[]{email});
|
emailRecordService.sendHtmlEmail(title, "register-success.html", data, new String[]{email});
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// 如果HTML邮件发送失败,降级为文本邮件
|
// 如果HTML邮件发送失败,降级为文本邮件
|
||||||
String content = "恭喜!您的WebSoft账号已注册成功\r\n用户名:" + username + "\r\n手机号码:" + phone + "\r\n密码:" + password;
|
String content = "恭喜!您的企业官网「" + siteName + "」已开通成功\r\n官网地址:" + siteUrl
|
||||||
emailRecordService.sendEmail("恭喜!您的WebSoft账号已注册成功", content, email, tenantId);
|
+ "\r\n后台地址:" + adminUrl + "\r\n登录账号:" + account + "\r\n登录密码:" + password;
|
||||||
|
emailRecordService.sendEmail("您的企业官网已开通成功", content, email, tenantId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,4 +197,30 @@ public class EmailTemplateUtil {
|
|||||||
|
|
||||||
sendNotificationEmailWithAction(title, content, email, tenantId, actionUrl, actionText);
|
sendNotificationEmailWithAction(title, content, email, tenantId, actionUrl, actionText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送邮箱验证码邮件
|
||||||
|
*
|
||||||
|
* @param email 收件人邮箱
|
||||||
|
* @param code 验证码
|
||||||
|
* @param tenantId 租户ID
|
||||||
|
*/
|
||||||
|
public void sendCaptchaEmail(String email, String code, Integer tenantId) {
|
||||||
|
if (email == null || email.trim().isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 标题直接带上验证码,手机通知预览/邮件列表一眼可见
|
||||||
|
String title = "【WebSoft】邮箱验证码:" + code;
|
||||||
|
try {
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("code", code);
|
||||||
|
data.put("sendTime", DateUtil.now());
|
||||||
|
|
||||||
|
emailRecordService.sendHtmlEmail(title, "captcha.html", data, new String[]{email});
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 如果HTML邮件发送失败,降级为文本邮件(标题仍含验证码)
|
||||||
|
String content = "【WebSoft】邮箱验证码\r\n\r\n您的验证码:" + code + "\r\n\r\n5 分钟内有效,请勿泄露给他人。如非本人操作,请忽略此邮件。";
|
||||||
|
emailRecordService.sendEmail(title, content, email, tenantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.gxwebsoft.openplatform.constant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放平台权限标识(scope)。
|
||||||
|
*
|
||||||
|
* <p>与 base-api 的权限字典、以及在管理后台勾选的权限一一对应。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
public final class OpenScopes {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询项目订单。
|
||||||
|
*
|
||||||
|
* <p>与 sys_menu.authority 里的既有权限点同名(菜单 157795「查询」、182274「项目订单」),
|
||||||
|
* 便于与内部权限体系对照。</p>
|
||||||
|
*/
|
||||||
|
public static final String SHOP_ORDER_LIST = "shop:shopOrder:list";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户列表。
|
||||||
|
*
|
||||||
|
* <p>与 sys_menu.authority 里的既有权限点同名(菜单 3561 等「查询」)。</p>
|
||||||
|
*/
|
||||||
|
public static final String SYS_USER_LIST = "sys:user:list";
|
||||||
|
|
||||||
|
private OpenScopes() {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.gxwebsoft.openplatform.controller;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.gxwebsoft.common.core.Constants;
|
||||||
|
import com.gxwebsoft.common.core.web.ApiResult;
|
||||||
|
import com.gxwebsoft.common.core.web.PageResult;
|
||||||
|
import com.gxwebsoft.common.system.entity.Order;
|
||||||
|
import com.gxwebsoft.common.system.param.OrderParam;
|
||||||
|
import com.gxwebsoft.common.system.service.OrderService;
|
||||||
|
import com.gxwebsoft.platform.openapi.OpenApi;
|
||||||
|
import com.gxwebsoft.platform.openapi.config.OpenPlatformProperties;
|
||||||
|
import com.gxwebsoft.openplatform.constant.OpenScopes;
|
||||||
|
import com.gxwebsoft.platform.openapi.context.OpenTenantContext;
|
||||||
|
import com.gxwebsoft.openplatform.param.OpenOrderPageParam;
|
||||||
|
import com.gxwebsoft.openplatform.vo.OpenOrderVO;
|
||||||
|
import com.gxwebsoft.platform.openapi.web.OpenPageResult;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放平台 · 订单接口。
|
||||||
|
*
|
||||||
|
* <p>路径带版本号 {@code /api/open/v1/}:对外接口是长期契约,发布后只增不改。</p>
|
||||||
|
*
|
||||||
|
* <p>鉴权分两层:框架层由 {@code OpenPlatformSecurityConfig} 校验 RS256 令牌的签名与 iss/aud/exp;
|
||||||
|
* 权限层由 {@code @PreAuthorize} 校验 scope。租户由 {@code OpenTenantInterceptor} 从令牌落到上下文。</p>
|
||||||
|
*
|
||||||
|
* <p>随 {@code open-platform.enabled} 一起装配:开关关闭时接口整体下线,
|
||||||
|
* 而不是退化到内部控制台的安全链(那条链对所有 GET 放行)。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
@Tag(name = "开放接口-订单")
|
||||||
|
@OpenApi
|
||||||
|
@RestController
|
||||||
|
@ConditionalOnProperty(prefix = "open-platform", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
@RequestMapping("/api/open/v1/order")
|
||||||
|
public class OpenOrderController {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private OrderService orderService;
|
||||||
|
@Resource
|
||||||
|
private OpenPlatformProperties properties;
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('SCOPE_" + OpenScopes.SHOP_ORDER_LIST + "')")
|
||||||
|
@Operation(summary = "分页查询本租户订单")
|
||||||
|
@GetMapping("/page")
|
||||||
|
public ApiResult<OpenPageResult<OpenOrderVO>> page(@AuthenticationPrincipal Jwt jwt,
|
||||||
|
OpenOrderPageParam query) {
|
||||||
|
// 租户只认令牌:拦截器已校验过 tenant_id 存在,这里不会拿到请求头里的值
|
||||||
|
Integer tenantId = OpenTenantContext.getTenantId();
|
||||||
|
|
||||||
|
OrderParam param = new OrderParam();
|
||||||
|
param.setPage(normalizePage(query.getPage()));
|
||||||
|
param.setLimit(normalizeLimit(query.getLimit()));
|
||||||
|
param.setTenantId(tenantId);
|
||||||
|
param.setOrderNo(StrUtil.trimToNull(query.getOrderNo()));
|
||||||
|
param.setType(query.getType());
|
||||||
|
param.setOrderStatus(query.getOrderStatus());
|
||||||
|
param.setPayStatus(query.getPayStatus());
|
||||||
|
param.setPayType(query.getPayType());
|
||||||
|
param.setCreateTimeStart(StrUtil.trimToNull(query.getCreateTimeStart()));
|
||||||
|
param.setCreateTimeEnd(StrUtil.trimToNull(query.getCreateTimeEnd()));
|
||||||
|
|
||||||
|
PageResult<Order> result = orderService.pageRel(param);
|
||||||
|
List<OpenOrderVO> list = OpenOrderVO.from(result.getList(), properties.isMaskSensitive());
|
||||||
|
return new ApiResult<>(Constants.RESULT_OK_CODE, Constants.RESULT_OK_MSG,
|
||||||
|
new OpenPageResult<>(list, result.getCount(), param.getPage(), param.getLimit()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long normalizePage(Long page) {
|
||||||
|
return (page == null || page < 1L) ? 1L : page;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long normalizeLimit(Long limit) {
|
||||||
|
if (limit == null || limit < 1L) {
|
||||||
|
return 20L;
|
||||||
|
}
|
||||||
|
return Math.min(limit, OpenOrderPageParam.MAX_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.gxwebsoft.openplatform.controller;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.gxwebsoft.common.core.Constants;
|
||||||
|
import com.gxwebsoft.common.core.web.ApiResult;
|
||||||
|
import com.gxwebsoft.common.core.web.PageResult;
|
||||||
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
|
import com.gxwebsoft.common.system.param.UserParam;
|
||||||
|
import com.gxwebsoft.common.system.service.UserService;
|
||||||
|
import com.gxwebsoft.platform.openapi.OpenApi;
|
||||||
|
import com.gxwebsoft.platform.openapi.config.OpenPlatformProperties;
|
||||||
|
import com.gxwebsoft.openplatform.constant.OpenScopes;
|
||||||
|
import com.gxwebsoft.platform.openapi.context.OpenTenantContext;
|
||||||
|
import com.gxwebsoft.openplatform.param.OpenUserPageParam;
|
||||||
|
import com.gxwebsoft.openplatform.vo.OpenUserVO;
|
||||||
|
import com.gxwebsoft.platform.openapi.web.OpenPageResult;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放平台 · 用户接口。
|
||||||
|
*
|
||||||
|
* <p>{@code sys_user} 不在 {@code MybatisPlusConfig} 的 {@code ignoreTable} 白名单里,
|
||||||
|
* 多租户插件会自动加上 {@code tenant_id} 条件;这里仍显式设置一次,
|
||||||
|
* 两个来源都指向令牌里的租户({@code OpenTenantContext}),属于双保险。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
@Tag(name = "开放接口-用户")
|
||||||
|
@OpenApi
|
||||||
|
@RestController
|
||||||
|
@ConditionalOnProperty(prefix = "open-platform", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
@RequestMapping("/api/open/v1/user")
|
||||||
|
public class OpenUserController {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private UserService userService;
|
||||||
|
@Resource
|
||||||
|
private OpenPlatformProperties properties;
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('SCOPE_" + OpenScopes.SYS_USER_LIST + "')")
|
||||||
|
@Operation(summary = "分页查询本租户用户")
|
||||||
|
@GetMapping("/page")
|
||||||
|
public ApiResult<OpenPageResult<OpenUserVO>> page(OpenUserPageParam query) {
|
||||||
|
// 租户只认令牌,拦截器已校验 tenant_id 存在
|
||||||
|
Integer tenantId = OpenTenantContext.getTenantId();
|
||||||
|
|
||||||
|
UserParam param = new UserParam();
|
||||||
|
param.setPage(normalizePage(query.getPage()));
|
||||||
|
param.setLimit(normalizeLimit(query.getLimit()));
|
||||||
|
param.setTenantId(tenantId);
|
||||||
|
param.setUsername(StrUtil.trimToNull(query.getUsername()));
|
||||||
|
param.setNickname(StrUtil.trimToNull(query.getNickname()));
|
||||||
|
param.setType(query.getType());
|
||||||
|
param.setStatus(query.getStatus());
|
||||||
|
param.setCreateTimeStart(StrUtil.trimToNull(query.getCreateTimeStart()));
|
||||||
|
param.setCreateTimeEnd(StrUtil.trimToNull(query.getCreateTimeEnd()));
|
||||||
|
|
||||||
|
PageResult<User> result = userService.pageRel(param);
|
||||||
|
List<OpenUserVO> list = OpenUserVO.from(result.getList(), properties.isMaskSensitive());
|
||||||
|
return new ApiResult<>(Constants.RESULT_OK_CODE, Constants.RESULT_OK_MSG,
|
||||||
|
new OpenPageResult<>(list, result.getCount(), param.getPage(), param.getLimit()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long normalizePage(Long page) {
|
||||||
|
return (page == null || page < 1L) ? 1L : page;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long normalizeLimit(Long limit) {
|
||||||
|
if (limit == null || limit < 1L) {
|
||||||
|
return 20L;
|
||||||
|
}
|
||||||
|
return Math.min(limit, OpenUserPageParam.MAX_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.gxwebsoft.openplatform.param;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口订单分页查询入参。
|
||||||
|
*
|
||||||
|
* <p>刻意不复用内部的 {@code OrderParam}:后者带着 {@code userId}、{@code keywords}、{@code deleted}
|
||||||
|
* 等可被构造的字段,直接暴露给第三方等于把内部查询能力整体开放。这里只保留白名单字段,
|
||||||
|
* 且<b>不提供 tenantId</b>——租户只来自令牌。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(name = "OpenOrderPageParam", description = "开放接口订单分页查询参数")
|
||||||
|
public class OpenOrderPageParam implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/** 每页最大条数,防止第三方一次拉全量 */
|
||||||
|
public static final long MAX_LIMIT = 100L;
|
||||||
|
|
||||||
|
@Schema(description = "页码,从 1 开始", example = "1")
|
||||||
|
private Long page = 1L;
|
||||||
|
|
||||||
|
@Schema(description = "每页数量,最大 100", example = "20")
|
||||||
|
private Long limit = 20L;
|
||||||
|
|
||||||
|
@Schema(description = "订单编号,模糊匹配")
|
||||||
|
private String orderNo;
|
||||||
|
|
||||||
|
@Schema(description = "订单类型,0产品 1插件")
|
||||||
|
private Integer type;
|
||||||
|
|
||||||
|
@Schema(description = "订单状态,0未完成 1已完成 2已取消 ...")
|
||||||
|
private Integer orderStatus;
|
||||||
|
|
||||||
|
@Schema(description = "是否已付款")
|
||||||
|
private Boolean payStatus;
|
||||||
|
|
||||||
|
@Schema(description = "支付方式")
|
||||||
|
private Integer payType;
|
||||||
|
|
||||||
|
@Schema(description = "下单时间起始,闭区间,格式 yyyy-MM-dd HH:mm:ss")
|
||||||
|
private String createTimeStart;
|
||||||
|
|
||||||
|
@Schema(description = "下单时间结束,闭区间,格式 yyyy-MM-dd HH:mm:ss")
|
||||||
|
private String createTimeEnd;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.gxwebsoft.openplatform.param;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口用户分页查询入参。
|
||||||
|
*
|
||||||
|
* <p>白名单字段,刻意不含 {@code password}、{@code tenantId} 等内部可构造字段。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(name = "OpenUserPageParam", description = "开放接口用户分页查询参数")
|
||||||
|
public class OpenUserPageParam implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/** 每页最大条数 */
|
||||||
|
public static final long MAX_LIMIT = 100L;
|
||||||
|
|
||||||
|
@Schema(description = "页码,从 1 开始", example = "1")
|
||||||
|
private Long page = 1L;
|
||||||
|
|
||||||
|
@Schema(description = "每页数量,最大 100", example = "20")
|
||||||
|
private Long limit = 20L;
|
||||||
|
|
||||||
|
@Schema(description = "账号,模糊匹配")
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@Schema(description = "昵称,模糊匹配")
|
||||||
|
private String nickname;
|
||||||
|
|
||||||
|
@Schema(description = "用户类型")
|
||||||
|
private Integer type;
|
||||||
|
|
||||||
|
@Schema(description = "用户状态")
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
@Schema(description = "注册时间起始,格式 yyyy-MM-dd HH:mm:ss")
|
||||||
|
private String createTimeStart;
|
||||||
|
|
||||||
|
@Schema(description = "注册时间结束,格式 yyyy-MM-dd HH:mm:ss")
|
||||||
|
private String createTimeEnd;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package com.gxwebsoft.openplatform.vo;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import com.gxwebsoft.common.system.entity.Order;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口的订单出参。
|
||||||
|
*
|
||||||
|
* <p>只暴露第三方确实需要的字段,避免把内部实体(含 adminUrl、menuParam、deleted 等)整体吐出去。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(name = "OpenOrder", description = "开放接口订单")
|
||||||
|
public class OpenOrderVO implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Schema(description = "订单id")
|
||||||
|
private Integer orderId;
|
||||||
|
|
||||||
|
@Schema(description = "订单编号")
|
||||||
|
private String orderNo;
|
||||||
|
|
||||||
|
@Schema(description = "订单类型,0产品 1插件")
|
||||||
|
private Integer type;
|
||||||
|
|
||||||
|
@Schema(description = "下单渠道,0网站 1小程序 2其他")
|
||||||
|
private Integer channel;
|
||||||
|
|
||||||
|
@Schema(description = "订单总额")
|
||||||
|
private BigDecimal totalPrice;
|
||||||
|
|
||||||
|
@Schema(description = "优惠金额")
|
||||||
|
private BigDecimal reducePrice;
|
||||||
|
|
||||||
|
@Schema(description = "实际付款金额")
|
||||||
|
private BigDecimal payPrice;
|
||||||
|
|
||||||
|
@Schema(description = "退款金额")
|
||||||
|
private BigDecimal refundMoney;
|
||||||
|
|
||||||
|
@Schema(description = "购买数量")
|
||||||
|
private Integer totalNum;
|
||||||
|
|
||||||
|
@Schema(description = "支付方式")
|
||||||
|
private Integer payType;
|
||||||
|
|
||||||
|
@Schema(description = "是否已付款")
|
||||||
|
private Boolean payStatus;
|
||||||
|
|
||||||
|
@Schema(description = "订单状态")
|
||||||
|
private Integer orderStatus;
|
||||||
|
|
||||||
|
@Schema(description = "第三方支付订单号")
|
||||||
|
private String transactionId;
|
||||||
|
|
||||||
|
@Schema(description = "下单人姓名")
|
||||||
|
private String realName;
|
||||||
|
|
||||||
|
@Schema(description = "下单人手机号")
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
@Schema(description = "备注")
|
||||||
|
private String comments;
|
||||||
|
|
||||||
|
// 与 Order 实体的序列化保持一致:输出库中存储的挂钟时间,不做时区换算。
|
||||||
|
// 注意 JacksonConfig 里的 @Primary ObjectMapper 是新建的,不加载 spring.jackson.* 配置,
|
||||||
|
// 不显式声明格式会退化成 ISO-8601(2024-11-12T21:03:31.000+00:00)。
|
||||||
|
@Schema(description = "支付时间,格式 yyyy-MM-dd HH:mm:ss")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private Date payTime;
|
||||||
|
|
||||||
|
@Schema(description = "退款时间,格式 yyyy-MM-dd HH:mm:ss")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private Date refundTime;
|
||||||
|
|
||||||
|
@Schema(description = "下单时间,格式 yyyy-MM-dd HH:mm:ss")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private Date createTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实体转出参。
|
||||||
|
*
|
||||||
|
* @param orders 订单列表
|
||||||
|
* @param maskSensitive 是否对手机号脱敏(默认开启,避免把终端用户信息直接交给第三方)
|
||||||
|
*/
|
||||||
|
public static List<OpenOrderVO> from(List<Order> orders, boolean maskSensitive) {
|
||||||
|
if (orders == null || orders.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
List<OpenOrderVO> list = new ArrayList<>(orders.size());
|
||||||
|
for (Order order : orders) {
|
||||||
|
OpenOrderVO vo = new OpenOrderVO();
|
||||||
|
vo.setOrderId(order.getOrderId());
|
||||||
|
vo.setOrderNo(order.getOrderNo());
|
||||||
|
vo.setType(order.getType());
|
||||||
|
vo.setChannel(order.getChannel());
|
||||||
|
vo.setTotalPrice(order.getTotalPrice());
|
||||||
|
vo.setReducePrice(order.getReducePrice());
|
||||||
|
vo.setPayPrice(order.getPayPrice());
|
||||||
|
vo.setRefundMoney(order.getRefundMoney());
|
||||||
|
vo.setTotalNum(order.getTotalNum());
|
||||||
|
vo.setPayType(order.getPayType());
|
||||||
|
vo.setPayStatus(order.getPayStatus());
|
||||||
|
vo.setOrderStatus(order.getOrderStatus());
|
||||||
|
vo.setTransactionId(order.getTransactionId());
|
||||||
|
vo.setRealName(order.getRealName());
|
||||||
|
vo.setPhone(maskSensitive ? maskPhone(order.getPhone()) : order.getPhone());
|
||||||
|
vo.setComments(order.getComments());
|
||||||
|
vo.setPayTime(order.getPayTime());
|
||||||
|
vo.setRefundTime(order.getRefundTime());
|
||||||
|
vo.setCreateTime(order.getCreateTime());
|
||||||
|
list.add(vo);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String maskPhone(String phone) {
|
||||||
|
if (phone == null || phone.length() < 7) {
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package com.gxwebsoft.openplatform.vo;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口的用户出参。
|
||||||
|
*
|
||||||
|
* <p><b>安全要点</b>:{@code User} 实体没有对 {@code password} 做 {@code @JsonIgnore},
|
||||||
|
* 且 UserMapper 用的是 {@code SELECT a.*},直接把实体返回给第三方会泄露密码哈希与支付密码。
|
||||||
|
* 这里只挑出必要字段,密码类字段一律不出现。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(name = "OpenUser", description = "开放接口用户")
|
||||||
|
public class OpenUserVO implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Schema(description = "用户id")
|
||||||
|
private Integer userId;
|
||||||
|
|
||||||
|
@Schema(description = "用户编码")
|
||||||
|
private String userCode;
|
||||||
|
|
||||||
|
@Schema(description = "账号")
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@Schema(description = "昵称")
|
||||||
|
private String nickname;
|
||||||
|
|
||||||
|
@Schema(description = "真实姓名")
|
||||||
|
private String realName;
|
||||||
|
|
||||||
|
@Schema(description = "用户类型")
|
||||||
|
private Integer type;
|
||||||
|
|
||||||
|
@Schema(description = "性别字典值")
|
||||||
|
private String sex;
|
||||||
|
|
||||||
|
@Schema(description = "性别名称")
|
||||||
|
private String sexName;
|
||||||
|
|
||||||
|
@Schema(description = "手机号(按配置脱敏)")
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
@Schema(description = "邮箱(按配置脱敏)")
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Schema(description = "邮箱是否验证,0否 1是")
|
||||||
|
private Integer emailVerified;
|
||||||
|
|
||||||
|
@Schema(description = "机构id")
|
||||||
|
private Integer organizationId;
|
||||||
|
|
||||||
|
@Schema(description = "机构名称")
|
||||||
|
private String organizationName;
|
||||||
|
|
||||||
|
@Schema(description = "状态")
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
@Schema(description = "审核状态:0待审核 1已通过 2已拒绝")
|
||||||
|
private Integer auditStatus;
|
||||||
|
|
||||||
|
@Schema(description = "注册时间,格式 yyyy-MM-dd HH:mm:ss")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实体转出参。
|
||||||
|
*
|
||||||
|
* @param users 用户列表
|
||||||
|
* @param maskSensitive 是否对手机号、邮箱脱敏
|
||||||
|
*/
|
||||||
|
public static List<OpenUserVO> from(List<User> users, boolean maskSensitive) {
|
||||||
|
if (users == null || users.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
List<OpenUserVO> list = new ArrayList<>(users.size());
|
||||||
|
for (User user : users) {
|
||||||
|
OpenUserVO vo = new OpenUserVO();
|
||||||
|
vo.setUserId(user.getUserId());
|
||||||
|
vo.setUserCode(user.getUserCode());
|
||||||
|
vo.setUsername(user.getUsername());
|
||||||
|
vo.setNickname(user.getNickname());
|
||||||
|
vo.setRealName(user.getRealName());
|
||||||
|
vo.setType(user.getType());
|
||||||
|
vo.setSex(user.getSex());
|
||||||
|
vo.setSexName(user.getSexName());
|
||||||
|
vo.setPhone(maskSensitive ? maskPhone(user.getPhone()) : user.getPhone());
|
||||||
|
vo.setEmail(maskSensitive ? maskEmail(user.getEmail()) : user.getEmail());
|
||||||
|
vo.setEmailVerified(user.getEmailVerified());
|
||||||
|
vo.setOrganizationId(user.getOrganizationId());
|
||||||
|
vo.setOrganizationName(user.getOrganizationName());
|
||||||
|
vo.setStatus(user.getStatus());
|
||||||
|
vo.setAuditStatus(user.getAuditStatus());
|
||||||
|
vo.setCreateTime(user.getCreateTime());
|
||||||
|
list.add(vo);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String maskPhone(String phone) {
|
||||||
|
if (phone == null || phone.length() < 7) {
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String maskEmail(String email) {
|
||||||
|
if (email == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int at = email.indexOf('@');
|
||||||
|
if (at <= 0) {
|
||||||
|
// 不是正常的邮箱格式,原样返回,不臆造
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
// 统一保留局部名首字符与完整域名:zhangsan@example.com -> z***@example.com
|
||||||
|
return email.charAt(0) + "***" + email.substring(at);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.gxwebsoft.websopy.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* app_config(db_websopy)跨表映射实体
|
||||||
|
* 实际查询通过 XML 使用全限定表名 db_websopy.app_config
|
||||||
|
*
|
||||||
|
* 实际表结构:
|
||||||
|
* config_id, app_id, config_key, config_value, config_type,
|
||||||
|
* is_encrypted, is_secret, updated_time, created_time,
|
||||||
|
* description, sort_number, tenant_id, deleted
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
* @since 2026-06-17
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class AppConfig {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@TableId(value = "config_id", type = IdType.AUTO)
|
||||||
|
private Integer configId;
|
||||||
|
|
||||||
|
@TableField("app_id")
|
||||||
|
private Integer appId;
|
||||||
|
|
||||||
|
@TableField("tenant_id")
|
||||||
|
private Integer tenantId;
|
||||||
|
|
||||||
|
/** 应用绑定的租户ID(用于精确匹配当前请求租户的配置) */
|
||||||
|
@TableField("bound_tenant_id")
|
||||||
|
private Integer boundTenantId;
|
||||||
|
|
||||||
|
@TableField("config_key")
|
||||||
|
private String configKey;
|
||||||
|
|
||||||
|
@TableField("config_value")
|
||||||
|
private String configValue;
|
||||||
|
|
||||||
|
/** 分类字段,如 wechat / payment / sms */
|
||||||
|
@TableField("config_type")
|
||||||
|
private String configType;
|
||||||
|
|
||||||
|
@TableField("is_encrypted")
|
||||||
|
private Integer isEncrypted;
|
||||||
|
|
||||||
|
@TableField("is_secret")
|
||||||
|
private Integer isSecret;
|
||||||
|
|
||||||
|
@TableField("description")
|
||||||
|
private String description;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.gxwebsoft.websopy.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.gxwebsoft.websopy.entity.AppConfig;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* app_config(db_websopy)跨表查询 Mapper
|
||||||
|
*
|
||||||
|
* 注意:selectByCategory 标注了 @InterceptorIgnore(tenantLine = "true")
|
||||||
|
* 因为 XML 中使用全限定表名 db_websopy.app_config,
|
||||||
|
* TenantLineInnerInterceptor 会自动追加 tenant_id 条件导致参数重复。
|
||||||
|
* 同时 app_config 的 tenant_id 与 gxwebsoft_core 不是同一套。
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
* @since 2026-06-17
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface AppConfigMapper extends BaseMapper<AppConfig> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跨表查询 db_websopy.app_config(按 config_type 分组)
|
||||||
|
*
|
||||||
|
* @param tenantId 租户ID(app_config 表中的 tenant_id)
|
||||||
|
* @param configType 配置分类(如 wechat)
|
||||||
|
* @return 配置项列表
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
List<AppConfig> selectByCategory(@Param("tenantId") Integer tenantId,
|
||||||
|
@Param("configType") String configType);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.gxwebsoft.websopy.mapper.AppConfigMapper">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
查询 db_websopy.app_config
|
||||||
|
注意:
|
||||||
|
1. 使用 bound_tenant_id 字段精确匹配当前请求租户的绑定配置
|
||||||
|
2. 表名带库名前缀 db_websopy.app_config(该表在 db_websopy 库中)
|
||||||
|
3. Mapper 方法已加 @InterceptorIgnore(tenantLine = "true"),
|
||||||
|
TenantLineInnerInterceptor 不会自动追加 tenant_id 条件
|
||||||
|
-->
|
||||||
|
<select id="selectByCategory" resultType="com.gxwebsoft.websopy.entity.AppConfig">
|
||||||
|
SELECT ac.config_id AS configId,
|
||||||
|
ac.app_id AS appId,
|
||||||
|
ac.tenant_id AS tenantId,
|
||||||
|
ac.bound_tenant_id AS boundTenantId,
|
||||||
|
ac.config_key AS configKey,
|
||||||
|
ac.config_value AS configValue,
|
||||||
|
ac.config_type AS configType,
|
||||||
|
ac.is_encrypted AS isEncrypted,
|
||||||
|
ac.is_secret AS isSecret,
|
||||||
|
ac.description
|
||||||
|
FROM db_websopy.app_config ac
|
||||||
|
WHERE ac.deleted = 0
|
||||||
|
AND ac.bound_tenant_id = #{tenantId}
|
||||||
|
AND ac.config_type = #{configType}
|
||||||
|
ORDER BY ac.config_id DESC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package com.gxwebsoft.websopy.service;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson.JSONObject;
|
||||||
|
import com.gxwebsoft.websopy.entity.AppConfig;
|
||||||
|
import com.gxwebsoft.websopy.mapper.AppConfigMapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* app_config(db_websopy)配置读取服务
|
||||||
|
* 优先读 Redis 缓存,未命中跨表查 gxwebsoft_core.app_config 并写入缓存
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
* @since 2026-06-17
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class AppConfigService {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private AppConfigMapper appConfigMapper;
|
||||||
|
|
||||||
|
// 字段名与 bean 名保持一致:stringRedisTemplate(StringRedisTemplate bean)
|
||||||
|
// 不能用 redisTemplate,容器中该名字对应的是 RedisTemplate<Object, Object>
|
||||||
|
@Resource
|
||||||
|
private StringRedisTemplate stringRedisTemplate;
|
||||||
|
|
||||||
|
/** 缓存有效期:2 小时(与微信 access_token 一致,避免长时间 stale) */
|
||||||
|
private static final long CACHE_TTL_HOURS = 2L;
|
||||||
|
|
||||||
|
/** 缓存 Key 前缀,避免与 setting:* 冲突 */
|
||||||
|
private static final String CACHE_KEY_PREFIX = "appConfig:";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 config_type 读取 app_config 全部配置项,并组装为 JSON
|
||||||
|
*
|
||||||
|
* @param configType 配置分类(如 wechat,对应 app_config.config_type 字段)
|
||||||
|
* @param tenantId 租户 ID(必传)
|
||||||
|
* @return 拼装后的 JSON;若 config_type 下无记录或发生异常,返回 null(由调用方回退到 sys_setting)
|
||||||
|
*/
|
||||||
|
public JSONObject getByCategory(String configType, Integer tenantId) {
|
||||||
|
if (tenantId == null) {
|
||||||
|
log.warn("[AppConfigService] tenantId 为空,跳过 configType={}", configType);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String cacheKey = buildCacheKey(configType, tenantId);
|
||||||
|
|
||||||
|
// 1. 命中 Redis 直接返回
|
||||||
|
String cached = stringRedisTemplate.opsForValue().get(cacheKey);
|
||||||
|
if (cached != null && !cached.isEmpty()) {
|
||||||
|
log.info("[AppConfigService] 命中 Redis 缓存 configType={}, tenantId={}, cacheKey={}, 缓存内容={}",
|
||||||
|
configType, tenantId, cacheKey, cached);
|
||||||
|
try {
|
||||||
|
return JSONObject.parseObject(cached);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[AppConfigService] 缓存解析失败,删除后回源 configType={}, tenantId={}, err={}",
|
||||||
|
configType, tenantId, e.getMessage());
|
||||||
|
stringRedisTemplate.delete(cacheKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 跨表查 db_websopy.app_config
|
||||||
|
List<AppConfig> list;
|
||||||
|
try {
|
||||||
|
log.info("[AppConfigService] 开始查询 app_config, configType={}, tenantId={}", configType, tenantId);
|
||||||
|
list = appConfigMapper.selectByCategory(tenantId, configType);
|
||||||
|
log.info("[AppConfigService] 查询结果: {} 条记录", list == null ? 0 : list.size());
|
||||||
|
if (list != null && !list.isEmpty()) {
|
||||||
|
for (AppConfig c : list) {
|
||||||
|
log.info("[AppConfigService] 配置项: configKey={}, configValue={}, configType={}, tenantId={}",
|
||||||
|
c.getConfigKey(), c.getConfigValue(), c.getConfigType(), c.getTenantId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[AppConfigService] 跨表查询失败 configType={}, tenantId={}, err={}",
|
||||||
|
configType, tenantId, e.getMessage(), e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (list == null || list.isEmpty()) {
|
||||||
|
log.info("[AppConfigService] 未找到配置 configType={}, tenantId={}", configType, tenantId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 组装为 JSON(去掉 config_key 中的 "configType." 前缀)
|
||||||
|
// 注意:按 config_id 降序后,相同 configKey 的后出现的会覆盖先出现的
|
||||||
|
// 所以降序后,最新插入/更新的配置会生效
|
||||||
|
JSONObject result = new JSONObject();
|
||||||
|
String prefix = configType + ".";
|
||||||
|
for (AppConfig c : list) {
|
||||||
|
String key = c.getConfigKey();
|
||||||
|
if (key == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
key = key.substring(prefix.length());
|
||||||
|
}
|
||||||
|
// 使用 putIfAbsent 保留先出现的(即 config_id 大的/最新的),
|
||||||
|
// 但如果需要后出现的覆盖,改为 result.put(key, c.getConfigValue())
|
||||||
|
result.putIfAbsent(key, c.getConfigValue()); // 保留第一个(最新)
|
||||||
|
log.info("[AppConfigService] 组装JSON: key={}, value={} (tenantId={}, configId={})",
|
||||||
|
key, c.getConfigValue(), c.getTenantId(), c.getConfigId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 写缓存 2 小时
|
||||||
|
try {
|
||||||
|
stringRedisTemplate.opsForValue().set(cacheKey, result.toJSONString(),
|
||||||
|
CACHE_TTL_HOURS, TimeUnit.HOURS);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[AppConfigService] 写缓存失败(不影响返回) err={}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("[AppConfigService] 从 app_config 读取配置 configType={}, tenantId={}, fields={}",
|
||||||
|
configType, tenantId, result.size());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主动失效缓存(供配置变更时调用)
|
||||||
|
*/
|
||||||
|
public void evict(String configType, Integer tenantId) {
|
||||||
|
if (tenantId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stringRedisTemplate.delete(buildCacheKey(configType, tenantId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildCacheKey(String configType, Integer tenantId) {
|
||||||
|
return CACHE_KEY_PREFIX + tenantId + ":" + configType;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
spring:
|
spring:
|
||||||
main:
|
main:
|
||||||
allow-circular-references: true
|
allow-circular-references: true
|
||||||
|
allow-bean-definition-overriding: true # 允许bean定义覆盖,解决RabbitMQConfig中的objectMapper bean冲突
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:mysql://47.119.165.234:13308/gxwebsoft_core?useSSL=false&serverTimezone=UTC
|
url: jdbc:mysql://47.119.165.234:13308/gxwebsoft_core?useSSL=false&serverTimezone=UTC
|
||||||
username: gxwebsoft_core
|
username: gxwebsoft_core
|
||||||
@@ -21,6 +22,8 @@ logging:
|
|||||||
level:
|
level:
|
||||||
com.gxwebsoft: DEBUG
|
com.gxwebsoft: DEBUG
|
||||||
com.baomidou.mybatisplus: DEBUG
|
com.baomidou.mybatisplus: DEBUG
|
||||||
|
com.gxwebsoft.websopy.mapper: DEBUG
|
||||||
|
com.gxwebsoft.websopy.service: DEBUG
|
||||||
|
|
||||||
socketio:
|
socketio:
|
||||||
host: localhost #IP地址
|
host: localhost #IP地址
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 生产环境配置
|
||||||
|
|
||||||
|
# 数据源配置
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:mysql://1Panel-mysql-HZz5:3306/gxwebsoft_core?useSSL=false&serverTimezone=UTC
|
||||||
|
username: gxwebsoft_core
|
||||||
|
password: jdj7HYEdYHnYEFBy
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
type: com.alibaba.druid.pool.DruidDataSource
|
||||||
|
redis:
|
||||||
|
database: 0
|
||||||
|
host: 1Panel-redis-5odv
|
||||||
|
port: 6379
|
||||||
|
password: redis_WSDb88
|
||||||
|
|
||||||
|
# 日志配置
|
||||||
|
logging:
|
||||||
|
file:
|
||||||
|
name: websoft-core.log
|
||||||
|
level:
|
||||||
|
root: WARN
|
||||||
|
com.gxwebsoft: ERROR
|
||||||
|
com.baomidou.mybatisplus: ERROR
|
||||||
|
|
||||||
|
socketio:
|
||||||
|
host: 0.0.0.0 #IP地址
|
||||||
|
|
||||||
|
knife4j:
|
||||||
|
# 开启knife4j增强
|
||||||
|
enable: true
|
||||||
|
# 开启生产环境屏蔽,一定要先开启knife4j增强才会生效
|
||||||
|
production: false
|
||||||
|
|
||||||
|
# 框架配置
|
||||||
|
config:
|
||||||
|
# 生产环境接口
|
||||||
|
server-url: https://paopao-server.websoft.top/api
|
||||||
|
upload-path: /www/wwwroot/file.ws
|
||||||
|
|
||||||
|
# 阿里云OSS云存储
|
||||||
|
endpoint: https://oss-cn-shenzhen.aliyuncs.com
|
||||||
|
accessKeyId: LTAI4GKGZ9Z2Z8JZ77c3GNZP
|
||||||
|
accessKeySecret: BiDkpS7UXj72HWwDWaFZxiXjNFBNCM
|
||||||
|
bucketName: oss-gxwebsoft
|
||||||
|
bucketDomain: https://oss.wsdns.cn
|
||||||
|
aliyunDomain: https://oss-gxwebsoft.oss-cn-shenzhen.aliyuncs.com
|
||||||
|
|
||||||
|
# 生产环境证书配置
|
||||||
|
certificate:
|
||||||
|
# 生产环境使用挂载卷模式
|
||||||
|
load-mode: VOLUME
|
||||||
|
cert-root-path: /app/certs
|
||||||
@@ -8,6 +8,10 @@ spring:
|
|||||||
password: jdj7HYEdYHnYEFBy
|
password: jdj7HYEdYHnYEFBy
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
type: com.alibaba.druid.pool.DruidDataSource
|
type: com.alibaba.druid.pool.DruidDataSource
|
||||||
|
|
||||||
|
# 允许bean定义覆盖,解决RabbitMQConfig和JacksonConfig中的objectMapper bean冲突
|
||||||
|
main:
|
||||||
|
allow-bean-definition-overriding: true
|
||||||
redis:
|
redis:
|
||||||
database: 0
|
database: 0
|
||||||
host: 1Panel-redis-Q1LE
|
host: 1Panel-redis-Q1LE
|
||||||
@@ -49,6 +53,9 @@ config:
|
|||||||
# websopy 服务地址(用于同步用户数据)
|
# websopy 服务地址(用于同步用户数据)
|
||||||
websopyUrl: https://websopy-api.websoft.top
|
websopyUrl: https://websopy-api.websoft.top
|
||||||
|
|
||||||
|
# 内部服务调用密钥(调用 websopy 等内部接口时使用,请务必与 websopy 端保持一致)
|
||||||
|
internal-key: ${INTERNAL_KEY:websopy-internal-2025}
|
||||||
|
|
||||||
# 生产环境证书配置
|
# 生产环境证书配置
|
||||||
certificate:
|
certificate:
|
||||||
# 生产环境使用挂载卷模式
|
# 生产环境使用挂载卷模式
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user