feat: 增强输入验证和安全防护

- 增强前端表单验证规则(用户名、密码、邮箱、手机号)
- 增强后端DTO验证注解(用户注册、角色创建)
- 添加后端Handler验证逻辑(用户创建、角色创建)
- 调整测试用例以适应系统实际情况
- 添加UAT测试套件(用户管理、角色管理、菜单管理、API交互、数据持久化、边界条件、安全测试)
- 修改远程分支为 https://git.f.novalon.cn/novalon/novalon-manage-system.git
This commit is contained in:
张翔
2026-03-27 21:31:30 +08:00
parent a05368d306
commit 24422c2c19
31 changed files with 1205 additions and 139 deletions
+89 -1
View File
@@ -23,8 +23,9 @@ novalon-manage-system/
## 技术栈
### 后端
- Java 21
- Spring Boot 3.5.12
- Spring Boot 3.5.13
- Spring Cloud Gateway
- Spring Security + JWT
- R2DBC (响应式数据库访问)
@@ -32,6 +33,7 @@ novalon-manage-system/
- Flyway (数据库迁移)
### 前端
- Vue 3 + TypeScript
- Element Plus
- Pinia (状态管理)
@@ -45,28 +47,33 @@ novalon-manage-system/
使用 Docker Compose 可以一键启动所有服务,包括数据库、后端和前端。
#### 前置要求
- Docker 20.10+
- Docker Compose 2.0+
#### 启动步骤
1. **克隆项目**
```bash
git clone <repository-url>
cd novalon-manage-system
```
2. **启动所有服务**
```bash
docker-compose up -d
```
3. **查看服务状态**
```bash
docker-compose ps
```
4. **查看日志**
```bash
# 查看所有服务日志
docker-compose logs -f
@@ -78,17 +85,20 @@ docker-compose logs -f frontend
```
5. **访问应用**
- 前端应用: http://localhost:3001
- 后端 API: http://localhost:8084
- API 文档: http://localhost:8084/swagger-ui.html
- 健康检查: http://localhost:8084/actuator/health
#### 停止服务
```bash
docker-compose down
```
#### 清理数据(包括数据库数据)
```bash
docker-compose down -v
```
@@ -98,6 +108,7 @@ docker-compose down -v
#### 1. 环境准备要求
##### 必需软件
- **Java**: JDK 21 或更高版本
- **Maven**: 3.8+ (用于后端构建)
- **Node.js**: 18+ (用于前端构建)
@@ -106,10 +117,12 @@ docker-compose down -v
- **Git**: 版本控制
##### 可选软件
- **Docker**: 用于容器化部署
- **IDE**: IntelliJ IDEA (推荐) 或 VS Code
##### 系统要求
- **操作系统**: macOS, Linux, Windows
- **内存**: 最低 4GB,推荐 8GB+
- **磁盘空间**: 最低 2GB 可用空间
@@ -119,6 +132,7 @@ docker-compose down -v
##### 2.1 安装 Java 和 Maven
**macOS (使用 Homebrew)**:
```bash
brew install openjdk@21
brew install maven
@@ -134,6 +148,7 @@ mvn -version
```
**Linux (Ubuntu/Debian)**:
```bash
# 安装 OpenJDK 21
sudo apt update
@@ -148,6 +163,7 @@ mvn -version
```
**Windows**:
1. 下载并安装 JDK 21: https://adoptium.net/
2. 下载并安装 Maven: https://maven.apache.org/download.cgi
3. 设置环境变量:
@@ -158,6 +174,7 @@ mvn -version
##### 2.2 安装 Node.js 和 pnpm
**使用 nvm (推荐)**:
```bash
# 安装 nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
@@ -178,14 +195,17 @@ pnpm -v
```
**macOS (使用 Homebrew)**:
```bash
brew install node
npm install -g pnpm
```
**Windows**:
1. 下载并安装 Node.js: https://nodejs.org/
2. 安装 pnpm:
```powershell
npm install -g pnpm
```
@@ -193,6 +213,7 @@ npm install -g pnpm
##### 2.3 安装 PostgreSQL
**macOS (使用 Homebrew)**:
```bash
brew install postgresql@15
brew services start postgresql@15
@@ -202,6 +223,7 @@ psql postgres
```
在 psql 中执行:
```sql
CREATE DATABASE manage_system;
CREATE USER novalon WITH PASSWORD 'novalon123';
@@ -210,6 +232,7 @@ GRANT ALL PRIVILEGES ON DATABASE manage_system TO novalon;
```
**Linux (Ubuntu/Debian)**:
```bash
sudo apt install postgresql-15 postgresql-contrib-15
sudo systemctl start postgresql
@@ -219,6 +242,7 @@ sudo -u postgres psql
```
在 psql 中执行:
```sql
CREATE DATABASE manage_system;
CREATE USER novalon WITH PASSWORD 'novalon123';
@@ -227,12 +251,14 @@ GRANT ALL PRIVILEGES ON DATABASE manage_system TO novalon;
```
**Windows**:
1. 下载并安装 PostgreSQL: https://www.postgresql.org/download/windows/
2. 使用 pgAdmin 创建数据库和用户,或使用命令行工具
##### 2.4 验证环境
创建并运行环境检查脚本:
```bash
# 检查 Java
java -version
@@ -253,6 +279,7 @@ psql --version
后端使用 Flyway 自动管理数据库迁移,数据库表结构会在首次启动时自动创建。
**开发环境配置** (`novalon-manage-api/manage-app/src/main/resources/application-dev.yml`):
```yaml
spring:
r2dbc:
@@ -264,6 +291,7 @@ spring:
```
**生产环境配置** (`novalon-manage-api/manage-app/src/main/resources/application-prod.yml`):
```yaml
spring:
r2dbc:
@@ -307,6 +335,7 @@ psql -U novalon -d manage_system -c "\dt"
##### 4.1 网关服务概述
`manage-gateway` 是系统的 API 网关,负责:
- 请求路由和转发
- JWT 认证过滤
- RBAC 权限控制
@@ -316,6 +345,7 @@ psql -U novalon -d manage_system -c "\dt"
##### 4.2 网关配置文件
**主配置** (`novalon-manage-api/manage-gateway/src/main/resources/application.yml`):
```yaml
server:
port: 8080
@@ -372,6 +402,7 @@ logging:
网关将所有 `/api/**` 路径的请求转发到 `manage-app` 服务 (端口 8084)。
**路由规则**:
- 所有以 `/api/` 开头的请求都会被转发到后端服务
- 请求会经过 JWT 认证和 RBAC 权限验证
- 失败的请求会自动重试(最多 3 次)
@@ -379,10 +410,12 @@ logging:
##### 4.4 JWT 配置
**环境变量**:
- `JWT_SECRET`: JWT 密钥(生产环境必须设置强密钥)
- `JWT_EXPIRATION`: Token 过期时间(毫秒,默认 24 小时)
**示例**:
```bash
export JWT_SECRET="your-strong-secret-key-here"
export JWT_EXPIRATION="86400000"
@@ -406,16 +439,19 @@ curl http://localhost:8080/actuator/metrics
##### 5.1 启动后端服务
**步骤 1: 进入后端项目目录**
```bash
cd novalon-manage-api
```
**步骤 2: 编译项目**
```bash
mvn clean install -DskipTests
```
**步骤 3: 启动网关服务**
```bash
cd manage-gateway
mvn spring-boot:run
@@ -425,6 +461,7 @@ mvn spring-boot:run
**步骤 4: 启动主应用服务**
打开新的终端窗口:
```bash
cd novalon-manage-api/manage-app
mvn spring-boot:run
@@ -433,6 +470,7 @@ mvn spring-boot:run
主应用将在 `http://localhost:8084` 启动。
**步骤 5: 验证后端服务**
```bash
# 检查网关健康状态
curl http://localhost:8080/actuator/health
@@ -447,11 +485,13 @@ open http://localhost:8084/swagger-ui.html
##### 5.2 启动前端服务
**步骤 1: 进入前端项目目录**
```bash
cd novalon-manage-web
```
**步骤 2: 安装依赖**
```bash
pnpm install
```
@@ -459,12 +499,14 @@ pnpm install
**步骤 3: 配置环境变量**
创建 `.env.local` 文件(如果不存在):
```env
VITE_API_BASE_URL=http://localhost:8080
VITE_APP_TITLE=Novalon管理系统
```
**步骤 4: 启动开发服务器**
```bash
pnpm dev
```
@@ -479,6 +521,7 @@ pnpm dev
##### 6.1 环境配置文件
后端支持多环境配置:
- `application.yml`: 主配置文件
- `application-dev.yml`: 开发环境配置
- `application-test.yml`: 测试环境配置
@@ -488,18 +531,21 @@ pnpm dev
##### 6.2 开发环境启动
**后端**:
```bash
cd novalon-manage-api/manage-app
mvn spring-boot:run -Dspring-boot.run.profiles=dev
```
**前端**:
```bash
cd novalon-manage-web
pnpm dev
```
**特点**:
- 使用本地数据库 (localhost:55432)
- DEBUG 日志级别
- 热重载启用
@@ -508,18 +554,21 @@ pnpm dev
##### 6.3 测试环境启动
**后端**:
```bash
cd novalon-manage-api/manage-app
mvn spring-boot:run -Dspring-boot.run.profiles=test
```
**前端**:
```bash
cd novalon-manage-web
pnpm dev:test
```
**特点**:
- 使用测试数据库
- INFO 日志级别
- 性能监控启用
@@ -528,6 +577,7 @@ pnpm dev:test
##### 6.4 生产环境启动
**后端**:
```bash
# 设置环境变量
export DB_USERNAME=your_prod_db_user
@@ -540,18 +590,21 @@ mvn spring-boot:run -Dspring-boot.run.profiles=prod
```
**前端构建**:
```bash
cd novalon-manage-web
pnpm build:prod
```
**前端部署**:
```bash
# 使用 nginx 或其他静态文件服务器部署 dist 目录
pnpm preview
```
**特点**:
- 使用生产数据库
- INFO/WARN 日志级别
- 性能优化
@@ -561,6 +614,7 @@ pnpm preview
##### 6.5 Docker 环境启动
**使用 docker-compose**:
```bash
# 开发环境
docker-compose -f docker-compose.yml up -d
@@ -570,6 +624,7 @@ docker-compose -f docker-compose.test.yml up -d
```
**特点**:
- 容器化部署
- 服务编排
- 健康检查
@@ -580,11 +635,13 @@ docker-compose -f docker-compose.test.yml up -d
##### 7.1 端口冲突问题
**症状**:
```
Port 8080 was already in use
```
**解决方案**:
```bash
# 查找占用端口的进程
lsof -i :8080 # macOS/Linux
@@ -601,11 +658,13 @@ taskkill /PID <PID> /F # Windows
##### 7.2 数据库连接失败
**症状**:
```
Connection refused: localhost:55432
```
**解决方案**:
```bash
# 检查 PostgreSQL 服务状态
brew services list | grep postgresql # macOS
@@ -625,11 +684,13 @@ sudo ufw allow 5432 # Linux
##### 7.3 Maven 依赖下载失败
**症状**:
```
Could not resolve dependencies
```
**解决方案**:
```bash
# 清理 Maven 缓存
rm -rf ~/.m2/repository
@@ -645,11 +706,13 @@ ping repo.maven.apache.org
##### 7.4 前端依赖安装失败
**症状**:
```
npm ERR! network request failed
```
**解决方案**:
```bash
# 清理缓存
pnpm store prune
@@ -665,12 +728,14 @@ pnpm install
##### 7.5 JWT 认证失败
**症状**:
```
401 Unauthorized
Invalid JWT token
```
**解决方案**:
```bash
# 检查 JWT_SECRET 配置
echo $JWT_SECRET
@@ -685,11 +750,13 @@ echo $JWT_SECRET
##### 7.6 Flyway 迁移失败
**症状**:
```
FlywayException: Validate failed
```
**解决方案**:
```bash
# 查看迁移历史
psql -U novalon -d manage_system -c "SELECT * FROM flyway_schema_history;"
@@ -709,12 +776,14 @@ DELETE FROM flyway_schema_history WHERE success = false;
##### 7.7 内存不足错误
**症状**:
```
Java heap space
OutOfMemoryError
```
**解决方案**:
```bash
# 增加 JVM 内存
export MAVEN_OPTS="-Xmx2g -Xms1g"
@@ -732,11 +801,13 @@ export MAVEN_OPTS="-Xmx2g -Xms1g"
##### 7.8 CORS 跨域问题
**症状**:
```
Access to XMLHttpRequest blocked by CORS policy
```
**解决方案**:
```bash
# 检查网关 CORS 配置
# 在 application.yml 中添加:
@@ -760,6 +831,7 @@ spring:
##### 7.9 日志查看和调试
**查看应用日志**:
```bash
# 后端日志
tail -f novalon-manage-api/manage-app/logs/application.log
@@ -773,6 +845,7 @@ docker-compose logs -f gateway
```
**启用 DEBUG 日志**:
```yaml
# 在 application.yml 中设置
logging:
@@ -787,6 +860,7 @@ logging:
##### 8.1 后端服务验证
**健康检查**:
```bash
# 网关健康检查
curl http://localhost:8080/actuator/health
@@ -799,6 +873,7 @@ curl http://localhost:8084/actuator/health
```
**API 文档访问**:
```bash
# 在浏览器中打开
open http://localhost:8084/swagger-ui.html
@@ -808,6 +883,7 @@ curl http://localhost:8084/swagger-ui.html
```
**数据库连接验证**:
```bash
# 检查数据库表是否创建成功
psql -U novalon -d manage_system -c "\dt"
@@ -817,6 +893,7 @@ psql -U novalon -d manage_system -c "\dt"
```
**API 端点测试**:
```bash
# 测试登录接口
curl -X POST http://localhost:8080/api/auth/login \
@@ -830,12 +907,14 @@ curl -X POST http://localhost:8080/api/auth/login \
##### 8.2 前端应用验证
**应用访问**:
```bash
# 在浏览器中打开
open http://localhost:5173
```
**功能验证清单**:
- [ ] 登录页面正常显示
- [ ] 能够成功登录(使用默认账号 admin/admin123
- [ ] 主页面正常加载
@@ -845,6 +924,7 @@ open http://localhost:5173
- [ ] 系统配置功能可用
**浏览器控制台检查**:
```javascript
// 打开浏览器开发者工具 (F12)
// 检查 Console 标签页,确保没有错误信息
@@ -854,6 +934,7 @@ open http://localhost:5173
##### 8.3 集成测试验证
**运行 API 集成测试**:
```bash
cd api_integration_tests
pip install -r requirements.txt
@@ -861,6 +942,7 @@ pytest tests/ -v
```
**运行 E2E 测试**:
```bash
cd novalon-manage-web
pnpm test:e2e
@@ -869,6 +951,7 @@ pnpm test:e2e
##### 8.4 性能验证
**后端性能测试**:
```bash
# 使用 k6 进行性能测试
cd novalon-manage-api/manage-sys/src/test/k6
@@ -876,6 +959,7 @@ k6 run performance-test.js
```
**前端性能测试**:
```bash
cd novalon-manage-web
pnpm test:perf
@@ -884,6 +968,7 @@ pnpm test:perf
##### 8.5 监控和日志
**查看应用指标**:
```bash
# 查看应用指标
curl http://localhost:8084/actuator/metrics
@@ -893,6 +978,7 @@ curl http://localhost:8084/actuator/metrics/jvm.memory.used
```
**查看日志**:
```bash
# 查看应用日志
tail -f novalon-manage-api/manage-app/logs/application.log
@@ -904,6 +990,7 @@ grep ERROR novalon-manage-api/manage-app/logs/application.log
##### 8.6 完整验证脚本
创建验证脚本 `verify-setup.sh`:
```bash
#!/bin/bash
@@ -948,6 +1035,7 @@ echo "=== 所有服务验证通过 ==="
```
运行验证脚本:
```bash
chmod +x verify-setup.sh
./verify-setup.sh
@@ -28,13 +28,12 @@ import java.util.concurrent.ConcurrentHashMap;
public class AuditLogService {
private static final Logger auditLogger = LoggerFactory.getLogger("AUDIT_LOG");
private static final Logger logger = LoggerFactory.getLogger(AuditLogService.class);
private final Map<String, AuditEntry> auditEntries = new ConcurrentHashMap<>();
public void logRequest(ServerHttpRequest request, String userId) {
String requestId = generateRequestId(request);
AuditEntry entry = new AuditEntry();
entry.setRequestId(requestId);
entry.setMethod(request.getMethod().name());
@@ -47,27 +46,27 @@ public class AuditLogService {
auditEntries.put(requestId, entry);
auditLogger.info("[REQUEST] {} {} - User: {}, IP: {}, RequestId: {}",
entry.getMethod(),
entry.getPath(),
entry.getUserId(),
entry.getClientIp(),
auditLogger.info("[REQUEST] {} {} - User: {}, IP: {}, RequestId: {}",
entry.getMethod(),
entry.getPath(),
entry.getUserId(),
entry.getClientIp(),
entry.getRequestId());
}
public void logResponse(String requestId, int statusCode, long durationMs) {
AuditEntry entry = auditEntries.get(requestId);
if (entry != null) {
entry.setStatusCode(statusCode);
entry.setEndTime(Instant.now());
entry.setDurationMs(durationMs);
auditLogger.info("[RESPONSE] {} {} - Status: {}, Duration: {}ms, RequestId: {}",
entry.getMethod(),
entry.getPath(),
entry.getStatusCode(),
entry.getDurationMs(),
auditLogger.info("[RESPONSE] {} {} - Status: {}, Duration: {}ms, RequestId: {}",
entry.getMethod(),
entry.getPath(),
entry.getStatusCode(),
entry.getDurationMs(),
entry.getRequestId());
auditEntries.remove(requestId);
@@ -76,73 +75,72 @@ public class AuditLogService {
public void logSecurityEvent(String requestId, String eventType, String details) {
AuditEntry entry = auditEntries.get(requestId);
if (entry != null) {
auditLogger.warn("[SECURITY] {} - Event: {}, Details: {}, User: {}, IP: {}, RequestId: {}",
entry.getPath(),
eventType,
details,
entry.getUserId(),
entry.getClientIp(),
auditLogger.warn("[SECURITY] {} - Event: {}, Details: {}, User: {}, IP: {}, RequestId: {}",
entry.getPath(),
eventType,
details,
entry.getUserId(),
entry.getClientIp(),
entry.getRequestId());
} else {
auditLogger.warn("[SECURITY] Event: {}, Details: {}, RequestId: {}",
eventType,
details,
auditLogger.warn("[SECURITY] Event: {}, Details: {}, RequestId: {}",
eventType,
details,
requestId);
}
}
public void logError(String requestId, String errorType, String errorMessage) {
AuditEntry entry = auditEntries.get(requestId);
if (entry != null) {
auditLogger.error("[ERROR] {} {} - Error: {}, Message: {}, User: {}, IP: {}, RequestId: {}",
entry.getMethod(),
entry.getPath(),
errorType,
errorMessage,
entry.getUserId(),
entry.getClientIp(),
auditLogger.error("[ERROR] {} {} - Error: {}, Message: {}, User: {}, IP: {}, RequestId: {}",
entry.getMethod(),
entry.getPath(),
errorType,
errorMessage,
entry.getUserId(),
entry.getClientIp(),
entry.getRequestId());
} else {
auditLogger.error("[ERROR] Error: {}, Message: {}, RequestId: {}",
errorType,
errorMessage,
auditLogger.error("[ERROR] Error: {}, Message: {}, RequestId: {}",
errorType,
errorMessage,
requestId);
}
}
private String generateRequestId(ServerHttpRequest request) {
String requestId = request.getHeaders().getFirst("X-Request-Id");
if (requestId == null || requestId.isEmpty()) {
requestId = String.format("%s-%d-%s",
requestId = String.format("%s-%d-%s",
request.getMethod().name().toLowerCase(),
System.currentTimeMillis(),
Integer.toHexString(request.hashCode()));
}
return requestId;
}
private String getClientIp(ServerHttpRequest request) {
String ip = request.getHeaders().getFirst("X-Forwarded-For");
if (ip == null || ip.isEmpty()) {
ip = request.getHeaders().getFirst("X-Real-IP");
}
if (ip == null || ip.isEmpty()) {
ip = request.getRemoteAddress() != null ?
request.getRemoteAddress().getAddress().getHostAddress() :
"unknown";
ip = request.getRemoteAddress() != null ? request.getRemoteAddress().getAddress().getHostAddress()
: "unknown";
}
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
}
@@ -183,10 +181,6 @@ public class AuditLogService {
this.path = path;
}
public String getQuery() {
return query;
}
public void setQuery(String query) {
this.query = query;
}
@@ -207,26 +201,14 @@ public class AuditLogService {
this.clientIp = clientIp;
}
public String getUserAgent() {
return userAgent;
}
public void setUserAgent(String userAgent) {
this.userAgent = userAgent;
}
public Instant getStartTime() {
return startTime;
}
public void setStartTime(Instant startTime) {
this.startTime = startTime;
}
public Instant getEndTime() {
return endTime;
}
public void setEndTime(Instant endTime) {
this.endTime = endTime;
}
@@ -2,7 +2,6 @@ package cn.novalon.manage.gateway.discovery;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.client.DefaultServiceInstance;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.discovery.DiscoveryClient;
import org.springframework.cloud.client.discovery.ReactiveDiscoveryClient;
@@ -7,7 +7,6 @@ import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
@@ -53,8 +52,6 @@ public class CompressionFilter implements GlobalFilter, Ordered {
"application/xml"
);
private static final int MIN_COMPRESS_SIZE = 1024;
private boolean compressionEnabled = true;
@Override
@@ -2,7 +2,6 @@ package cn.novalon.manage.gateway.filter;
import cn.novalon.manage.gateway.config.RateLimitConfig;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -40,9 +40,6 @@ import java.util.List;
public class SignatureFilter implements GlobalFilter, Ordered {
private static final Logger logger = LoggerFactory.getLogger(SignatureFilter.class);
private static final String SIGNATURE_HEADER = "X-Signature";
private static final String TIMESTAMP_HEADER = "X-Timestamp";
private static final String NONCE_HEADER = "X-Nonce";
private final SignatureService signatureService;
@@ -2,9 +2,7 @@ package cn.novalon.manage.gateway.health;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
@@ -32,7 +30,6 @@ public class GatewayHealthIndicator implements HealthIndicator {
private final CircuitBreakerRegistry circuitBreakerRegistry;
private final RateLimiterRegistry rateLimiterRegistry;
@Autowired
public GatewayHealthIndicator(
CircuitBreakerRegistry circuitBreakerRegistry,
RateLimiterRegistry rateLimiterRegistry) {
@@ -14,11 +14,9 @@ import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.spec.KeySpec;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
@@ -10,7 +10,6 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
@Component
@@ -5,13 +5,11 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpMethod;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import java.net.InetSocketAddress;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* AuditLogService单元测试
@@ -3,15 +3,11 @@ package cn.novalon.manage.gateway.cache;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpMethod;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import reactor.test.StepVerifier;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(MockitoExtension.class)
@@ -6,11 +6,8 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.http.server.reactive.MockServerHttpResponse;
import org.springframework.mock.web.server.MockServerWebExchange;
import reactor.core.publisher.Mono;
@@ -1,6 +1,5 @@
package cn.novalon.manage.gateway.health;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import io.github.resilience4j.ratelimiter.RateLimiterConfig;
@@ -1,9 +1,6 @@
package cn.novalon.manage.gateway.integration;
import cn.novalon.manage.gateway.filter.RbacAuthorizationFilter;
import cn.novalon.manage.gateway.model.Permission;
import cn.novalon.manage.gateway.model.Role;
import cn.novalon.manage.gateway.model.User;
import cn.novalon.manage.gateway.service.PermissionService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -18,11 +15,6 @@ import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.when;
@@ -1,6 +1,5 @@
package cn.novalon.manage.gateway.metrics;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.BeforeEach;
@@ -14,10 +14,6 @@ import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@@ -1,6 +1,5 @@
package cn.novalon.manage.gateway.service.impl;
import cn.novalon.manage.gateway.service.JwtKeyService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -9,7 +8,6 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import javax.crypto.SecretKey;
import java.lang.reflect.Field;
import static org.junit.jupiter.api.Assertions.*;
@@ -96,7 +94,6 @@ class JwtKeyServiceImplTest {
void testRotateKey_CreatesNewVersion() {
jwtKeyService.initializeKeys();
String oldVersion = jwtKeyService.getCurrentKeyVersion();
SecretKey oldKey = jwtKeyService.getCurrentSigningKey();
jwtKeyService.rotateKey();
@@ -10,13 +10,9 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClient.RequestHeadersUriSpec;
import org.springframework.web.reactive.function.client.WebClient.RequestHeadersSpec;
import org.springframework.web.reactive.function.client.WebClient.ResponseSpec;
import reactor.core.publisher.Mono;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@@ -36,13 +32,13 @@ class PermissionServiceImplTest {
private WebClient webClient;
@Mock
private RequestHeadersUriSpec requestHeadersUriSpec;
private WebClient.RequestHeadersUriSpec<?> requestHeadersUriSpec;
@Mock
private RequestHeadersSpec requestHeadersSpec;
private WebClient.RequestHeadersSpec<?> requestHeadersSpec;
@Mock
private ResponseSpec responseSpec;
private WebClient.ResponseSpec responseSpec;
private PermissionService permissionService;
@@ -1,7 +1,11 @@
package cn.novalon.manage.sys.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
/**
* 角色创建请求DTO
@@ -13,17 +17,26 @@ import jakarta.validation.constraints.NotNull;
* @author 张翔
* @date 2026-03-13
*/
@Schema(description = "角色创建请求")
public class RoleCreateRequest {
@Schema(description = "角色名称", example = "管理员")
@NotBlank(message = "角色名称不能为空")
@Size(min = 2, max = 50, message = "角色名称长度必须在2-50之间")
private String roleName;
@Schema(description = "角色权限字符串", example = "admin")
@NotBlank(message = "角色权限字符串不能为空")
@Size(min = 2, max = 50, message = "角色权限字符串长度必须在2-50之间")
@Pattern(regexp = "^[a-zA-Z0-9_-]+$", message = "角色权限字符串只能包含字母、数字、下划线和横线")
private String roleKey;
@Schema(description = "显示顺序", example = "1")
@NotNull(message = "显示顺序不能为空")
@Min(value = 1, message = "显示顺序必须大于0")
private Integer roleSort;
@Schema(description = "状态", example = "1")
private Integer status;
public String getRoleName() {
@@ -3,6 +3,7 @@ package cn.novalon.manage.sys.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
/**
@@ -17,23 +18,28 @@ public class UserRegisterRequest {
@Schema(description = "用户名", example = "testuser")
@NotBlank(message = "用户名不能为空")
@Size(min = 3, max = 50, message = "用户名长度必须在3-50之间")
@Pattern(regexp = "^[a-zA-Z0-9_-]+$", message = "用户名只能包含字母、数字、下划线和横线")
private String username;
@Schema(description = "昵称", example = "测试用户")
@Size(max = 100, message = "昵称长度不能超过100")
private String nickname;
@Schema(description = "密码", example = "123456")
@Schema(description = "密码", example = "Admin123")
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 100, message = "密码长度必须在6-100之间")
@Size(min = 8, max = 20, message = "密码长度必须在8-20之间")
@Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$", message = "密码必须包含大小写字母和数字")
private String password;
@Schema(description = "邮箱", example = "test@example.com")
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
@Size(max = 100, message = "邮箱长度不能超过100")
private String email;
@Schema(description = "手机号", example = "13800138000")
@Size(max = 20, message = "手机号长度不能超过20")
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
public String getUsername() {
@@ -9,12 +9,16 @@ import cn.novalon.manage.sys.core.command.CreateRoleCommand;
import cn.novalon.manage.sys.core.command.UpdateRoleCommand;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Validator;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.util.HashMap;
import java.util.Map;
/**
* 系统角色处理器
*
@@ -26,9 +30,11 @@ import reactor.core.publisher.Mono;
public class SysRoleHandler {
private final ISysRoleService roleService;
private final Validator validator;
public SysRoleHandler(ISysRoleService roleService) {
public SysRoleHandler(ISysRoleService roleService, Validator validator) {
this.roleService = roleService;
this.validator = validator;
}
@Operation(summary = "获取所有角色", description = "获取系统中所有角色列表")
@@ -88,14 +94,23 @@ public class SysRoleHandler {
@Operation(summary = "创建角色", description = "创建新角色")
public Mono<ServerResponse> createRole(ServerRequest request) {
return request.bodyToMono(RoleCreateRequest.class)
.map(req -> CreateRoleCommand.of(
req.getRoleName(),
req.getRoleKey(),
req.getRoleSort(),
req.getStatus()
))
.flatMap(roleService::createRole)
.flatMap(role -> ServerResponse.status(HttpStatus.CREATED).bodyValue(role));
.flatMap(req -> {
var violations = validator.validate(req);
if (!violations.isEmpty()) {
Map<String, String> errors = new HashMap<>();
violations.forEach(v -> errors.put(v.getPropertyPath().toString(), v.getMessage()));
return ServerResponse.badRequest().bodyValue(errors);
}
return Mono.just(CreateRoleCommand.of(
req.getRoleName(),
req.getRoleKey(),
req.getRoleSort(),
req.getStatus()
))
.flatMap(roleService::createRole)
.flatMap(role -> ServerResponse.status(HttpStatus.CREATED).bodyValue(role));
});
}
@Operation(summary = "更新角色", description = "更新角色信息")
@@ -10,6 +10,7 @@ import cn.novalon.manage.sys.core.command.CreateUserCommand;
import cn.novalon.manage.sys.core.command.UpdateUserCommand;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Validator;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
@@ -19,7 +20,7 @@ import reactor.core.publisher.Mono;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 用户处理器
@@ -36,9 +37,11 @@ import java.util.Map;
public class SysUserHandler {
private final ISysUserService userService;
private final Validator validator;
public SysUserHandler(ISysUserService userService) {
public SysUserHandler(ISysUserService userService, Validator validator) {
this.userService = userService;
this.validator = validator;
}
@Operation(summary = "获取所有用户", description = "获取系统中所有用户列表")
@@ -110,17 +113,26 @@ public class SysUserHandler {
@Operation(summary = "创建用户", description = "创建新用户")
public Mono<ServerResponse> createUser(ServerRequest request) {
return request.bodyToMono(UserRegisterRequest.class)
.map(req -> CreateUserCommand.of(
req.getUsername(),
req.getPassword(),
req.getEmail(),
req.getNickname(),
req.getPhone(),
null,
null
))
.flatMap(userService::createUser)
.flatMap(user -> ServerResponse.status(HttpStatus.CREATED).bodyValue(user));
.flatMap(req -> {
var violations = validator.validate(req);
if (!violations.isEmpty()) {
Map<String, String> errors = new HashMap<>();
violations.forEach(v -> errors.put(v.getPropertyPath().toString(), v.getMessage()));
return ServerResponse.badRequest().bodyValue(errors);
}
return Mono.just(CreateUserCommand.of(
req.getUsername(),
req.getPassword(),
req.getEmail(),
req.getNickname(),
req.getPhone(),
null,
null
))
.flatMap(userService::createUser)
.flatMap(user -> ServerResponse.status(HttpStatus.CREATED).bodyValue(user));
});
}
@Operation(summary = "更新用户", description = "更新用户信息")
+2 -2
View File
@@ -7,7 +7,7 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.12</version>
<version>3.5.13</version>
<relativePath />
</parent>
@@ -24,7 +24,7 @@
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-boot.version>3.5.12</spring-boot.version>
<spring-boot.version>3.5.13</spring-boot.version>
<spring-cloud.version>2025.0.0</spring-cloud.version>
<lombok.version>1.18.30</lombok.version>
<resilience4j.version>2.2.0</resilience4j.version>
@@ -0,0 +1,78 @@
import { test, expect } from '@playwright/test';
test.describe('UAT阶段二:用户管理功能验证', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
const usernameInput = page.locator('input[type="text"]').first();
const passwordInput = page.locator('input[type="password"]').first();
const loginButton = page.locator('button:has-text("登录")');
await usernameInput.fill('admin');
await passwordInput.fill('admin123');
await loginButton.click();
await page.waitForURL('**/dashboard', { timeout: 30000 });
await page.waitForLoadState('networkidle');
});
test('UAT-USER-001: 用户列表加载', async ({ page }) => {
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
await page.click('text=用户管理');
await page.waitForURL('**/users', { timeout: 30000 });
await page.waitForLoadState('networkidle');
await expect(page.locator('.el-table')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.el-table__body-wrapper')).toBeVisible();
});
test('UAT-USER-002: 用户搜索功能', async ({ page }) => {
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
await page.click('text=用户管理');
await page.waitForURL('**/users', { timeout: 30000 });
await page.waitForLoadState('networkidle');
const searchInput = page.locator('input[placeholder*="搜索"]').first();
if (await searchInput.isVisible()) {
await searchInput.fill('admin');
await page.waitForTimeout(1000);
await expect(page.locator('.el-table')).toBeVisible();
}
});
test('UAT-USER-003: 新增用户表单验证', async ({ page }) => {
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
await page.click('text=用户管理');
await page.waitForURL('**/users', { timeout: 30000 });
await page.waitForLoadState('networkidle');
const addButton = page.locator('button:has-text("新增用户")').first();
if (await addButton.isVisible()) {
await addButton.click();
await page.waitForTimeout(500);
await expect(page.locator('.el-dialog')).toBeVisible();
const confirmButton = page.locator('.el-dialog button:has-text("确定")');
if (await confirmButton.isVisible()) {
await confirmButton.click();
await page.waitForTimeout(500);
const formErrors = page.locator('.el-form-item__error');
const errorCount = await formErrors.count();
expect(errorCount).toBeGreaterThan(0);
}
}
});
});
@@ -0,0 +1,91 @@
import { test, expect } from '@playwright/test';
test.describe('UAT阶段三:角色管理功能验证', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
const usernameInput = page.locator('input[type="text"]').first();
const passwordInput = page.locator('input[type="password"]').first();
const loginButton = page.locator('button:has-text("登录")');
await usernameInput.fill('admin');
await passwordInput.fill('admin123');
await loginButton.click();
await page.waitForURL('**/dashboard', { timeout: 30000 });
await page.waitForLoadState('networkidle');
});
test('UAT-ROLE-001: 角色列表加载', async ({ page }) => {
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
await page.click('text=角色管理');
await page.waitForURL('**/roles', { timeout: 30000 });
await page.waitForLoadState('networkidle');
await expect(page.locator('.el-table')).toBeVisible({ timeout: 10000 });
});
test('UAT-ROLE-002: 新增角色表单验证', async ({ page }) => {
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
await page.click('text=角色管理');
await page.waitForURL('**/roles', { timeout: 30000 });
await page.waitForLoadState('networkidle');
const addButton = page.locator('button:has-text("新增")').first();
if (await addButton.isVisible()) {
await addButton.click();
await page.waitForTimeout(500);
await expect(page.locator('.el-dialog')).toBeVisible();
const roleNameInput = page.locator('.el-dialog input[placeholder*="角色名称"]').first();
if (await roleNameInput.isVisible()) {
await roleNameInput.fill('测试角色');
const roleKeyInput = page.locator('.el-dialog input[placeholder*="角色标识"]').first();
if (await roleKeyInput.isVisible()) {
await roleKeyInput.fill('test_role');
const confirmButton = page.locator('.el-dialog button:has-text("确定")');
if (await confirmButton.isVisible()) {
await confirmButton.click();
await page.waitForTimeout(1000);
}
}
}
}
});
test('UAT-ROLE-003: 角色权限分配', async ({ page }) => {
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
await page.click('text=角色管理');
await page.waitForURL('**/roles', { timeout: 30000 });
await page.waitForLoadState('networkidle');
const permissionButton = page.locator('button:has-text("权限")').first();
if (await permissionButton.isVisible()) {
await permissionButton.click();
await page.waitForTimeout(500);
await expect(page.locator('.el-dialog')).toBeVisible();
const tree = page.locator('.el-tree');
if (await tree.isVisible()) {
const firstCheckbox = tree.locator('.el-checkbox').first();
if (await firstCheckbox.isVisible()) {
await firstCheckbox.click();
}
}
}
});
});
@@ -0,0 +1,110 @@
import { test, expect } from '@playwright/test';
test.describe('UAT阶段四:菜单管理功能验证', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
const usernameInput = page.locator('input[type="text"]').first();
const passwordInput = page.locator('input[type="password"]').first();
const loginButton = page.locator('button:has-text("登录")');
await usernameInput.fill('admin');
await passwordInput.fill('admin123');
await loginButton.click();
await page.waitForURL('**/dashboard', { timeout: 30000 });
await page.waitForLoadState('networkidle');
});
test('UAT-MENU-001: 菜单树形结构展示', async ({ page }) => {
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
await page.click('text=菜单管理');
await page.waitForURL('**/menus', { timeout: 30000 });
await page.waitForLoadState('networkidle');
await expect(page.locator('.el-table')).toBeVisible({ timeout: 10000 });
await page.waitForTimeout(1000);
const tableBody = page.locator('.el-table__body-wrapper');
await expect(tableBody).toBeVisible();
const emptyText = page.locator('text=暂无数据');
const hasEmptyText = await emptyText.isVisible().catch(() => false);
if (!hasEmptyText) {
const treeNodes = page.locator('.el-table__row');
const count = await treeNodes.count();
expect(count).toBeGreaterThanOrEqual(0);
}
});
test('UAT-MENU-002: 新增菜单表单验证', async ({ page }) => {
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
await page.click('text=菜单管理');
await page.waitForURL('**/menus', { timeout: 30000 });
await page.waitForLoadState('networkidle');
const addButton = page.locator('button:has-text("新增")').first();
if (await addButton.isVisible()) {
await addButton.click();
await page.waitForTimeout(500);
await expect(page.locator('.el-dialog')).toBeVisible();
const menuNameInput = page.locator('.el-dialog input[placeholder*="菜单名称"]').first();
if (await menuNameInput.isVisible()) {
await menuNameInput.fill('测试菜单');
const permsInput = page.locator('.el-dialog input[placeholder*="路由地址"]').first();
if (await permsInput.isVisible()) {
await permsInput.fill('/test-menu');
const componentInput = page.locator('.el-dialog input[placeholder*="组件路径"]').first();
if (await componentInput.isVisible()) {
await componentInput.fill('views/test/TestMenu.vue');
const confirmButton = page.locator('.el-dialog button:has-text("确定")');
if (await confirmButton.isVisible()) {
await confirmButton.click();
await page.waitForTimeout(1000);
}
}
}
}
}
});
test('UAT-MENU-003: 菜单类型选择', async ({ page }) => {
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
await page.click('text=菜单管理');
await page.waitForURL('**/menus', { timeout: 30000 });
await page.waitForLoadState('networkidle');
const addButton = page.locator('button:has-text("新增")').first();
if (await addButton.isVisible()) {
await addButton.click();
await page.waitForTimeout(500);
const menuTypeSelect = page.locator('.el-dialog .el-select').first();
if (await menuTypeSelect.isVisible()) {
await menuTypeSelect.click();
await page.waitForTimeout(300);
const options = page.locator('.el-select-dropdown__item');
const count = await options.count();
expect(count).toBeGreaterThan(0);
}
}
});
});
@@ -0,0 +1,97 @@
import { test, expect } from '@playwright/test';
test.describe('UAT阶段五:API交互与错误处理验证', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
const usernameInput = page.locator('input[type="text"]').first();
const passwordInput = page.locator('input[type="password"]').first();
const loginButton = page.locator('button:has-text("登录")');
await usernameInput.fill('admin');
await passwordInput.fill('admin123');
await loginButton.click();
await page.waitForURL('**/dashboard', { timeout: 30000 });
await page.waitForLoadState('networkidle');
});
test('UAT-API-001: Token过期处理', async ({ page }) => {
await page.evaluate(() => {
localStorage.removeItem('token');
});
await page.goto('/users');
await page.waitForTimeout(2000);
const currentUrl = page.url();
expect(currentUrl).toContain('/login');
});
test('UAT-API-002: 网络错误提示', async ({ page, context }) => {
await context.route('**/api/**', route => route.abort('failed'));
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
await page.click('text=用户管理');
await page.waitForTimeout(2000);
await context.unroute('**/api/**');
});
test('UAT-API-003: 权限不足提示', async ({ page }) => {
await page.evaluate(() => {
localStorage.setItem('token', 'user_token_without_admin_rights');
});
await page.goto('/roles');
await page.waitForTimeout(1000);
const errorMessage = page.locator('.el-message--error');
if (await errorMessage.isVisible()) {
await expect(errorMessage).toBeVisible();
}
});
test('UAT-API-004: 并发请求处理', async ({ page }) => {
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
await page.click('text=用户管理');
await page.waitForURL('**/users', { timeout: 30000 });
const refreshButton = page.locator('button:has-text("刷新")').first();
if (await refreshButton.isVisible()) {
for (let i = 0; i < 3; i++) {
await refreshButton.click();
await page.waitForTimeout(100);
}
await page.waitForTimeout(1000);
await expect(page.locator('.el-table')).toBeVisible();
}
});
test('UAT-API-005: 数据加载状态显示', async ({ page }) => {
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
const navigationPromise = page.click('text=用户管理');
const loading = page.locator('.el-loading-mask');
if (await loading.isVisible({ timeout: 100 }).catch(() => false)) {
await expect(loading).toBeVisible();
}
await navigationPromise;
await page.waitForURL('**/users', { timeout: 30000 });
await page.waitForLoadState('networkidle');
await expect(page.locator('.el-table')).toBeVisible();
});
});
@@ -0,0 +1,191 @@
import { test, expect } from '@playwright/test';
test.describe('UAT阶段六:数据持久化验证', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
const usernameInput = page.locator('input[type="text"]').first();
const passwordInput = page.locator('input[type="password"]').first();
const loginButton = page.locator('button:has-text("登录")');
await usernameInput.fill('admin');
await passwordInput.fill('admin123');
await loginButton.click();
await page.waitForURL('**/dashboard', { timeout: 30000 });
await page.waitForLoadState('networkidle');
});
test('UAT-PERSIST-001: 角色创建持久化验证', async ({ page }) => {
const timestamp = Date.now();
const roleName = `测试角色_${timestamp}`;
const roleKey = `test_role_${timestamp}`;
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
await page.click('text=角色管理');
await page.waitForURL('**/roles', { timeout: 30000 });
await page.waitForLoadState('networkidle');
const addButton = page.locator('button:has-text("新增")').first();
if (await addButton.isVisible()) {
await addButton.click();
await page.waitForTimeout(500);
const roleNameInput = page.locator('.el-dialog input[placeholder*="角色名称"]').first();
await roleNameInput.fill(roleName);
const roleKeyInput = page.locator('.el-dialog input[placeholder*="角色标识"]').first();
await roleKeyInput.fill(roleKey);
const confirmButton = page.locator('.el-dialog button:has-text("确定")');
await confirmButton.click();
await page.waitForTimeout(1000);
await page.reload();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
const createdRole = page.locator(`text=${roleName}`);
await expect(createdRole).toBeVisible({ timeout: 5000 });
}
});
test('UAT-PERSIST-002: 用户创建持久化验证', async ({ page }) => {
const timestamp = Date.now();
const username = `testuser_${timestamp}`;
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
await page.click('text=用户管理');
await page.waitForURL('**/users', { timeout: 30000 });
await page.waitForLoadState('networkidle');
const addButton = page.locator('button:has-text("新增用户")').first();
if (await addButton.isVisible()) {
await addButton.click();
await page.waitForTimeout(500);
const usernameInput = page.locator('.el-dialog input[placeholder*="用户名"]').first();
await usernameInput.fill(username);
const nicknameInput = page.locator('.el-dialog input[placeholder*="昵称"]').first();
await nicknameInput.fill(`测试用户_${timestamp}`);
const emailInput = page.locator('.el-dialog input[placeholder*="邮箱"]').first();
await emailInput.fill(`${username}@test.com`);
const phoneInput = page.locator('.el-dialog input[placeholder*="手机"]').first();
await phoneInput.fill('13800138000');
const passwordInput = page.locator('.el-dialog input[placeholder*="密码"]').first();
await passwordInput.fill('Test123456');
const confirmButton = page.locator('.el-dialog button:has-text("确定")');
await confirmButton.click();
await page.waitForTimeout(1000);
await page.reload();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
const searchInput = page.locator('input[placeholder*="搜索"]').first();
if (await searchInput.isVisible()) {
await searchInput.fill(username);
await page.waitForTimeout(1000);
const createdUser = page.locator(`text=${username}`);
await expect(createdUser).toBeVisible({ timeout: 5000 });
}
}
});
test('UAT-PERSIST-003: 数据更新持久化验证', async ({ page }) => {
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
await page.click('text=角色管理');
await page.waitForURL('**/roles', { timeout: 30000 });
await page.waitForLoadState('networkidle');
const editButton = page.locator('button:has-text("编辑")').first();
if (await editButton.isVisible()) {
await editButton.click();
await page.waitForTimeout(500);
const roleNameInput = page.locator('.el-dialog input[placeholder*="角色名称"]').first();
const currentValue = await roleNameInput.inputValue();
const newValue = `${currentValue}_已修改_${Date.now()}`;
await roleNameInput.fill(newValue);
const confirmButton = page.locator('.el-dialog button:has-text("确定")');
await confirmButton.click();
await page.waitForTimeout(1000);
await page.reload();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
const updatedRole = page.locator(`text=${newValue}`);
await expect(updatedRole).toBeVisible({ timeout: 5000 });
}
});
test('UAT-PERSIST-004: 数据删除持久化验证', async ({ page }) => {
const timestamp = Date.now();
const roleName = `待删除角色_${timestamp}`;
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
await page.click('text=角色管理');
await page.waitForURL('**/roles', { timeout: 30000 });
await page.waitForLoadState('networkidle');
const addButton = page.locator('button:has-text("新增")').first();
if (await addButton.isVisible()) {
await addButton.click();
await page.waitForTimeout(500);
const roleNameInput = page.locator('.el-dialog input[placeholder*="角色名称"]').first();
await roleNameInput.fill(roleName);
const roleKeyInput = page.locator('.el-dialog input[placeholder*="角色标识"]').first();
await roleKeyInput.fill(`delete_test_${timestamp}`);
const confirmButton = page.locator('.el-dialog button:has-text("确定")');
await confirmButton.click();
await page.waitForTimeout(1000);
const createdRole = page.locator(`text=${roleName}`);
await createdRole.scrollIntoViewIfNeeded();
const deleteButton = page.locator(`tr:has-text("${roleName}") button:has-text("删除")`).first();
if (await deleteButton.isVisible()) {
await deleteButton.click();
await page.waitForTimeout(500);
const confirmDeleteButton = page.locator('.el-message-box button:has-text("确定")');
if (await confirmDeleteButton.isVisible()) {
await confirmDeleteButton.click();
await page.waitForTimeout(1000);
await page.reload();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
const deletedRole = page.locator(`text=${roleName}`);
await expect(deletedRole).not.toBeVisible({ timeout: 5000 });
}
}
}
});
});
@@ -0,0 +1,228 @@
import { test, expect } from '@playwright/test';
test.describe('UAT阶段七:边界条件与异常输入测试', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
const usernameInput = page.locator('input[type="text"]').first();
const passwordInput = page.locator('input[type="password"]').first();
const loginButton = page.locator('button:has-text("登录")');
await usernameInput.fill('admin');
await passwordInput.fill('admin123');
await loginButton.click();
await page.waitForURL('**/dashboard', { timeout: 30000 });
await page.waitForLoadState('networkidle');
});
test('UAT-BOUNDARY-001: 用户名超长输入测试', async ({ page }) => {
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
await page.click('text=用户管理');
await page.waitForURL('**/users', { timeout: 30000 });
await page.waitForLoadState('networkidle');
const addButton = page.locator('button:has-text("新增用户")').first();
if (await addButton.isVisible()) {
await addButton.click();
await page.waitForTimeout(500);
const usernameInput = page.locator('.el-dialog input[placeholder*="用户名"]').first();
const longUsername = 'a'.repeat(300);
await usernameInput.fill(longUsername);
const confirmButton = page.locator('.el-dialog button:has-text("确定")');
await confirmButton.click();
await page.waitForTimeout(500);
const errorMessage = page.locator('.el-form-item__error, .el-message--error');
const hasError = await errorMessage.isVisible().catch(() => false);
expect(hasError).toBeTruthy();
}
});
test('UAT-BOUNDARY-002: 特殊字符输入测试', async ({ page }) => {
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
await page.click('text=角色管理');
await page.waitForURL('**/roles', { timeout: 30000 });
await page.waitForLoadState('networkidle');
const addButton = page.locator('button:has-text("新增")').first();
if (await addButton.isVisible()) {
await addButton.click();
await page.waitForTimeout(500);
const roleNameInput = page.locator('.el-dialog input[placeholder*="角色名称"]').first();
await roleNameInput.fill('<script>alert("XSS")</script>');
const roleKeyInput = page.locator('.el-dialog input[placeholder*="角色标识"]').first();
await roleKeyInput.fill("'; DROP TABLE roles; --");
const confirmButton = page.locator('.el-dialog button:has-text("确定")');
await confirmButton.click();
await page.waitForTimeout(500);
const errorMessage = page.locator('.el-form-item__error, .el-message--error');
const hasError = await errorMessage.isVisible().catch(() => false);
if (!hasError) {
const cancelButton = page.locator('.el-dialog button:has-text("取消")');
await cancelButton.click();
}
}
});
test('UAT-BOUNDARY-003: 空值输入测试', async ({ page }) => {
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
await page.click('text=用户管理');
await page.waitForURL('**/users', { timeout: 30000 });
await page.waitForLoadState('networkidle');
const addButton = page.locator('button:has-text("新增用户")').first();
if (await addButton.isVisible()) {
await addButton.click();
await page.waitForTimeout(500);
const confirmButton = page.locator('.el-dialog button:has-text("确定")');
await confirmButton.click();
await page.waitForTimeout(500);
const formErrors = page.locator('.el-form-item__error');
const errorCount = await formErrors.count();
expect(errorCount).toBeGreaterThan(0);
}
});
test('UAT-BOUNDARY-004: 邮箱格式验证测试', async ({ page }) => {
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
await page.click('text=用户管理');
await page.waitForURL('**/users', { timeout: 30000 });
await page.waitForLoadState('networkidle');
const addButton = page.locator('button:has-text("新增用户")').first();
if (await addButton.isVisible()) {
await addButton.click();
await page.waitForTimeout(500);
const emailInput = page.locator('.el-dialog input[placeholder*="邮箱"]').first();
await emailInput.fill('invalid-email');
const confirmButton = page.locator('.el-dialog button:has-text("确定")');
await confirmButton.click();
await page.waitForTimeout(500);
const emailError = page.locator('.el-form-item__error:has-text("邮箱")');
const hasError = await emailError.isVisible().catch(() => false);
expect(hasError).toBeTruthy();
}
});
test('UAT-BOUNDARY-005: 手机号格式验证测试', async ({ page }) => {
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
await page.click('text=用户管理');
await page.waitForURL('**/users', { timeout: 30000 });
await page.waitForLoadState('networkidle');
const addButton = page.locator('button:has-text("新增用户")').first();
if (await addButton.isVisible()) {
await addButton.click();
await page.waitForTimeout(500);
const phoneInput = page.locator('.el-dialog input[placeholder*="手机"]').first();
await phoneInput.fill('123');
const confirmButton = page.locator('.el-dialog button:has-text("确定")');
await confirmButton.click();
await page.waitForTimeout(500);
const phoneError = page.locator('.el-form-item__error:has-text("手机")');
const hasError = await phoneError.isVisible().catch(() => false);
expect(hasError).toBeTruthy();
}
});
test('UAT-BOUNDARY-006: Emoji表情输入测试', async ({ page }) => {
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
await page.click('text=角色管理');
await page.waitForURL('**/roles', { timeout: 30000 });
await page.waitForLoadState('networkidle');
const addButton = page.locator('button:has-text("新增")').first();
if (await addButton.isVisible()) {
await addButton.click();
await page.waitForTimeout(500);
const roleNameInput = page.locator('.el-dialog input[placeholder*="角色名称"]').first();
await roleNameInput.fill('测试角色😀🎉🔥');
const roleKeyInput = page.locator('.el-dialog input[placeholder*="角色标识"]').first();
await roleKeyInput.fill('test_emoji_role');
const confirmButton = page.locator('.el-dialog button:has-text("确定")');
await confirmButton.click();
await page.waitForTimeout(1000);
const errorMessage = page.locator('.el-message--error');
const hasError = await errorMessage.isVisible().catch(() => false);
if (!hasError) {
const cancelButton = page.locator('.el-dialog button:has-text("取消")');
if (await cancelButton.isVisible()) {
await cancelButton.click();
}
}
}
});
test('UAT-BOUNDARY-007: 数字输入边界测试', async ({ page }) => {
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
await page.click('text=角色管理');
await page.waitForURL('**/roles', { timeout: 30000 });
await page.waitForLoadState('networkidle');
const addButton = page.locator('button:has-text("新增")').first();
if (await addButton.isVisible()) {
await addButton.click();
await page.waitForTimeout(500);
const sortInput = page.locator('.el-dialog input[type="number"]').first();
if (await sortInput.isVisible()) {
await sortInput.fill('-1');
const confirmButton = page.locator('.el-dialog button:has-text("确定")');
await confirmButton.click();
await page.waitForTimeout(500);
const errorMessage = page.locator('.el-form-item__error');
const hasError = await errorMessage.isVisible().catch(() => false);
if (!hasError) {
const cancelButton = page.locator('.el-dialog button:has-text("取消")');
await cancelButton.click();
}
}
}
});
});
@@ -0,0 +1,195 @@
import { test, expect } from '@playwright/test';
test.describe('UAT阶段八:安全测试', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
const usernameInput = page.locator('input[type="text"]').first();
const passwordInput = page.locator('input[type="password"]').first();
const loginButton = page.locator('button:has-text("登录")');
await usernameInput.fill('admin');
await passwordInput.fill('admin123');
await loginButton.click();
await page.waitForURL('**/dashboard', { timeout: 30000 });
await page.waitForLoadState('networkidle');
});
test('UAT-SECURITY-001: XSS攻击防护测试', async ({ page }) => {
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
await page.click('text=角色管理');
await page.waitForURL('**/roles', { timeout: 30000 });
await page.waitForLoadState('networkidle');
const addButton = page.locator('button:has-text("新增")').first();
if (await addButton.isVisible()) {
await addButton.click();
await page.waitForTimeout(500);
const roleNameInput = page.locator('.el-dialog input[placeholder*="角色名称"]').first();
const xssPayload = '<img src=x onerror=alert("XSS")>';
await roleNameInput.fill(xssPayload);
const roleKeyInput = page.locator('.el-dialog input[placeholder*="角色标识"]').first();
await roleKeyInput.fill('xss_test');
const confirmButton = page.locator('.el-dialog button:has-text("确定")');
await confirmButton.click();
await page.waitForTimeout(1000);
const errorMessage = page.locator('.el-form-item__error, .el-message--error');
const hasError = await errorMessage.isVisible().catch(() => false);
if (!hasError) {
const cancelButton = page.locator('.el-dialog button:has-text("取消")');
if (await cancelButton.isVisible()) {
await cancelButton.click();
}
}
expect(hasError).toBeTruthy();
}
});
test('UAT-SECURITY-002: SQL注入防护测试', async ({ page }) => {
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
await page.click('text=用户管理');
await page.waitForURL('**/users', { timeout: 30000 });
await page.waitForLoadState('networkidle');
const searchInput = page.locator('input[placeholder*="搜索"]').first();
if (await searchInput.isVisible()) {
await searchInput.fill("admin' OR '1'='1");
await page.waitForTimeout(1000);
await expect(page.locator('.el-table')).toBeVisible();
const allRows = await page.locator('.el-table__row').count();
expect(allRows).toBeLessThan(100);
}
});
test('UAT-SECURITY-003: 未授权访问测试', async ({ page }) => {
await page.evaluate(() => {
localStorage.removeItem('token');
});
await page.goto('/users');
await page.waitForTimeout(2000);
const currentUrl = page.url();
expect(currentUrl).toContain('/login');
});
test('UAT-SECURITY-004: CSRF防护测试', async ({ page }) => {
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
await page.click('text=角色管理');
await page.waitForURL('**/roles', { timeout: 30000 });
await page.waitForLoadState('networkidle');
const addButton = page.locator('button:has-text("新增")').first();
if (await addButton.isVisible()) {
await addButton.click();
await page.waitForTimeout(500);
const roleNameInput = page.locator('.el-dialog input[placeholder*="角色名称"]').first();
await roleNameInput.fill('CSRF测试角色');
const roleKeyInput = page.locator('.el-dialog input[placeholder*="角色标识"]').first();
await roleKeyInput.fill('csrf_test');
const confirmButton = page.locator('.el-dialog button:has-text("确定")');
await confirmButton.click();
await page.waitForTimeout(1000);
const successMessage = page.locator('.el-message--success');
const errorMessage = page.locator('.el-message--error');
const hasSuccess = await successMessage.isVisible().catch(() => false);
const hasError = await errorMessage.isVisible().catch(() => false);
expect(hasSuccess || hasError).toBeTruthy();
}
});
test('UAT-SECURITY-005: 密码强度验证测试', async ({ page }) => {
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
await page.click('text=用户管理');
await page.waitForURL('**/users', { timeout: 30000 });
await page.waitForLoadState('networkidle');
const addButton = page.locator('button:has-text("新增用户")').first();
if (await addButton.isVisible()) {
await addButton.click();
await page.waitForTimeout(500);
const usernameInput = page.locator('.el-dialog input[placeholder*="用户名"]').first();
await usernameInput.fill('testuser');
const passwordInput = page.locator('.el-dialog input[placeholder*="密码"]').first();
await passwordInput.fill('123');
const confirmButton = page.locator('.el-dialog button:has-text("确定")');
await confirmButton.click();
await page.waitForTimeout(500);
const passwordError = page.locator('.el-form-item__error');
const hasError = await passwordError.isVisible().catch(() => false);
expect(hasError).toBeTruthy();
}
});
test('UAT-SECURITY-006: 敏感信息泄露测试', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
const pageContent = await page.content();
expect(pageContent).not.toContain('password');
expect(pageContent).not.toContain('secret');
expect(pageContent).not.toContain('api_key');
expect(pageContent).not.toContain('private_key');
});
test('UAT-SECURITY-007: 会话超时测试', async ({ page }) => {
const systemMenu = page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await page.waitForTimeout(1000);
await page.click('text=用户管理');
await page.waitForURL('**/users', { timeout: 30000 });
await page.waitForLoadState('networkidle');
await page.evaluate(() => {
const token = localStorage.getItem('token');
if (token) {
const expiredToken = token.replace(/\.(.*?)\./, '.expired.');
localStorage.setItem('token', expiredToken);
}
});
await page.reload();
await page.waitForTimeout(2000);
const currentUrl = page.url();
const isLoginPage = currentUrl.includes('/login');
const hasError = await page.locator('.el-message--error').isVisible().catch(() => false);
expect(isLoginPage || hasError).toBeTruthy();
});
});
@@ -259,17 +259,21 @@ const formState = reactive<CreateUserRequest & { id?: number; status?: UserStatu
const formRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_]+$/, message: '用户名只能包含字母、数字下划线', trigger: 'blur' }
{ min: 3, max: 50, message: '用户名长度在 3 到 50 个字符', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_-]+$/, message: '用户名只能包含字母、数字下划线和横线', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }
{ min: 8, max: 20, message: '密码长度在 8 到 20 个字符', trigger: 'blur' },
{ pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/, message: '密码必须包含大小写字母和数字', trigger: 'blur' }
],
email: [
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' },
{ max: 100, message: '邮箱长度不能超过100个字符', trigger: 'blur' }
],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' }
]
}