1155 lines
33 KiB
Markdown
1155 lines
33 KiB
Markdown
# Ether 智慧物业管理平台 - 开发规范
|
||
|
||
**文档版本**: v2.0
|
||
**最后更新**: 2026-03-28
|
||
**适用范围**: ether-pms 单体应用
|
||
|
||
---
|
||
|
||
## 一、代码规范
|
||
|
||
### 1.1 基础规范
|
||
|
||
| 项目 | 规范 | 说明 |
|
||
| -------------- | ------------------------------ | -------------------- |
|
||
| **JDK版本** | Java 17+ | 使用LTS版本 |
|
||
| **编码规范** | Alibaba Java Coding Guidelines | 遵循阿里巴巴编码规范 |
|
||
| **代码格式化** | 统一使用IDE格式化配置 | 避免格式差异 |
|
||
| **文件编码** | UTF-8 | 所有源文件 |
|
||
|
||
### 1.2 命名规范
|
||
|
||
```java
|
||
// 类名: 大驼峰
|
||
public class WorkOrderService { }
|
||
public class WorkOrderController { }
|
||
|
||
// 方法名: 小驼峰
|
||
public void createWorkOrder() { }
|
||
public WorkOrder getById(UUID id) { }
|
||
|
||
// 变量名: 小驼峰
|
||
private String orderNo;
|
||
private LocalDateTime createdAt;
|
||
|
||
// 常量: 全大写下划线
|
||
private static final int MAX_RETRY_COUNT = 3;
|
||
private static final String DEFAULT_STATUS = "CREATED";
|
||
|
||
// 包名: 全小写
|
||
package com.ether.ops.service;
|
||
package com.ether.ops.controller;
|
||
```
|
||
|
||
### 1.3 类结构规范
|
||
|
||
```java
|
||
/**
|
||
* 工单服务
|
||
* 负责工单的创建、分配、流转等业务逻辑
|
||
*
|
||
* @author Ether Team
|
||
* @since 1.0.0
|
||
*/
|
||
@Service
|
||
@RequiredArgsConstructor
|
||
@Slf4j
|
||
public class WorkOrderService {
|
||
|
||
// 1. 常量定义
|
||
private static final String ORDER_PREFIX = "WO";
|
||
private static final int ORDER_NO_LENGTH = 16;
|
||
|
||
// 2. 依赖注入
|
||
private final WorkOrderRepository workOrderRepository;
|
||
private final WorkOrderFlowRepository flowRepository;
|
||
private final NotificationService notificationService;
|
||
|
||
// 3. 业务方法
|
||
/**
|
||
* 创建工单
|
||
*
|
||
* @param request 创建请求
|
||
* @return 创建的工单
|
||
* @throws BusinessException 业务异常
|
||
*/
|
||
@Transactional
|
||
public WorkOrder create(WorkOrderCreateRequest request) {
|
||
// 实现逻辑
|
||
}
|
||
|
||
// 4. 私有方法
|
||
private String generateOrderNo() {
|
||
// 实现逻辑
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 二、数据库规范
|
||
|
||
### 2.1 命名规范
|
||
|
||
| 对象 | 规范 | 示例 |
|
||
| ---------- | -------------------- | ---------------------------------- |
|
||
| **表名** | 小写下划线,模块前缀 | `ops_work_order`, `mdm_space_node` |
|
||
| **字段名** | 小写下划线 | `created_at`, `project_id` |
|
||
| **索引名** | `idx_表名_字段名` | `idx_work_order_status` |
|
||
| **外键名** | `fk_表名_关联表` | `fk_work_order_assignee` |
|
||
|
||
### 2.2 字段规范
|
||
|
||
```sql
|
||
-- 必备字段
|
||
CREATE TABLE example_table (
|
||
id UUID PRIMARY KEY, -- 主键
|
||
project_id UUID NOT NULL, -- 项目ID(多租户)
|
||
|
||
-- 业务字段
|
||
status VARCHAR(20) NOT NULL, -- 状态
|
||
|
||
-- 审计字段
|
||
created_at TIMESTAMP NOT NULL DEFAULT NOW(), -- 创建时间
|
||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(), -- 更新时间
|
||
created_by UUID, -- 创建人
|
||
updated_by UUID -- 更新人
|
||
);
|
||
|
||
-- 索引规范
|
||
CREATE INDEX idx_example_status ON example_table(status);
|
||
CREATE INDEX idx_example_project ON example_table(project_id);
|
||
CREATE INDEX idx_example_created ON example_table(created_at);
|
||
```
|
||
|
||
### 2.3 数据类型规范
|
||
|
||
| 数据类型 | 使用场景 | 示例 |
|
||
| -------------- | ---------------- | ---------------------- |
|
||
| `UUID` | 主键、外键 | `id UUID PRIMARY KEY` |
|
||
| `VARCHAR(n)` | 字符串,长度确定 | `code VARCHAR(50)` |
|
||
| `TEXT` | 长文本 | `description TEXT` |
|
||
| `NUMERIC(p,s)` | 金额、精确数值 | `amount NUMERIC(12,2)` |
|
||
| `INTEGER` | 整数 | `sort_order INTEGER` |
|
||
| `BIGINT` | 大整数、计数 | `view_count BIGINT` |
|
||
| `BOOLEAN` | 布尔值 | `is_enabled BOOLEAN` |
|
||
| `TIMESTAMP` | 日期时间 | `created_at TIMESTAMP` |
|
||
| `DATE` | 日期 | `birth_date DATE` |
|
||
| `JSONB` | JSON数据 | `attributes JSONB` |
|
||
|
||
---
|
||
|
||
## 三、接口规范
|
||
|
||
### 3.1 RESTful API 规范
|
||
|
||
```java
|
||
@RestController
|
||
@RequestMapping("/api/v1/ops/work-orders")
|
||
@RequiredArgsConstructor
|
||
@Tag(name = "工单管理", description = "工单相关接口")
|
||
public class WorkOrderController {
|
||
|
||
private final WorkOrderService workOrderService;
|
||
|
||
// 创建资源: POST
|
||
@PostMapping
|
||
@Operation(summary = "创建工单")
|
||
public Result<WorkOrderVO> create(@RequestBody @Valid WorkOrderCreateRequest request) {
|
||
return Result.success(workOrderService.create(request));
|
||
}
|
||
|
||
// 查询单个: GET /{id}
|
||
@GetMapping("/{id}")
|
||
@Operation(summary = "获取工单详情")
|
||
public Result<WorkOrderVO> getById(@PathVariable UUID id) {
|
||
return Result.success(workOrderService.getById(id));
|
||
}
|
||
|
||
// 查询列表: GET
|
||
@GetMapping
|
||
@Operation(summary = "分页查询工单")
|
||
public Result<Page<WorkOrderVO>> page(WorkOrderQueryRequest request) {
|
||
return Result.success(workOrderService.page(request));
|
||
}
|
||
|
||
// 更新资源: PUT /{id}
|
||
@PutMapping("/{id}")
|
||
@Operation(summary = "更新工单")
|
||
public Result<WorkOrderVO> update(@PathVariable UUID id,
|
||
@RequestBody @Valid WorkOrderUpdateRequest request) {
|
||
return Result.success(workOrderService.update(id, request));
|
||
}
|
||
|
||
// 删除资源: DELETE /{id}
|
||
@DeleteMapping("/{id}")
|
||
@Operation(summary = "删除工单")
|
||
public Result<Void> delete(@PathVariable UUID id) {
|
||
workOrderService.delete(id);
|
||
return Result.success();
|
||
}
|
||
|
||
// 业务操作: POST /{id}/action
|
||
@PostMapping("/{id}/assign")
|
||
@Operation(summary = "分配工单")
|
||
public Result<WorkOrderVO> assign(@PathVariable UUID id,
|
||
@RequestBody @Valid WorkOrderAssignRequest request) {
|
||
return Result.success(workOrderService.assign(id, request));
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.2 接口路径规范
|
||
|
||
| 操作 | HTTP方法 | 路径示例 | 说明 |
|
||
| -------- | -------- | ------------------------------------- | ------------ |
|
||
| 创建 | POST | `/api/v1/ops/work-orders` | 创建资源 |
|
||
| 查询单个 | GET | `/api/v1/ops/work-orders/{id}` | 根据ID查询 |
|
||
| 查询列表 | GET | `/api/v1/ops/work-orders` | 分页查询 |
|
||
| 更新 | PUT | `/api/v1/ops/work-orders/{id}` | 全量更新 |
|
||
| 删除 | DELETE | `/api/v1/ops/work-orders/{id}` | 删除资源 |
|
||
| 业务操作 | POST | `/api/v1/ops/work-orders/{id}/assign` | 特定业务操作 |
|
||
|
||
### 3.3 统一响应格式
|
||
|
||
```java
|
||
@Data
|
||
public class Result<T> {
|
||
private Integer code; // 状态码: 200成功, 其他错误
|
||
private String message; // 提示信息
|
||
private T data; // 数据
|
||
private Long timestamp; // 时间戳
|
||
|
||
// 成功响应
|
||
public static <T> Result<T> success(T data) {
|
||
Result<T> result = new Result<>();
|
||
result.setCode(200);
|
||
result.setMessage("success");
|
||
result.setData(data);
|
||
result.setTimestamp(System.currentTimeMillis());
|
||
return result;
|
||
}
|
||
|
||
// 错误响应
|
||
public static <T> Result<T> error(String message) {
|
||
Result<T> result = new Result<>();
|
||
result.setCode(500);
|
||
result.setMessage(message);
|
||
result.setTimestamp(System.currentTimeMillis());
|
||
return result;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.4 分页请求/响应
|
||
|
||
```java
|
||
// 分页请求
|
||
@Data
|
||
public class PageRequest {
|
||
private Integer pageNum = 1; // 页码,从1开始
|
||
private Integer pageSize = 10; // 每页大小
|
||
private String sortField; // 排序字段
|
||
private String sortOrder; // 排序方式: asc/desc
|
||
}
|
||
|
||
// 分页响应
|
||
@Data
|
||
public class Page<T> {
|
||
private List<T> list; // 数据列表
|
||
private Long total; // 总记录数
|
||
private Integer pageNum; // 当前页码
|
||
private Integer pageSize; // 每页大小
|
||
private Integer totalPages; // 总页数
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 四、异常处理规范
|
||
|
||
### 4.1 异常分类
|
||
|
||
```java
|
||
// 业务异常
|
||
public class BusinessException extends RuntimeException {
|
||
private final Integer code;
|
||
|
||
public BusinessException(String message) {
|
||
super(message);
|
||
this.code = 500;
|
||
}
|
||
|
||
public BusinessException(Integer code, String message) {
|
||
super(message);
|
||
this.code = code;
|
||
}
|
||
}
|
||
|
||
// 参数异常
|
||
public class ValidationException extends BusinessException {
|
||
public ValidationException(String message) {
|
||
super(400, message);
|
||
}
|
||
}
|
||
|
||
// 未授权异常
|
||
public class UnauthorizedException extends BusinessException {
|
||
public UnauthorizedException(String message) {
|
||
super(401, message);
|
||
}
|
||
}
|
||
|
||
// 无权限异常
|
||
public class ForbiddenException extends BusinessException {
|
||
public ForbiddenException(String message) {
|
||
super(403, message);
|
||
}
|
||
}
|
||
|
||
// 资源不存在异常
|
||
public class NotFoundException extends BusinessException {
|
||
public NotFoundException(String message) {
|
||
super(404, message);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 4.2 全局异常处理
|
||
|
||
```java
|
||
@RestControllerAdvice
|
||
@Slf4j
|
||
public class GlobalExceptionHandler {
|
||
|
||
// 业务异常
|
||
@ExceptionHandler(BusinessException.class)
|
||
public Result<Void> handleBusinessException(BusinessException e) {
|
||
log.warn("业务异常: {}", e.getMessage());
|
||
return Result.error(e.getCode(), e.getMessage());
|
||
}
|
||
|
||
// 参数校验异常
|
||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||
public Result<Void> handleValidationException(MethodArgumentNotValidException e) {
|
||
String message = e.getBindingResult().getFieldErrors().stream()
|
||
.map(FieldError::getDefaultMessage)
|
||
.collect(Collectors.joining(", "));
|
||
log.warn("参数校验失败: {}", message);
|
||
return Result.error(400, message);
|
||
}
|
||
|
||
// 其他异常
|
||
@ExceptionHandler(Exception.class)
|
||
public Result<Void> handleException(Exception e) {
|
||
log.error("系统异常", e);
|
||
return Result.error(500, "系统繁忙,请稍后重试");
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 五、日志规范
|
||
|
||
### 5.1 日志级别使用
|
||
|
||
| 级别 | 使用场景 | 示例 |
|
||
| --------- | ---------------------- | ---------------------------- |
|
||
| **ERROR** | 系统错误,需要立即处理 | 数据库连接失败、关键业务异常 |
|
||
| **WARN** | 警告,需要注意 | 参数校验失败、业务规则冲突 |
|
||
| **INFO** | 正常业务日志 | 业务流程记录、关键操作记录 |
|
||
| **DEBUG** | 调试信息 | 详细参数、执行过程 |
|
||
| **TRACE** | 最详细跟踪 | 方法进入退出、循环内部 |
|
||
|
||
### 5.2 日志格式
|
||
|
||
```java
|
||
@Slf4j
|
||
@Service
|
||
public class WorkOrderService {
|
||
|
||
public WorkOrder create(WorkOrderCreateRequest request) {
|
||
// 入口日志
|
||
log.info("创建工单开始, title={}", request.getTitle());
|
||
|
||
try {
|
||
// 业务逻辑
|
||
WorkOrder workOrder = doCreate(request);
|
||
|
||
// 成功日志
|
||
log.info("创建工单成功, orderNo={}, id={}", workOrder.getOrderNo(), workOrder.getId());
|
||
return workOrder;
|
||
} catch (Exception e) {
|
||
// 错误日志
|
||
log.error("创建工单失败, title={}", request.getTitle(), e);
|
||
throw e;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 六、测试规范
|
||
|
||
### 6.1 单元测试
|
||
|
||
```java
|
||
@ExtendWith(MockitoExtension.class)
|
||
class WorkOrderServiceTest {
|
||
|
||
@Mock
|
||
private WorkOrderRepository workOrderRepository;
|
||
|
||
@InjectMocks
|
||
private WorkOrderService workOrderService;
|
||
|
||
@Test
|
||
@DisplayName("创建工单-成功")
|
||
void create_Success() {
|
||
// Given
|
||
WorkOrderCreateRequest request = new WorkOrderCreateRequest();
|
||
request.setTitle("测试工单");
|
||
request.setDescription("测试描述");
|
||
|
||
when(workOrderRepository.save(any())).thenAnswer(invocation -> {
|
||
WorkOrder order = invocation.getArgument(0);
|
||
order.setId(UUID.randomUUID());
|
||
return order;
|
||
});
|
||
|
||
// When
|
||
WorkOrder result = workOrderService.create(request);
|
||
|
||
// Then
|
||
assertNotNull(result.getId());
|
||
assertEquals("测试工单", result.getTitle());
|
||
assertEquals(WorkOrderStatus.CREATED, result.getStatus());
|
||
verify(workOrderRepository).save(any());
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("创建工单-参数为空")
|
||
void create_NullTitle_ThrowsException() {
|
||
// Given
|
||
WorkOrderCreateRequest request = new WorkOrderCreateRequest();
|
||
request.setTitle(null);
|
||
|
||
// When & Then
|
||
assertThrows(ValidationException.class, () -> {
|
||
workOrderService.create(request);
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
### 6.2 测试命名规范
|
||
|
||
- 测试类: `被测类名 + Test`
|
||
- 测试方法: `被测方法名_场景_预期结果`
|
||
- 使用 `@DisplayName` 添加中文说明
|
||
|
||
### 6.3 E2E 测试规范
|
||
|
||
#### 6.3.1 测试框架架构
|
||
|
||
E2E 测试采用**组合方案**架构,平衡性能和稳定性:
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ E2E 测试执行流程 │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ 1. resetPageState() - 轻量级页面状态重置 │
|
||
│ ├── 导航到登录页 │
|
||
│ ├── 清除 localStorage/sessionStorage │
|
||
│ └── 清除 Cookies │
|
||
│ │
|
||
│ 2. login() - 登录操作 │
|
||
│ ├── 调用 resetPageState() │
|
||
│ ├── 填写用户名密码 │
|
||
│ └── 点击登录按钮 │
|
||
│ │
|
||
│ 3. waitForAppReady() - 智能等待应用就绪 │
|
||
│ ├── 等待侧边栏加载 (可配置) │
|
||
│ ├── 等待 Loading 消失 │
|
||
│ └── 等待网络空闲 │
|
||
│ │
|
||
│ 4. 执行测试用例 │
|
||
│ ├── 业务操作 │
|
||
│ └── 结果断言 │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
#### 6.3.2 核心方法实现
|
||
|
||
```typescript
|
||
class EtherE2ETestRunner {
|
||
/**
|
||
* 轻量级页面状态重置
|
||
* 在每个测试用例前调用,确保测试隔离性
|
||
*/
|
||
private async resetPageState() {
|
||
if (!this.page) throw new Error("Page not initialized");
|
||
|
||
await this.page.goto(`${this.baseUrl}/login`, {
|
||
waitUntil: "networkidle2",
|
||
timeout: 30000,
|
||
});
|
||
|
||
await this.page.evaluate(() => {
|
||
localStorage.clear();
|
||
sessionStorage.clear();
|
||
const cookies = document.cookie.split(";");
|
||
for (const cookie of cookies) {
|
||
const eqPos = cookie.indexOf("=");
|
||
const name = eqPos > -1 ? cookie.substr(0, eqPos) : cookie;
|
||
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`;
|
||
}
|
||
});
|
||
|
||
await sleep(500);
|
||
}
|
||
|
||
/**
|
||
* 智能等待应用就绪
|
||
* 多重等待策略,适应不同网络环境
|
||
*/
|
||
private async waitForAppReady(options?: { requireSider?: boolean }) {
|
||
if (!this.page) throw new Error("Page not initialized");
|
||
|
||
const { requireSider = true } = options || {};
|
||
|
||
try {
|
||
// 1. 等待侧边栏加载(可配置)
|
||
if (requireSider) {
|
||
await this.page.waitForSelector(".ant-layout-sider", {
|
||
timeout: 20000,
|
||
});
|
||
}
|
||
|
||
// 2. 等待 Loading 状态消失
|
||
await this.page.waitForFunction(
|
||
() => {
|
||
const loading = document.querySelector(".ant-spin-spinning");
|
||
return !loading;
|
||
},
|
||
{ timeout: 10000 },
|
||
);
|
||
|
||
// 3. 等待网络空闲
|
||
await this.page
|
||
.waitForNetworkIdle({
|
||
idleTime: 300,
|
||
timeout: 5000,
|
||
})
|
||
.catch(() => {});
|
||
} catch (error) {
|
||
const currentUrl = this.page.url();
|
||
throw new Error(
|
||
`应用未就绪: 当前URL=${currentUrl}, 错误=${error.message}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 登录方法
|
||
* 组合 resetPageState + 登录操作 + waitForAppReady
|
||
*/
|
||
private async login(username: string, password: string) {
|
||
if (!this.page) throw new Error("Page not initialized");
|
||
|
||
await this.resetPageState();
|
||
|
||
await this.page.waitForSelector('input[placeholder*="用户名"]', {
|
||
timeout: 10000,
|
||
});
|
||
|
||
await this.page.type('input[placeholder*="用户名"]', username, {
|
||
delay: 50,
|
||
});
|
||
await this.page.type('input[placeholder*="密码"]', password, { delay: 50 });
|
||
|
||
const buttons = await this.page.$$("button");
|
||
for (const btn of buttons) {
|
||
const text = await this.page.evaluate((el) => el.innerText, btn);
|
||
if (text.includes("登")) {
|
||
await btn.click();
|
||
break;
|
||
}
|
||
}
|
||
|
||
await this.waitForAppReady();
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 6.3.3 测试用例编写规范
|
||
|
||
```typescript
|
||
// ✅ 正确示例:使用 waitForAppReady 替代固定等待
|
||
await this.runTest("工单管理", "TC-WORK-001: 创建工单-打开弹窗", async () => {
|
||
if (!this.page) throw new Error("Page not initialized");
|
||
|
||
// 1. 登录(内部已包含 resetPageState 和 waitForAppReady)
|
||
await this.login("testuser", "Admin@123");
|
||
|
||
// 2. 选择项目
|
||
const projectSelector = await this.page.$(".project-selector .ant-select");
|
||
if (projectSelector) {
|
||
await projectSelector.click();
|
||
await sleep(500);
|
||
const options = await this.page.$$(".ant-select-dropdown .ant-select-item");
|
||
if (options.length > 0) {
|
||
await options[0].click();
|
||
await sleep(1000);
|
||
}
|
||
}
|
||
|
||
// 3. 导航到目标页面
|
||
await this.clickMenuByText("工单管理");
|
||
|
||
// 4. 等待页面就绪(不需要侧边栏时设置 requireSider: false)
|
||
await this.waitForAppReady({ requireSider: false });
|
||
|
||
// 5. 执行业务操作
|
||
const clicked = await this.clickButtonByText("新增");
|
||
if (!clicked) {
|
||
throw new Error("未找到新增工单按钮");
|
||
}
|
||
|
||
// 6. 断言结果
|
||
await sleep(1000);
|
||
const modal = await this.page.$(".ant-modal");
|
||
if (!modal) {
|
||
throw new Error("新建工单弹窗未显示");
|
||
}
|
||
});
|
||
|
||
// ❌ 错误示例:使用固定等待时间
|
||
await this.page.waitForSelector(".ant-layout-sider", { timeout: 10000 });
|
||
await sleep(2000); // 不推荐:固定等待时间不可靠
|
||
```
|
||
|
||
#### 6.3.4 测试用例命名规范
|
||
|
||
| 格式 | 示例 |
|
||
| -------- | --------------------------------------- |
|
||
| 功能测试 | `TC-{模块}-{编号}: {功能描述}` |
|
||
| 权限测试 | `TC-{模块}-{编号}: {功能描述} ({角色})` |
|
||
| 异常测试 | `TC-{模块}-{编号}: {功能描述}-异常场景` |
|
||
|
||
示例:
|
||
|
||
- `TC-AUTH-001: 超级管理员登录成功`
|
||
- `TC-WORK-018: 工单列表查询 (项目成员)`
|
||
- `TC-USER-004: 创建用户-用户名重复`
|
||
|
||
#### 6.3.5 等待策略选择指南
|
||
|
||
| 场景 | 推荐策略 | 说明 |
|
||
| ------------------ | ------------------------------------------ | -------------------------- |
|
||
| 登录后等待主页加载 | `waitForAppReady()` | 默认等待侧边栏 |
|
||
| 页面内导航后等待 | `waitForAppReady({ requireSider: false })` | 侧边栏已存在,无需重复等待 |
|
||
| 等待特定元素 | `waitForSelector()` | 针对特定元素的精确等待 |
|
||
| 等待 API 响应 | `waitForNetworkIdle()` | 等待网络请求完成 |
|
||
| 等待动画完成 | `sleep(500)` | 短暂固定等待,仅用于动画 |
|
||
|
||
#### 6.3.6 测试数据管理
|
||
|
||
```typescript
|
||
class EtherE2ETestRunner {
|
||
// 测试数据存储
|
||
private testData = {
|
||
createdUserId: "",
|
||
createdRoleId: "",
|
||
createdProjectId: "",
|
||
createdWorkOrderId: "",
|
||
};
|
||
|
||
// 测试前准备数据
|
||
async prepareTestData() {
|
||
// 确保测试用户存在
|
||
// 确保测试项目存在
|
||
}
|
||
|
||
// 测试后清理数据
|
||
async cleanupTestData() {
|
||
// 删除创建的测试数据
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 6.3.7 测试报告
|
||
|
||
测试报告自动保存至 `docs/测试用例/测试报告_{timestamp}.md`,包含:
|
||
|
||
- 执行时间
|
||
- 各模块通过率
|
||
- 失败用例详情
|
||
- 错误堆栈信息
|
||
|
||
---
|
||
|
||
## 七、安全规范
|
||
|
||
### 7.1 敏感数据处理
|
||
|
||
```java
|
||
// 密码加密
|
||
@Component
|
||
public class PasswordEncoder {
|
||
|
||
public String encode(String password, String salt) {
|
||
// MD5(salt + password)
|
||
return DigestUtils.md5Hex(salt + password);
|
||
}
|
||
|
||
public String generateSalt() {
|
||
// 生成8位随机盐值
|
||
return RandomStringUtils.randomAlphanumeric(8);
|
||
}
|
||
}
|
||
|
||
// 敏感字段脱敏
|
||
public class MaskUtils {
|
||
|
||
// 手机号脱敏: 138****8888
|
||
public static String maskPhone(String phone) {
|
||
if (StringUtils.isBlank(phone) || phone.length() != 11) {
|
||
return phone;
|
||
}
|
||
return phone.substring(0, 3) + "****" + phone.substring(7);
|
||
}
|
||
|
||
// 身份证号脱敏: 110101********1234
|
||
public static String maskIdCard(String idCard) {
|
||
if (StringUtils.isBlank(idCard) || idCard.length() != 18) {
|
||
return idCard;
|
||
}
|
||
return idCard.substring(0, 6) + "********" + idCard.substring(14);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 7.2 接口安全
|
||
|
||
```java
|
||
// JWT Token验证
|
||
@Component
|
||
public class JwtTokenProvider {
|
||
|
||
public String generateToken(User user) {
|
||
Date now = new Date();
|
||
Date expiryDate = new Date(now.getTime() + jwtExpiration);
|
||
|
||
return Jwts.builder()
|
||
.setSubject(user.getId().toString())
|
||
.claim("username", user.getUsername())
|
||
.claim("projectId", user.getProjectId())
|
||
.setIssuedAt(now)
|
||
.setExpiration(expiryDate)
|
||
.signWith(secretKey)
|
||
.compact();
|
||
}
|
||
|
||
public boolean validateToken(String token) {
|
||
try {
|
||
Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token);
|
||
return true;
|
||
} catch (Exception e) {
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 八、性能规范
|
||
|
||
### 8.1 数据库性能
|
||
|
||
```java
|
||
// 1. 批量操作
|
||
@Transactional
|
||
public void batchCreate(List<WorkOrderCreateRequest> requests) {
|
||
List<WorkOrder> orders = requests.stream()
|
||
.map(this::convertToEntity)
|
||
.collect(Collectors.toList());
|
||
|
||
// 批量保存
|
||
workOrderRepository.saveAll(orders);
|
||
}
|
||
|
||
// 2. 分页查询
|
||
public Page<WorkOrder> pageQuery(WorkOrderQueryRequest request) {
|
||
Pageable pageable = PageRequest.of(
|
||
request.getPageNum() - 1,
|
||
request.getPageSize(),
|
||
Sort.by("createdAt").descending()
|
||
);
|
||
|
||
return workOrderRepository.findAll(pageable);
|
||
}
|
||
|
||
// 3. 缓存使用
|
||
@Cacheable(value = "workOrder", key = "#id")
|
||
public WorkOrder getById(UUID id) {
|
||
return workOrderRepository.findById(id)
|
||
.orElseThrow(() -> new NotFoundException("工单不存在"));
|
||
}
|
||
|
||
@CacheEvict(value = "workOrder", key = "#id")
|
||
public void update(UUID id, WorkOrderUpdateRequest request) {
|
||
// 更新逻辑
|
||
}
|
||
```
|
||
|
||
### 8.2 异步处理
|
||
|
||
```java
|
||
// 异步方法
|
||
@Async("taskExecutor")
|
||
public void sendNotificationAsync(WorkOrder workOrder) {
|
||
notificationService.send(workOrder);
|
||
}
|
||
|
||
// 线程池配置
|
||
@Configuration
|
||
public class AsyncConfig {
|
||
|
||
@Bean("taskExecutor")
|
||
public Executor taskExecutor() {
|
||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||
executor.setCorePoolSize(5);
|
||
executor.setMaxPoolSize(10);
|
||
executor.setQueueCapacity(100);
|
||
executor.setThreadNamePrefix("async-");
|
||
executor.initialize();
|
||
return executor;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 8.3 服务稳定性配置
|
||
|
||
#### 8.3.1 数据库连接池配置
|
||
|
||
**必须配置 HikariCP 连接池参数**,避免连接泄漏和资源耗尽:
|
||
|
||
```yaml
|
||
spring:
|
||
datasource:
|
||
hikari:
|
||
minimum-idle: 2 # 最小空闲连接数
|
||
maximum-pool-size: 5 # 最大连接数(开发环境)
|
||
idle-timeout: 30000 # 空闲超时(毫秒)
|
||
pool-name: ServiceHikariPool # 连接池名称
|
||
max-lifetime: 1800000 # 连接最大生命周期(30分钟)
|
||
connection-timeout: 30000 # 连接超时(30秒)
|
||
connection-test-query: SELECT 1 # 连接测试查询
|
||
```
|
||
|
||
**连接池大小计算公式**:`connections = ((core_count * 2) + effective_spindle_count)`
|
||
|
||
| 环境 | maximum-pool-size | minimum-idle |
|
||
| -------- | ----------------- | ------------ |
|
||
| 开发环境 | 5 | 2 |
|
||
| 测试环境 | 10 | 5 |
|
||
| 生产环境 | 20 | 10 |
|
||
|
||
#### 8.3.2 健康检查配置
|
||
|
||
**必须配置 Actuator 健康端点**,支持服务监控:
|
||
|
||
```yaml
|
||
management:
|
||
endpoints:
|
||
web:
|
||
exposure:
|
||
include: health,info,metrics
|
||
endpoint:
|
||
health:
|
||
show-details: always
|
||
```
|
||
|
||
#### 8.3.3 日志配置
|
||
|
||
**必须配置文件日志输出**,便于问题排查:
|
||
|
||
```yaml
|
||
logging:
|
||
level:
|
||
root: INFO
|
||
com.ether: DEBUG
|
||
org.springframework.web: INFO
|
||
org.hibernate.SQL: WARN
|
||
file:
|
||
name: logs/ether-service.log
|
||
pattern:
|
||
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
|
||
```
|
||
|
||
#### 8.3.4 健康检查配置
|
||
|
||
---
|
||
|
||
## 九、代码质量规范
|
||
|
||
### 9.1 导入管理
|
||
|
||
**规则**: 禁止保留未使用的导入语句
|
||
|
||
**原因**: 未使用的导入会增加代码体积,降低代码可读性
|
||
|
||
**IDE配置**: 启用 "Optimize imports on save" 或提交前自动优化
|
||
|
||
```java
|
||
// 错误示例
|
||
import java.time.LocalDateTime; // 未使用
|
||
import java.util.List; // 未使用
|
||
import java.util.Map; // 未使用
|
||
|
||
public class UserService {
|
||
// ...
|
||
}
|
||
|
||
// 正确示例
|
||
public class UserService {
|
||
// ...
|
||
}
|
||
```
|
||
|
||
### 9.2 变量使用
|
||
|
||
**规则**: 禁止声明后未使用的局部变量和字段
|
||
|
||
**例外情况**:
|
||
- 测试代码中的预期结果验证
|
||
- 接口实现要求的必须参数
|
||
|
||
```java
|
||
// 错误示例
|
||
public void processOrder(UUID orderId) {
|
||
WorkOrder order = workOrderRepository.findById(orderId); // 未使用
|
||
// 缺少对 order 的处理
|
||
}
|
||
|
||
// 正确示例
|
||
public void processOrder(UUID orderId) {
|
||
WorkOrder order = workOrderRepository.findById(orderId);
|
||
order.setStatus(WorkOrderStatus.PROCESSING);
|
||
workOrderRepository.save(order);
|
||
}
|
||
```
|
||
|
||
### 9.3 泛型类型安全
|
||
|
||
**规则**: 禁止使用原始类型(Raw Type),必须指定泛型参数
|
||
|
||
```java
|
||
// 错误示例
|
||
Map result = new HashMap(); // 原始类型
|
||
List items = new ArrayList(); // 原始类型
|
||
|
||
// 正确示例
|
||
Map<String, Object> result = new HashMap<>();
|
||
List<WorkOrder> items = new ArrayList<>();
|
||
```
|
||
|
||
### 9.4 类型转换安全
|
||
|
||
**规则**: 使用泛型方法避免 unchecked cast 警告
|
||
|
||
```java
|
||
// 错误示例
|
||
Map<String, Object> result = (Map<String, Object>) objectMapper.readValue(json, Map.class);
|
||
|
||
// 正确示例
|
||
Map<String, Object> result = objectMapper.readValue(json, new TypeReference<Map<String, Object>>() {});
|
||
```
|
||
|
||
### 9.5 废弃API处理
|
||
|
||
**规则**: 禁止使用已废弃的API,使用推荐替代方案
|
||
|
||
```java
|
||
// 错误示例
|
||
BigDecimal result = amount.divide(100, BigDecimal.ROUND_HALF_UP); // 废弃
|
||
|
||
// 正确示例
|
||
BigDecimal result = amount.divide(100, RoundingMode.HALF_UP);
|
||
```
|
||
|
||
### 9.6 CSS兼容性
|
||
|
||
**规则**: 使用浏览器前缀时,同时定义标准属性
|
||
|
||
```css
|
||
/* 错误示例 */
|
||
.box {
|
||
-webkit-line-clamp: 3;
|
||
}
|
||
|
||
/* 正确示例 */
|
||
.box {
|
||
-webkit-line-clamp: 3;
|
||
line-clamp: 3;
|
||
}
|
||
```
|
||
|
||
### 9.7 IDE警告处理标准
|
||
|
||
| 警告类型 | 处理方式 | 优先级 |
|
||
|----------|----------|--------|
|
||
| 未使用的导入 | 立即删除 | P1 |
|
||
| 未使用的变量 | 删除或使用 | P1 |
|
||
| 原始类型 | 添加泛型参数 | P1 |
|
||
| 类型安全警告 | 使用泛型方法 | P2 |
|
||
| 废弃API | 替换为推荐方案 | P2 |
|
||
| CSS兼容性 | 添加标准属性 | P3 |
|
||
| TODO注释 | 记录到任务清单 | P3 |
|
||
|
||
---
|
||
|
||
## 十、文档维护
|
||
|
||
**维护原则**:
|
||
|
||
- 新增规范时在此文档补充
|
||
- 定期审查规范的适用性
|
||
- 所有开发人员必须遵守
|
||
|
||
## 十一、开发经验总结
|
||
|
||
### 11.1 多模块项目构建
|
||
|
||
| 场景 | 操作 | 命令 |
|
||
|------|------|------|
|
||
| 修改子模块代码后 | 重新编译子模块 | `mvn install -pl module-auth -am -DskipTests` |
|
||
| 整个项目重新构建 | 清理并编译 | `mvn clean compile` |
|
||
| 启动时确保模块加载 | 先安装再启动 | `mvn install -DskipTests` 后 `mvn spring-boot:run` |
|
||
|
||
**教训**: 子模块代码修改后必须重新 `mvn install`,否则启动类不会加载新代码。
|
||
|
||
### 11.2 Spring Security 配置
|
||
|
||
**问题**: Spring Boot 自动配置与自定义配置冲突
|
||
|
||
**解决**: 在 `application.yml` 中排除自动配置
|
||
|
||
```yaml
|
||
spring:
|
||
autoconfigure:
|
||
exclude:
|
||
- org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration
|
||
```
|
||
|
||
**教训**: 前后端分离项目必须禁用 CSRF,使用 JWT 认证。
|
||
|
||
### 11.3 测试账号管理
|
||
|
||
| 要求 | 说明 |
|
||
|------|------|
|
||
| 密码格式 | BCrypt 加密格式 |
|
||
| 文档同步 | 修改密码后必须更新 `docs/08-DATABASE/test-users.sql` |
|
||
| 生成密码 | 使用 `BCryptPasswordEncoder` 生成 |
|
||
|
||
```java
|
||
// 生成 BCrypt 密码
|
||
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
|
||
String encoded = encoder.encode("Admin@123");
|
||
```
|
||
|
||
### 11.4 前端调试技巧
|
||
|
||
| 问题 | 解决 |
|
||
|------|------|
|
||
| 热更新不生效 | 重启前端服务 |
|
||
| 路由跳转失败 | 检查路由守卫 `isLoggedIn()` 实现 |
|
||
| 登录状态判断错误 | 优先读取 `localStorage` 而非 store 状态 |
|
||
|
||
```typescript
|
||
// 正确示例:isLoggedIn 同时检查 localStorage 和 store
|
||
const isLoggedIn = () => {
|
||
return !!localStorage.getItem('token') || !!token.value
|
||
}
|
||
```
|
||
|
||
### 11.5 代码优化最佳实践
|
||
|
||
**调试代码清理规范**:
|
||
|
||
| 场景 | 操作 |
|
||
|------|------|
|
||
| 上线前 | 移除所有 `console.log`、`console.error` |
|
||
| 上线前 | 移除所有 `log.info` 调试日志 |
|
||
| 生产环境 | 敏感日志改为 `log.debug` |
|
||
| 上线前 | 移除路由守卫中的调试日志 |
|
||
|
||
**代码示例**:
|
||
|
||
```java
|
||
// ❌ 错误:生产环境使用 info 级别
|
||
log.info("Authenticated user: {}", username);
|
||
|
||
// ✅ 正确:认证日志使用 debug 级别
|
||
log.debug("Authenticated user: {}", username);
|
||
```
|
||
|
||
```typescript
|
||
// ❌ 错误:保留调试代码
|
||
console.log('准备跳转...')
|
||
router.push('/').then(() => {
|
||
console.log('跳转完成', router.currentRoute.value.path)
|
||
})
|
||
|
||
// ✅ 正确:简洁的跳转逻辑
|
||
router.push('/')
|
||
```
|
||
|
||
### 11.6 快速测试命令
|
||
|
||
```bash
|
||
# 后端启动
|
||
cd ether-pms/pms-starter && mvn spring-boot:run
|
||
|
||
# 前端启动
|
||
cd ether-admin && npm run dev
|
||
|
||
# 手动测试登录
|
||
curl -X POST http://localhost:8080/api/auth/login \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"username":"admin","password":"Admin@123"}'
|
||
|
||
# 重启前端
|
||
lsof -i :5175 | grep LISTEN | awk '{print $2}' | xargs kill -9
|
||
npm run dev
|
||
|
||
# 重启后端(修改子模块后)
|
||
cd ether-pms && mvn install -pl module-auth -am -DskipTests
|
||
cd ether-pms/pms-starter && mvn spring-boot:run
|
||
```
|
||
|
||
### 11.7 问题排查清单
|
||
|
||
当遇到问题时,按以下顺序排查:
|
||
|
||
1. **后端模块是否加载**? 检查日志中是否有自定义配置类的输出
|
||
2. **Security 配置是否生效**? 检查日志 `SecurityFilterChain` 是自定义还是默认
|
||
3. **CSRF 是否拦截请求**? 查看日志 `Invalid CSRF token`
|
||
4. **前端 token 是否保存**? 检查 `localStorage.getItem('token')`
|
||
5. **路由守卫是否正确判断登录状态**? 添加调试日志
|
||
|
||
**更新记录**:
|
||
|
||
| 版本 | 日期 | 更新内容 |
|
||
| ---- | ---------- | ------------------------------- |
|
||
| v1.0 | 2026-02-10 | 初始版本 |
|
||
| v1.1 | 2026-02-13 | 新增 E2E 测试规范(6.3节) |
|
||
| v1.2 | 2026-02-13 | 新增服务稳定性配置规范(8.3节) |
|
||
| v1.3 | 2026-02-13 | 新增跨业务数据访问规范(8.4节) |
|
||
| v1.4 | 2026-03-19 | 新增开发经验总结(11节) |
|
||
| v1.5 | 2026-03-20 | 新增代码优化最佳实践(11.5节) |
|