新增二维码生成接口及工具类
This commit is contained in:
188
docs/QR_CODE_API_USAGE.md
Normal file
188
docs/QR_CODE_API_USAGE.md
Normal file
@@ -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`参数
|
||||||
306
docs/QrCode_BusinessType_Usage.md
Normal file
306
docs/QrCode_BusinessType_Usage.md
Normal file
@@ -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. **文档维护**:及时更新业务类型的文档说明
|
||||||
215
docs/QrCode_Encryption_Usage.md
Normal file
215
docs/QrCode_Encryption_Usage.md
Normal file
@@ -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分钟之间`:过期时间设置不合理
|
||||||
197
docs/QrCode_Two_Modes_Explanation.md
Normal file
197
docs/QrCode_Two_Modes_Explanation.md
Normal file
@@ -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. **业务模式**:门店有预设的业务密钥,用户生成时用这个密钥加密
|
||||||
|
|
||||||
|
**推荐使用业务模式**,因为它更符合实际的商业应用需求,安全性更高,且支持防重复核销。
|
||||||
@@ -2,16 +2,25 @@ package com.gxwebsoft.common.core.controller;
|
|||||||
|
|
||||||
import cn.hutool.extra.qrcode.QrCodeUtil;
|
import cn.hutool.extra.qrcode.QrCodeUtil;
|
||||||
import cn.hutool.extra.qrcode.QrConfig;
|
import cn.hutool.extra.qrcode.QrConfig;
|
||||||
|
import com.gxwebsoft.common.core.dto.qr.*;
|
||||||
|
import com.gxwebsoft.common.core.utils.EncryptedQrCodeUtil;
|
||||||
|
import com.gxwebsoft.common.core.utils.QrCodeDecryptResult;
|
||||||
import com.gxwebsoft.common.core.web.ApiResult;
|
import com.gxwebsoft.common.core.web.ApiResult;
|
||||||
import com.gxwebsoft.common.core.web.BaseController;
|
import com.gxwebsoft.common.core.web.BaseController;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import javax.servlet.http.HttpServletResponse;
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import javax.validation.Valid;
|
||||||
import java.awt.*;
|
import java.awt.*;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 二维码生成控制器
|
* 二维码生成控制器
|
||||||
@@ -20,15 +29,19 @@ import java.io.IOException;
|
|||||||
* @since 2025-08-18
|
* @since 2025-08-18
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api")
|
@RequestMapping("/api/qr-code")
|
||||||
@Tag(name = "二维码生成API")
|
@Tag(name = "二维码生成API")
|
||||||
|
@Validated
|
||||||
public class QrCodeController extends BaseController {
|
public class QrCodeController extends BaseController {
|
||||||
|
|
||||||
@Operation(summary = "生成二维码")
|
@Autowired
|
||||||
|
private EncryptedQrCodeUtil encryptedQrCodeUtil;
|
||||||
|
|
||||||
|
@Operation(summary = "生成普通二维码")
|
||||||
@GetMapping("/create-qr-code")
|
@GetMapping("/create-qr-code")
|
||||||
public void createQrCode(
|
public void createQrCode(
|
||||||
@RequestParam("data") String data,
|
@Parameter(description = "要编码的数据") @RequestParam("data") String data,
|
||||||
@RequestParam(value = "size", defaultValue = "200x200") String size,
|
@Parameter(description = "二维码尺寸,格式:宽x高 或 单个数字") @RequestParam(value = "size", defaultValue = "200x200") String size,
|
||||||
HttpServletResponse response) throws IOException {
|
HttpServletResponse response) throws IOException {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -66,4 +79,180 @@ public class QrCodeController extends BaseController {
|
|||||||
response.getWriter().write("生成二维码失败:" + e.getMessage());
|
response.getWriter().write("生成二维码失败:" + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "生成加密二维码")
|
||||||
|
@PostMapping("/create-encrypted-qr-code")
|
||||||
|
public ApiResult<?> createEncryptedQrCode(@Valid @RequestBody CreateEncryptedQrCodeRequest request) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 生成加密二维码
|
||||||
|
Map<String, Object> result = encryptedQrCodeUtil.generateEncryptedQrCode(
|
||||||
|
request.getData(),
|
||||||
|
request.getWidth(),
|
||||||
|
request.getHeight(),
|
||||||
|
request.getExpireMinutes(),
|
||||||
|
request.getBusinessType());
|
||||||
|
|
||||||
|
return success("生成加密二维码成功", result);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
return fail("生成加密二维码失败:" + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "生成加密二维码图片流")
|
||||||
|
@GetMapping("/create-encrypted-qr-image")
|
||||||
|
public void createEncryptedQrImage(
|
||||||
|
@Parameter(description = "要加密的数据") @RequestParam("data") String data,
|
||||||
|
@Parameter(description = "二维码尺寸") @RequestParam(value = "size", defaultValue = "200x200") String size,
|
||||||
|
@Parameter(description = "过期时间(分钟)") @RequestParam(value = "expireMinutes", defaultValue = "30") Long expireMinutes,
|
||||||
|
@Parameter(description = "业务类型(可选)") @RequestParam(value = "businessType", required = false) String businessType,
|
||||||
|
HttpServletResponse response) throws IOException {
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 解析尺寸
|
||||||
|
String[] dimensions = size.split("x");
|
||||||
|
int width = Integer.parseInt(dimensions[0]);
|
||||||
|
int height = dimensions.length > 1 ? Integer.parseInt(dimensions[1]) : width;
|
||||||
|
|
||||||
|
// 验证尺寸范围
|
||||||
|
if (width < 50 || width > 1000 || height < 50 || height > 1000) {
|
||||||
|
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
|
||||||
|
response.getWriter().write("尺寸必须在50-1000像素之间");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证过期时间
|
||||||
|
if (expireMinutes <= 0 || expireMinutes > 1440) {
|
||||||
|
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
|
||||||
|
response.getWriter().write("过期时间必须在1-1440分钟之间");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成加密二维码
|
||||||
|
Map<String, Object> result = encryptedQrCodeUtil.generateEncryptedQrCode(data, width, height, expireMinutes, businessType);
|
||||||
|
String base64Image = (String) result.get("qrCodeBase64");
|
||||||
|
|
||||||
|
// 解码Base64图片
|
||||||
|
byte[] imageBytes = Base64.getDecoder().decode(base64Image);
|
||||||
|
|
||||||
|
// 设置响应头
|
||||||
|
response.setContentType("image/png");
|
||||||
|
response.setHeader("Cache-Control", "no-cache");
|
||||||
|
response.setHeader("Content-Disposition", "inline; filename=encrypted_qrcode.png");
|
||||||
|
response.setHeader("X-QR-Token", (String) result.get("token"));
|
||||||
|
response.setHeader("X-QR-Expire-Minutes", result.get("expireMinutes").toString());
|
||||||
|
|
||||||
|
// 输出图片
|
||||||
|
response.getOutputStream().write(imageBytes);
|
||||||
|
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
|
||||||
|
response.getWriter().write("尺寸格式错误,请使用如:200x200 的格式");
|
||||||
|
} catch (Exception e) {
|
||||||
|
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
|
||||||
|
response.getWriter().write("生成加密二维码失败:" + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "解密二维码数据")
|
||||||
|
@PostMapping("/decrypt-qr-data")
|
||||||
|
public ApiResult<?> decryptQrData(@Valid @RequestBody DecryptQrDataRequest request) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
String decryptedData = encryptedQrCodeUtil.decryptData(request.getToken(), request.getEncryptedData());
|
||||||
|
return success("解密成功", decryptedData);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
return fail("解密失败:" + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "验证并解密二维码内容(自包含模式)")
|
||||||
|
@PostMapping("/verify-and-decrypt-qr")
|
||||||
|
public ApiResult<?> verifyAndDecryptQr(@Valid @RequestBody VerifyQrContentRequest request) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
String originalData = encryptedQrCodeUtil.verifyAndDecryptQrCode(request.getQrContent());
|
||||||
|
return success("验证和解密成功", originalData);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
return fail("验证和解密失败:" + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "验证并解密二维码内容(返回完整结果,包含业务类型)")
|
||||||
|
@PostMapping("/verify-and-decrypt-qr-with-type")
|
||||||
|
public ApiResult<QrCodeDecryptResult> verifyAndDecryptQrWithType(@Valid @RequestBody VerifyQrContentRequest request) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
QrCodeDecryptResult result = encryptedQrCodeUtil.verifyAndDecryptQrCodeWithResult(request.getQrContent());
|
||||||
|
return success("验证和解密成功", result);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
return fail("验证和解密失败:" + e.getMessage(),null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "生成业务加密二维码(门店核销模式)")
|
||||||
|
@PostMapping("/create-business-encrypted-qr-code")
|
||||||
|
public ApiResult<?> createBusinessEncryptedQrCode(@Valid @RequestBody CreateBusinessEncryptedQrCodeRequest request) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 生成业务加密二维码
|
||||||
|
Map<String, Object> result = encryptedQrCodeUtil.generateBusinessEncryptedQrCode(
|
||||||
|
request.getData(),
|
||||||
|
request.getWidth(),
|
||||||
|
request.getHeight(),
|
||||||
|
request.getBusinessKey(),
|
||||||
|
request.getExpireMinutes(),
|
||||||
|
request.getBusinessType());
|
||||||
|
|
||||||
|
return success("生成业务加密二维码成功", result);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
return fail("生成业务加密二维码失败:" + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "门店核销二维码(业务模式)")
|
||||||
|
@PostMapping("/verify-business-qr")
|
||||||
|
public ApiResult<?> verifyBusinessQr(@Valid @RequestBody VerifyBusinessQrRequest request) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
String originalData = encryptedQrCodeUtil.verifyAndDecryptQrCodeWithBusinessKey(
|
||||||
|
request.getQrContent(), request.getBusinessKey());
|
||||||
|
return success("核销成功", originalData);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
return fail("核销失败:" + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "检查token是否有效")
|
||||||
|
@GetMapping("/check-token")
|
||||||
|
public ApiResult<?> checkToken(
|
||||||
|
@Parameter(description = "要检查的token") @RequestParam("token") String token) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
boolean isValid = encryptedQrCodeUtil.isTokenValid(token);
|
||||||
|
return success("检查完成", isValid);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
return fail("检查token失败:" + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "使token失效")
|
||||||
|
@PostMapping("/invalidate-token")
|
||||||
|
public ApiResult<?> invalidateToken(@Valid @RequestBody InvalidateTokenRequest request) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
encryptedQrCodeUtil.invalidateToken(request.getToken());
|
||||||
|
return success("token已失效");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
return fail("使token失效失败:" + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 + '\'' +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,12 +6,18 @@ import com.gxwebsoft.common.core.web.ApiResult;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.security.access.AccessDeniedException;
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.validation.BindException;
|
||||||
|
import org.springframework.validation.FieldError;
|
||||||
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.ResponseBody;
|
import org.springframework.web.bind.annotation.ResponseBody;
|
||||||
|
|
||||||
import javax.servlet.http.HttpServletResponse;
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import javax.validation.ConstraintViolation;
|
||||||
|
import javax.validation.ConstraintViolationException;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全局异常处理器
|
* 全局异常处理器
|
||||||
@@ -45,6 +51,33 @@ public class GlobalExceptionHandler {
|
|||||||
return new ApiResult<>(e.getCode(), e.getMessage());
|
return new ApiResult<>(e.getCode(), e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ResponseBody
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public ApiResult<?> methodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e, HttpServletResponse response) {
|
||||||
|
CommonUtil.addCrossHeaders(response);
|
||||||
|
FieldError fieldError = e.getBindingResult().getFieldError();
|
||||||
|
String message = fieldError != null ? fieldError.getDefaultMessage() : "参数验证失败";
|
||||||
|
return new ApiResult<>(Constants.RESULT_ERROR_CODE, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ResponseBody
|
||||||
|
@ExceptionHandler(BindException.class)
|
||||||
|
public ApiResult<?> bindExceptionHandler(BindException e, HttpServletResponse response) {
|
||||||
|
CommonUtil.addCrossHeaders(response);
|
||||||
|
FieldError fieldError = e.getBindingResult().getFieldError();
|
||||||
|
String message = fieldError != null ? fieldError.getDefaultMessage() : "参数绑定失败";
|
||||||
|
return new ApiResult<>(Constants.RESULT_ERROR_CODE, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ResponseBody
|
||||||
|
@ExceptionHandler(ConstraintViolationException.class)
|
||||||
|
public ApiResult<?> constraintViolationExceptionHandler(ConstraintViolationException e, HttpServletResponse response) {
|
||||||
|
CommonUtil.addCrossHeaders(response);
|
||||||
|
Set<ConstraintViolation<?>> violations = e.getConstraintViolations();
|
||||||
|
String message = violations.isEmpty() ? "参数验证失败" : violations.iterator().next().getMessage();
|
||||||
|
return new ApiResult<>(Constants.RESULT_ERROR_CODE, message);
|
||||||
|
}
|
||||||
|
|
||||||
@ResponseBody
|
@ResponseBody
|
||||||
@ExceptionHandler(Throwable.class)
|
@ExceptionHandler(Throwable.class)
|
||||||
public ApiResult<?> exceptionHandler(Throwable e, HttpServletResponse response) {
|
public ApiResult<?> exceptionHandler(Throwable e, HttpServletResponse response) {
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ public class SecurityConfig {
|
|||||||
"/api/chat/**",
|
"/api/chat/**",
|
||||||
"/api/shop/getShopInfo",
|
"/api/shop/getShopInfo",
|
||||||
"/api/shop/shop-order/test",
|
"/api/shop/shop-order/test",
|
||||||
"/api/create-qr-code"
|
"/api/qr-code/**"
|
||||||
)
|
)
|
||||||
.permitAll()
|
.permitAll()
|
||||||
.anyRequest()
|
.anyRequest()
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
75
test_qr_business_type.md
Normal file
75
test_qr_business_type.md
Normal file
@@ -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格式)
|
||||||
Reference in New Issue
Block a user