ai-engineering-init 1.4.3 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cursor/skills/bug-detective/SKILL.md +19 -19
- package/.cursor/skills/project-navigator/SKILL.md +164 -258
- package/README.md +20 -236
- package/bin/index.js +437 -7
- package/package.json +7 -1
- package/scripts/build-skills.js +180 -0
- package/src/platform-map.json +56 -0
- package/src/skills/add-skill/SKILL.md +488 -0
- package/src/skills/add-todo/SKILL.md +269 -0
- package/src/skills/api-development/SKILL.md +266 -0
- package/src/skills/architecture-design/SKILL.md +262 -0
- package/src/skills/backend-annotations/SKILL.md +302 -0
- package/src/skills/banana-image/CHANGELOG.md +37 -0
- package/src/skills/banana-image/README.md +146 -0
- package/src/skills/banana-image/SKILL.md +171 -0
- package/src/skills/banana-image/assets/logo.png +0 -0
- package/src/skills/banana-image/references/advanced-usage.md +189 -0
- package/src/skills/banana-image/scripts/apply_template.py +125 -0
- package/src/skills/banana-image/scripts/banana_image_exec.ts +412 -0
- package/src/skills/banana-image/scripts/batch_prep.py +82 -0
- package/src/skills/banana-image/scripts/package-lock.json +1437 -0
- package/src/skills/banana-image/scripts/package.json +18 -0
- package/src/skills/banana-image/scripts/requirements.txt +10 -0
- package/src/skills/banana-image/templates/poster.json +22 -0
- package/src/skills/banana-image/templates/product.json +17 -0
- package/src/skills/banana-image/templates/social.json +22 -0
- package/src/skills/banana-image/templates/thumbnail.json +17 -0
- package/src/skills/brainstorm/SKILL.md +216 -0
- package/src/skills/bug-detective/SKILL.md +256 -0
- package/src/skills/bug-detective/references/error-patterns.md +242 -0
- package/src/skills/check/SKILL.md +367 -0
- package/src/skills/code-patterns/SKILL.md +280 -0
- package/src/skills/code-patterns/references/leniu-code-patterns.md +87 -0
- package/src/skills/codex-code-review/SKILL.md +135 -0
- package/src/skills/collaborating-with-codex/SKILL.md +174 -0
- package/src/skills/collaborating-with-codex/scripts/codex_bridge.py +275 -0
- package/src/skills/collaborating-with-gemini/SKILL.md +194 -0
- package/src/skills/collaborating-with-gemini/scripts/gemini_bridge.py +275 -0
- package/src/skills/crud/SKILL.md +265 -0
- package/src/skills/crud-development/SKILL.md +409 -0
- package/src/skills/data-permission/SKILL.md +292 -0
- package/src/skills/data-permission/references/custom-data-scope.md +90 -0
- package/src/skills/database-ops/SKILL.md +407 -0
- package/src/skills/dev/SKILL.md +187 -0
- package/src/skills/error-handler/SKILL.md +371 -0
- package/src/skills/file-oss-management/SKILL.md +255 -0
- package/src/skills/file-oss-management/references/entities.md +105 -0
- package/src/skills/file-oss-management/references/service-impl.md +104 -0
- package/src/skills/git-workflow/SKILL.md +397 -0
- package/src/skills/init-docs/SKILL.md +194 -0
- package/src/skills/json-serialization/SKILL.md +357 -0
- package/src/skills/leniu-api-development/SKILL.md +319 -0
- package/src/skills/leniu-api-development/references/real-examples.md +273 -0
- package/src/skills/leniu-architecture-design/SKILL.md +383 -0
- package/src/skills/leniu-backend-annotations/SKILL.md +277 -0
- package/src/skills/leniu-brainstorm/SKILL.md +242 -0
- package/src/skills/leniu-brainstorm/references/business-scenarios.md +162 -0
- package/src/skills/leniu-code-patterns/SKILL.md +411 -0
- package/src/skills/leniu-crud-development/SKILL.md +404 -0
- package/src/skills/leniu-crud-development/references/templates.md +597 -0
- package/src/skills/leniu-customization-location/SKILL.md +410 -0
- package/src/skills/leniu-data-permission/SKILL.md +341 -0
- package/src/skills/leniu-database-ops/SKILL.md +426 -0
- package/src/skills/leniu-error-handler/SKILL.md +462 -0
- package/src/skills/leniu-java-amount-handling/SKILL.md +461 -0
- package/src/skills/leniu-java-code-style/SKILL.md +510 -0
- package/src/skills/leniu-java-concurrent/SKILL.md +400 -0
- package/src/skills/leniu-java-entity/SKILL.md +237 -0
- package/src/skills/leniu-java-entity/references/templates.md +237 -0
- package/src/skills/leniu-java-export/SKILL.md +570 -0
- package/src/skills/leniu-java-logging/SKILL.md +229 -0
- package/src/skills/leniu-java-logging/references/data-mask.md +46 -0
- package/src/skills/leniu-java-logging/references/logging-scenarios.md +113 -0
- package/src/skills/leniu-java-mq/SKILL.md +338 -0
- package/src/skills/leniu-java-mybatis/SKILL.md +267 -0
- package/src/skills/leniu-java-mybatis/references/report-mapper.md +88 -0
- package/src/skills/leniu-java-report-query-param/SKILL.md +291 -0
- package/src/skills/leniu-java-task/SKILL.md +367 -0
- package/src/skills/leniu-java-total-line/SKILL.md +196 -0
- package/src/skills/leniu-marketing-price-rule-customizer/SKILL.md +301 -0
- package/src/skills/leniu-marketing-recharge-rule-customizer/SKILL.md +285 -0
- package/src/skills/leniu-mealtime/SKILL.md +215 -0
- package/src/skills/leniu-redis-cache/SKILL.md +331 -0
- package/src/skills/leniu-report-customization/SKILL.md +335 -0
- package/src/skills/leniu-report-customization/references/table-fields.md +93 -0
- package/src/skills/leniu-report-standard-customization/SKILL.md +328 -0
- package/src/skills/leniu-report-standard-customization/references/analysis-module.md +64 -0
- package/src/skills/leniu-report-standard-customization/references/table-fields.md +113 -0
- package/src/skills/leniu-security-guard/SKILL.md +306 -0
- package/src/skills/leniu-utils-toolkit/SKILL.md +380 -0
- package/src/skills/mysql-debug/SKILL.md +364 -0
- package/src/skills/next/SKILL.md +137 -0
- package/src/skills/openspec-apply-change/SKILL.md +165 -0
- package/src/skills/openspec-archive-change/SKILL.md +122 -0
- package/src/skills/openspec-bulk-archive-change/SKILL.md +254 -0
- package/src/skills/openspec-continue-change/SKILL.md +126 -0
- package/src/skills/openspec-explore/SKILL.md +299 -0
- package/src/skills/openspec-ff-change/SKILL.md +109 -0
- package/src/skills/openspec-new-change/SKILL.md +82 -0
- package/src/skills/openspec-onboard/SKILL.md +414 -0
- package/src/skills/openspec-sync-specs/SKILL.md +146 -0
- package/src/skills/openspec-verify-change/SKILL.md +176 -0
- package/src/skills/performance-doctor/SKILL.md +303 -0
- package/src/skills/progress/SKILL.md +193 -0
- package/src/skills/project-navigator/SKILL.md +211 -0
- package/src/skills/redis-cache/SKILL.md +333 -0
- package/src/skills/redis-cache/references/listeners.md +23 -0
- package/src/skills/scheduled-jobs/SKILL.md +314 -0
- package/src/skills/security-guard/SKILL.md +353 -0
- package/src/skills/security-guard/references/encrypt-config.md +103 -0
- package/src/skills/security-guard/references/sensitive-strategies.md +42 -0
- package/src/skills/sms-mail/SKILL.md +308 -0
- package/src/skills/sms-mail/references/mail-config.md +88 -0
- package/src/skills/sms-mail/references/sms-config.md +74 -0
- package/src/skills/social-login/SKILL.md +266 -0
- package/src/skills/social-login/references/provider-configs.md +118 -0
- package/src/skills/start/SKILL.md +154 -0
- package/src/skills/store-pc/SKILL.md +366 -0
- package/src/skills/sync/SKILL.md +149 -0
- package/src/skills/task-tracker/SKILL.md +307 -0
- package/src/skills/tech-decision/SKILL.md +393 -0
- package/src/skills/tenant-management/SKILL.md +288 -0
- package/src/skills/tenant-management/references/tenant-scenarios.md +91 -0
- package/src/skills/test-development/SKILL.md +301 -0
- package/src/skills/test-development/references/parameterized-examples.md +119 -0
- package/src/skills/ui-pc/SKILL.md +438 -0
- package/src/skills/update-status/SKILL.md +159 -0
- package/src/skills/utils-toolkit/SKILL.md +362 -0
- package/src/skills/utils-toolkit/references/redis-utils-api.md +56 -0
- package/src/skills/websocket-sse/SKILL.md +271 -0
- package/src/skills/workflow-engine/SKILL.md +321 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tenant-management
|
|
3
|
+
description: |
|
|
4
|
+
多租户数据隔离开发指南。基于 MyBatis-Plus 租户插件,自动 SQL/Redis/Cache 隔离。
|
|
5
|
+
|
|
6
|
+
触发场景:
|
|
7
|
+
- 为业务表添加租户隔离
|
|
8
|
+
- 临时忽略租户过滤查询全量数据
|
|
9
|
+
- 切换到其他租户执行操作
|
|
10
|
+
- 配置排除租户过滤的表
|
|
11
|
+
- 定时任务遍历所有租户
|
|
12
|
+
|
|
13
|
+
触发词:多租户、租户隔离、TenantEntity、TenantHelper、tenantId、跨租户、ignore租户、动态租户、租户切换、SaaS、数据隔离
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# 多租户开发指南
|
|
17
|
+
|
|
18
|
+
> **模块**:`ruoyi-common-tenant` | **核心类**:`TenantHelper`、`TenantEntity`
|
|
19
|
+
|
|
20
|
+
## 一、Entity 规范
|
|
21
|
+
|
|
22
|
+
### 继承 TenantEntity
|
|
23
|
+
|
|
24
|
+
```java
|
|
25
|
+
import org.dromara.common.tenant.core.TenantEntity;
|
|
26
|
+
|
|
27
|
+
// TenantEntity 继承关系:TenantEntity → BaseEntity
|
|
28
|
+
// TenantEntity 额外字段:tenantId
|
|
29
|
+
// BaseEntity 字段:createDept, createBy, createTime, updateBy, updateTime, params
|
|
30
|
+
|
|
31
|
+
@Data
|
|
32
|
+
@EqualsAndHashCode(callSuper = true)
|
|
33
|
+
@TableName("biz_order")
|
|
34
|
+
public class BizOrder extends TenantEntity {
|
|
35
|
+
@TableId(value = "id")
|
|
36
|
+
private Long id;
|
|
37
|
+
private String orderNo;
|
|
38
|
+
private String status;
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### 数据库表必须含 tenant_id
|
|
43
|
+
|
|
44
|
+
```sql
|
|
45
|
+
CREATE TABLE biz_order (
|
|
46
|
+
id BIGINT(20) NOT NULL COMMENT '主键ID',
|
|
47
|
+
tenant_id VARCHAR(20) DEFAULT '000000' COMMENT '租户ID', -- 必须有
|
|
48
|
+
order_no VARCHAR(64) NOT NULL COMMENT '订单号',
|
|
49
|
+
status CHAR(1) DEFAULT '0' COMMENT '状态',
|
|
50
|
+
create_dept BIGINT(20) DEFAULT NULL COMMENT '创建部门',
|
|
51
|
+
create_by BIGINT(20) DEFAULT NULL COMMENT '创建人',
|
|
52
|
+
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
53
|
+
update_by BIGINT(20) DEFAULT NULL COMMENT '更新人',
|
|
54
|
+
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
55
|
+
del_flag CHAR(1) DEFAULT '0' COMMENT '删除标志',
|
|
56
|
+
PRIMARY KEY (id),
|
|
57
|
+
KEY idx_tenant_id (tenant_id)
|
|
58
|
+
);
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## 二、TenantHelper 工具类
|
|
64
|
+
|
|
65
|
+
**位置**:`org.dromara.common.tenant.helper.TenantHelper`
|
|
66
|
+
|
|
67
|
+
### API 速查
|
|
68
|
+
|
|
69
|
+
| 方法 | 说明 | 返回值 |
|
|
70
|
+
|------|------|--------|
|
|
71
|
+
| `isEnable()` | 租户功能是否启用 | boolean |
|
|
72
|
+
| `getTenantId()` | 获取当前租户ID(优先动态租户) | String |
|
|
73
|
+
| `ignore(Runnable)` | 忽略租户执行(无返回值) | void |
|
|
74
|
+
| `ignore(Supplier<T>)` | 忽略租户执行(有返回值) | T |
|
|
75
|
+
| `dynamic(tenantId, Runnable)` | 切换租户执行(无返回值) | void |
|
|
76
|
+
| `dynamic(tenantId, Supplier<T>)` | 切换租户执行(有返回值) | T |
|
|
77
|
+
| `setDynamic(tenantId)` | 手动设置动态租户(需配合 clearDynamic) | void |
|
|
78
|
+
| `setDynamic(tenantId, true)` | 设置全局动态租户(跨请求,存 Redis) | void |
|
|
79
|
+
| `clearDynamic()` | 清除动态租户 | void |
|
|
80
|
+
|
|
81
|
+
### 核心用法
|
|
82
|
+
|
|
83
|
+
```java
|
|
84
|
+
import org.dromara.common.tenant.helper.TenantHelper;
|
|
85
|
+
|
|
86
|
+
// 1. 忽略租户(查全量数据)
|
|
87
|
+
List<SysUser> allUsers = TenantHelper.ignore(() -> {
|
|
88
|
+
return userMapper.selectList(null); // 不追加 tenant_id 条件
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// 2. 切换到指定租户(推荐用法)
|
|
92
|
+
TenantHelper.dynamic("000001", () -> {
|
|
93
|
+
userMapper.insert(user); // 使用租户 000001
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// 3. 手动管理(复杂场景)
|
|
97
|
+
TenantHelper.setDynamic("000001");
|
|
98
|
+
try {
|
|
99
|
+
userMapper.insert(user);
|
|
100
|
+
} finally {
|
|
101
|
+
TenantHelper.clearDynamic(); // 必须清理!
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## 三、配置
|
|
108
|
+
|
|
109
|
+
```yaml
|
|
110
|
+
# application.yml
|
|
111
|
+
tenant:
|
|
112
|
+
enable: true
|
|
113
|
+
excludes: # 不追加 tenant_id 条件的表
|
|
114
|
+
- sys_menu
|
|
115
|
+
- sys_dict_type
|
|
116
|
+
- sys_dict_data
|
|
117
|
+
- sys_oss_config
|
|
118
|
+
- sys_config
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**自动配置组件**(`tenant.enable=true` 时生效):
|
|
122
|
+
|
|
123
|
+
| 组件 | 功能 |
|
|
124
|
+
|------|------|
|
|
125
|
+
| `TenantLineInnerInterceptor` | SQL 自动追加租户条件 |
|
|
126
|
+
| `TenantKeyPrefixHandler` | Redis Key 自动添加租户前缀 |
|
|
127
|
+
| `TenantSpringCacheManager` | Spring Cache 租户隔离 |
|
|
128
|
+
| `TenantSaTokenDao` | Sa-Token 会话租户隔离 |
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## 四、常见场景
|
|
133
|
+
|
|
134
|
+
### 场景 1:管理员查看所有租户数据
|
|
135
|
+
|
|
136
|
+
```java
|
|
137
|
+
@SaCheckRole("superadmin")
|
|
138
|
+
public List<SysUserVo> listAllTenantUsers() {
|
|
139
|
+
return TenantHelper.ignore(() -> userMapper.selectVoList(null));
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### 场景 2:跨租户数据同步
|
|
144
|
+
|
|
145
|
+
```java
|
|
146
|
+
public void syncConfigToAllTenants(SysConfig config) {
|
|
147
|
+
List<String> tenantIds = TenantHelper.ignore(() ->
|
|
148
|
+
tenantMapper.selectList(null).stream()
|
|
149
|
+
.map(SysTenant::getTenantId).collect(Collectors.toList())
|
|
150
|
+
);
|
|
151
|
+
for (String tenantId : tenantIds) {
|
|
152
|
+
TenantHelper.dynamic(tenantId, () -> {
|
|
153
|
+
SysConfig existing = configMapper.selectByKey(config.getConfigKey());
|
|
154
|
+
if (existing == null) configMapper.insert(config);
|
|
155
|
+
else configMapper.updateById(config);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### 场景 3:定时任务处理所有租户
|
|
162
|
+
|
|
163
|
+
> 详细示例见 `references/tenant-scenarios.md`
|
|
164
|
+
|
|
165
|
+
```java
|
|
166
|
+
@Scheduled(cron = "0 0 2 * * ?")
|
|
167
|
+
public void cleanupExpiredOrders() {
|
|
168
|
+
List<SysTenant> tenants = TenantHelper.ignore(() ->
|
|
169
|
+
tenantMapper.selectList(Wrappers.<SysTenant>lambdaQuery().eq(SysTenant::getStatus, "0"))
|
|
170
|
+
);
|
|
171
|
+
for (SysTenant tenant : tenants) {
|
|
172
|
+
try {
|
|
173
|
+
TenantHelper.dynamic(tenant.getTenantId(), () -> {
|
|
174
|
+
orderMapper.delete(Wrappers.<Order>lambdaQuery()
|
|
175
|
+
.eq(Order::getStatus, "CANCELLED")
|
|
176
|
+
.lt(Order::getCreateTime, DateUtils.addDays(new Date(), -30)));
|
|
177
|
+
});
|
|
178
|
+
} catch (Exception e) {
|
|
179
|
+
log.error("清理租户 {} 订单失败: {}", tenant.getTenantId(), e.getMessage());
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## 五、Redis 缓存隔离
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
原始 Key: user:info:1001
|
|
191
|
+
实际 Key: 000000:user:info:1001 (租户 000000)
|
|
192
|
+
实际 Key: 000001:user:info:1001 (租户 000001)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
**全局 Key(不隔离)**:使用 `GlobalConstants.GLOBAL_REDIS_KEY` 前缀
|
|
196
|
+
|
|
197
|
+
```java
|
|
198
|
+
import org.dromara.common.core.constant.GlobalConstants;
|
|
199
|
+
|
|
200
|
+
// 全局缓存(所有租户共享)
|
|
201
|
+
String globalKey = GlobalConstants.GLOBAL_REDIS_KEY + "config:site_name";
|
|
202
|
+
RedisUtils.setCacheObject(globalKey, "网站名称");
|
|
203
|
+
|
|
204
|
+
// 租户隔离缓存(自动添加前缀)
|
|
205
|
+
RedisUtils.setCacheObject("user:info:" + userId, userInfo);
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
> `TenantHelper.ignore()` 中的 Redis 操作不添加租户前缀。
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## 六、常见错误
|
|
213
|
+
|
|
214
|
+
### 1. 忘记继承 TenantEntity
|
|
215
|
+
|
|
216
|
+
```java
|
|
217
|
+
// ❌ 继承 BaseEntity,没有 tenantId
|
|
218
|
+
public class BizOrder extends BaseEntity { }
|
|
219
|
+
|
|
220
|
+
// ✅ 继承 TenantEntity
|
|
221
|
+
public class BizOrder extends TenantEntity { }
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### 2. 数据库表缺少 tenant_id
|
|
225
|
+
|
|
226
|
+
```sql
|
|
227
|
+
-- ❌ 表没有 tenant_id
|
|
228
|
+
CREATE TABLE biz_order (id BIGINT NOT NULL, order_no VARCHAR(64));
|
|
229
|
+
|
|
230
|
+
-- ✅ 添加 tenant_id
|
|
231
|
+
CREATE TABLE biz_order (id BIGINT NOT NULL, tenant_id VARCHAR(20) DEFAULT '000000', order_no VARCHAR(64));
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### 3. setDynamic 后忘记 clearDynamic
|
|
235
|
+
|
|
236
|
+
```java
|
|
237
|
+
// ❌ 租户ID泄漏到其他请求
|
|
238
|
+
TenantHelper.setDynamic("000001");
|
|
239
|
+
userMapper.insert(user);
|
|
240
|
+
// 忘记 clearDynamic()
|
|
241
|
+
|
|
242
|
+
// ✅ 推荐使用 dynamic() 方法(自动清理)
|
|
243
|
+
TenantHelper.dynamic("000001", () -> userMapper.insert(user));
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### 4. 事务中切换租户
|
|
247
|
+
|
|
248
|
+
```java
|
|
249
|
+
// ❌ 事务内切换租户导致数据错乱
|
|
250
|
+
@Transactional
|
|
251
|
+
public void wrongMethod() {
|
|
252
|
+
orderMapper.insert(order);
|
|
253
|
+
TenantHelper.dynamic("000001", () -> logMapper.insert(log));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ✅ 使用独立事务
|
|
257
|
+
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
|
258
|
+
public void saveLogToOtherTenant(String tenantId, Log log) {
|
|
259
|
+
TenantHelper.dynamic(tenantId, () -> logMapper.insert(log));
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### 5. 排除表配置不当
|
|
264
|
+
|
|
265
|
+
```yaml
|
|
266
|
+
# ❌ 业务表不应排除
|
|
267
|
+
tenant:
|
|
268
|
+
excludes:
|
|
269
|
+
- biz_order
|
|
270
|
+
|
|
271
|
+
# ✅ 只排除共享的系统表
|
|
272
|
+
tenant:
|
|
273
|
+
excludes:
|
|
274
|
+
- sys_menu
|
|
275
|
+
- sys_dict_type
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
## 七、参考代码位置
|
|
281
|
+
|
|
282
|
+
| 类型 | 位置 |
|
|
283
|
+
|------|------|
|
|
284
|
+
| 租户基类 | `ruoyi-common/ruoyi-common-tenant/.../core/TenantEntity.java` |
|
|
285
|
+
| 租户助手 | `ruoyi-common/ruoyi-common-tenant/.../helper/TenantHelper.java` |
|
|
286
|
+
| 租户处理器 | `ruoyi-common/ruoyi-common-tenant/.../handle/PlusTenantLineHandler.java` |
|
|
287
|
+
| Redis Key 处理 | `ruoyi-common/ruoyi-common-tenant/.../handle/TenantKeyPrefixHandler.java` |
|
|
288
|
+
| 配置文件 | `ruoyi-admin/src/main/resources/application.yml` |
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# 多租户扩展场景
|
|
2
|
+
|
|
3
|
+
## 统计所有租户数据
|
|
4
|
+
|
|
5
|
+
```java
|
|
6
|
+
public List<TenantOrderStats> getTenantOrderStats() {
|
|
7
|
+
return TenantHelper.ignore(() -> {
|
|
8
|
+
return orderMapper.selectMaps(
|
|
9
|
+
new QueryWrapper<Order>()
|
|
10
|
+
.select("tenant_id", "COUNT(*) as order_count", "SUM(amount) as total_amount")
|
|
11
|
+
.groupBy("tenant_id")
|
|
12
|
+
).stream().map(map -> {
|
|
13
|
+
TenantOrderStats stats = new TenantOrderStats();
|
|
14
|
+
stats.setTenantId((String) map.get("tenant_id"));
|
|
15
|
+
stats.setOrderCount(((Number) map.get("order_count")).longValue());
|
|
16
|
+
stats.setTotalAmount((BigDecimal) map.get("total_amount"));
|
|
17
|
+
return stats;
|
|
18
|
+
}).collect(Collectors.toList());
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## 定时任务完整模板
|
|
24
|
+
|
|
25
|
+
```java
|
|
26
|
+
@Service
|
|
27
|
+
@RequiredArgsConstructor
|
|
28
|
+
public class OrderCleanupJob {
|
|
29
|
+
|
|
30
|
+
private final OrderMapper orderMapper;
|
|
31
|
+
private final SysTenantMapper tenantMapper;
|
|
32
|
+
|
|
33
|
+
@Scheduled(cron = "0 0 2 * * ?")
|
|
34
|
+
public void cleanupExpiredOrders() {
|
|
35
|
+
// 1. 获取所有启用的租户
|
|
36
|
+
List<SysTenant> tenants = TenantHelper.ignore(() -> {
|
|
37
|
+
return tenantMapper.selectList(
|
|
38
|
+
Wrappers.<SysTenant>lambdaQuery()
|
|
39
|
+
.eq(SysTenant::getStatus, "0")
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// 2. 逐个租户处理
|
|
44
|
+
for (SysTenant tenant : tenants) {
|
|
45
|
+
try {
|
|
46
|
+
TenantHelper.dynamic(tenant.getTenantId(), () -> {
|
|
47
|
+
orderMapper.delete(
|
|
48
|
+
Wrappers.<Order>lambdaQuery()
|
|
49
|
+
.eq(Order::getStatus, "CANCELLED")
|
|
50
|
+
.lt(Order::getCreateTime, DateUtils.addDays(new Date(), -30))
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
} catch (Exception e) {
|
|
54
|
+
log.error("清理租户 {} 订单失败: {}", tenant.getTenantId(), e.getMessage());
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## 跨租户数据同步完整模板
|
|
62
|
+
|
|
63
|
+
```java
|
|
64
|
+
@Service
|
|
65
|
+
@RequiredArgsConstructor
|
|
66
|
+
public class DataSyncServiceImpl implements IDataSyncService {
|
|
67
|
+
|
|
68
|
+
private final SysConfigMapper configMapper;
|
|
69
|
+
private final SysTenantMapper tenantMapper;
|
|
70
|
+
|
|
71
|
+
public void syncConfigToAllTenants(SysConfig config) {
|
|
72
|
+
List<String> tenantIds = TenantHelper.ignore(() -> {
|
|
73
|
+
return tenantMapper.selectList(null)
|
|
74
|
+
.stream()
|
|
75
|
+
.map(SysTenant::getTenantId)
|
|
76
|
+
.collect(Collectors.toList());
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
for (String tenantId : tenantIds) {
|
|
80
|
+
TenantHelper.dynamic(tenantId, () -> {
|
|
81
|
+
SysConfig existing = configMapper.selectByKey(config.getConfigKey());
|
|
82
|
+
if (existing == null) {
|
|
83
|
+
configMapper.insert(config);
|
|
84
|
+
} else {
|
|
85
|
+
configMapper.updateById(config);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: test-development
|
|
3
|
+
description: |
|
|
4
|
+
测试开发技能,编写单元测试、集成测试、Controller测试。基于 JUnit5 + Spring Boot Test + Mockito 标准测试框架。
|
|
5
|
+
|
|
6
|
+
触发场景:
|
|
7
|
+
- 编写单元测试(工具类、枚举、POJO)
|
|
8
|
+
- 编写 Spring 集成测试(Service、Controller、Mapper)
|
|
9
|
+
- Mock 外部依赖
|
|
10
|
+
- 参数化测试
|
|
11
|
+
- 测试数据构造
|
|
12
|
+
- 测试覆盖率提升
|
|
13
|
+
|
|
14
|
+
触发词:测试、单元测试、集成测试、@Test、JUnit5、JUnit、Mockito、Mock、断言、test、测试用例、测试覆盖率、测试数据、@SpringBootTest、@Mock、@MockBean、Assertions、测试类、测试方法、@ParameterizedTest、参数化测试、@BeforeEach、@AfterEach、@DisplayName
|
|
15
|
+
|
|
16
|
+
注意:本项目使用标准的 JUnit5 + Spring Boot Test,没有自定义测试基类。
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
# 测试开发规范
|
|
20
|
+
|
|
21
|
+
## 测试分层策略
|
|
22
|
+
|
|
23
|
+
| 层次 | 测试类型 | 启动 Spring | 执行速度 |
|
|
24
|
+
|------|---------|------------|---------|
|
|
25
|
+
| 单元测试 | 工具类/枚举/POJO | 否 | < 1s |
|
|
26
|
+
| 集成测试 | Service/Controller/Mapper | 是(`@SpringBootTest`) | 5-10s |
|
|
27
|
+
|
|
28
|
+
## 测试文件位置
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
src/test/java/org/dromara/test/ # 通用测试
|
|
32
|
+
src/test/java/org/dromara/{模块}/ # 模块测试(与源码包路径一致)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**重要**:`@SpringBootTest` 只能在 SpringBoot 主包下使用(需包含 main 方法和 yml 配置)。
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## 1. 单元测试(纯 JUnit5,无 Spring)
|
|
40
|
+
|
|
41
|
+
```java
|
|
42
|
+
package org.dromara.test;
|
|
43
|
+
|
|
44
|
+
import org.junit.jupiter.api.Assertions;
|
|
45
|
+
import org.junit.jupiter.api.DisplayName;
|
|
46
|
+
import org.junit.jupiter.api.Test;
|
|
47
|
+
|
|
48
|
+
@DisplayName("StringUtils 测试")
|
|
49
|
+
public class StringUtilsTest {
|
|
50
|
+
|
|
51
|
+
@Test
|
|
52
|
+
@DisplayName("测试 isBlank 方法")
|
|
53
|
+
public void testIsBlank() {
|
|
54
|
+
Assertions.assertTrue(StringUtils.isBlank(null));
|
|
55
|
+
Assertions.assertTrue(StringUtils.isBlank(""));
|
|
56
|
+
Assertions.assertFalse(StringUtils.isBlank("hello"));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@Test
|
|
60
|
+
@DisplayName("测试异常抛出")
|
|
61
|
+
public void testThrowsException() {
|
|
62
|
+
Assertions.assertThrows(NullPointerException.class, () -> {
|
|
63
|
+
String str = null;
|
|
64
|
+
str.length();
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## 2. Service 集成测试
|
|
73
|
+
|
|
74
|
+
```java
|
|
75
|
+
@SpringBootTest
|
|
76
|
+
@Transactional // 测试后自动回滚,不污染数据库
|
|
77
|
+
@DisplayName("TestDemo Service 测试")
|
|
78
|
+
public class TestDemoServiceTest {
|
|
79
|
+
|
|
80
|
+
@Autowired
|
|
81
|
+
private ITestDemoService testDemoService;
|
|
82
|
+
|
|
83
|
+
@Test
|
|
84
|
+
@DisplayName("测试添加数据")
|
|
85
|
+
public void testAdd() {
|
|
86
|
+
// Arrange
|
|
87
|
+
TestDemoBo bo = new TestDemoBo();
|
|
88
|
+
bo.setDeptId(103L);
|
|
89
|
+
bo.setUserId(1L);
|
|
90
|
+
bo.setTestKey("测试数据");
|
|
91
|
+
bo.setValue("test_value");
|
|
92
|
+
|
|
93
|
+
// Act
|
|
94
|
+
Boolean result = testDemoService.insertByBo(bo);
|
|
95
|
+
|
|
96
|
+
// Assert
|
|
97
|
+
Assertions.assertTrue(result);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@Test
|
|
101
|
+
@DisplayName("测试查询详情")
|
|
102
|
+
public void testGetById() {
|
|
103
|
+
TestDemoBo bo = new TestDemoBo();
|
|
104
|
+
bo.setTestKey("测试查询");
|
|
105
|
+
bo.setValue("test_query");
|
|
106
|
+
testDemoService.insertByBo(bo);
|
|
107
|
+
|
|
108
|
+
TestDemoVo vo = testDemoService.queryById(bo.getId());
|
|
109
|
+
Assertions.assertNotNull(vo);
|
|
110
|
+
Assertions.assertEquals("测试查询", vo.getTestKey());
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Mock 外部依赖
|
|
116
|
+
|
|
117
|
+
```java
|
|
118
|
+
@SpringBootTest
|
|
119
|
+
@DisplayName("订单服务测试")
|
|
120
|
+
public class OrderServiceTest {
|
|
121
|
+
|
|
122
|
+
@Autowired
|
|
123
|
+
private IOrderService orderService;
|
|
124
|
+
|
|
125
|
+
@MockBean
|
|
126
|
+
private IPaymentService paymentService;
|
|
127
|
+
|
|
128
|
+
@Test
|
|
129
|
+
@DisplayName("测试订单支付(Mock 支付服务)")
|
|
130
|
+
public void testPayOrder() {
|
|
131
|
+
Long orderId = 123L;
|
|
132
|
+
Mockito.when(paymentService.pay(orderId)).thenReturn(true);
|
|
133
|
+
|
|
134
|
+
Boolean success = orderService.payOrder(orderId);
|
|
135
|
+
|
|
136
|
+
Assertions.assertTrue(success);
|
|
137
|
+
Mockito.verify(paymentService, Mockito.times(1)).pay(orderId);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## 3. Controller 测试
|
|
145
|
+
|
|
146
|
+
```java
|
|
147
|
+
@SpringBootTest
|
|
148
|
+
@AutoConfigureMockMvc
|
|
149
|
+
@DisplayName("TestDemo Controller 测试")
|
|
150
|
+
public class TestDemoControllerTest {
|
|
151
|
+
|
|
152
|
+
@Autowired
|
|
153
|
+
private MockMvc mockMvc;
|
|
154
|
+
|
|
155
|
+
@Test
|
|
156
|
+
@DisplayName("测试分页查询")
|
|
157
|
+
public void testPageList() throws Exception {
|
|
158
|
+
mockMvc.perform(get("/demo/demo/list")
|
|
159
|
+
.param("pageNum", "1")
|
|
160
|
+
.param("pageSize", "10"))
|
|
161
|
+
.andExpect(status().isOk())
|
|
162
|
+
.andExpect(jsonPath("$.code").value(200));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
@Test
|
|
166
|
+
@DisplayName("测试添加数据")
|
|
167
|
+
public void testAdd() throws Exception {
|
|
168
|
+
String requestBody = """
|
|
169
|
+
{
|
|
170
|
+
"deptId": 103,
|
|
171
|
+
"userId": 1,
|
|
172
|
+
"testKey": "测试数据",
|
|
173
|
+
"value": "test_value"
|
|
174
|
+
}
|
|
175
|
+
""";
|
|
176
|
+
|
|
177
|
+
mockMvc.perform(post("/demo/demo")
|
|
178
|
+
.contentType(MediaType.APPLICATION_JSON)
|
|
179
|
+
.content(requestBody))
|
|
180
|
+
.andExpect(status().isOk())
|
|
181
|
+
.andExpect(jsonPath("$.code").value(200));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## 4. 参数化测试
|
|
189
|
+
|
|
190
|
+
```java
|
|
191
|
+
@DisplayName("参数化测试")
|
|
192
|
+
public class ParamUnitTest {
|
|
193
|
+
|
|
194
|
+
@ParameterizedTest
|
|
195
|
+
@ValueSource(strings = {"t1", "t2", "t3"})
|
|
196
|
+
public void testValueSource(String str) {
|
|
197
|
+
Assertions.assertNotNull(str);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
@ParameterizedTest
|
|
201
|
+
@EnumSource(UserType.class)
|
|
202
|
+
public void testEnumSource(UserType type) {
|
|
203
|
+
Assertions.assertNotNull(type);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
@ParameterizedTest
|
|
207
|
+
@CsvSource({"1, Banner, 横幅广告", "2, Popup, 弹窗广告"})
|
|
208
|
+
public void testCsvData(String code, String name, String desc) {
|
|
209
|
+
Assertions.assertNotNull(code);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
@ParameterizedTest
|
|
213
|
+
@MethodSource("getParam")
|
|
214
|
+
public void testMethodSource(String str) {
|
|
215
|
+
Assertions.assertNotNull(str);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
public static Stream<String> getParam() {
|
|
219
|
+
return Stream.of("t1", "t2", "t3");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
> 更多参数化测试示例详见 `references/parameterized-examples.md`
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## 5. 异常测试
|
|
229
|
+
|
|
230
|
+
```java
|
|
231
|
+
@Test
|
|
232
|
+
@DisplayName("测试 key 为空抛出异常")
|
|
233
|
+
public void testAdd_ThrowsException() {
|
|
234
|
+
TestDemoBo bo = new TestDemoBo();
|
|
235
|
+
// 不设置必填字段
|
|
236
|
+
|
|
237
|
+
ServiceException exception = Assertions.assertThrows(
|
|
238
|
+
ServiceException.class,
|
|
239
|
+
() -> testDemoService.insertByBo(bo)
|
|
240
|
+
);
|
|
241
|
+
Assertions.assertTrue(exception.getMessage().contains("key键不能为空"));
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## 6. 测试标签(@Tag)
|
|
248
|
+
|
|
249
|
+
```java
|
|
250
|
+
@Test @Tag("dev") public void testDev() { }
|
|
251
|
+
@Test @Tag("prod") public void testProd() { }
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
mvn test -Dgroups=dev # 运行 dev 标签
|
|
256
|
+
mvn test -DexcludedGroups=prod # 排除 prod 标签
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## 运行测试
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
mvn test # 运行所有测试
|
|
265
|
+
mvn test -Dtest=AdServiceTest # 运行单个测试类
|
|
266
|
+
mvn test -Dtest=AdServiceTest#testAddAd # 运行单个方法
|
|
267
|
+
mvn clean install -DskipTests # 跳过测试
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## 开发检查清单
|
|
273
|
+
|
|
274
|
+
### 命名规范
|
|
275
|
+
|
|
276
|
+
- [ ] 测试类:`{被测试类名}Test`
|
|
277
|
+
- [ ] 测试方法:`test{功能}`
|
|
278
|
+
- [ ] 使用 `@DisplayName` 添加中文描述
|
|
279
|
+
- [ ] 位于 `src/test/java`,包路径与源码一致
|
|
280
|
+
|
|
281
|
+
### 注解选择
|
|
282
|
+
|
|
283
|
+
| 场景 | 注解组合 |
|
|
284
|
+
|------|---------|
|
|
285
|
+
| 工具类/枚举/POJO | 不加 `@SpringBootTest` |
|
|
286
|
+
| Service 测试 | `@SpringBootTest` + `@Transactional` |
|
|
287
|
+
| Controller 测试 | `@SpringBootTest` + `@AutoConfigureMockMvc` |
|
|
288
|
+
| Mock Spring Bean | `@MockBean` |
|
|
289
|
+
| Mock(纯单元测试) | `@Mock` + `@InjectMocks` |
|
|
290
|
+
|
|
291
|
+
### 常见错误
|
|
292
|
+
|
|
293
|
+
| 错误 | 正确做法 |
|
|
294
|
+
|------|---------|
|
|
295
|
+
| 测试在 `src/main/java` | 放到 `src/test/java` |
|
|
296
|
+
| 缺少 `@DisplayName` | 必须添加描述 |
|
|
297
|
+
| 需要注入但没加 `@SpringBootTest` | 加上注解 |
|
|
298
|
+
| `@SpringBootTest` 在非主包下 | 确保在主类同包或子包 |
|
|
299
|
+
| Mock 后不验证调用 | `Mockito.verify()` |
|
|
300
|
+
| 测试方法相互依赖 | 每个测试独立 |
|
|
301
|
+
| Service 测试不加 `@Transactional` | 加上防止污染数据库 |
|