提交 305b9662 authored 作者: 陈泽健's avatar 陈泽健

feat(test): 新增兰州登录MQTT测试用例生成脚本

- 创建测试用例生成脚本,支持从JSON配置自动生成Excel用例
- 实现表头复制与样式保留功能
- 支持预置条件和操作步骤自动换行显示
- 添加列宽自适应调整逻辑
- 集成兰州项目登录MQTT相关测试场景配置
- 提供完整的测试用例数据结构定义
上级 b7f7fccb
# 兰州登录功能开发
## 📋 概述
用户登陆系统后,本机再次登陆同账号提示登陆失败。本机已登录的页面关闭后,再次登陆可正常进入系统。
如用户异地登陆,可进入系统,后登陆账号挤掉在线账号,被挤掉的账号系统弹出提示,点击"确定"后返回登陆页(点击×也返回登陆页,不点击不变化)。
### 背景
目前已经实现了mqtt+登录接口控制+查询数据库配置的登录功能。
但是存在以下问题:
1. 当网络差,或者出现了异常情况下,前端不能稳定的调用后端的登出接口,导致用户无法登出。当用户再次登录时,会提示用户已本地登录。
解决方案:使用MQTT遗嘱机制(Last Will and Testament, LWT)。
### 🔧 MQTT 遗嘱机制整体方案设计
1. **目标**
- **登录态实时同步**:同一账号在不同终端/浏览器的在线状态实时感知。
- **可靠挤下线控制**:异地登录、同账号重复登录时,后端通过 MQTT 主动通知已在线客户端下线。
- **弱网/异常场景兜底**:即使前端未正常调用"登出接口",依然可以通过 MQTT 遗嘱机制 + Token 校验,保证后端侧登录态的一致性。
2. **基本设计思路**
- 用户登录成功后,前端在建立会话(拿到 Token)后,**立即建立 MQTT 连接并设置遗嘱消息**
- 遗嘱消息包含:token、IP、account、companyNumber 等信息。
- 当客户端异常断开(网络断开、浏览器关闭、崩溃等)时,MQTT 服务器会自动发布遗嘱消息。
- 后端监听遗嘱 topic(`/iot/v1/user/will/{公司编号}/{用户id}`),收到遗嘱消息后自动清除 Redis 中的所有三种类型的 token。
- 当检测到同一账号再次登录、或后台强制踢人时,由后端通过 MQTT 给对应客户端下发"下线通知"消息。
### 🧱 后端实现要求(Java + Spring Boot)
1. **MQTT 遗嘱机制配置**
- 遗嘱 Topic:`/iot/v1/user/will/{公司编号}/{用户id}`
- 后端订阅通配符 Topic:`/iot/v1/user/will/+/+`,监听所有用户的遗嘱消息
- 创建 `LoginWillMqttConfiguration` 配置类,配置 MQTT 订阅遗嘱 topic
- 创建 `LoginWillMessageHandler` 处理器,处理收到的遗嘱消息并清除 token
2. **遗嘱消息处理器**
- 新建 `LoginWillMessageHandler`,实现 `MessageHandler` 接口
- 监听 `loginWillMQTT` 通道,接收遗嘱消息
- 遗嘱消息格式:
```json
{
"type": "WILL",
"token": "用户token(不包含tokenHead前缀)",
"ip": "用户IP地址"
}
```
- 收到遗嘱消息后:
- topic 中解析公司编号和用户id:`/iot/v1/user/will/{公司编号}/{用户id}`
- payload 中解析 token IP
- 调用 `jwtTokenUtil.removeTokenWithIp(token, ip)` 清除所有三种类型的 token:
1. `access_token:username:token`
2. `user:token:username`
3. `user:ip:username`
- 记录日志,便于排查
3. **与现有登录/登出逻辑联动**
- 复用现有唯一端登录判断:
- `clearOldTokenIfOnlyLogin(...)` 仍负责清除 Redis 内旧 token,并返回 `needSendMQ`。
- 登录成功后保持 `sendOnlyLoginNotification(...)` 调用顺序不变。
- 调整 `sendOnlyLoginNotification` 签名,增加 `userAccount` 参数,并在方法内部串联:
1. **MQTT**:继续调用 `mqttService.sendUserServiceRequest(...)`,保持原有挤下线通知。
- 登出接口在成功清理 Redis token 后,前端应主动断开 MQTT 连接(正常断开不会触发遗嘱)。
4. **MQTT 遗嘱机制说明**
- **遗嘱机制原理**:
- 客户端连接 MQTT 服务器时,可以设置遗嘱消息(Last Will and Testament, LWT)
- 当客户端异常断开(网络断开、进程崩溃、浏览器关闭等)时,MQTT 服务器会自动发布遗嘱消息
- 后端订阅遗嘱 topic,收到遗嘱消息后自动清除 token
- **优势**:
- 更可靠:即使客户端异常断开,MQTT 服务器也能检测到并发布遗嘱消息
- 无需维护连接状态:不需要像 WebSocket 那样维护连接映射关系
- 自动清理:MQTT 服务器自动处理异常断开,无需前端主动调用登出接口
- **Topic 区分 + Payload 标记双保险**:
- 遗嘱消息使用独立的 topic:`/iot/v1/user/will/{公司编号}/{用户id}`
- 正常业务消息使用 topic:`/iot/v1/user/service/request/{公司编号}/{用户id}`
- 遗嘱消息 payload 必须包含 `type: "WILL"` 字段,确保双重识别
5. **异常 & 预留**
- 弱网重连:前端断线后带旧 token 重连,后端需重新校验 token(若已过期/被踢,拒绝连接并返回原因)。
- 预留 `clientType/deviceId` 字段:当前先忽略,后续如需"PC+移动端同时在线"仅需在连接管理器层判定即可,无需改登录接口。
## 💻 前端实现要求
### 1. **登录成功后建立 MQTT 连接**
#### 1.1 连接配置
- **连接时机**:登录接口返回 token 后,立即建立 MQTT 连接
- **连接地址**:从后端配置获取 MQTT Broker 地址(如:`tcp://mqtt.example.com:1883`)
- **客户端 ID**:建议使用 `{companyNumber}_{userId}_{timestamp}` 格式,确保唯一性
- **连接参数**:
- `cleanSession: false`(持久会话,确保遗嘱消息能正确发布)
- `keepAliveInterval: 60`(心跳间隔,单位:秒)
- `reconnectPeriod: 5000`(重连间隔,单位:毫秒)
#### 1.2 设置遗嘱消息(Last Will and Testament)
遗嘱消息在连接建立时设置,当客户端异常断开时,MQTT 服务器会自动发布。
**遗嘱配置参数**:
- **遗嘱 Topic**:`/iot/v1/user/will/{公司编号}/{用户id}`
- 示例:`/iot/v1/user/will/CN-SZ-00-0201/12345`
- **遗嘱 Payload**(JSON 格式):
```json
{
"type": "WILL",
"token": "用户token(不包含tokenHead前缀)",
"ip": "用户IP地址"
}
```
- **QoS**:`1`(至少送达一次)
- **Retained**:`false`(不保留消息)
**代码示例(使用 mqtt.js)**:
```javascript
import mqtt from 'mqtt';
// 登录成功后
const connectMqtt = (loginResponse) => {
const { token, companyNumber, userId, userIp } = loginResponse;
// 构建遗嘱消息
const willTopic = `/iot/v1/user/will/${companyNumber}/${userId}`;
const willPayload = JSON.stringify({
type: "WILL",
token: token, // 不包含 tokenHead 前缀
ip: userIp || getClientIp() // 获取客户端IP
});
// MQTT 连接选项
const options = {
clientId: `${companyNumber}_${userId}_${Date.now()}`,
clean: false, // 持久会话
keepalive: 60, // 心跳间隔60秒
reconnectPeriod: 5000, // 重连间隔5秒
will: {
topic: willTopic,
payload: willPayload,
qos: 1,
retain: false
}
};
// 建立连接
const client = mqtt.connect('tcp://mqtt.example.com:1883', options);
// 连接成功回调
client.on('connect', () => {
console.log('MQTT连接成功,遗嘱消息已设置');
// 保存 client 实例到全局状态管理
store.commit('setMqttClient', client);
});
// 连接失败回调
client.on('error', (error) => {
console.error('MQTT连接失败:', error);
// 可以尝试重新连接或提示用户
});
// 连接断开回调
client.on('close', () => {
console.log('MQTT连接已断开');
});
return client;
};
```
### 2. **正常登出处理**
正常登出时,需要先断开 MQTT 连接,再清理本地 token,这样不会触发遗嘱消息。
**代码示例**:
```javascript
const logout = async () => {
const mqttClient = store.state.mqttClient;
// 1. 先断开 MQTT 连接(正常断开不会触发遗嘱)
if (mqttClient && mqttClient.connected) {
mqttClient.end(false, () => {
console.log('MQTT连接已正常断开');
});
}
// 2. 调用后端登出接口(可选,后端会清除token)
try {
await api.post('/logout');
} catch (error) {
console.error('登出接口调用失败:', error);
}
// 3. 清理本地 token 和状态
localStorage.removeItem('token');
store.commit('clearUserInfo');
// 4. 跳转到登录页
router.push('/login');
};
```
### 3. **异常断开处理**
当 MQTT 连接异常断开时(网络问题、浏览器关闭等),MQTT 服务器会自动发布遗嘱消息,后端会自动清除 token。前端需要处理重连逻辑。
**代码示例**:
```javascript
client.on('close', () => {
console.log('MQTT连接已断开');
// 检查是否是正常断开
if (!isNormalLogout) {
// 异常断开,尝试重连
console.log('检测到异常断开,尝试重连...');
reconnectMqtt();
}
});
const reconnectMqtt = () => {
const token = localStorage.getItem('token');
if (!token) {
console.log('token不存在,停止重连');
return;
}
// 指数退避重连策略
let retryCount = 0;
const maxRetries = 5;
const reconnect = () => {
if (retryCount >= maxRetries) {
console.error('MQTT重连失败,超过最大重试次数');
// 提示用户网络异常,需要重新登录
showNetworkErrorDialog();
return;
}
retryCount++;
const delay = Math.min(1000 * Math.pow(2, retryCount), 30000); // 最大30秒
setTimeout(() => {
console.log(`第${retryCount}次重连MQTT...`);
// 重新建立连接(需要重新设置遗嘱)
const newClient = connectMqtt({
token: token,
companyNumber: store.state.userInfo.companyNumber,
userId: store.state.userInfo.userId
});
newClient.on('connect', () => {
console.log('MQTT重连成功');
retryCount = 0; // 重置重试计数
});
newClient.on('error', () => {
reconnect(); // 继续重连
});
}, delay);
};
reconnect();
};
```
### 4. **获取客户端 IP 地址**
前端需要获取客户端 IP 地址,用于遗嘱消息。可以通过以下方式:
**方式1:从登录接口返回(推荐)**
```javascript
// 登录接口返回中包含 userIp
const loginResponse = await api.post('/login', { username, password });
const { token, companyNumber, userId, userIp } = loginResponse;
```
**方式2:前端获取(如果后端未返回)**
```javascript
// 使用第三方服务获取IP(注意:需要用户授权)
const getClientIp = async () => {
try {
const response = await fetch('https://api.ipify.org?format=json');
const data = await response.json();
return data.ip;
} catch (error) {
console.error('获取IP失败:', error);
return ''; // 如果获取失败,可以传空字符串,后端会记录警告日志
}
};
```
### 5. **多标签页协调**
如果用户打开多个标签页,需要确保所有标签页共享同一个 MQTT 连接,或者每个标签页独立连接但能同步登出状态。
**方案1:共享连接(推荐)**
```javascript
// 使用 BroadcastChannel 或 localStorage 事件协调
const channel = new BroadcastChannel('mqtt_channel');
// 标签页1:建立连接后通知其他标签页
channel.postMessage({
type: 'MQTT_CONNECTED',
clientId: client.options.clientId
});
// 其他标签页:监听消息,复用连接或同步状态
channel.onmessage = (event) => {
if (event.data.type === 'MQTT_CONNECTED') {
// 复用连接或同步状态
} else if (event.data.type === 'LOGOUT') {
// 同步登出
handleLogout();
}
};
```
**方案2:独立连接**
每个标签页独立建立 MQTT 连接,但通过 BroadcastChannel 同步登出状态:
```javascript
// 标签页1:登出时通知其他标签页
const logout = () => {
channel.postMessage({ type: 'LOGOUT' });
// ... 执行登出逻辑
};
// 其他标签页:监听登出消息
channel.onmessage = (event) => {
if (event.data.type === 'LOGOUT') {
handleLogout();
}
};
```
### 6. **完整实现示例**
```javascript
// mqttService.js
import mqtt from 'mqtt';
class MqttService {
constructor() {
this.client = null;
this.isNormalLogout = false;
}
/**
* 登录成功后建立MQTT连接并设置遗嘱
*/
connect(loginResponse) {
const { token, companyNumber, userId, userIp } = loginResponse;
// 如果已有连接,先断开
if (this.client) {
this.disconnect();
}
const willTopic = `/iot/v1/user/will/${companyNumber}/${userId}`;
const willPayload = JSON.stringify({
type: "WILL",
token: token,
ip: userIp || this.getClientIp()
});
const options = {
clientId: `${companyNumber}_${userId}_${Date.now()}`,
clean: false,
keepalive: 60,
reconnectPeriod: 5000,
will: {
topic: willTopic,
payload: willPayload,
qos: 1,
retain: false
}
};
this.client = mqtt.connect('tcp://mqtt.example.com:1883', options);
this.client.on('connect', () => {
console.log('MQTT连接成功,遗嘱已设置');
});
this.client.on('error', (error) => {
console.error('MQTT连接错误:', error);
});
this.client.on('close', () => {
if (!this.isNormalLogout) {
console.log('MQTT异常断开,将触发遗嘱消息');
}
});
return this.client;
}
/**
* 正常登出,断开MQTT连接
*/
disconnect() {
this.isNormalLogout = true;
if (this.client && this.client.connected) {
this.client.end(false, () => {
console.log('MQTT连接已正常断开');
this.client = null;
});
}
}
/**
* 获取客户端IP(如果后端未返回)
*/
async getClientIp() {
try {
const response = await fetch('https://api.ipify.org?format=json');
const data = await response.json();
return data.ip;
} catch (error) {
console.error('获取IP失败:', error);
return '';
}
}
}
export default new MqttService();
```
**在登录流程中使用**:
```javascript
// login.js
import mqttService from '@/services/mqttService';
const handleLogin = async (loginForm) => {
try {
// 1. 调用登录接口
const response = await api.post('/login', loginForm);
const { token, companyNumber, userId, userIp } = response.data;
// 2. 保存token
localStorage.setItem('token', token);
// 3. 建立MQTT连接并设置遗嘱
mqttService.connect({
token,
companyNumber,
userId,
userIp
});
// 4. 跳转到主页
router.push('/home');
} catch (error) {
console.error('登录失败:', error);
}
};
// logout.js
import mqttService from '@/services/mqttService';
const handleLogout = async () => {
// 1. 先断开MQTT连接(正常断开不会触发遗嘱)
mqttService.disconnect();
// 2. 调用登出接口
await api.post('/logout');
// 3. 清理本地状态
localStorage.removeItem('token');
// 4. 跳转到登录页
router.push('/login');
};
```
### 7. **注意事项**
1. **Token 格式**:遗嘱消息中的 token 不包含 `tokenHead` 前缀(如 "Bearer"),直接使用原始 token
2. **IP 地址**:如果后端登录接口返回了 IP,优先使用返回的 IP;否则前端尝试获取,获取失败可以传空字符串
3. **连接唯一性**:确保同一用户在同一时间只有一个有效的 MQTT 连接,避免重复设置遗嘱
4. **异常处理**:网络异常时,MQTT 会自动重连,但需要前端处理重连失败的情况
5. **页面刷新**:页面刷新时,需要重新建立 MQTT 连接并设置遗嘱
6. **浏览器关闭**:浏览器关闭时,MQTT 连接会异常断开,触发遗嘱消息,后端自动清除 token
## 🔄 登录态联动流程图
```
[用户输入账号密码]
|
v
[调用登录接口 → 校验账号/密码/验证码/本机重复登录]
|
v
[clearOldTokenIfOnlyLogin 清理旧 token]
|
v
[生成新 JWT → 存入 Redis → 返回 token 给前端]
|
v
┌───────────────────────────────────────────────┐
│ 前端同时执行两步 │
│ 1. 缓存 token │
│ 2. 建立 MQTT 连接并设置遗嘱 │
│ Topic: /iot/v1/user/will/{公司}/{用户} │
│ Payload: {type:"WILL", token, ip} │
└───────────────────────────────────────────────┘
|
v
[MQTT 连接成功,遗嘱消息已设置]
|
v
[保持连接,后端监听遗嘱 topic]
|
v
[若新登录触发唯一端策略]
|
v
[sendOnlyLoginNotification]
|
v
[MQTT 挤下线消息 → 旧终端收到消息 → 弹窗提示 → 清理 token → 断开 MQTT → 跳登录页]
|
v
[异常场景:浏览器关闭/网络断开 → MQTT服务器自动发布遗嘱 → 后端清除token]
|
v
[异常兜底:接口 401/403 → 清理 token + 退回登录页]
```
[
{
"序号": 1, "功能模块": "登录与MQTT", "功能类别": "功能测试", "用例编号": "LZ-LOGIN-001",
"功能描述": "正常登录成功", "用例等级": "高", "功能编号": "",
"用例名称": "正常账号密码登录成功",
"预置条件": "1. 系统已部署且MQTT/Redis可用; 2. 存在有效账号A",
"操作步骤": "1. 打开登录页; 2. 输入账号A正确用户名密码; 3. 点击“登录”; 4. 观察接口返回和页面跳转",
"JSON": "",
"预期结果": "接口返回token/companyNumber/userId/userIp; 页面跳首页; Redis写入3类token键; 前端缓存token",
"测试结果": "", "测试结论": "", "日志截屏": "", "备注": ""
},
{
"序号": 2, "功能模块": "登录与MQTT", "功能类别": "功能测试", "用例编号": "LZ-LOGIN-002",
"功能描述": "登录后建立MQTT并设置遗嘱", "用例等级": "高", "功能编号": "",
"用例名称": "登录成功后建立MQTT连接",
"预置条件": "1. 用例1已成功, 账号A在首页; 2. 可查看开发者工具或抓包",
"操作步骤": "1. 登录成功后打开开发者工具; 2. 找到MQTT连接; 3. 展开will配置并核对参数",
"JSON": "",
"预期结果": "MQTT连接成功; clientId格式正确; clean=false; will topic/payload/qos/retain符合设计",
"测试结果": "", "测试结论": "", "日志截屏": "", "备注": ""
},
{
"序号": 3, "功能模块": "登录与MQTT", "功能类别": "功能测试", "用例编号": "LZ-LOGIN-003",
"功能描述": "本机同浏览器重复登录", "用例等级": "中", "功能编号": "",
"用例名称": "本机同浏览器再次登录同账号",
"预置条件": "账号A已在当前浏览器登录",
"操作步骤": "1. 新建同浏览器标签页; 2. 输入账号A和正确密码; 3. 点击“登录”; 4. 观察返回结果",
"JSON": "",
"预期结果": "按策略提示“本机已登录”或清旧token生成新token, 无系统异常",
"测试结果": "", "测试结论": "", "日志截屏": "", "备注": "根据最终策略确认"
},
{
"序号": 4, "功能模块": "登录与MQTT", "功能类别": "功能测试", "用例编号": "LZ-LOGIN-004",
"功能描述": "异地登录触发唯一端控制", "用例等级": "高", "功能编号": "",
"用例名称": "终端B登录成功并挤掉终端A",
"预置条件": "终端A浏览器已登录账号A; 终端B未登录",
"操作步骤": "1. 终端B打开登录页输入账号A和正确密码; 2. 点击“登录”; 3. 登录成功后访问受保护页面; 4. 在终端A刷新页面或访问接口",
"JSON": "",
"预期结果": "终端B正常使用; 后端清理终端A旧token并发MQTT挤下线通知; 终端A接口401/403或弹窗被挤下线",
"测试结果": "", "测试结论": "", "日志截屏": "", "备注": ""
},
{
"序号": 5, "功能模块": "登录与MQTT", "功能类别": "功能测试", "用例编号": "LZ-LOGIN-005",
"功能描述": "被挤端弹窗提示并返回登录页", "用例等级": "高", "功能编号": "",
"用例名称": "被挤下线端处理逻辑",
"预置条件": "终端A停留在业务页; 终端B用账号A刚完成登录",
"操作步骤": "1. 观察终端A是否弹出被挤下线提示; 2. 在终端A点击“确定”; 3. 重新执行并点击“×”; 4. 再次在终端A访问接口或刷新页面",
"JSON": "",
"预期结果": "终端A弹窗提示; 点击“确定/×”后清token、断开MQTT并跳登录页; 未点击时接口401/403并被拦截到登录页",
"测试结果": "", "测试结论": "", "日志截屏": "", "备注": ""
},
{
"序号": 6, "功能模块": "登录与MQTT", "功能类别": "功能测试", "用例编号": "LZ-LOGIN-006",
"功能描述": "正常登出不触发遗嘱", "用例等级": "高", "功能编号": "",
"用例名称": "正常登出流程验证",
"预置条件": "账号A已登录, MQTT连接正常",
"操作步骤": "1. 在页面点击“退出登录”; 2. 观察前端MQTT断开日志; 3. 查看/logout响应; 4. 确认无遗嘱消息; 5. 再访问业务页",
"JSON": "POST /logout",
"预期结果": "前端先正常断开MQTT且无遗嘱; /logout成功; 前端清token并回登录页; 后端未收到WILL",
"测试结果": "", "测试结论": "", "日志截屏": "", "备注": ""
},
{
"序号": 7, "功能模块": "登录与MQTT", "功能类别": "异常场景", "用例编号": "LZ-LOGIN-007",
"功能描述": "浏览器关闭触发遗嘱清理token", "用例等级": "高", "功能编号": "",
"用例名称": "浏览器关闭异常断开",
"预置条件": "账号A已登录, MQTT正常",
"操作步骤": "1. 登录成功后停留在业务页; 2. 直接关闭浏览器窗口或结束进程; 3. 查看后端是否收到WILL; 4. 查看Redis中token; 5. 使用旧token访问接口",
"JSON": "",
"预期结果": "浏览器关闭导致MQTT异常断开并发布WILL; 后端清理3类token; 旧token访问接口返回401/403",
"测试结果": "", "测试结论": "", "日志截屏": "", "备注": ""
},
{
"序号": 8, "功能模块": "登录与MQTT", "功能类别": "异常场景", "用例编号": "LZ-LOGIN-008",
"功能描述": "网络中断触发遗嘱并尝试重连", "用例等级": "中", "功能编号": "",
"用例名称": "弱网下MQTT异常断开与重连",
"预置条件": "账号A已登录, MQTT正常",
"操作步骤": "1. 登录成功后立即断网; 2. 观察前端close事件日志; 3. 查看后端是否收到WILL并清理token; 4. 恢复网络; 5. 观察重连日志与结果",
"JSON": "",
"预期结果": "断网触发异常断开; 后端清token; 前端按指数退避多次重连; token失效时最终提示重新登录",
"测试结果": "", "测试结论": "", "日志截屏": "", "备注": ""
},
{
"序号": 9, "功能模块": "登录与MQTT", "功能类别": "异常场景", "用例编号": "LZ-LOGIN-009",
"功能描述": "弱网多次重连失败达到最大次数", "用例等级": "中", "功能编号": "",
"用例名称": "MQTT重连超过最大次数处理",
"预置条件": "前端配置maxRetries=5; 账号A已登录",
"操作步骤": "1. 登录成功后断网并保持异常; 2. 观察重连次数与间隔; 3. 等待超过5次; 4. 观察前端提示; 5. 确认不再继续重连",
"JSON": "",
"预期结果": "最多重试5次且间隔指数退避; 超过次数后停止重连并弹出网络异常提示, 引导重新登录",
"测试结果": "", "测试结论": "", "日志截屏": "", "备注": ""
},
{
"序号": 10, "功能模块": "登录与MQTT", "功能类别": "功能测试", "用例编号": "LZ-LOGIN-010",
"功能描述": "刷新页面后的登录态与MQTT重建", "用例等级": "中", "功能编号": "",
"用例名称": "刷新页面保持/失效逻辑",
"预置条件": "账号A已登录并停留在业务页",
"操作步骤": "1. 在业务页按F5刷新; 2. 观察是否仍在业务页; 3. 查看是否重新建立MQTT并设置遗嘱; 4. 再次访问业务接口",
"JSON": "",
"预期结果": "token有效则刷新后重建MQTT并可继续使用; token已清则接口401/403并跳登录页",
"测试结果": "", "测试结论": "", "日志截屏": "", "备注": ""
},
{
"序号": 11, "功能模块": "登录与MQTT", "功能类别": "功能测试", "用例编号": "LZ-LOGIN-011",
"功能描述": "多标签页共享/同步登出", "用例等级": "中", "功能编号": "",
"用例名称": "多标签页BroadcastChannel同步登出",
"预置条件": "同一浏览器两个标签页均登录账号A",
"操作步骤": "1. 在标签页1点击“退出登录”; 2. 观察标签页1退回登录页; 3. 切到标签页2观察是否自动退回登录页; 4. 在任一标签页访问接口验证需重新登录",
"JSON": "",
"预期结果": "标签页1登出并发送LOGOUT; 标签页2收到后断开MQTT、清token并回登录页; 所有标签页都需重新登录",
"测试结果": "", "测试结论": "", "日志截屏": "", "备注": ""
},
{
"序号": 12, "功能模块": "登录与MQTT", "功能类别": "安全/健壮", "用例编号": "LZ-LOGIN-012",
"功能描述": "遗嘱payload type异常不应清理token", "用例等级": "中", "功能编号": "",
"用例名称": "非WILL类型payload处理",
"预置条件": "后端MQTT订阅正常, Redis中有合法token",
"操作步骤": "1. 用MQTT客户端向will topic发送payload {type:'OTHER',token:'fake',ip:'1.1.1.1'}; 2. 观察后端日志与Redis中token变化",
"JSON": "",
"预期结果": "后端识别type≠WILL,仅记录告警, 不清理token, 服务正常",
"测试结果": "", "测试结论": "", "日志截屏": "", "备注": ""
},
{
"序号": 13, "功能模块": "登录与MQTT", "功能类别": "安全/健壮", "用例编号": "LZ-LOGIN-013",
"功能描述": "遗嘱payload字段缺失/格式错误处理", "用例等级": "中", "功能编号": "",
"用例名称": "异常payload健壮性验证",
"预置条件": "后端MQTT订阅正常, 可查看错误日志",
"操作步骤": "1. 发送payload {type:'WILL',ip:'1.1.1.1'}; 2. 再发送非JSON文本“bad payload”; 3. 观察错误日志; 4. 验证后续合法遗嘱仍能正常处理",
"JSON": "",
"预期结果": "异常payload被容错处理并记录日志; 不清理token; 订阅不中断; 后续合法遗嘱正常",
"测试结果": "", "测试结论": "", "日志截屏": "", "备注": ""
},
{
"序号": 14, "功能模块": "登录与MQTT", "功能类别": "安全/鉴权", "用例编号": "LZ-LOGIN-014",
"功能描述": "使用被清理的旧token访问接口", "用例等级": "高", "功能编号": "",
"用例名称": "旧token失效验证",
"预置条件": "账号A已被挤下线或触发遗嘱, token已清理; 已记录旧token值",
"操作步骤": "1. 在Postman/浏览器设置Authorization: Bearer 旧token; 2. 调用受保护接口; 3. 观察响应; 4. 在前端刷新业务页观察处理",
"JSON": "",
"预期结果": "接口统一返回401/403; 前端清token并提示重新登录; 不存在仍可用旧token的接口",
"测试结果": "", "测试结论": "", "日志截屏": "", "备注": ""
},
{
"序号": 15, "功能模块": "登录与MQTT", "功能类别": "运维可观测", "用例编号": "LZ-LOGIN-015",
"功能描述": "关键链路日志完整性检查", "用例等级": "中", "功能编号": "",
"用例名称": "登录/登出/遗嘱/挤下线日志检查",
"预置条件": "系统开启业务日志, 有权限查看",
"操作步骤": "1. 执行一次正常登录→登出; 2. 执行一次异地挤下线; 3. 执行一次浏览器关闭触发遗嘱; 4. 在日志平台查看对应记录",
"JSON": "",
"预期结果": "登录成功、MQTT连接、挤下线通知、遗嘱触发、token清理等关键步骤均有日志, 异常场景有告警记录",
"测试结果": "", "测试结论": "", "日志截屏": "", "备注": ""
}
]
\ No newline at end of file
# -*- coding: utf-8 -*-
"""
根据模板用例 Excel,在同一个工作簿中新建 Sheet,并从 JSON 配置文件中读取用例数据写入。
后续只需要维护 JSON 文件即可复用。
"""
import os
import json
from copy import copy
from openpyxl import load_workbook
from openpyxl.styles import Alignment
# ===== 1. 配置路径(改为相对当前脚本的路径) =====
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
TEMPLATE_PATH = os.path.join(BASE_DIR, "用例文件", "兰州中石化项目测试用例20251203.xlsx")
NEW_SHEET_NAME = "兰州登录MQTT用例" # 新建的 Sheet 名
CASES_FILE = os.path.join(BASE_DIR, "config", "兰州用例.json") # 用例配置文件(JSON)
# 与你表头对应的顺序(根据截图)
headers_order = [
"序号", "功能模块", "功能类别", "用例编号", "功能描述", "用例等级",
"功能编号", "用例名称", "预置条件", "操作步骤", "JSON", "预期结果",
"测试结果", "测试结论", "日志截屏", "备注",
]
def load_cases():
"""从 JSON 配置文件加载用例列表。"""
if not os.path.exists(CASES_FILE):
raise FileNotFoundError(f"找不到用例配置文件: {CASES_FILE}")
with open(CASES_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, list):
raise ValueError("用例配置文件的根节点必须是列表(List)")
return data
def main():
if not os.path.exists(TEMPLATE_PATH):
print("找不到模板文件:", TEMPLATE_PATH)
return
try:
cases = load_cases()
except Exception as e:
print("加载用例配置失败:", e)
return
wb = load_workbook(TEMPLATE_PATH)
# 以第一个sheet作为模板
template_sheet = wb.worksheets[0]
# 如果新sheet已存在就先删除
if NEW_SHEET_NAME in wb.sheetnames:
ws_new = wb[NEW_SHEET_NAME]
wb.remove(ws_new)
ws_new = wb.create_sheet(NEW_SHEET_NAME)
# ===== 复制表头(仅值 + 简单样式) =====
# 假设模板表头在第3行(根据截图),如有偏差可以改成2或其它
header_row_index = 3
for col_idx, cell in enumerate(template_sheet[header_row_index], start=1):
new_cell = ws_new.cell(row=1, column=col_idx, value=cell.value)
if cell.has_style:
new_cell.font = copy(cell.font)
new_cell.fill = copy(cell.fill)
new_cell.border = copy(cell.border)
new_cell.alignment = copy(cell.alignment)
new_cell.number_format = cell.number_format
ws_new.freeze_panes = "B2"
# ===== 写入用例数据(预置条件/操作步骤自动换行,JSON列统一留空) =====
row = 2
for case in cases:
for col_idx, header in enumerate(headers_order, start=1):
val = case.get(header, "")
if header in ("预置条件", "操作步骤") and isinstance(val, str):
# 把分号+空格变成换行,Excel 中会显示为多行
val = val.replace("; ", "\n")
if header == "JSON":
# JSON 列统一留空
val = ""
ws_new.cell(row=row, column=col_idx, value=val)
row += 1
# ===== 对“预置条件”和“操作步骤”开启自动换行 =====
col_idx_pre = headers_order.index("预置条件") + 1
col_idx_steps = headers_order.index("操作步骤") + 1
for r in range(1, row):
cell_pre = ws_new.cell(row=r, column=col_idx_pre)
cell_steps = ws_new.cell(row=r, column=col_idx_steps)
cell_pre.alignment = Alignment(wrap_text=True, vertical="top")
cell_steps.alignment = Alignment(wrap_text=True, vertical="top")
# 调整列宽
for col in ws_new.columns:
max_len = 0
col_letter = col[0].column_letter
for c in col:
v = c.value
if v is None:
continue
l = len(str(v))
if l > max_len:
max_len = l
ws_new.column_dimensions[col_letter].width = min(max_len + 2, 60)
wb.save(TEMPLATE_PATH)
print("已在原文件中创建新Sheet:", NEW_SHEET_NAME)
if __name__ == "__main__":
main()
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论