18 changed files with 2382 additions and 5 deletions
@ -0,0 +1,188 @@ |
|||
# QR码API使用说明 |
|||
|
|||
## 概述 |
|||
|
|||
QR码API已经升级为接收JSON格式的请求数据,提供更好的类型安全和扩展性。 |
|||
|
|||
## API接口说明 |
|||
|
|||
### 1. 生成加密二维码 |
|||
|
|||
**接口地址:** `POST /api/qr-code/create-encrypted-qr-code` |
|||
|
|||
**请求格式:** JSON |
|||
|
|||
**请求示例:** |
|||
```json |
|||
{ |
|||
"data": "用户ID:12345", |
|||
"width": 200, |
|||
"height": 200, |
|||
"expireMinutes": 30, |
|||
"businessType": "LOGIN" |
|||
} |
|||
``` |
|||
|
|||
**参数说明:** |
|||
- `data` (必填): 要加密的数据 |
|||
- `width` (可选): 二维码宽度,默认200,范围50-1000 |
|||
- `height` (可选): 二维码高度,默认200,范围50-1000 |
|||
- `expireMinutes` (可选): 过期时间(分钟),默认30,范围1-1440 |
|||
- `businessType` (可选): 业务类型 |
|||
|
|||
### 2. 解密二维码数据 |
|||
|
|||
**接口地址:** `POST /api/qr-code/decrypt-qr-data` |
|||
|
|||
**请求示例:** |
|||
```json |
|||
{ |
|||
"token": "abc123def456", |
|||
"encryptedData": "encrypted_data_string" |
|||
} |
|||
``` |
|||
|
|||
### 3. 验证并解密二维码内容 |
|||
|
|||
**接口地址:** `POST /api/qr-code/verify-and-decrypt-qr` |
|||
|
|||
**请求示例:** |
|||
```json |
|||
{ |
|||
"qrContent": "qr_content_string" |
|||
} |
|||
``` |
|||
|
|||
### 4. 验证并解密二维码内容(返回完整结果) |
|||
|
|||
**接口地址:** `POST /api/qr-code/verify-and-decrypt-qr-with-type` |
|||
|
|||
**请求示例:** |
|||
```json |
|||
{ |
|||
"qrContent": "qr_content_string" |
|||
} |
|||
``` |
|||
|
|||
### 5. 生成业务加密二维码 |
|||
|
|||
**接口地址:** `POST /api/qr-code/create-business-encrypted-qr-code` |
|||
|
|||
**请求示例:** |
|||
```json |
|||
{ |
|||
"data": "订单ID:ORDER123", |
|||
"businessKey": "store_key_123", |
|||
"width": 200, |
|||
"height": 200, |
|||
"expireMinutes": 30, |
|||
"businessType": "ORDER" |
|||
} |
|||
``` |
|||
|
|||
### 6. 门店核销二维码 |
|||
|
|||
**接口地址:** `POST /api/qr-code/verify-business-qr` |
|||
|
|||
**请求示例:** |
|||
```json |
|||
{ |
|||
"qrContent": "qr_content_string", |
|||
"businessKey": "store_key_123" |
|||
} |
|||
``` |
|||
|
|||
### 7. 使token失效 |
|||
|
|||
**接口地址:** `POST /api/qr-code/invalidate-token` |
|||
|
|||
**请求示例:** |
|||
```json |
|||
{ |
|||
"token": "abc123def456" |
|||
} |
|||
``` |
|||
|
|||
## GET接口(保持不变) |
|||
|
|||
以下GET接口保持原有的@RequestParam方式: |
|||
|
|||
### 生成普通二维码 |
|||
`GET /api/qr-code/create-qr-code?data=要编码的数据&size=200x200` |
|||
|
|||
### 生成加密二维码图片流 |
|||
`GET /api/qr-code/create-encrypted-qr-image?data=要加密的数据&size=200x200&expireMinutes=30&businessType=LOGIN` |
|||
|
|||
**参数说明:** |
|||
- `data` (必填): 要加密的数据 |
|||
- `size` (可选): 二维码尺寸,格式:宽x高,默认200x200 |
|||
- `expireMinutes` (可选): 过期时间(分钟),默认30 |
|||
- `businessType` (可选): 业务类型,如LOGIN、ORDER等 |
|||
|
|||
### 检查token是否有效 |
|||
`GET /api/qr-code/check-token?token=abc123def456` |
|||
|
|||
## 错误处理 |
|||
|
|||
API现在包含完整的参数验证,会返回具体的错误信息: |
|||
|
|||
**验证失败示例:** |
|||
```json |
|||
{ |
|||
"code": 500, |
|||
"message": "数据不能为空", |
|||
"data": null |
|||
} |
|||
``` |
|||
|
|||
**常见验证错误:** |
|||
- "数据不能为空" |
|||
- "宽度不能小于50像素" |
|||
- "宽度不能大于1000像素" |
|||
- "过期时间不能小于1分钟" |
|||
- "过期时间不能大于1440分钟" |
|||
- "token不能为空" |
|||
- "业务密钥不能为空" |
|||
|
|||
## 前端调用示例 |
|||
|
|||
### JavaScript/Axios |
|||
```javascript |
|||
// 生成加密二维码 |
|||
const response = await axios.post('/api/qr-code/create-encrypted-qr-code', { |
|||
data: '用户ID:12345', |
|||
width: 200, |
|||
height: 200, |
|||
expireMinutes: 30, |
|||
businessType: 'LOGIN' |
|||
}); |
|||
|
|||
console.log(response.data); |
|||
``` |
|||
|
|||
### jQuery |
|||
```javascript |
|||
$.ajax({ |
|||
url: '/api/qr-code/create-encrypted-qr-code', |
|||
type: 'POST', |
|||
contentType: 'application/json', |
|||
data: JSON.stringify({ |
|||
data: '用户ID:12345', |
|||
width: 200, |
|||
height: 200, |
|||
expireMinutes: 30, |
|||
businessType: 'LOGIN' |
|||
}), |
|||
success: function(response) { |
|||
console.log(response); |
|||
} |
|||
}); |
|||
``` |
|||
|
|||
## 升级说明 |
|||
|
|||
1. **向下兼容性:** GET接口保持不变,现有的GET请求不受影响 |
|||
2. **类型安全:** JSON格式提供更好的类型检查和验证 |
|||
3. **扩展性:** 新的DTO结构便于后续添加新字段 |
|||
4. **错误处理:** 提供更详细和友好的错误信息 |
|||
5. **功能增强:** `create-encrypted-qr-image`接口现在支持`businessType`参数 |
@ -0,0 +1,306 @@ |
|||
# 二维码业务类型使用指南 |
|||
|
|||
## 概述 |
|||
|
|||
现在二维码系统支持业务类型(businessType)参数,允许前端在生成二维码时指定业务类型,解密后可以根据不同的业务类型进行相应的处理。 |
|||
|
|||
## 支持的业务类型示例 |
|||
|
|||
```javascript |
|||
// 常见的业务类型 |
|||
const BUSINESS_TYPES = { |
|||
ORDER: 'order', // 订单二维码 |
|||
USER: 'user', // 用户信息二维码 |
|||
COUPON: 'coupon', // 优惠券二维码 |
|||
GIFT: 'gitf', // 礼品卡二维码 |
|||
TICKET: 'ticket', // 门票二维码 |
|||
PAYMENT: 'payment', // 支付二维码 |
|||
CHECKIN: 'checkin', // 签到二维码 |
|||
PRODUCT: 'product', // 商品二维码 |
|||
MEMBER: 'member', // 会员卡二维码 |
|||
PARKING: 'parking', // 停车二维码 |
|||
ACCESS: 'access' // 门禁二维码 |
|||
}; |
|||
``` |
|||
|
|||
## API 接口使用 |
|||
|
|||
### 1. 生成带业务类型的加密二维码 |
|||
|
|||
```http |
|||
POST /api/qr-code/create-encrypted-qr-code |
|||
``` |
|||
|
|||
**参数:** |
|||
```javascript |
|||
{ |
|||
data: "order_id:12345,amount:88.50", |
|||
width: 300, |
|||
height: 300, |
|||
expireMinutes: 60, |
|||
businessType: "order" // 新增的业务类型参数 |
|||
} |
|||
``` |
|||
|
|||
**响应:** |
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "生成加密二维码成功", |
|||
"data": { |
|||
"qrCodeBase64": "iVBORw0KGgoAAAANSUhEUgAA...", |
|||
"token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", |
|||
"originalData": "order_id:12345,amount:88.50", |
|||
"expireMinutes": "60", |
|||
"businessType": "order" |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### 2. 解密二维码(返回完整结果) |
|||
|
|||
```http |
|||
POST /api/qr-code/verify-and-decrypt-qr-with-type |
|||
``` |
|||
|
|||
**参数:** |
|||
```javascript |
|||
{ |
|||
qrContent: '{"token":"...","data":"...","type":"encrypted","businessType":"order"}' |
|||
} |
|||
``` |
|||
|
|||
**响应:** |
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "验证和解密成功", |
|||
"data": { |
|||
"originalData": "order_id:12345,amount:88.50", |
|||
"businessType": "order", |
|||
"qrType": "encrypted", |
|||
"qrId": null, |
|||
"expireTime": null, |
|||
"expired": false |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### 3. 生成业务模式二维码 |
|||
|
|||
```http |
|||
POST /api/qr-code/create-business-encrypted-qr-code |
|||
``` |
|||
|
|||
**参数:** |
|||
```javascript |
|||
{ |
|||
data: "coupon_id:C001,discount:20%", |
|||
businessKey: "store_001_secret", |
|||
width: 250, |
|||
height: 250, |
|||
expireMinutes: 120, |
|||
businessType: "coupon" |
|||
} |
|||
``` |
|||
|
|||
## 前端使用示例 |
|||
|
|||
### 场景1:餐厅点餐系统 |
|||
|
|||
```javascript |
|||
// 1. 生成订单二维码 |
|||
async function generateOrderQrCode(orderData) { |
|||
const response = await fetch('/api/qr-code/create-encrypted-qr-code', { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
|||
body: new URLSearchParams({ |
|||
data: JSON.stringify(orderData), |
|||
businessType: 'order', |
|||
expireMinutes: 30 |
|||
}) |
|||
}); |
|||
|
|||
const result = await response.json(); |
|||
return result.data; |
|||
} |
|||
|
|||
// 2. 扫码处理订单 |
|||
async function handleQrCodeScan(qrContent) { |
|||
const response = await fetch('/api/qr-code/verify-and-decrypt-qr-with-type', { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
|||
body: new URLSearchParams({ qrContent }) |
|||
}); |
|||
|
|||
const result = await response.json(); |
|||
if (result.code === 200) { |
|||
const { originalData, businessType } = result.data; |
|||
|
|||
// 根据业务类型处理不同逻辑 |
|||
switch (businessType) { |
|||
case 'order': |
|||
handleOrderQrCode(originalData); |
|||
break; |
|||
case 'coupon': |
|||
handleCouponQrCode(originalData); |
|||
break; |
|||
case 'user': |
|||
handleUserQrCode(originalData); |
|||
break; |
|||
default: |
|||
console.log('未知的业务类型:', businessType); |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 3. 处理订单二维码 |
|||
function handleOrderQrCode(orderData) { |
|||
const order = JSON.parse(orderData); |
|||
console.log('处理订单:', order); |
|||
|
|||
// 跳转到订单详情页 |
|||
window.location.href = `/order/detail?id=${order.orderId}`; |
|||
} |
|||
|
|||
// 4. 处理优惠券二维码 |
|||
function handleCouponQrCode(couponData) { |
|||
const coupon = JSON.parse(couponData); |
|||
console.log('处理优惠券:', coupon); |
|||
|
|||
// 显示优惠券使用界面 |
|||
showCouponDialog(coupon); |
|||
} |
|||
``` |
|||
|
|||
### 场景2:会员系统 |
|||
|
|||
```javascript |
|||
// 生成会员卡二维码 |
|||
async function generateMemberQrCode(memberId) { |
|||
const memberData = { |
|||
memberId: memberId, |
|||
timestamp: Date.now() |
|||
}; |
|||
|
|||
const qrResult = await generateQrCode({ |
|||
data: JSON.stringify(memberData), |
|||
businessType: 'member', |
|||
expireMinutes: 1440 // 24小时 |
|||
}); |
|||
|
|||
return qrResult; |
|||
} |
|||
|
|||
// 扫码验证会员 |
|||
async function verifyMemberQrCode(qrContent) { |
|||
const result = await verifyQrCode(qrContent); |
|||
|
|||
if (result.businessType === 'member') { |
|||
const memberData = JSON.parse(result.originalData); |
|||
|
|||
// 验证会员身份 |
|||
const member = await getMemberInfo(memberData.memberId); |
|||
if (member) { |
|||
showMemberInfo(member); |
|||
} else { |
|||
showError('会员不存在'); |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### 场景3:门店核销系统 |
|||
|
|||
```javascript |
|||
// 门店核销优惠券 |
|||
async function verifyStoreCoupon(qrContent, storeKey) { |
|||
const response = await fetch('/api/qr-code/verify-business-qr', { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
|||
body: new URLSearchParams({ |
|||
qrContent: qrContent, |
|||
businessKey: storeKey |
|||
}) |
|||
}); |
|||
|
|||
const result = await response.json(); |
|||
if (result.code === 200) { |
|||
// 解析二维码内容获取业务类型 |
|||
const qrData = JSON.parse(qrContent); |
|||
const businessType = qrData.businessType; |
|||
|
|||
switch (businessType) { |
|||
case 'coupon': |
|||
handleCouponVerification(result.data); |
|||
break; |
|||
case 'ticket': |
|||
handleTicketVerification(result.data); |
|||
break; |
|||
default: |
|||
console.log('门店不支持此类型的二维码:', businessType); |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
## 业务类型的好处 |
|||
|
|||
### 1. **前端路由分发** |
|||
```javascript |
|||
function routeByBusinessType(businessType, data) { |
|||
const routes = { |
|||
'order': '/order/scan', |
|||
'coupon': '/coupon/verify', |
|||
'user': '/user/profile', |
|||
'ticket': '/ticket/check', |
|||
'payment': '/payment/process' |
|||
}; |
|||
|
|||
const route = routes[businessType]; |
|||
if (route) { |
|||
window.location.href = `${route}?data=${encodeURIComponent(data)}`; |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### 2. **权限控制** |
|||
```javascript |
|||
function checkPermission(businessType, userRole) { |
|||
const permissions = { |
|||
'order': ['waiter', 'manager'], |
|||
'coupon': ['cashier', 'manager'], |
|||
'ticket': ['security', 'manager'], |
|||
'payment': ['cashier', 'manager'] |
|||
}; |
|||
|
|||
return permissions[businessType]?.includes(userRole); |
|||
} |
|||
``` |
|||
|
|||
### 3. **统计分析** |
|||
```javascript |
|||
function trackQrCodeUsage(businessType, action) { |
|||
analytics.track('qr_code_scan', { |
|||
business_type: businessType, |
|||
action: action, |
|||
timestamp: Date.now() |
|||
}); |
|||
} |
|||
``` |
|||
|
|||
## 注意事项 |
|||
|
|||
1. **业务类型验证**:前端应该验证业务类型是否符合预期 |
|||
2. **权限检查**:根据业务类型检查用户是否有相应权限 |
|||
3. **错误处理**:优雅处理未知的业务类型 |
|||
4. **安全性**:业务类型不应包含敏感信息 |
|||
5. **向后兼容**:支持没有业务类型的旧二维码 |
|||
|
|||
## 最佳实践 |
|||
|
|||
1. **统一业务类型常量**:在前后端定义统一的业务类型常量 |
|||
2. **类型验证**:在解密后验证业务类型是否符合当前场景 |
|||
3. **日志记录**:记录不同业务类型的使用情况 |
|||
4. **监控告警**:监控异常的业务类型使用 |
|||
5. **文档维护**:及时更新业务类型的文档说明 |
@ -0,0 +1,215 @@ |
|||
# 加密二维码使用说明 |
|||
|
|||
## 概述 |
|||
|
|||
本系统提供了基于token的二维码加密和解密功能,可以安全地生成包含敏感信息的二维码,并设置过期时间。 |
|||
|
|||
## 主要特性 |
|||
|
|||
1. **AES加密**:使用AES对称加密算法保护二维码数据 |
|||
2. **Token机制**:每个二维码都有唯一的token作为密钥 |
|||
3. **过期控制**:可设置二维码的有效期(1-1440分钟) |
|||
4. **Redis存储**:token和原始数据存储在Redis中,支持自动过期 |
|||
5. **数据验证**:解密时会验证数据完整性 |
|||
|
|||
## API接口 |
|||
|
|||
### 1. 生成普通二维码 |
|||
|
|||
```http |
|||
GET /api/qr-code/create-qr-code?data=https://example.com&size=200x200 |
|||
``` |
|||
|
|||
**参数:** |
|||
- `data`: 要编码的数据(必需) |
|||
- `size`: 二维码尺寸,格式:宽x高 或 单个数字(可选,默认200x200) |
|||
|
|||
**响应:** 直接返回PNG图片流 |
|||
|
|||
### 2. 生成加密二维码(返回JSON) |
|||
|
|||
```http |
|||
POST /api/qr-code/create-encrypted-qr-code |
|||
``` |
|||
|
|||
**参数:** |
|||
- `data`: 要加密的数据(必需) |
|||
- `width`: 二维码宽度(可选,默认200) |
|||
- `height`: 二维码高度(可选,默认200) |
|||
- `expireMinutes`: 过期时间分钟数(可选,默认30,最大1440) |
|||
|
|||
**响应示例:** |
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "生成加密二维码成功", |
|||
"data": { |
|||
"qrCodeBase64": "iVBORw0KGgoAAAANSUhEUgAA...", |
|||
"token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", |
|||
"originalData": "https://example.com/user/123", |
|||
"expireMinutes": "30" |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### 3. 生成加密二维码(返回图片流) |
|||
|
|||
```http |
|||
GET /api/qr-code/create-encrypted-qr-image?data=https://example.com&size=300x300&expireMinutes=60 |
|||
``` |
|||
|
|||
**参数:** |
|||
- `data`: 要加密的数据(必需) |
|||
- `size`: 二维码尺寸(可选,默认200x200) |
|||
- `expireMinutes`: 过期时间分钟数(可选,默认30) |
|||
|
|||
**响应:** 直接返回PNG图片流,并在响应头中包含token信息 |
|||
- `X-QR-Token`: 二维码的token |
|||
- `X-QR-Expire-Minutes`: 过期时间 |
|||
|
|||
### 4. 解密二维码数据 |
|||
|
|||
```http |
|||
POST /api/qr-code/decrypt-qr-data |
|||
``` |
|||
|
|||
**参数:** |
|||
- `token`: token密钥(必需) |
|||
- `encryptedData`: 加密的数据(必需) |
|||
|
|||
**响应示例:** |
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "解密成功", |
|||
"data": "https://example.com/user/123" |
|||
} |
|||
``` |
|||
|
|||
### 5. 验证并解密二维码内容 |
|||
|
|||
```http |
|||
POST /api/qr-code/verify-and-decrypt-qr |
|||
``` |
|||
|
|||
**参数:** |
|||
- `qrContent`: 二维码扫描得到的完整JSON内容(必需) |
|||
|
|||
**二维码内容格式:** |
|||
```json |
|||
{ |
|||
"token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", |
|||
"data": "encrypted_data_here", |
|||
"type": "encrypted" |
|||
} |
|||
``` |
|||
|
|||
**响应示例:** |
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "验证和解密成功", |
|||
"data": "https://example.com/user/123" |
|||
} |
|||
``` |
|||
|
|||
### 6. 检查token是否有效 |
|||
|
|||
```http |
|||
GET /api/qr-code/check-token?token=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6 |
|||
``` |
|||
|
|||
**响应示例:** |
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "检查完成", |
|||
"data": true |
|||
} |
|||
``` |
|||
|
|||
### 7. 使token失效 |
|||
|
|||
```http |
|||
POST /api/qr-code/invalidate-token |
|||
``` |
|||
|
|||
**参数:** |
|||
- `token`: 要使失效的token(必需) |
|||
|
|||
**响应示例:** |
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "token已失效" |
|||
} |
|||
``` |
|||
|
|||
## 使用场景 |
|||
|
|||
### 场景1:用户身份验证二维码 |
|||
|
|||
```javascript |
|||
// 生成包含用户ID的加密二维码 |
|||
const response = await fetch('/api/qr-code/create-encrypted-qr-code', { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
|||
body: 'data=user_id:12345&expireMinutes=10' |
|||
}); |
|||
|
|||
const result = await response.json(); |
|||
// 显示二维码图片:result.data.qrCodeBase64 |
|||
// 保存token用于后续验证:result.data.token |
|||
``` |
|||
|
|||
### 场景2:临时访问链接 |
|||
|
|||
```javascript |
|||
// 生成临时访问链接的二维码 |
|||
const accessUrl = 'https://example.com/temp-access?session=abc123'; |
|||
const response = await fetch('/api/qr-code/create-encrypted-qr-image?' + |
|||
new URLSearchParams({ |
|||
data: accessUrl, |
|||
size: '250x250', |
|||
expireMinutes: '5' |
|||
})); |
|||
|
|||
// 直接使用返回的图片流 |
|||
// token信息在响应头 X-QR-Token 中 |
|||
``` |
|||
|
|||
### 场景3:扫码验证 |
|||
|
|||
```javascript |
|||
// 扫描二维码后验证 |
|||
const qrContent = '{"token":"...","data":"...","type":"encrypted"}'; |
|||
const response = await fetch('/api/qr-code/verify-and-decrypt-qr', { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
|||
body: 'qrContent=' + encodeURIComponent(qrContent) |
|||
}); |
|||
|
|||
const result = await response.json(); |
|||
if (result.code === 200) { |
|||
console.log('原始数据:', result.data); |
|||
} else { |
|||
console.log('验证失败:', result.message); |
|||
} |
|||
``` |
|||
|
|||
## 安全注意事项 |
|||
|
|||
1. **token保护**:token是解密的关键,应妥善保管 |
|||
2. **过期时间**:根据安全需求设置合适的过期时间 |
|||
3. **HTTPS传输**:生产环境中应使用HTTPS传输 |
|||
4. **访问控制**:可根据需要添加接口访问权限控制 |
|||
5. **日志记录**:建议记录二维码生成和验证的操作日志 |
|||
|
|||
## 错误处理 |
|||
|
|||
常见错误及处理: |
|||
|
|||
- `token已过期或无效`:二维码已过期,需要重新生成 |
|||
- `数据验证失败`:加密数据被篡改或token不匹配 |
|||
- `尺寸必须在50-1000像素之间`:二维码尺寸超出允许范围 |
|||
- `过期时间必须在1-1440分钟之间`:过期时间设置不合理 |
@ -0,0 +1,197 @@ |
|||
# 二维码加密的两种模式详解 |
|||
|
|||
## 问题背景 |
|||
|
|||
您提出的问题很关键:**用户生成二维码时的token和门店核销时的token是否一样?** |
|||
|
|||
在实际业务场景中,这确实是个问题。我们提供了两种解决方案: |
|||
|
|||
## 模式一:自包含模式(Self-Contained Mode) |
|||
|
|||
### 特点 |
|||
- 二维码**包含所有解密所需的信息** |
|||
- 扫码方**无需额外的密钥或token** |
|||
- 适用于**点对点**的场景 |
|||
|
|||
### 工作流程 |
|||
``` |
|||
1. 用户生成二维码 |
|||
↓ |
|||
2. 系统生成随机token作为密钥 |
|||
↓ |
|||
3. 用token加密数据 |
|||
↓ |
|||
4. 二维码内容 = {token, 加密数据, 类型} |
|||
↓ |
|||
5. 任何人扫码都能解密(因为token在二维码中) |
|||
``` |
|||
|
|||
### 二维码内容示例 |
|||
```json |
|||
{ |
|||
"token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", |
|||
"data": "encrypted_data_here", |
|||
"type": "encrypted" |
|||
} |
|||
``` |
|||
|
|||
### 使用场景 |
|||
- 临时分享链接 |
|||
- 个人信息展示 |
|||
- 一次性验证码 |
|||
|
|||
### 安全性 |
|||
- ✅ 数据加密保护 |
|||
- ✅ 支持过期时间 |
|||
- ⚠️ 任何人扫码都能解密 |
|||
- ⚠️ 二维码泄露 = 数据泄露 |
|||
|
|||
--- |
|||
|
|||
## 模式二:业务模式(Business Mode) |
|||
|
|||
### 特点 |
|||
- 使用**统一的业务密钥** |
|||
- 门店有**预设的解密密钥** |
|||
- 支持**防重复核销** |
|||
- 适用于**商业核销**场景 |
|||
|
|||
### 工作流程 |
|||
``` |
|||
1. 用户生成二维码 |
|||
↓ |
|||
2. 使用预设的业务密钥(如门店密钥)加密 |
|||
↓ |
|||
3. 生成唯一的二维码ID |
|||
↓ |
|||
4. 二维码内容 = {二维码ID, 加密数据, 类型} |
|||
↓ |
|||
5. 门店用相同的业务密钥解密 |
|||
↓ |
|||
6. 系统标记该二维码为已使用 |
|||
``` |
|||
|
|||
### 二维码内容示例 |
|||
```json |
|||
{ |
|||
"qrId": "abc123def456", |
|||
"data": "encrypted_data_here", |
|||
"type": "business_encrypted", |
|||
"expire": "1692345678000" |
|||
} |
|||
``` |
|||
|
|||
### 密钥管理 |
|||
``` |
|||
门店A: businessKey = "store_001_secret_key" |
|||
门店B: businessKey = "store_002_secret_key" |
|||
门店C: businessKey = "store_003_secret_key" |
|||
``` |
|||
|
|||
### 使用场景 |
|||
- 🎫 **优惠券核销** |
|||
- 🍔 **餐厅点餐码** |
|||
- 🎬 **电影票验证** |
|||
- 🚗 **停车场进出** |
|||
- 💊 **药品溯源** |
|||
|
|||
### 安全性 |
|||
- ✅ 数据加密保护 |
|||
- ✅ 防重复核销 |
|||
- ✅ 门店权限控制 |
|||
- ✅ 即使二维码泄露,没有密钥也无法解密 |
|||
|
|||
--- |
|||
|
|||
## 实际应用示例 |
|||
|
|||
### 场景:餐厅点餐系统 |
|||
|
|||
#### 1. 用户下单生成二维码 |
|||
```java |
|||
// 用户订单信息 |
|||
String orderData = "orderId:12345,tableNo:8,amount:88.50"; |
|||
|
|||
// 使用餐厅的业务密钥 |
|||
String restaurantKey = "restaurant_001_secret"; |
|||
|
|||
// 生成业务加密二维码 |
|||
Map<String, Object> result = encryptedQrCodeUtil.generateBusinessEncryptedQrCode( |
|||
orderData, 300, 300, restaurantKey, 60L |
|||
); |
|||
``` |
|||
|
|||
#### 2. 服务员扫码核销 |
|||
```java |
|||
// 扫码得到的内容 |
|||
String qrContent = "{\"qrId\":\"abc123\",\"data\":\"encrypted...\",\"type\":\"business_encrypted\"}"; |
|||
|
|||
// 使用餐厅密钥解密 |
|||
String orderInfo = encryptedQrCodeUtil.verifyAndDecryptQrCodeWithBusinessKey( |
|||
qrContent, "restaurant_001_secret" |
|||
); |
|||
|
|||
// 结果:orderId:12345,tableNo:8,amount:88.50 |
|||
``` |
|||
|
|||
#### 3. 防重复核销 |
|||
```java |
|||
// 第二次扫同一个二维码 |
|||
try { |
|||
String orderInfo = encryptedQrCodeUtil.verifyAndDecryptQrCodeWithBusinessKey( |
|||
qrContent, "restaurant_001_secret" |
|||
); |
|||
} catch (RuntimeException e) { |
|||
// 抛出异常:二维码已被使用 |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## API接口对比 |
|||
|
|||
### 自包含模式 |
|||
```http |
|||
# 生成 |
|||
POST /api/qr-code/create-encrypted-qr-code |
|||
data=user_info&width=200&height=200&expireMinutes=30 |
|||
|
|||
# 解密(任何人都可以) |
|||
POST /api/qr-code/verify-and-decrypt-qr |
|||
qrContent={"token":"...","data":"...","type":"encrypted"} |
|||
``` |
|||
|
|||
### 业务模式 |
|||
```http |
|||
# 生成 |
|||
POST /api/qr-code/create-business-encrypted-qr-code |
|||
data=order_info&businessKey=store_001_key&width=200&height=200&expireMinutes=60 |
|||
|
|||
# 核销(需要对应的业务密钥) |
|||
POST /api/qr-code/verify-business-qr |
|||
qrContent={"qrId":"...","data":"...","type":"business_encrypted"}&businessKey=store_001_key |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 选择建议 |
|||
|
|||
| 场景 | 推荐模式 | 原因 | |
|||
|------|----------|------| |
|||
| 个人信息分享 | 自包含模式 | 简单方便,无需额外配置 | |
|||
| 临时链接分享 | 自包含模式 | 接收方无需特殊权限 | |
|||
| 商业核销 | 业务模式 | 安全性高,防重复使用 | |
|||
| 门店验证 | 业务模式 | 权限控制,业务流程完整 | |
|||
| 支付码 | 业务模式 | 安全要求高 | |
|||
| 会员卡 | 业务模式 | 需要权限验证 | |
|||
|
|||
--- |
|||
|
|||
## 总结 |
|||
|
|||
**您的疑问是对的!** 在门店核销场景中: |
|||
|
|||
1. **自包含模式**:token在二维码中,门店直接扫码即可解密 |
|||
2. **业务模式**:门店有预设的业务密钥,用户生成时用这个密钥加密 |
|||
|
|||
**推荐使用业务模式**,因为它更符合实际的商业应用需求,安全性更高,且支持防重复核销。 |
@ -0,0 +1,114 @@ |
|||
package com.gxwebsoft.common.core.dto.qr; |
|||
|
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
|
|||
import javax.validation.constraints.*; |
|||
|
|||
/** |
|||
* 创建业务加密二维码请求DTO |
|||
* |
|||
* @author WebSoft |
|||
* @since 2025-08-18 |
|||
*/ |
|||
@Schema(description = "创建业务加密二维码请求") |
|||
public class CreateBusinessEncryptedQrCodeRequest { |
|||
|
|||
@Schema(description = "要加密的数据", required = true, example = "订单ID:ORDER123") |
|||
@NotBlank(message = "数据不能为空") |
|||
private String data; |
|||
|
|||
@Schema(description = "业务密钥(如门店密钥)", required = true, example = "store_key_123") |
|||
@NotBlank(message = "业务密钥不能为空") |
|||
private String businessKey; |
|||
|
|||
@Schema(description = "二维码宽度", example = "200") |
|||
@Min(value = 50, message = "宽度不能小于50像素") |
|||
@Max(value = 1000, message = "宽度不能大于1000像素") |
|||
private Integer width = 200; |
|||
|
|||
@Schema(description = "二维码高度", example = "200") |
|||
@Min(value = 50, message = "高度不能小于50像素") |
|||
@Max(value = 1000, message = "高度不能大于1000像素") |
|||
private Integer height = 200; |
|||
|
|||
@Schema(description = "过期时间(分钟)", example = "30") |
|||
@Min(value = 1, message = "过期时间不能小于1分钟") |
|||
@Max(value = 1440, message = "过期时间不能大于1440分钟") |
|||
private Long expireMinutes = 30L; |
|||
|
|||
@Schema(description = "业务类型(可选)", example = "ORDER") |
|||
private String businessType; |
|||
|
|||
// 构造函数
|
|||
public CreateBusinessEncryptedQrCodeRequest() {} |
|||
|
|||
public CreateBusinessEncryptedQrCodeRequest(String data, String businessKey, Integer width, Integer height, Long expireMinutes, String businessType) { |
|||
this.data = data; |
|||
this.businessKey = businessKey; |
|||
this.width = width; |
|||
this.height = height; |
|||
this.expireMinutes = expireMinutes; |
|||
this.businessType = businessType; |
|||
} |
|||
|
|||
// Getter和Setter方法
|
|||
public String getData() { |
|||
return data; |
|||
} |
|||
|
|||
public void setData(String data) { |
|||
this.data = data; |
|||
} |
|||
|
|||
public String getBusinessKey() { |
|||
return businessKey; |
|||
} |
|||
|
|||
public void setBusinessKey(String businessKey) { |
|||
this.businessKey = businessKey; |
|||
} |
|||
|
|||
public Integer getWidth() { |
|||
return width; |
|||
} |
|||
|
|||
public void setWidth(Integer width) { |
|||
this.width = width; |
|||
} |
|||
|
|||
public Integer getHeight() { |
|||
return height; |
|||
} |
|||
|
|||
public void setHeight(Integer height) { |
|||
this.height = height; |
|||
} |
|||
|
|||
public Long getExpireMinutes() { |
|||
return expireMinutes; |
|||
} |
|||
|
|||
public void setExpireMinutes(Long expireMinutes) { |
|||
this.expireMinutes = expireMinutes; |
|||
} |
|||
|
|||
public String getBusinessType() { |
|||
return businessType; |
|||
} |
|||
|
|||
public void setBusinessType(String businessType) { |
|||
this.businessType = businessType; |
|||
} |
|||
|
|||
@Override |
|||
public String toString() { |
|||
return "CreateBusinessEncryptedQrCodeRequest{" + |
|||
"data='" + data + '\'' + |
|||
", businessKey='" + businessKey + '\'' + |
|||
", width=" + width + |
|||
", height=" + height + |
|||
", expireMinutes=" + expireMinutes + |
|||
", businessType='" + businessType + '\'' + |
|||
'}'; |
|||
} |
|||
} |
@ -0,0 +1,100 @@ |
|||
package com.gxwebsoft.common.core.dto.qr; |
|||
|
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
|
|||
import javax.validation.constraints.*; |
|||
|
|||
/** |
|||
* 创建加密二维码请求DTO |
|||
* |
|||
* @author WebSoft |
|||
* @since 2025-08-18 |
|||
*/ |
|||
@Schema(description = "创建加密二维码请求") |
|||
public class CreateEncryptedQrCodeRequest { |
|||
|
|||
@Schema(description = "要加密的数据", required = true, example = "用户ID:12345") |
|||
@NotBlank(message = "数据不能为空") |
|||
private String data; |
|||
|
|||
@Schema(description = "二维码宽度", example = "200") |
|||
@Min(value = 50, message = "宽度不能小于50像素") |
|||
@Max(value = 1000, message = "宽度不能大于1000像素") |
|||
private Integer width = 200; |
|||
|
|||
@Schema(description = "二维码高度", example = "200") |
|||
@Min(value = 50, message = "高度不能小于50像素") |
|||
@Max(value = 1000, message = "高度不能大于1000像素") |
|||
private Integer height = 200; |
|||
|
|||
@Schema(description = "过期时间(分钟)", example = "30") |
|||
@Min(value = 1, message = "过期时间不能小于1分钟") |
|||
@Max(value = 1440, message = "过期时间不能大于1440分钟") |
|||
private Long expireMinutes = 30L; |
|||
|
|||
@Schema(description = "业务类型(可选)", example = "LOGIN") |
|||
private String businessType; |
|||
|
|||
// 构造函数
|
|||
public CreateEncryptedQrCodeRequest() {} |
|||
|
|||
public CreateEncryptedQrCodeRequest(String data, Integer width, Integer height, Long expireMinutes, String businessType) { |
|||
this.data = data; |
|||
this.width = width; |
|||
this.height = height; |
|||
this.expireMinutes = expireMinutes; |
|||
this.businessType = businessType; |
|||
} |
|||
|
|||
// Getter和Setter方法
|
|||
public String getData() { |
|||
return data; |
|||
} |
|||
|
|||
public void setData(String data) { |
|||
this.data = data; |
|||
} |
|||
|
|||
public Integer getWidth() { |
|||
return width; |
|||
} |
|||
|
|||
public void setWidth(Integer width) { |
|||
this.width = width; |
|||
} |
|||
|
|||
public Integer getHeight() { |
|||
return height; |
|||
} |
|||
|
|||
public void setHeight(Integer height) { |
|||
this.height = height; |
|||
} |
|||
|
|||
public Long getExpireMinutes() { |
|||
return expireMinutes; |
|||
} |
|||
|
|||
public void setExpireMinutes(Long expireMinutes) { |
|||
this.expireMinutes = expireMinutes; |
|||
} |
|||
|
|||
public String getBusinessType() { |
|||
return businessType; |
|||
} |
|||
|
|||
public void setBusinessType(String businessType) { |
|||
this.businessType = businessType; |
|||
} |
|||
|
|||
@Override |
|||
public String toString() { |
|||
return "CreateEncryptedQrCodeRequest{" + |
|||
"data='" + data + '\'' + |
|||
", width=" + width + |
|||
", height=" + height + |
|||
", expireMinutes=" + expireMinutes + |
|||
", businessType='" + businessType + '\'' + |
|||
'}'; |
|||
} |
|||
} |
@ -0,0 +1,56 @@ |
|||
package com.gxwebsoft.common.core.dto.qr; |
|||
|
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
|
|||
import javax.validation.constraints.NotBlank; |
|||
|
|||
/** |
|||
* 解密二维码数据请求DTO |
|||
* |
|||
* @author WebSoft |
|||
* @since 2025-08-18 |
|||
*/ |
|||
@Schema(description = "解密二维码数据请求") |
|||
public class DecryptQrDataRequest { |
|||
|
|||
@Schema(description = "token密钥", required = true, example = "abc123def456") |
|||
@NotBlank(message = "token不能为空") |
|||
private String token; |
|||
|
|||
@Schema(description = "加密的数据", required = true, example = "encrypted_data_string") |
|||
@NotBlank(message = "加密数据不能为空") |
|||
private String encryptedData; |
|||
|
|||
// 构造函数
|
|||
public DecryptQrDataRequest() {} |
|||
|
|||
public DecryptQrDataRequest(String token, String encryptedData) { |
|||
this.token = token; |
|||
this.encryptedData = encryptedData; |
|||
} |
|||
|
|||
// Getter和Setter方法
|
|||
public String getToken() { |
|||
return token; |
|||
} |
|||
|
|||
public void setToken(String token) { |
|||
this.token = token; |
|||
} |
|||
|
|||
public String getEncryptedData() { |
|||
return encryptedData; |
|||
} |
|||
|
|||
public void setEncryptedData(String encryptedData) { |
|||
this.encryptedData = encryptedData; |
|||
} |
|||
|
|||
@Override |
|||
public String toString() { |
|||
return "DecryptQrDataRequest{" + |
|||
"token='" + token + '\'' + |
|||
", encryptedData='" + encryptedData + '\'' + |
|||
'}'; |
|||
} |
|||
} |
@ -0,0 +1,42 @@ |
|||
package com.gxwebsoft.common.core.dto.qr; |
|||
|
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
|
|||
import javax.validation.constraints.NotBlank; |
|||
|
|||
/** |
|||
* 使token失效请求DTO |
|||
* |
|||
* @author WebSoft |
|||
* @since 2025-08-18 |
|||
*/ |
|||
@Schema(description = "使token失效请求") |
|||
public class InvalidateTokenRequest { |
|||
|
|||
@Schema(description = "要使失效的token", required = true, example = "abc123def456") |
|||
@NotBlank(message = "token不能为空") |
|||
private String token; |
|||
|
|||
// 构造函数
|
|||
public InvalidateTokenRequest() {} |
|||
|
|||
public InvalidateTokenRequest(String token) { |
|||
this.token = token; |
|||
} |
|||
|
|||
// Getter和Setter方法
|
|||
public String getToken() { |
|||
return token; |
|||
} |
|||
|
|||
public void setToken(String token) { |
|||
this.token = token; |
|||
} |
|||
|
|||
@Override |
|||
public String toString() { |
|||
return "InvalidateTokenRequest{" + |
|||
"token='" + token + '\'' + |
|||
'}'; |
|||
} |
|||
} |
@ -0,0 +1,56 @@ |
|||
package com.gxwebsoft.common.core.dto.qr; |
|||
|
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
|
|||
import javax.validation.constraints.NotBlank; |
|||
|
|||
/** |
|||
* 门店核销二维码请求DTO |
|||
* |
|||
* @author WebSoft |
|||
* @since 2025-08-18 |
|||
*/ |
|||
@Schema(description = "门店核销二维码请求") |
|||
public class VerifyBusinessQrRequest { |
|||
|
|||
@Schema(description = "二维码扫描得到的完整内容", required = true, example = "qr_content_string") |
|||
@NotBlank(message = "二维码内容不能为空") |
|||
private String qrContent; |
|||
|
|||
@Schema(description = "门店业务密钥", required = true, example = "store_key_123") |
|||
@NotBlank(message = "业务密钥不能为空") |
|||
private String businessKey; |
|||
|
|||
// 构造函数
|
|||
public VerifyBusinessQrRequest() {} |
|||
|
|||
public VerifyBusinessQrRequest(String qrContent, String businessKey) { |
|||
this.qrContent = qrContent; |
|||
this.businessKey = businessKey; |
|||
} |
|||
|
|||
// Getter和Setter方法
|
|||
public String getQrContent() { |
|||
return qrContent; |
|||
} |
|||
|
|||
public void setQrContent(String qrContent) { |
|||
this.qrContent = qrContent; |
|||
} |
|||
|
|||
public String getBusinessKey() { |
|||
return businessKey; |
|||
} |
|||
|
|||
public void setBusinessKey(String businessKey) { |
|||
this.businessKey = businessKey; |
|||
} |
|||
|
|||
@Override |
|||
public String toString() { |
|||
return "VerifyBusinessQrRequest{" + |
|||
"qrContent='" + qrContent + '\'' + |
|||
", businessKey='" + businessKey + '\'' + |
|||
'}'; |
|||
} |
|||
} |
@ -0,0 +1,42 @@ |
|||
package com.gxwebsoft.common.core.dto.qr; |
|||
|
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
|
|||
import javax.validation.constraints.NotBlank; |
|||
|
|||
/** |
|||
* 验证二维码内容请求DTO |
|||
* |
|||
* @author WebSoft |
|||
* @since 2025-08-18 |
|||
*/ |
|||
@Schema(description = "验证二维码内容请求") |
|||
public class VerifyQrContentRequest { |
|||
|
|||
@Schema(description = "二维码扫描得到的完整内容", required = true, example = "qr_content_string") |
|||
@NotBlank(message = "二维码内容不能为空") |
|||
private String qrContent; |
|||
|
|||
// 构造函数
|
|||
public VerifyQrContentRequest() {} |
|||
|
|||
public VerifyQrContentRequest(String qrContent) { |
|||
this.qrContent = qrContent; |
|||
} |
|||
|
|||
// Getter和Setter方法
|
|||
public String getQrContent() { |
|||
return qrContent; |
|||
} |
|||
|
|||
public void setQrContent(String qrContent) { |
|||
this.qrContent = qrContent; |
|||
} |
|||
|
|||
@Override |
|||
public String toString() { |
|||
return "VerifyQrContentRequest{" + |
|||
"qrContent='" + qrContent + '\'' + |
|||
'}'; |
|||
} |
|||
} |
@ -0,0 +1,433 @@ |
|||
package com.gxwebsoft.common.core.utils; |
|||
|
|||
import cn.hutool.core.util.CharsetUtil; |
|||
import cn.hutool.core.util.RandomUtil; |
|||
import cn.hutool.core.util.StrUtil; |
|||
import cn.hutool.crypto.SecureUtil; |
|||
import cn.hutool.crypto.symmetric.AES; |
|||
import cn.hutool.crypto.symmetric.SymmetricAlgorithm; |
|||
import cn.hutool.extra.qrcode.QrCodeUtil; |
|||
import cn.hutool.extra.qrcode.QrConfig; |
|||
import com.alibaba.fastjson.JSONObject; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import javax.crypto.spec.SecretKeySpec; |
|||
import java.awt.*; |
|||
import java.io.ByteArrayOutputStream; |
|||
import java.util.Base64; |
|||
import java.util.HashMap; |
|||
import java.util.Map; |
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
/** |
|||
* 加密二维码工具类 |
|||
* 使用token作为密钥对二维码数据进行AES加密 |
|||
* |
|||
* @author WebSoft |
|||
* @since 2025-08-18 |
|||
*/ |
|||
@Component |
|||
public class EncryptedQrCodeUtil { |
|||
|
|||
@Autowired |
|||
private RedisUtil redisUtil; |
|||
|
|||
private static final String QR_TOKEN_PREFIX = "qr_token:"; |
|||
private static final long DEFAULT_EXPIRE_MINUTES = 30; // 默认30分钟过期
|
|||
|
|||
/** |
|||
* 生成加密的二维码数据 |
|||
* |
|||
* @param originalData 原始数据 |
|||
* @param expireMinutes 过期时间(分钟) |
|||
* @return 包含token和加密数据的Map |
|||
*/ |
|||
public Map<String, String> generateEncryptedData(String originalData, Long expireMinutes) { |
|||
if (StrUtil.isBlank(originalData)) { |
|||
throw new IllegalArgumentException("原始数据不能为空"); |
|||
} |
|||
|
|||
if (expireMinutes == null || expireMinutes <= 0) { |
|||
expireMinutes = DEFAULT_EXPIRE_MINUTES; |
|||
} |
|||
|
|||
// 生成随机token作为密钥
|
|||
String token = RandomUtil.randomString(32); |
|||
|
|||
try { |
|||
// 使用token生成AES密钥
|
|||
AES aes = createAESFromToken(token); |
|||
|
|||
// 加密原始数据
|
|||
String encryptedData = aes.encryptHex(originalData); |
|||
|
|||
// 将token和原始数据存储到Redis中,设置过期时间
|
|||
String redisKey = QR_TOKEN_PREFIX + token; |
|||
redisUtil.set(redisKey, originalData, expireMinutes, TimeUnit.MINUTES); |
|||
|
|||
Map<String, String> result = new HashMap<>(); |
|||
result.put("token", token); |
|||
result.put("encryptedData", encryptedData); |
|||
result.put("expireMinutes", expireMinutes.toString()); |
|||
|
|||
return result; |
|||
|
|||
} catch (Exception e) { |
|||
throw new RuntimeException("生成加密数据失败: " + e.getMessage(), e); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 解密二维码数据 |
|||
* |
|||
* @param token 密钥token |
|||
* @param encryptedData 加密的数据 |
|||
* @return 解密后的原始数据 |
|||
*/ |
|||
public String decryptData(String token, String encryptedData) { |
|||
if (StrUtil.isBlank(token) || StrUtil.isBlank(encryptedData)) { |
|||
throw new IllegalArgumentException("token和加密数据不能为空"); |
|||
} |
|||
|
|||
try { |
|||
// 从Redis验证token是否有效
|
|||
String redisKey = QR_TOKEN_PREFIX + token; |
|||
String originalData = redisUtil.get(redisKey); |
|||
|
|||
if (StrUtil.isBlank(originalData)) { |
|||
throw new RuntimeException("token已过期或无效"); |
|||
} |
|||
|
|||
// 使用token生成AES密钥
|
|||
AES aes = createAESFromToken(token); |
|||
|
|||
// 解密数据
|
|||
String decryptedData = aes.decryptStr(encryptedData, CharsetUtil.CHARSET_UTF_8); |
|||
|
|||
// 验证解密结果与Redis中存储的数据是否一致
|
|||
if (!originalData.equals(decryptedData)) { |
|||
throw new RuntimeException("数据验证失败"); |
|||
} |
|||
|
|||
return decryptedData; |
|||
|
|||
} catch (Exception e) { |
|||
throw new RuntimeException("解密数据失败: " + e.getMessage(), e); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 生成加密的二维码图片(自包含模式) |
|||
* |
|||
* @param originalData 原始数据 |
|||
* @param width 二维码宽度 |
|||
* @param height 二维码高度 |
|||
* @param expireMinutes 过期时间(分钟) |
|||
* @param businessType 业务类型(可选,如:order、user、coupon等) |
|||
* @return 包含二维码图片Base64和token的Map |
|||
*/ |
|||
public Map<String, Object> generateEncryptedQrCode(String originalData, int width, int height, Long expireMinutes, String businessType) { |
|||
try { |
|||
// 生成加密数据
|
|||
Map<String, String> encryptedInfo = generateEncryptedData(originalData, expireMinutes); |
|||
|
|||
// 创建二维码内容(包含token、加密数据和业务类型)
|
|||
Map<String, String> qrContent = new HashMap<>(); |
|||
qrContent.put("token", encryptedInfo.get("token")); |
|||
qrContent.put("data", encryptedInfo.get("encryptedData")); |
|||
qrContent.put("type", "encrypted"); |
|||
|
|||
// 添加业务类型(如果提供)
|
|||
if (StrUtil.isNotBlank(businessType)) { |
|||
qrContent.put("businessType", businessType); |
|||
} |
|||
|
|||
String qrDataJson = JSONObject.toJSONString(qrContent); |
|||
|
|||
// 配置二维码
|
|||
QrConfig config = new QrConfig(width, height); |
|||
config.setMargin(1); |
|||
config.setForeColor(Color.BLACK); |
|||
config.setBackColor(Color.WHITE); |
|||
|
|||
// 生成二维码图片
|
|||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); |
|||
QrCodeUtil.generate(qrDataJson, config, "png", outputStream); |
|||
|
|||
// 转换为Base64
|
|||
String base64Image = Base64.getEncoder().encodeToString(outputStream.toByteArray()); |
|||
|
|||
Map<String, Object> result = new HashMap<>(); |
|||
result.put("qrCodeBase64", base64Image); |
|||
result.put("token", encryptedInfo.get("token")); |
|||
result.put("originalData", originalData); |
|||
result.put("expireMinutes", encryptedInfo.get("expireMinutes")); |
|||
result.put("businessType", businessType); |
|||
|
|||
return result; |
|||
|
|||
} catch (Exception e) { |
|||
throw new RuntimeException("生成加密二维码失败: " + e.getMessage(), e); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 生成加密的二维码图片(自包含模式,无业务类型) |
|||
* 向后兼容的重载方法 |
|||
* |
|||
* @param originalData 原始数据 |
|||
* @param width 二维码宽度 |
|||
* @param height 二维码高度 |
|||
* @param expireMinutes 过期时间(分钟) |
|||
* @return 包含二维码图片Base64和token的Map |
|||
*/ |
|||
public Map<String, Object> generateEncryptedQrCode(String originalData, int width, int height, Long expireMinutes) { |
|||
return generateEncryptedQrCode(originalData, width, height, expireMinutes, null); |
|||
} |
|||
|
|||
/** |
|||
* 生成业务加密二维码(门店核销模式) |
|||
* 使用统一的业务密钥,门店可以直接解密 |
|||
* |
|||
* @param originalData 原始数据 |
|||
* @param width 二维码宽度 |
|||
* @param height 二维码高度 |
|||
* @param businessKey 业务密钥(如门店密钥) |
|||
* @param expireMinutes 过期时间(分钟) |
|||
* @param businessType 业务类型(如:order、coupon、ticket等) |
|||
* @return 包含二维码图片Base64的Map |
|||
*/ |
|||
public Map<String, Object> generateBusinessEncryptedQrCode(String originalData, int width, int height, |
|||
String businessKey, Long expireMinutes, String businessType) { |
|||
try { |
|||
if (StrUtil.isBlank(businessKey)) { |
|||
throw new IllegalArgumentException("业务密钥不能为空"); |
|||
} |
|||
|
|||
if (expireMinutes == null || expireMinutes <= 0) { |
|||
expireMinutes = DEFAULT_EXPIRE_MINUTES; |
|||
} |
|||
|
|||
// 生成唯一的二维码ID
|
|||
String qrId = RandomUtil.randomString(16); |
|||
|
|||
// 使用业务密钥加密数据
|
|||
AES aes = createAESFromToken(businessKey); |
|||
String encryptedData = aes.encryptHex(originalData); |
|||
|
|||
// 将二维码信息存储到Redis(用于验证和防重复使用)
|
|||
String qrInfoKey = "qr_info:" + qrId; |
|||
Map<String, String> qrInfo = new HashMap<>(); |
|||
qrInfo.put("originalData", originalData); |
|||
qrInfo.put("createTime", String.valueOf(System.currentTimeMillis())); |
|||
qrInfo.put("businessKey", businessKey); |
|||
redisUtil.set(qrInfoKey, JSONObject.toJSONString(qrInfo), expireMinutes, TimeUnit.MINUTES); |
|||
|
|||
// 创建二维码内容
|
|||
Map<String, String> qrContent = new HashMap<>(); |
|||
qrContent.put("qrId", qrId); |
|||
qrContent.put("data", encryptedData); |
|||
qrContent.put("type", "business_encrypted"); |
|||
qrContent.put("expire", String.valueOf(System.currentTimeMillis() + expireMinutes * 60 * 1000)); |
|||
|
|||
// 添加业务类型(如果提供)
|
|||
if (StrUtil.isNotBlank(businessType)) { |
|||
qrContent.put("businessType", businessType); |
|||
} |
|||
|
|||
String qrDataJson = JSONObject.toJSONString(qrContent); |
|||
|
|||
// 配置二维码
|
|||
QrConfig config = new QrConfig(width, height); |
|||
config.setMargin(1); |
|||
config.setForeColor(Color.BLACK); |
|||
config.setBackColor(Color.WHITE); |
|||
|
|||
// 生成二维码图片
|
|||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); |
|||
QrCodeUtil.generate(qrDataJson, config, "png", outputStream); |
|||
|
|||
// 转换为Base64
|
|||
String base64Image = Base64.getEncoder().encodeToString(outputStream.toByteArray()); |
|||
|
|||
Map<String, Object> result = new HashMap<>(); |
|||
result.put("qrCodeBase64", base64Image); |
|||
result.put("qrId", qrId); |
|||
result.put("originalData", originalData); |
|||
result.put("expireMinutes", expireMinutes.toString()); |
|||
result.put("businessType", businessType); |
|||
// 注意:出于安全考虑,不返回businessKey
|
|||
|
|||
return result; |
|||
|
|||
} catch (Exception e) { |
|||
throw new RuntimeException("生成业务加密二维码失败: " + e.getMessage(), e); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 生成业务加密二维码(门店核销模式,无业务类型) |
|||
* 向后兼容的重载方法 |
|||
* |
|||
* @param originalData 原始数据 |
|||
* @param width 二维码宽度 |
|||
* @param height 二维码高度 |
|||
* @param businessKey 业务密钥(如门店密钥) |
|||
* @param expireMinutes 过期时间(分钟) |
|||
* @return 包含二维码图片Base64的Map |
|||
*/ |
|||
public Map<String, Object> generateBusinessEncryptedQrCode(String originalData, int width, int height, |
|||
String businessKey, Long expireMinutes) { |
|||
return generateBusinessEncryptedQrCode(originalData, width, height, businessKey, expireMinutes, null); |
|||
} |
|||
|
|||
/** |
|||
* 验证并解密二维码内容(自包含模式) |
|||
* 二维码包含token和加密数据,扫码方无需额外信息 |
|||
* |
|||
* @param qrContent 二维码扫描得到的内容 |
|||
* @return 解密后的原始数据 |
|||
*/ |
|||
public String verifyAndDecryptQrCode(String qrContent) { |
|||
QrCodeDecryptResult result = verifyAndDecryptQrCodeWithResult(qrContent); |
|||
return result.getOriginalData(); |
|||
} |
|||
|
|||
/** |
|||
* 验证并解密二维码内容(自包含模式,返回完整结果) |
|||
* 包含业务类型等详细信息 |
|||
* |
|||
* @param qrContent 二维码扫描得到的内容 |
|||
* @return 包含解密数据和业务类型的完整结果 |
|||
*/ |
|||
public QrCodeDecryptResult verifyAndDecryptQrCodeWithResult(String qrContent) { |
|||
try { |
|||
// 解析二维码内容
|
|||
@SuppressWarnings("unchecked") |
|||
Map<String, String> contentMap = JSONObject.parseObject(qrContent, Map.class); |
|||
|
|||
String type = contentMap.get("type"); |
|||
|
|||
// 严格验证二维码类型,防止前端伪造
|
|||
if (!isValidQrCodeType(type, "encrypted")) { |
|||
throw new RuntimeException("无效的二维码类型或二维码已被篡改"); |
|||
} |
|||
|
|||
String token = contentMap.get("token"); |
|||
String encryptedData = contentMap.get("data"); |
|||
|
|||
// 验证必要字段
|
|||
if (StrUtil.isBlank(token) || StrUtil.isBlank(encryptedData)) { |
|||
throw new RuntimeException("二维码数据不完整"); |
|||
} |
|||
|
|||
String businessType = contentMap.get("businessType"); // 获取业务类型
|
|||
|
|||
// 解密数据(自包含模式:token就在二维码中)
|
|||
String originalData = decryptData(token, encryptedData); |
|||
|
|||
// 返回包含业务类型的完整结果
|
|||
return QrCodeDecryptResult.createEncryptedResult(originalData, businessType); |
|||
|
|||
} catch (Exception e) { |
|||
throw new RuntimeException("验证和解密二维码失败: " + e.getMessage(), e); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 验证并解密二维码内容(业务模式) |
|||
* 适用于门店核销场景:门店有统一的解密密钥 |
|||
* |
|||
* @param qrContent 二维码扫描得到的内容 |
|||
* @param businessKey 业务密钥(如门店密钥) |
|||
* @return 解密后的原始数据 |
|||
*/ |
|||
public String verifyAndDecryptQrCodeWithBusinessKey(String qrContent, String businessKey) { |
|||
try { |
|||
// 解析二维码内容
|
|||
@SuppressWarnings("unchecked") |
|||
Map<String, String> contentMap = JSONObject.parseObject(qrContent, Map.class); |
|||
|
|||
String type = contentMap.get("type"); |
|||
if (!"business_encrypted".equals(type)) { |
|||
throw new RuntimeException("不是业务加密类型的二维码"); |
|||
} |
|||
|
|||
String encryptedData = contentMap.get("data"); |
|||
String qrId = contentMap.get("qrId"); // 二维码唯一ID
|
|||
|
|||
// 验证二维码是否已被使用(防止重复核销)
|
|||
String usedKey = "qr_used:" + qrId; |
|||
if (StrUtil.isNotBlank(redisUtil.get(usedKey))) { |
|||
throw new RuntimeException("二维码已被使用"); |
|||
} |
|||
|
|||
// 使用业务密钥解密
|
|||
AES aes = createAESFromToken(businessKey); |
|||
String decryptedData = aes.decryptStr(encryptedData, CharsetUtil.CHARSET_UTF_8); |
|||
|
|||
// 标记二维码为已使用(24小时过期,防止重复使用)
|
|||
redisUtil.set(usedKey, "used", 24L, TimeUnit.HOURS); |
|||
|
|||
return decryptedData; |
|||
|
|||
} catch (Exception e) { |
|||
throw new RuntimeException("业务验证和解密二维码失败: " + e.getMessage(), e); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 删除token(使二维码失效) |
|||
* |
|||
* @param token 要删除的token |
|||
*/ |
|||
public void invalidateToken(String token) { |
|||
if (StrUtil.isNotBlank(token)) { |
|||
String redisKey = QR_TOKEN_PREFIX + token; |
|||
redisUtil.delete(redisKey); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 检查token是否有效 |
|||
* |
|||
* @param token 要检查的token |
|||
* @return true表示有效,false表示无效或过期 |
|||
*/ |
|||
public boolean isTokenValid(String token) { |
|||
if (StrUtil.isBlank(token)) { |
|||
return false; |
|||
} |
|||
|
|||
String redisKey = QR_TOKEN_PREFIX + token; |
|||
String data = redisUtil.get(redisKey); |
|||
return StrUtil.isNotBlank(data); |
|||
} |
|||
|
|||
/** |
|||
* 验证二维码类型是否有效 |
|||
* |
|||
* @param actualType 实际的类型 |
|||
* @param expectedType 期望的类型 |
|||
* @return true表示有效,false表示无效 |
|||
*/ |
|||
private boolean isValidQrCodeType(String actualType, String expectedType) { |
|||
return expectedType.equals(actualType); |
|||
} |
|||
|
|||
/** |
|||
* 根据token创建AES加密器 |
|||
* |
|||
* @param token 密钥token |
|||
* @return AES加密器 |
|||
*/ |
|||
private AES createAESFromToken(String token) { |
|||
// 使用token生成固定长度的密钥
|
|||
String keyString = SecureUtil.md5(token); |
|||
// 取前16字节作为AES密钥
|
|||
byte[] keyBytes = keyString.substring(0, 16).getBytes(CharsetUtil.CHARSET_UTF_8); |
|||
SecretKeySpec secretKey = new SecretKeySpec(keyBytes, SymmetricAlgorithm.AES.getValue()); |
|||
return SecureUtil.aes(secretKey.getEncoded()); |
|||
} |
|||
} |
@ -0,0 +1,93 @@ |
|||
package com.gxwebsoft.common.core.utils; |
|||
|
|||
import lombok.Data; |
|||
|
|||
/** |
|||
* 二维码解密结果类 |
|||
* 包含解密后的数据和业务类型信息 |
|||
* |
|||
* @author WebSoft |
|||
* @since 2025-08-18 |
|||
*/ |
|||
@Data |
|||
public class QrCodeDecryptResult { |
|||
|
|||
/** |
|||
* 解密后的原始数据 |
|||
*/ |
|||
private String originalData; |
|||
|
|||
/** |
|||
* 业务类型(如:order、user、coupon、ticket等) |
|||
*/ |
|||
private String businessType; |
|||
|
|||
/** |
|||
* 二维码类型(encrypted 或 business_encrypted) |
|||
*/ |
|||
private String qrType; |
|||
|
|||
/** |
|||
* 二维码ID(仅业务模式有) |
|||
*/ |
|||
private String qrId; |
|||
|
|||
/** |
|||
* 过期时间戳(仅业务模式有) |
|||
*/ |
|||
private Long expireTime; |
|||
|
|||
/** |
|||
* 是否已过期 |
|||
*/ |
|||
private Boolean expired; |
|||
|
|||
public QrCodeDecryptResult() {} |
|||
|
|||
public QrCodeDecryptResult(String originalData, String businessType, String qrType) { |
|||
this.originalData = originalData; |
|||
this.businessType = businessType; |
|||
this.qrType = qrType; |
|||
this.expired = false; |
|||
} |
|||
|
|||
/** |
|||
* 创建自包含模式的解密结果 |
|||
*/ |
|||
public static QrCodeDecryptResult createEncryptedResult(String originalData, String businessType) { |
|||
return new QrCodeDecryptResult(originalData, businessType, "encrypted"); |
|||
} |
|||
|
|||
/** |
|||
* 创建业务模式的解密结果 |
|||
*/ |
|||
public static QrCodeDecryptResult createBusinessResult(String originalData, String businessType, |
|||
String qrId, Long expireTime) { |
|||
QrCodeDecryptResult result = new QrCodeDecryptResult(originalData, businessType, "business_encrypted"); |
|||
result.setQrId(qrId); |
|||
result.setExpireTime(expireTime); |
|||
result.setExpired(expireTime != null && System.currentTimeMillis() > expireTime); |
|||
return result; |
|||
} |
|||
|
|||
/** |
|||
* 检查是否有业务类型 |
|||
*/ |
|||
public boolean hasBusinessType() { |
|||
return businessType != null && !businessType.trim().isEmpty(); |
|||
} |
|||
|
|||
/** |
|||
* 检查是否为业务模式 |
|||
*/ |
|||
public boolean isBusinessMode() { |
|||
return "business_encrypted".equals(qrType); |
|||
} |
|||
|
|||
/** |
|||
* 检查是否为自包含模式 |
|||
*/ |
|||
public boolean isEncryptedMode() { |
|||
return "encrypted".equals(qrType); |
|||
} |
|||
} |
@ -0,0 +1,102 @@ |
|||
package com.gxwebsoft.common.core.controller; |
|||
|
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.gxwebsoft.common.core.dto.qr.CreateEncryptedQrCodeRequest; |
|||
import com.gxwebsoft.common.core.utils.EncryptedQrCodeUtil; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.http.MediaType; |
|||
import org.springframework.test.web.servlet.MockMvc; |
|||
|
|||
import java.util.HashMap; |
|||
import java.util.Map; |
|||
|
|||
import static org.mockito.ArgumentMatchers.*; |
|||
import static org.mockito.Mockito.when; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; |
|||
|
|||
/** |
|||
* QR码控制器测试 |
|||
* |
|||
* @author WebSoft |
|||
* @since 2025-08-18 |
|||
*/ |
|||
@WebMvcTest(QrCodeController.class) |
|||
public class QrCodeControllerTest { |
|||
|
|||
@Autowired |
|||
private MockMvc mockMvc; |
|||
|
|||
@MockBean |
|||
private EncryptedQrCodeUtil encryptedQrCodeUtil; |
|||
|
|||
@Autowired |
|||
private ObjectMapper objectMapper; |
|||
|
|||
@Test |
|||
public void testCreateEncryptedQrCodeWithValidRequest() throws Exception { |
|||
// 准备测试数据
|
|||
CreateEncryptedQrCodeRequest request = new CreateEncryptedQrCodeRequest(); |
|||
request.setData("test data"); |
|||
request.setWidth(200); |
|||
request.setHeight(200); |
|||
request.setExpireMinutes(30L); |
|||
request.setBusinessType("TEST"); |
|||
|
|||
Map<String, Object> mockResult = new HashMap<>(); |
|||
mockResult.put("qrCodeBase64", "base64_encoded_image"); |
|||
mockResult.put("token", "test_token"); |
|||
|
|||
when(encryptedQrCodeUtil.generateEncryptedQrCode(anyString(), anyInt(), anyInt(), anyLong(), anyString())) |
|||
.thenReturn(mockResult); |
|||
|
|||
// 执行测试
|
|||
mockMvc.perform(post("/api/qr-code/create-encrypted-qr-code") |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content(objectMapper.writeValueAsString(request))) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.code").value(200)) |
|||
.andExpect(jsonPath("$.message").value("生成加密二维码成功")) |
|||
.andExpect(jsonPath("$.data.qrCodeBase64").value("base64_encoded_image")) |
|||
.andExpect(jsonPath("$.data.token").value("test_token")); |
|||
} |
|||
|
|||
@Test |
|||
public void testCreateEncryptedQrCodeWithInvalidRequest() throws Exception { |
|||
// 准备无效的测试数据(data为空)
|
|||
CreateEncryptedQrCodeRequest request = new CreateEncryptedQrCodeRequest(); |
|||
request.setData(""); // 空字符串,应该触发验证失败
|
|||
request.setWidth(200); |
|||
request.setHeight(200); |
|||
request.setExpireMinutes(30L); |
|||
|
|||
// 执行测试
|
|||
mockMvc.perform(post("/api/qr-code/create-encrypted-qr-code") |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content(objectMapper.writeValueAsString(request))) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.code").value(500)) |
|||
.andExpect(jsonPath("$.message").value("数据不能为空")); |
|||
} |
|||
|
|||
@Test |
|||
public void testCreateEncryptedQrCodeWithInvalidSize() throws Exception { |
|||
// 准备无效的测试数据(尺寸超出范围)
|
|||
CreateEncryptedQrCodeRequest request = new CreateEncryptedQrCodeRequest(); |
|||
request.setData("test data"); |
|||
request.setWidth(2000); // 超出最大值1000
|
|||
request.setHeight(200); |
|||
request.setExpireMinutes(30L); |
|||
|
|||
// 执行测试
|
|||
mockMvc.perform(post("/api/qr-code/create-encrypted-qr-code") |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content(objectMapper.writeValueAsString(request))) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.code").value(500)) |
|||
.andExpect(jsonPath("$.message").value("宽度不能大于1000像素")); |
|||
} |
|||
} |
@ -0,0 +1,136 @@ |
|||
package com.gxwebsoft.common.core.utils; |
|||
|
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
|
|||
import javax.annotation.Resource; |
|||
import java.util.Map; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
|
|||
/** |
|||
* 加密二维码工具类测试 |
|||
* |
|||
* @author WebSoft |
|||
* @since 2025-08-18 |
|||
*/ |
|||
@SpringBootTest |
|||
@ActiveProfiles("dev") |
|||
public class EncryptedQrCodeUtilTest { |
|||
|
|||
@Resource |
|||
private EncryptedQrCodeUtil encryptedQrCodeUtil; |
|||
|
|||
@Test |
|||
public void testGenerateAndDecryptData() { |
|||
// 测试数据
|
|||
String originalData = "https://www.example.com/user/123"; |
|||
Long expireMinutes = 30L; |
|||
|
|||
// 生成加密数据
|
|||
Map<String, String> encryptedInfo = encryptedQrCodeUtil.generateEncryptedData(originalData, expireMinutes); |
|||
|
|||
assertNotNull(encryptedInfo); |
|||
assertNotNull(encryptedInfo.get("token")); |
|||
assertNotNull(encryptedInfo.get("encryptedData")); |
|||
assertEquals(expireMinutes.toString(), encryptedInfo.get("expireMinutes")); |
|||
|
|||
// 解密数据
|
|||
String decryptedData = encryptedQrCodeUtil.decryptData( |
|||
encryptedInfo.get("token"), |
|||
encryptedInfo.get("encryptedData") |
|||
); |
|||
|
|||
assertEquals(originalData, decryptedData); |
|||
} |
|||
|
|||
@Test |
|||
public void testGenerateEncryptedQrCode() { |
|||
// 测试数据
|
|||
String originalData = "测试二维码数据"; |
|||
int width = 300; |
|||
int height = 300; |
|||
Long expireMinutes = 60L; |
|||
|
|||
// 生成加密二维码
|
|||
Map<String, Object> result = encryptedQrCodeUtil.generateEncryptedQrCode( |
|||
originalData, width, height, expireMinutes |
|||
); |
|||
|
|||
assertNotNull(result); |
|||
assertNotNull(result.get("qrCodeBase64")); |
|||
assertNotNull(result.get("token")); |
|||
assertEquals(originalData, result.get("originalData")); |
|||
assertEquals(expireMinutes.toString(), result.get("expireMinutes")); |
|||
} |
|||
|
|||
@Test |
|||
public void testTokenValidation() { |
|||
// 生成测试数据
|
|||
String originalData = "token验证测试"; |
|||
Map<String, String> encryptedInfo = encryptedQrCodeUtil.generateEncryptedData(originalData, 30L); |
|||
String token = encryptedInfo.get("token"); |
|||
|
|||
// 验证token有效性
|
|||
assertTrue(encryptedQrCodeUtil.isTokenValid(token)); |
|||
|
|||
// 使token失效
|
|||
encryptedQrCodeUtil.invalidateToken(token); |
|||
|
|||
// 再次验证token应该无效
|
|||
assertFalse(encryptedQrCodeUtil.isTokenValid(token)); |
|||
} |
|||
|
|||
@Test |
|||
public void testInvalidToken() { |
|||
// 测试无效token
|
|||
assertFalse(encryptedQrCodeUtil.isTokenValid("invalid_token")); |
|||
assertFalse(encryptedQrCodeUtil.isTokenValid("")); |
|||
assertFalse(encryptedQrCodeUtil.isTokenValid(null)); |
|||
} |
|||
|
|||
@Test |
|||
public void testDecryptWithInvalidToken() { |
|||
// 测试用无效token解密
|
|||
assertThrows(RuntimeException.class, () -> { |
|||
encryptedQrCodeUtil.decryptData("invalid_token", "encrypted_data"); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testGenerateEncryptedQrCodeWithBusinessType() { |
|||
// 测试数据
|
|||
String originalData = "用户登录数据"; |
|||
int width = 200; |
|||
int height = 200; |
|||
Long expireMinutes = 30L; |
|||
String businessType = "LOGIN"; |
|||
|
|||
// 生成带业务类型的加密二维码
|
|||
Map<String, Object> result = encryptedQrCodeUtil.generateEncryptedQrCode( |
|||
originalData, width, height, expireMinutes, businessType |
|||
); |
|||
|
|||
assertNotNull(result); |
|||
assertNotNull(result.get("qrCodeBase64")); |
|||
assertNotNull(result.get("token")); |
|||
assertEquals(originalData, result.get("originalData")); |
|||
assertEquals(expireMinutes.toString(), result.get("expireMinutes")); |
|||
assertEquals(businessType, result.get("businessType")); |
|||
|
|||
System.out.println("=== 带BusinessType的二维码生成测试 ==="); |
|||
System.out.println("原始数据: " + originalData); |
|||
System.out.println("业务类型: " + businessType); |
|||
System.out.println("Token: " + result.get("token")); |
|||
System.out.println("二维码Base64长度: " + ((String)result.get("qrCodeBase64")).length()); |
|||
|
|||
// 验证不传businessType的情况
|
|||
Map<String, Object> resultWithoutType = encryptedQrCodeUtil.generateEncryptedQrCode( |
|||
originalData, width, height, expireMinutes |
|||
); |
|||
|
|||
assertNull(resultWithoutType.get("businessType")); |
|||
System.out.println("不传BusinessType时的结果: " + resultWithoutType.get("businessType")); |
|||
} |
|||
} |
@ -0,0 +1,75 @@ |
|||
# QR码BusinessType测试说明 |
|||
|
|||
## 问题描述 |
|||
`createEncryptedQrImage`接口传入`businessType`参数后,生成的二维码内容中没有包含该字段。 |
|||
|
|||
## 问题原因 |
|||
1. **JSON库导入错误**:代码中混合使用了`JSONUtil`(项目自定义)和`JSONObject`(fastjson) |
|||
2. **方法调用不一致**:部分地方使用了不存在的`JSONUtil.toJSONString`方法 |
|||
|
|||
## 修复内容 |
|||
1. **统一使用fastjson**:将所有JSON操作统一使用`JSONObject` |
|||
2. **修复方法调用**: |
|||
- `JSONUtil.toJSONString` → `JSONObject.toJSONString` |
|||
- `JSONUtil.parseObject` → `JSONObject.parseObject` |
|||
|
|||
## 修复后的二维码内容结构 |
|||
|
|||
### 带businessType的二维码内容: |
|||
```json |
|||
{ |
|||
"token": "生成的token", |
|||
"data": "加密的数据", |
|||
"type": "encrypted", |
|||
"businessType": "LOGIN" |
|||
} |
|||
``` |
|||
|
|||
### 不带businessType的二维码内容: |
|||
```json |
|||
{ |
|||
"token": "生成的token", |
|||
"data": "加密的数据", |
|||
"type": "encrypted" |
|||
} |
|||
``` |
|||
|
|||
## 测试方法 |
|||
|
|||
### 1. 测试带businessType的接口 |
|||
```bash |
|||
curl "http://localhost:8080/api/qr-code/create-encrypted-qr-image?data=测试数据&businessType=LOGIN&size=200x200&expireMinutes=30" |
|||
``` |
|||
|
|||
### 2. 扫描生成的二维码 |
|||
扫描后应该能看到包含`businessType: "LOGIN"`的JSON内容 |
|||
|
|||
### 3. 验证解密 |
|||
```bash |
|||
curl -X POST "http://localhost:8080/api/qr-code/verify-and-decrypt-qr-with-type" \ |
|||
-H "Content-Type: application/json" \ |
|||
-d '{"qrContent": "扫描得到的JSON字符串"}' |
|||
``` |
|||
|
|||
返回结果应该包含businessType字段: |
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "验证和解密成功", |
|||
"data": { |
|||
"originalData": "测试数据", |
|||
"businessType": "LOGIN" |
|||
} |
|||
} |
|||
``` |
|||
|
|||
## 相关文件修改 |
|||
- `EncryptedQrCodeUtil.java` - 修复JSON序列化问题 |
|||
- `QrCodeController.java` - 添加businessType参数支持 |
|||
- `GlobalExceptionHandler.java` - 添加参数验证异常处理 |
|||
|
|||
## 注意事项 |
|||
现在所有生成加密二维码的接口都正确支持businessType参数: |
|||
- ✅ `POST /create-encrypted-qr-code` (JSON格式) |
|||
- ✅ `GET /create-encrypted-qr-image` (URL参数) |
|||
- ✅ `POST /create-business-encrypted-qr-code` (JSON格式) |
Loading…
Reference in new issue