@nebula-skills/nebula-code-standards 0.1.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/README.md +45 -0
- package/bin/cli.cjs +50 -0
- package/package.json +45 -0
- package/scripts/postinstall.cjs +18 -0
- package/skill/SKILL.md +133 -0
- package/skill/backend-standards.md +611 -0
- package/skill/boot-components-catalog.md +546 -0
- package/skill/db-standards.md +232 -0
- package/skill/framework-standards.md +610 -0
- package/skill/frontend-standards.md +310 -0
- package/skill/full-crud-example.md +608 -0
- package/skill/init-new-project.md +842 -0
- package/skill/microapp-guide.md +510 -0
- package/skill/pitfalls-checklist.md +188 -0
- package/skill/rpc-api-reference.md +313 -0
- package/skill/upgrade-decision.md +151 -0
- package/skill/workspace-overview.md +194 -0
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
# 后端开发规范
|
|
2
|
+
|
|
3
|
+
> 适用于所有 Nebula 框架业务后端项目。
|
|
4
|
+
|
|
5
|
+
## 一、分层架构与职责边界
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
HTTP 请求
|
|
9
|
+
│
|
|
10
|
+
▼
|
|
11
|
+
Controller 层 ← 接收/校验入参,调用 Service,包装 R<T> 响应;不含任何业务 if 逻辑
|
|
12
|
+
│
|
|
13
|
+
▼
|
|
14
|
+
Service 层 ← 业务逻辑、唯一性校验、事务边界、抛出 BusinessException;不写 LambdaQueryWrapper
|
|
15
|
+
│
|
|
16
|
+
┌┴──────────────────────┐
|
|
17
|
+
▼ ▼
|
|
18
|
+
Mapper 接口 MapperExt 类
|
|
19
|
+
(固定/注解 SQL) (动态 LambdaQueryWrapper、分页两段式、唯一性检查)
|
|
20
|
+
│
|
|
21
|
+
▼
|
|
22
|
+
MySQL(Flyway 版本化管理)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**禁止事项:**
|
|
26
|
+
|
|
27
|
+
| 禁止 | 原因 |
|
|
28
|
+
|------|------|
|
|
29
|
+
| Controller 中写 `if` 业务判断 | 违反分层原则 |
|
|
30
|
+
| Controller 中处理文件流、设置响应头、读写 InputStream | 业务逻辑必须下沉到 Service,Controller 只做透传 |
|
|
31
|
+
| Service 中注入 `HttpServletRequest` | 违反分层原则 |
|
|
32
|
+
| Service 中写 `LambdaQueryWrapper` | 应放在 MapperExt |
|
|
33
|
+
| Controller 直接返回 DO | 泄露数据库结构 |
|
|
34
|
+
| 返回 `null` 给前端的 VO | 应返回空对象或空列表 |
|
|
35
|
+
| `catch(Exception e) { /* 吞掉 */ }` | 掩盖问题,难以排查 |
|
|
36
|
+
| IO/系统异常 `throw new RuntimeException(...)` | 应使用 `SystemException(GlobalErrorCode.INTERNAL_SERVER_ERROR, msg, e)` |
|
|
37
|
+
| XML Mapper / `@Select` 注解 SQL 缺少 `delete_flag = 0` 或租户过滤 | 自定义 SQL 绕过 MP 拦截器,会查出已删除/跨租户数据 |
|
|
38
|
+
| Mapper 接口用 `@Select` 写含动态条件的查询 | 应使用 `MapperExt` + `LambdaQueryWrapper`,确保多租户拦截器生效 |
|
|
39
|
+
| `new ObjectMapper().readValue(...)` | 配置不一致,应使用 `JsonUtils.parseObject()` |
|
|
40
|
+
| `StringRedisTemplate.setIfAbsent` 实现分布式锁 | 非原子释放,应使用 `NebulaLockHelper` 或 `RedissonClient` |
|
|
41
|
+
| `StringRedisTemplate` 直接读写 Redis | 应使用 `NebulaCacheHelper`(自动追加 Key 前缀、JSON 序列化封装) |
|
|
42
|
+
| 模块 `build.gradle` 中硬编码第三方库版本号 | 版本应统一在 `nebula-support-dependencies` 的 BOM 中管理 |
|
|
43
|
+
| Service 实现类放在 `service/` 目录(非 `impl/`) | 违反包结构约定,应放在 `service/impl/` |
|
|
44
|
+
| Framework 层模块依赖 Starter 层 | 违反分层原则,Framework 层只能依赖同层或更底层 |
|
|
45
|
+
| 业务常量(状态值、策略枚举、类型码)硬编码在 Service/Controller 中 | 应提取到 `{域}Constants` 常量类集中管理 |
|
|
46
|
+
| Service 中直接注入**其他业务域**的 Mapper(如 `SysFileMetaMapper`) | 跨模块操作必须通过对方的 Service 接口,不绕过 Service 层调用底层 Mapper |
|
|
47
|
+
| VO(RespVO)和 RPC DTO 中包含存储实现细节字段(`storagePath`、`bucketName`、`storageType`) | 对外接口不暴露内部存储实现,调用方需访问文件时应通过 `getAccessUrl` 接口获取 URL |
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## 二、包结构约定
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
com.huida.nebula.{业务域}.{模块名}/
|
|
55
|
+
├── controller/
|
|
56
|
+
│ └── {业务名}Controller.java
|
|
57
|
+
├── service/
|
|
58
|
+
│ ├── I{业务名}Service.java
|
|
59
|
+
│ └── impl/
|
|
60
|
+
│ └── {业务名}ServiceImpl.java
|
|
61
|
+
├── mapper/
|
|
62
|
+
│ ├── {业务名}Mapper.java # 固定 SQL / 基础 CRUD
|
|
63
|
+
│ └── {业务名}MapperExt.java # 动态查询(LambdaQueryWrapper)
|
|
64
|
+
├── entity/
|
|
65
|
+
│ └── {业务名}DO.java
|
|
66
|
+
├── vo/
|
|
67
|
+
│ ├── param/
|
|
68
|
+
│ │ ├── {业务名}PageParamVO.java
|
|
69
|
+
│ │ └── {业务名}SaveParamVO.java
|
|
70
|
+
│ └── resp/
|
|
71
|
+
│ ├── {业务名}RespVO.java
|
|
72
|
+
│ └── {业务名}DetailVO.java
|
|
73
|
+
└── convert/
|
|
74
|
+
└── {业务名}Convert.java
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## 三、实体类规范(DO)
|
|
80
|
+
|
|
81
|
+
```java
|
|
82
|
+
@Data
|
|
83
|
+
@EqualsAndHashCode(callSuper = true)
|
|
84
|
+
@TableName("t_demo") // 必须:映射数据库表名(t_ 前缀)
|
|
85
|
+
@Schema(description = "示例实体") // 必须:Swagger 文档描述
|
|
86
|
+
public class DemoDO extends BaseEntity { // 必须继承 BaseEntity
|
|
87
|
+
|
|
88
|
+
@Schema(description = "示例名称")
|
|
89
|
+
private String demoName; // 驼峰,自动映射 demo_name 列
|
|
90
|
+
|
|
91
|
+
@Schema(description = "状态(0:正常 1:禁用)")
|
|
92
|
+
private Integer status;
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**注意:**
|
|
97
|
+
- `BaseEntity` 已有的字段(`id`、`tenantId`、`creator`、`createTime`、`remark` 等 11 个)**不要**在子类重复声明
|
|
98
|
+
- 字段类型:`VARCHAR → String`、`INT → Integer`、`BIGINT → Long`、`DATETIME → LocalDateTime`
|
|
99
|
+
- 枚举值在注释中说明,如:`状态(0:正常 1:禁用)`
|
|
100
|
+
- DO 中**禁止**添加计算属性或前端展示逻辑
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## 四、VO 规范
|
|
105
|
+
|
|
106
|
+
### 4.1 分页入参(XxxPageParamVO)
|
|
107
|
+
|
|
108
|
+
```java
|
|
109
|
+
@Data
|
|
110
|
+
@EqualsAndHashCode(callSuper = true)
|
|
111
|
+
@Schema(description = "示例分页查询参数")
|
|
112
|
+
public class DemoPageParamVO extends PageParam { // 必须继承 PageParam
|
|
113
|
+
|
|
114
|
+
@Schema(description = "示例名称(支持模糊匹配)")
|
|
115
|
+
private String demoName; // 所有过滤字段均为可选,不传则不过滤
|
|
116
|
+
|
|
117
|
+
@Schema(description = "状态(0:正常 1:禁用)")
|
|
118
|
+
private Integer status;
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`PageParam` 内置:`pageNo`(默认1)、`pageSize`(默认10)、`sorts`(`List<SortItem>`)
|
|
123
|
+
|
|
124
|
+
### 4.2 新增/修改入参(XxxSaveParamVO)
|
|
125
|
+
|
|
126
|
+
新增和修改**共用一个 VO**,通过校验分组区分:
|
|
127
|
+
|
|
128
|
+
```java
|
|
129
|
+
@Data
|
|
130
|
+
@Schema(description = "示例新增/修改参数")
|
|
131
|
+
public class DemoSaveParamVO implements Serializable {
|
|
132
|
+
|
|
133
|
+
/** 修改专用校验分组 */
|
|
134
|
+
public interface UpdateGroup {}
|
|
135
|
+
|
|
136
|
+
@NotNull(groups = UpdateGroup.class, message = "修改时主键 id 不能为空")
|
|
137
|
+
@Schema(description = "主键(新增时不传,修改时必填)")
|
|
138
|
+
private Long id;
|
|
139
|
+
|
|
140
|
+
@NotBlank(message = "示例名称不能为空")
|
|
141
|
+
@Size(max = 100, message = "名称不能超过 100 个字符")
|
|
142
|
+
@Schema(description = "示例名称", requiredMode = Schema.RequiredMode.REQUIRED)
|
|
143
|
+
private String demoName;
|
|
144
|
+
|
|
145
|
+
/** 判断是否为新增操作 */
|
|
146
|
+
public boolean isNew() { return id == null; }
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Controller 使用方式:
|
|
151
|
+
- 新增:`@Valid @RequestBody`(不校验 id)
|
|
152
|
+
- 修改:`@Validated(DemoSaveParamVO.UpdateGroup.class) @RequestBody`(id 变为必填)
|
|
153
|
+
|
|
154
|
+
### 4.3 列表响应 VO(XxxRespVO)
|
|
155
|
+
|
|
156
|
+
只返回列表展示所需字段:
|
|
157
|
+
|
|
158
|
+
```java
|
|
159
|
+
@Data
|
|
160
|
+
@Schema(description = "示例列表项")
|
|
161
|
+
public class DemoRespVO implements Serializable {
|
|
162
|
+
@Schema(description = "主键ID") private Long id;
|
|
163
|
+
@Schema(description = "示例名称") private String demoName;
|
|
164
|
+
@Schema(description = "示例编码") private String demoCode;
|
|
165
|
+
@Schema(description = "状态") private Integer status;
|
|
166
|
+
@Schema(description = "创建人") private String creator;
|
|
167
|
+
@Schema(description = "创建时间") private LocalDateTime createTime;
|
|
168
|
+
@Schema(description = "最后修改时间") private LocalDateTime modifyTime;
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### 4.4 详情响应 VO(XxxDetailVO)
|
|
173
|
+
|
|
174
|
+
包含所有相关字段,含备注、审计信息:
|
|
175
|
+
|
|
176
|
+
```java
|
|
177
|
+
@Data
|
|
178
|
+
@Schema(description = "示例详情")
|
|
179
|
+
public class DemoDetailVO implements Serializable {
|
|
180
|
+
// 基础字段(与 RespVO 一致)
|
|
181
|
+
private Long id;
|
|
182
|
+
private String demoName;
|
|
183
|
+
private String demoCode;
|
|
184
|
+
private Integer status;
|
|
185
|
+
// 详情专有字段
|
|
186
|
+
@Schema(description = "备注") private String remark;
|
|
187
|
+
@Schema(description = "自定义扩展字段") private String customFields;
|
|
188
|
+
@Schema(description = "创建人") private String creator;
|
|
189
|
+
@Schema(description = "创建时间") private LocalDateTime createTime;
|
|
190
|
+
@Schema(description = "创建人ID") private Long createUserId;
|
|
191
|
+
@Schema(description = "最后修改人用户名") private String updater;
|
|
192
|
+
@Schema(description = "最后修改人ID") private Long modifyUserId;
|
|
193
|
+
@Schema(description = "最后修改时间") private LocalDateTime modifyTime;
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## 五、MapStruct 对象转换规范
|
|
200
|
+
|
|
201
|
+
```java
|
|
202
|
+
@Mapper(config = BaseMapperConfig.class) // 必须指定,忽略未映射字段(防编译报错)
|
|
203
|
+
public interface DemoConvert {
|
|
204
|
+
|
|
205
|
+
DemoConvert INSTANCE = Mappers.getMapper(DemoConvert.class); // 全局单例,直接调用
|
|
206
|
+
|
|
207
|
+
DemoRespVO doToRespVO(DemoDO demoDO); // 实体 → 列表VO(分页查询)
|
|
208
|
+
DemoDetailVO doToDetailVO(DemoDO demoDO); // 实体 → 详情VO(详情查询)
|
|
209
|
+
DemoDO saveParamToDo(DemoSaveParamVO saveParam); // 入参 → 实体(新增)
|
|
210
|
+
|
|
211
|
+
// 入参合并到现有实体(修改):只覆盖 saveParam 中有值的字段,createTime 等不受影响
|
|
212
|
+
void saveParamMergeToDo(DemoSaveParamVO saveParam, @MappingTarget DemoDO demoDO);
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
四种场景:
|
|
217
|
+
|
|
218
|
+
```java
|
|
219
|
+
// 场景1:分页查询 → 批量转换
|
|
220
|
+
return pageResult.convert(DemoConvert.INSTANCE::doToRespVO);
|
|
221
|
+
|
|
222
|
+
// 场景2:详情查询
|
|
223
|
+
return DemoConvert.INSTANCE.doToDetailVO(demoDO);
|
|
224
|
+
|
|
225
|
+
// 场景3:新增
|
|
226
|
+
DemoDO demoDO = DemoConvert.INSTANCE.saveParamToDo(saveParam);
|
|
227
|
+
save(demoDO); // id、tenantId、createTime 等由框架自动填充
|
|
228
|
+
|
|
229
|
+
// 场景4:修改(增量更新)
|
|
230
|
+
DemoDO demoDO = getByIdOrThrow(id);
|
|
231
|
+
DemoConvert.INSTANCE.saveParamMergeToDo(saveParam, demoDO);
|
|
232
|
+
updateById(demoDO);
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## 六、MapperExt 规范(动态查询)
|
|
238
|
+
|
|
239
|
+
```java
|
|
240
|
+
@Repository
|
|
241
|
+
@RequiredArgsConstructor
|
|
242
|
+
public class DemoMapperExt {
|
|
243
|
+
|
|
244
|
+
private final DemoMapper demoMapper;
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### 6.1 分页两段式查询(必须遵守)
|
|
249
|
+
|
|
250
|
+
```java
|
|
251
|
+
public PageResult<DemoDO> pageQuery(DemoPageParamVO query) {
|
|
252
|
+
LambdaQueryWrapper<DemoDO> wrapper = buildPageWrapper(query);
|
|
253
|
+
|
|
254
|
+
// 第一段:先 count,count=0 时短路,避免无效的 LIMIT 查询
|
|
255
|
+
Long count = demoMapper.selectCount(wrapper);
|
|
256
|
+
if (count == null || count == 0) {
|
|
257
|
+
return PageResult.empty(query);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 第二段:fetch,false 表示不让 MP 自动再 count 一次
|
|
261
|
+
Page<DemoDO> mpPage = new Page<>(query.getPageNo(), query.getPageSize(), false);
|
|
262
|
+
applySort(mpPage, query);
|
|
263
|
+
Page<DemoDO> result = demoMapper.selectPage(mpPage, wrapper);
|
|
264
|
+
return PageResult.of(result.getRecords(), count, query);
|
|
265
|
+
}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
### 6.2 动态条件构建规则
|
|
269
|
+
|
|
270
|
+
```java
|
|
271
|
+
private LambdaQueryWrapper<DemoDO> buildPageWrapper(DemoPageParamVO query) {
|
|
272
|
+
return new LambdaQueryWrapper<DemoDO>()
|
|
273
|
+
// String 类型:用 StringUtils.hasText() 判空(防止空字符串参与过滤)
|
|
274
|
+
.like(StringUtils.hasText(query.getDemoName()), DemoDO::getDemoName, query.getDemoName())
|
|
275
|
+
.eq(StringUtils.hasText(query.getDemoCode()), DemoDO::getDemoCode, query.getDemoCode())
|
|
276
|
+
// 对象类型:用 != null 判空
|
|
277
|
+
.eq(query.getStatus() != null, DemoDO::getStatus, query.getStatus())
|
|
278
|
+
.orderByDesc(DemoDO::getCreateTime);
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
**判空规则:**
|
|
283
|
+
- `String` 字段 → `StringUtils.hasText(value)`
|
|
284
|
+
- `Integer` / `Long` / 其他对象 → `value != null`
|
|
285
|
+
|
|
286
|
+
### 6.3 唯一性检查
|
|
287
|
+
|
|
288
|
+
```java
|
|
289
|
+
// excludeId:修改时传入自身 id(排除自身),新增时传 null
|
|
290
|
+
public boolean existsByCode(String demoCode, Long excludeId) {
|
|
291
|
+
return demoMapper.selectCount(
|
|
292
|
+
new LambdaQueryWrapper<DemoDO>()
|
|
293
|
+
.eq(DemoDO::getDemoCode, demoCode)
|
|
294
|
+
.ne(excludeId != null, DemoDO::getId, excludeId)
|
|
295
|
+
) > 0;
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
### 6.4 排序处理
|
|
300
|
+
|
|
301
|
+
```java
|
|
302
|
+
private void applySort(Page<DemoDO> mpPage, DemoPageParamVO query) {
|
|
303
|
+
if (!query.hasSorts()) return;
|
|
304
|
+
for (SortItem sortItem : query.getSorts()) {
|
|
305
|
+
String col = camelToSnake(sortItem.getColumn()); // 驼峰转下划线
|
|
306
|
+
mpPage.addOrder(sortItem.isAscending() ? OrderItem.asc(col) : OrderItem.desc(col));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private static String camelToSnake(String camel) {
|
|
311
|
+
StringBuilder sb = new StringBuilder();
|
|
312
|
+
for (int i = 0; i < camel.length(); i++) {
|
|
313
|
+
char c = camel.charAt(i);
|
|
314
|
+
if (Character.isUpperCase(c) && i > 0) sb.append('_');
|
|
315
|
+
sb.append(Character.toLowerCase(c));
|
|
316
|
+
}
|
|
317
|
+
return sb.toString();
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
---
|
|
322
|
+
|
|
323
|
+
## 七、Mapper 接口规范
|
|
324
|
+
|
|
325
|
+
```java
|
|
326
|
+
@Mapper
|
|
327
|
+
public interface DemoMapper extends BaseMapper<DemoDO> {
|
|
328
|
+
// BaseMapper 已提供:save / removeById / updateById / selectById / selectPage 等
|
|
329
|
+
// 极少数需要固定 SQL 且无动态条件时,才在此处用 @Select,必须手动加租户和删除过滤
|
|
330
|
+
@Select("SELECT * FROM t_demo WHERE demo_code = #{code} AND delete_flag = 0 LIMIT 1")
|
|
331
|
+
Optional<DemoDO> selectByCode(@Param("code") String code);
|
|
332
|
+
}
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
**多租户与逻辑删除的过滤规则:**
|
|
336
|
+
|
|
337
|
+
| SQL 来源 | 自动过滤 `delete_flag` | 自动追加 `tenant_id` |
|
|
338
|
+
|---------|----------------------|---------------------|
|
|
339
|
+
| MP BaseMapper / `LambdaQueryWrapper` | ✅ 自动 | ✅ 自动(TenantLineInnerInterceptor) |
|
|
340
|
+
| `@Select` 注解 / XML Mapper | ❌ 需手动加 | ❌ 需手动加 |
|
|
341
|
+
|
|
342
|
+
**结论:凡是需要多租户隔离的查询,必须走 `LambdaQueryWrapper`(放在 `MapperExt`),不能用 `@Select` 注解 SQL。**
|
|
343
|
+
|
|
344
|
+
```java
|
|
345
|
+
// ✅ 正确:MapperExt 中使用 LambdaQueryWrapper,MP 自动追加租户和逻辑删除过滤
|
|
346
|
+
public DemoDO findByCode(String code) {
|
|
347
|
+
return demoMapper.selectOne(
|
|
348
|
+
new LambdaQueryWrapper<DemoDO>()
|
|
349
|
+
.eq(DemoDO::getDemoCode, code)
|
|
350
|
+
.last("LIMIT 1")
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ❌ 错误:@Select 注解绕过了 TenantLineInnerInterceptor,有跨租户数据泄露风险
|
|
355
|
+
@Select("SELECT * FROM t_demo WHERE demo_code = #{code} LIMIT 1")
|
|
356
|
+
DemoDO selectByCode(@Param("code") String code);
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
---
|
|
360
|
+
|
|
361
|
+
## 八、Service 层规范
|
|
362
|
+
|
|
363
|
+
### 8.1 接口定义
|
|
364
|
+
|
|
365
|
+
```java
|
|
366
|
+
public interface IDemoService extends BaseService<DemoDO> {
|
|
367
|
+
PageResult<DemoRespVO> pageDemo(DemoPageParamVO query);
|
|
368
|
+
DemoDetailVO getDemoById(Long id);
|
|
369
|
+
Long createDemo(DemoSaveParamVO saveParam);
|
|
370
|
+
void updateDemo(DemoSaveParamVO saveParam);
|
|
371
|
+
void deleteDemo(List<Long> ids);
|
|
372
|
+
void enableDemo(Long id); // 语义化命名,而非 updateStatus
|
|
373
|
+
void disableDemo(Long id);
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### 8.2 实现类
|
|
378
|
+
|
|
379
|
+
```java
|
|
380
|
+
@Slf4j
|
|
381
|
+
@Service
|
|
382
|
+
@RequiredArgsConstructor
|
|
383
|
+
public class DemoServiceImpl extends BaseServiceImpl<DemoMapper, DemoDO>
|
|
384
|
+
implements IDemoService {
|
|
385
|
+
|
|
386
|
+
private final DemoMapperExt demoMapperExt;
|
|
387
|
+
}
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
### 8.3 关键规范
|
|
391
|
+
|
|
392
|
+
**写操作加事务:**
|
|
393
|
+
```java
|
|
394
|
+
@Transactional(rollbackFor = Exception.class)
|
|
395
|
+
public Long createDemo(DemoSaveParamVO saveParam) { ... }
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
**查询不存在时抛异常(不要手动判 null):**
|
|
399
|
+
```java
|
|
400
|
+
DemoDO demoDO = getByIdOrThrow(id); // 不存在自动抛 BusinessException(DATA_NOT_FOUND)
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
**业务校验抛异常:**
|
|
404
|
+
```java
|
|
405
|
+
if (demoMapperExt.existsByCode(saveParam.getDemoCode(), null)) {
|
|
406
|
+
throw new BusinessException(GlobalErrorCode.PARAM_INVALID.getCode(),
|
|
407
|
+
"编码 [" + saveParam.getDemoCode() + "] 已存在");
|
|
408
|
+
}
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
**状态变更(最小更新,不污染其他字段):**
|
|
412
|
+
```java
|
|
413
|
+
public void enableDemo(Long id) {
|
|
414
|
+
getByIdOrThrow(id);
|
|
415
|
+
DemoDO update = new DemoDO();
|
|
416
|
+
update.setId(id);
|
|
417
|
+
update.setStatus(0);
|
|
418
|
+
updateById(update);
|
|
419
|
+
log.info("启用示例成功,id={}", id);
|
|
420
|
+
}
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
**关键写操作必须打日志:**
|
|
424
|
+
```java
|
|
425
|
+
log.info("新增示例成功,id={},code={}", demoDO.getId(), demoDO.getDemoCode());
|
|
426
|
+
log.info("修改示例成功,id={}", id);
|
|
427
|
+
log.info("批量逻辑删除成功,ids={}", ids);
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
---
|
|
431
|
+
|
|
432
|
+
## 九、Controller 层规范
|
|
433
|
+
|
|
434
|
+
### 9.1 类结构
|
|
435
|
+
|
|
436
|
+
```java
|
|
437
|
+
@Tag(name = "示例管理", description = "t_demo 增删改查接口") // Swagger 分组
|
|
438
|
+
@Validated // 激活方法级参数校验
|
|
439
|
+
@RestController
|
|
440
|
+
@RequestMapping("/{domain}/demo") // 路径格式:/{业务域}/{资源名}
|
|
441
|
+
@RequiredArgsConstructor
|
|
442
|
+
public class DemoController {
|
|
443
|
+
|
|
444
|
+
private final IDemoService demoService; // 只依赖接口,不依赖 Impl
|
|
445
|
+
}
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
### 9.2 Swagger 注解规范
|
|
449
|
+
|
|
450
|
+
| 注解 | 位置 | 说明 |
|
|
451
|
+
|------|------|------|
|
|
452
|
+
| `@Tag(name, description)` | Controller 类 | 接口分组 |
|
|
453
|
+
| `@Operation(summary)` | 接口方法 | 接口描述 |
|
|
454
|
+
| `@Parameter(name, description, required)` | Path/Query 参数 | 参数说明 |
|
|
455
|
+
| `@Schema(description)` | VO 字段 | 字段说明 |
|
|
456
|
+
| `@Schema(requiredMode = REQUIRED)` | VO 必填字段 | 标记必填 |
|
|
457
|
+
|
|
458
|
+
### 9.3 统一响应规范
|
|
459
|
+
|
|
460
|
+
- 返回类型**必须**为 `R<T>`,不允许直接返回裸对象
|
|
461
|
+
- 有数据:`R.ok(data)`
|
|
462
|
+
- 无数据(写操作):`R.ok()`
|
|
463
|
+
- Controller 中**不处理**异常,由全局异常处理器统一响应
|
|
464
|
+
|
|
465
|
+
---
|
|
466
|
+
|
|
467
|
+
## 十、通用 Java 规范
|
|
468
|
+
|
|
469
|
+
### 依赖注入
|
|
470
|
+
统一使用构造器注入,配合 `@RequiredArgsConstructor`(字段必须为 `final`):
|
|
471
|
+
```java
|
|
472
|
+
private final DemoMapperExt demoMapperExt; // 不使用 @Autowired
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
### 日志规范
|
|
476
|
+
```java
|
|
477
|
+
@Slf4j // Lombok 自动注入 log 字段
|
|
478
|
+
// 使用 log.info/warn/error,不使用 System.out.println
|
|
479
|
+
// 异常捕获:log.error("操作失败", e)
|
|
480
|
+
// 不在日志中打印密码、Token 等敏感信息
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
### 空值处理
|
|
484
|
+
```java
|
|
485
|
+
// String 判空:使用 StringUtils.hasText(同时排除 null 和空白字符串)
|
|
486
|
+
if (!StringUtils.hasText(demoCode)) { ... }
|
|
487
|
+
|
|
488
|
+
// 集合判空:先判 null 再判 empty
|
|
489
|
+
if (ids == null || ids.isEmpty()) { return; }
|
|
490
|
+
|
|
491
|
+
// 对象判空:使用框架提供的 getByIdOrThrow,不要手动 if(xx == null) throw ...
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
### 异常处理
|
|
495
|
+
|
|
496
|
+
| 场景 | 正确用法 |
|
|
497
|
+
|------|---------|
|
|
498
|
+
| 业务校验失败(参数非法、数据不存在、状态不符) | `BusinessException(GlobalErrorCode.PARAM_INVALID, "...")` |
|
|
499
|
+
| 系统级失败(IO 异常、外部调用失败、序列化失败) | `SystemException(GlobalErrorCode.INTERNAL_SERVER_ERROR, "...", e)` |
|
|
500
|
+
| 查询不存在直接抛异常 | `getByIdOrThrow(id)`(`BaseServiceImpl` 提供,自动包装 `DATA_NOT_FOUND`) |
|
|
501
|
+
|
|
502
|
+
```java
|
|
503
|
+
// ✅ IO 异常正确写法
|
|
504
|
+
try {
|
|
505
|
+
StreamUtils.copy(in, out);
|
|
506
|
+
} catch (IOException e) {
|
|
507
|
+
throw new SystemException(GlobalErrorCode.INTERNAL_SERVER_ERROR, "文件下载失败: " + e.getMessage(), e);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ❌ 禁止
|
|
511
|
+
throw new RuntimeException("文件读取失败", e);
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
- 不允许吞掉异常(`catch(Exception e) {}`)
|
|
515
|
+
- `@Transactional` 必须指定 `rollbackFor = Exception.class`
|
|
516
|
+
|
|
517
|
+
### 编码规范
|
|
518
|
+
- 所有 Java 文件使用 **UTF-8** 编码
|
|
519
|
+
- 所有注释使用**中文**
|
|
520
|
+
- 注释只写"为什么这样做",不写"做了什么"
|
|
521
|
+
|
|
522
|
+
---
|
|
523
|
+
|
|
524
|
+
## 十一、文件流与特殊响应规范
|
|
525
|
+
|
|
526
|
+
Controller **禁止**直接处理 `InputStream`/`OutputStream`,所有文件下载、预览、流式响应逻辑必须下沉到 Service 层:
|
|
527
|
+
|
|
528
|
+
```java
|
|
529
|
+
// ✅ Controller:只做透传
|
|
530
|
+
@GetMapping("/{id}/download")
|
|
531
|
+
public void download(@PathVariable Long id, HttpServletResponse response) {
|
|
532
|
+
fileService.download(id, response); // 业务逻辑全在 Service
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ❌ Controller:手动设置响应头、读写流 —— 严格禁止
|
|
536
|
+
@GetMapping("/{id}/download")
|
|
537
|
+
public void download(@PathVariable Long id, HttpServletResponse response) throws Exception {
|
|
538
|
+
FileMetaRespVO meta = fileService.getFileMeta(id);
|
|
539
|
+
response.setContentType(meta.getMimeType()); // ❌ 业务逻辑泄露到 Controller
|
|
540
|
+
FileMetaDO do = fileService.getById(id); // ❌ 双重查询
|
|
541
|
+
// ... 手动 byte[] 循环 ... // ❌ 应用 StreamUtils.copy
|
|
542
|
+
}
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
**Service 中文件流处理规范:**
|
|
546
|
+
|
|
547
|
+
```java
|
|
548
|
+
// ✅ 使用 StreamUtils.copy 替代手动 byte[] 循环
|
|
549
|
+
try (InputStream in = storageService.download(path, bucket);
|
|
550
|
+
OutputStream out = response.getOutputStream()) {
|
|
551
|
+
StreamUtils.copy(in, out); // org.springframework.util.StreamUtils
|
|
552
|
+
out.flush();
|
|
553
|
+
} catch (IOException e) {
|
|
554
|
+
throw new SystemException(GlobalErrorCode.INTERNAL_SERVER_ERROR, "文件下载失败", e);
|
|
555
|
+
}
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
---
|
|
559
|
+
|
|
560
|
+
## 十二、常量管理规范
|
|
561
|
+
|
|
562
|
+
业务域内使用的字符串常量(状态值、枚举码、策略标识等)必须集中定义到 `{域}Constants` 类,**禁止在 Service/Controller 中出现硬编码字符串常量**:
|
|
563
|
+
|
|
564
|
+
```java
|
|
565
|
+
// 位置:{业务模块}-api/src/main/java/com/.../api/constant/{域名}Constants.java
|
|
566
|
+
public final class FsmConstants {
|
|
567
|
+
private FsmConstants() {}
|
|
568
|
+
|
|
569
|
+
// ==================== 服务名(供 Feign Client 引用) ====================
|
|
570
|
+
public static final String SERVICE_NAME = "nebula-fsm";
|
|
571
|
+
|
|
572
|
+
// ==================== RPC 路径前缀 ====================
|
|
573
|
+
public static final String URI_PREFIX = CommonConstants.RPC_URI_PREFIX + "/nebula/fsm";
|
|
574
|
+
|
|
575
|
+
// ==================== 业务枚举值 ====================
|
|
576
|
+
public static final String ACCESS_POLICY_PUBLIC = "PUBLIC";
|
|
577
|
+
public static final String ACCESS_POLICY_PRIVATE = "PRIVATE";
|
|
578
|
+
public static final String UPLOAD_STATUS_COMPLETE = "COMPLETE";
|
|
579
|
+
public static final String UPLOAD_STATUS_UPLOADING = "UPLOADING";
|
|
580
|
+
}
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
**常量类放置规则:**
|
|
584
|
+
|
|
585
|
+
| 常量类型 | 放置位置 |
|
|
586
|
+
|---------|---------|
|
|
587
|
+
| 微服务 RPC 路径、服务名、业务枚举值 | `{业务模块}-api` 的 `constant/` 包下 |
|
|
588
|
+
| 框架内部常量(不对外暴露) | `{业务模块}-core` 的 `constant/` 包下 |
|
|
589
|
+
| 全局公共常量 | `nebula-framework-core` 的 `CommonConstants` |
|
|
590
|
+
|
|
591
|
+
---
|
|
592
|
+
|
|
593
|
+
## 十三、新业务模块开发检查清单
|
|
594
|
+
|
|
595
|
+
- [ ] 在 `nebula-{domain}-server/src/main/resources/db/migration/` 创建 Flyway DDL 脚本
|
|
596
|
+
- [ ] 在 `nebula-system` 迁移脚本中补充菜单和字典 DML(如需要)
|
|
597
|
+
- [ ] 创建 `{业务名}DO`,继承 `BaseEntity`
|
|
598
|
+
- [ ] 创建 `{业务名}Mapper`,继承 `BaseMapper<DO>`
|
|
599
|
+
- [ ] 创建 `{业务名}MapperExt`(如需动态查询,将 `LambdaQueryWrapper` 放此处)
|
|
600
|
+
- [ ] 创建 `{业务名}PageParamVO` 和 `{业务名}SaveParamVO`(含 `UpdateGroup`)
|
|
601
|
+
- [ ] 创建 `{业务名}RespVO` 和 `{业务名}DetailVO`
|
|
602
|
+
- [ ] 创建 `{业务名}Convert`,指定 `BaseMapperConfig`
|
|
603
|
+
- [ ] 创建 `I{业务名}Service` 和 `{业务名}ServiceImpl`(实现类在 `service/impl/`)
|
|
604
|
+
- [ ] 创建 `{业务名}Controller`,完成所有 REST 接口并添加 Swagger 注解
|
|
605
|
+
- [ ] 验证 Swagger 文档(`/doc.html`)接口展示正常
|
|
606
|
+
- [ ] 验证逻辑删除、多租户过滤是否生效
|
|
607
|
+
- [ ] 业务常量是否已提取到 `{域}Constants` 常量类
|
|
608
|
+
- [ ] 跨服务 RPC 接口是否按三件套规范实现(Constants + Feign Client + LocalStub)
|
|
609
|
+
- [ ] 跨模块操作是否通过 Service 接口(不直接注入其他业务域的 Mapper)
|
|
610
|
+
- [ ] 对外 VO / RPC DTO 是否不含存储内部字段(storagePath、bucketName 等)
|
|
611
|
+
- [ ] DDL 是否包含 BaseEntity 完整 11 个公共字段(特别检查 `remark` 和 `custom_fields`)
|