From bddda435dea5f7d8ed6795bf5104b6740cc084b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Tue, 19 Aug 2025 00:05:14 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BA=8C=E7=BB=B4=E7=A0=81?= =?UTF-8?q?=E7=94=9F=E6=88=90=E6=8E=A5=E5=8F=A3=E5=8F=8A=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/QR_CODE_API_USAGE.md | 188 ++++++++ docs/QrCode_BusinessType_Usage.md | 306 +++++++++++++ docs/QrCode_Encryption_Usage.md | 215 +++++++++ docs/QrCode_Two_Modes_Explanation.md | 197 ++++++++ .../core/controller/QrCodeController.java | 197 +++++++- .../CreateBusinessEncryptedQrCodeRequest.java | 114 +++++ .../dto/qr/CreateEncryptedQrCodeRequest.java | 100 ++++ .../core/dto/qr/DecryptQrDataRequest.java | 56 +++ .../core/dto/qr/InvalidateTokenRequest.java | 42 ++ .../core/dto/qr/VerifyBusinessQrRequest.java | 56 +++ .../core/dto/qr/VerifyQrContentRequest.java | 42 ++ .../exception/GlobalExceptionHandler.java | 33 ++ .../common/core/security/SecurityConfig.java | 2 +- .../core/utils/EncryptedQrCodeUtil.java | 433 ++++++++++++++++++ .../core/utils/QrCodeDecryptResult.java | 93 ++++ .../core/controller/QrCodeControllerTest.java | 102 +++++ .../core/utils/EncryptedQrCodeUtilTest.java | 136 ++++++ test_qr_business_type.md | 75 +++ 18 files changed, 2382 insertions(+), 5 deletions(-) create mode 100644 docs/QR_CODE_API_USAGE.md create mode 100644 docs/QrCode_BusinessType_Usage.md create mode 100644 docs/QrCode_Encryption_Usage.md create mode 100644 docs/QrCode_Two_Modes_Explanation.md create mode 100644 src/main/java/com/gxwebsoft/common/core/dto/qr/CreateBusinessEncryptedQrCodeRequest.java create mode 100644 src/main/java/com/gxwebsoft/common/core/dto/qr/CreateEncryptedQrCodeRequest.java create mode 100644 src/main/java/com/gxwebsoft/common/core/dto/qr/DecryptQrDataRequest.java create mode 100644 src/main/java/com/gxwebsoft/common/core/dto/qr/InvalidateTokenRequest.java create mode 100644 src/main/java/com/gxwebsoft/common/core/dto/qr/VerifyBusinessQrRequest.java create mode 100644 src/main/java/com/gxwebsoft/common/core/dto/qr/VerifyQrContentRequest.java create mode 100644 src/main/java/com/gxwebsoft/common/core/utils/EncryptedQrCodeUtil.java create mode 100644 src/main/java/com/gxwebsoft/common/core/utils/QrCodeDecryptResult.java create mode 100644 src/test/java/com/gxwebsoft/common/core/controller/QrCodeControllerTest.java create mode 100644 src/test/java/com/gxwebsoft/common/core/utils/EncryptedQrCodeUtilTest.java create mode 100644 test_qr_business_type.md diff --git a/docs/QR_CODE_API_USAGE.md b/docs/QR_CODE_API_USAGE.md new file mode 100644 index 0000000..0c08644 --- /dev/null +++ b/docs/QR_CODE_API_USAGE.md @@ -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`参数 diff --git a/docs/QrCode_BusinessType_Usage.md b/docs/QrCode_BusinessType_Usage.md new file mode 100644 index 0000000..ff5c034 --- /dev/null +++ b/docs/QrCode_BusinessType_Usage.md @@ -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. **文档维护**:及时更新业务类型的文档说明 diff --git a/docs/QrCode_Encryption_Usage.md b/docs/QrCode_Encryption_Usage.md new file mode 100644 index 0000000..08ccc8f --- /dev/null +++ b/docs/QrCode_Encryption_Usage.md @@ -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分钟之间`:过期时间设置不合理 diff --git a/docs/QrCode_Two_Modes_Explanation.md b/docs/QrCode_Two_Modes_Explanation.md new file mode 100644 index 0000000..8ffd1e1 --- /dev/null +++ b/docs/QrCode_Two_Modes_Explanation.md @@ -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 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. **业务模式**:门店有预设的业务密钥,用户生成时用这个密钥加密 + +**推荐使用业务模式**,因为它更符合实际的商业应用需求,安全性更高,且支持防重复核销。 diff --git a/src/main/java/com/gxwebsoft/common/core/controller/QrCodeController.java b/src/main/java/com/gxwebsoft/common/core/controller/QrCodeController.java index a0ab135..b589901 100644 --- a/src/main/java/com/gxwebsoft/common/core/controller/QrCodeController.java +++ b/src/main/java/com/gxwebsoft/common/core/controller/QrCodeController.java @@ -2,16 +2,25 @@ package com.gxwebsoft.common.core.controller; import cn.hutool.extra.qrcode.QrCodeUtil; 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.BaseController; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; 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 javax.annotation.Resource; import javax.servlet.http.HttpServletResponse; +import javax.validation.Valid; import java.awt.*; import java.io.IOException; +import java.util.Base64; +import java.util.Map; /** * 二维码生成控制器 @@ -20,15 +29,19 @@ import java.io.IOException; * @since 2025-08-18 */ @RestController -@RequestMapping("/api") +@RequestMapping("/api/qr-code") @Tag(name = "二维码生成API") +@Validated public class QrCodeController extends BaseController { - @Operation(summary = "生成二维码") + @Autowired + private EncryptedQrCodeUtil encryptedQrCodeUtil; + + @Operation(summary = "生成普通二维码") @GetMapping("/create-qr-code") public void createQrCode( - @RequestParam("data") String data, - @RequestParam(value = "size", defaultValue = "200x200") String size, + @Parameter(description = "要编码的数据") @RequestParam("data") String data, + @Parameter(description = "二维码尺寸,格式:宽x高 或 单个数字") @RequestParam(value = "size", defaultValue = "200x200") String size, HttpServletResponse response) throws IOException { try { @@ -66,4 +79,180 @@ public class QrCodeController extends BaseController { response.getWriter().write("生成二维码失败:" + e.getMessage()); } } + + @Operation(summary = "生成加密二维码") + @PostMapping("/create-encrypted-qr-code") + public ApiResult createEncryptedQrCode(@Valid @RequestBody CreateEncryptedQrCodeRequest request) { + + try { + // 生成加密二维码 + Map 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 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 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 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()); + } + } } diff --git a/src/main/java/com/gxwebsoft/common/core/dto/qr/CreateBusinessEncryptedQrCodeRequest.java b/src/main/java/com/gxwebsoft/common/core/dto/qr/CreateBusinessEncryptedQrCodeRequest.java new file mode 100644 index 0000000..bae53d8 --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/dto/qr/CreateBusinessEncryptedQrCodeRequest.java @@ -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 + '\'' + + '}'; + } +} diff --git a/src/main/java/com/gxwebsoft/common/core/dto/qr/CreateEncryptedQrCodeRequest.java b/src/main/java/com/gxwebsoft/common/core/dto/qr/CreateEncryptedQrCodeRequest.java new file mode 100644 index 0000000..f81c9d1 --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/dto/qr/CreateEncryptedQrCodeRequest.java @@ -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 + '\'' + + '}'; + } +} diff --git a/src/main/java/com/gxwebsoft/common/core/dto/qr/DecryptQrDataRequest.java b/src/main/java/com/gxwebsoft/common/core/dto/qr/DecryptQrDataRequest.java new file mode 100644 index 0000000..3d35550 --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/dto/qr/DecryptQrDataRequest.java @@ -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 + '\'' + + '}'; + } +} diff --git a/src/main/java/com/gxwebsoft/common/core/dto/qr/InvalidateTokenRequest.java b/src/main/java/com/gxwebsoft/common/core/dto/qr/InvalidateTokenRequest.java new file mode 100644 index 0000000..1e8aa67 --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/dto/qr/InvalidateTokenRequest.java @@ -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 + '\'' + + '}'; + } +} diff --git a/src/main/java/com/gxwebsoft/common/core/dto/qr/VerifyBusinessQrRequest.java b/src/main/java/com/gxwebsoft/common/core/dto/qr/VerifyBusinessQrRequest.java new file mode 100644 index 0000000..fbdfe3a --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/dto/qr/VerifyBusinessQrRequest.java @@ -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 + '\'' + + '}'; + } +} diff --git a/src/main/java/com/gxwebsoft/common/core/dto/qr/VerifyQrContentRequest.java b/src/main/java/com/gxwebsoft/common/core/dto/qr/VerifyQrContentRequest.java new file mode 100644 index 0000000..eb60b09 --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/dto/qr/VerifyQrContentRequest.java @@ -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 + '\'' + + '}'; + } +} diff --git a/src/main/java/com/gxwebsoft/common/core/exception/GlobalExceptionHandler.java b/src/main/java/com/gxwebsoft/common/core/exception/GlobalExceptionHandler.java index 6649a2d..7fb4c8c 100644 --- a/src/main/java/com/gxwebsoft/common/core/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/gxwebsoft/common/core/exception/GlobalExceptionHandler.java @@ -6,12 +6,18 @@ import com.gxwebsoft.common.core.web.ApiResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; 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.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseBody; 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()); } + @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> violations = e.getConstraintViolations(); + String message = violations.isEmpty() ? "参数验证失败" : violations.iterator().next().getMessage(); + return new ApiResult<>(Constants.RESULT_ERROR_CODE, message); + } + @ResponseBody @ExceptionHandler(Throwable.class) public ApiResult exceptionHandler(Throwable e, HttpServletResponse response) { diff --git a/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java b/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java index 619a7a5..28dcabc 100644 --- a/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java +++ b/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java @@ -77,7 +77,7 @@ public class SecurityConfig { "/api/chat/**", "/api/shop/getShopInfo", "/api/shop/shop-order/test", - "/api/create-qr-code" + "/api/qr-code/**" ) .permitAll() .anyRequest() diff --git a/src/main/java/com/gxwebsoft/common/core/utils/EncryptedQrCodeUtil.java b/src/main/java/com/gxwebsoft/common/core/utils/EncryptedQrCodeUtil.java new file mode 100644 index 0000000..ff66ddc --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/utils/EncryptedQrCodeUtil.java @@ -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 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 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 generateEncryptedQrCode(String originalData, int width, int height, Long expireMinutes, String businessType) { + try { + // 生成加密数据 + Map encryptedInfo = generateEncryptedData(originalData, expireMinutes); + + // 创建二维码内容(包含token、加密数据和业务类型) + Map 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 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 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 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 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 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 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 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 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 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()); + } +} diff --git a/src/main/java/com/gxwebsoft/common/core/utils/QrCodeDecryptResult.java b/src/main/java/com/gxwebsoft/common/core/utils/QrCodeDecryptResult.java new file mode 100644 index 0000000..bbfe3b6 --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/utils/QrCodeDecryptResult.java @@ -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); + } +} diff --git a/src/test/java/com/gxwebsoft/common/core/controller/QrCodeControllerTest.java b/src/test/java/com/gxwebsoft/common/core/controller/QrCodeControllerTest.java new file mode 100644 index 0000000..9677d80 --- /dev/null +++ b/src/test/java/com/gxwebsoft/common/core/controller/QrCodeControllerTest.java @@ -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 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像素")); + } +} diff --git a/src/test/java/com/gxwebsoft/common/core/utils/EncryptedQrCodeUtilTest.java b/src/test/java/com/gxwebsoft/common/core/utils/EncryptedQrCodeUtilTest.java new file mode 100644 index 0000000..bb0ebcd --- /dev/null +++ b/src/test/java/com/gxwebsoft/common/core/utils/EncryptedQrCodeUtilTest.java @@ -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 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 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 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 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 resultWithoutType = encryptedQrCodeUtil.generateEncryptedQrCode( + originalData, width, height, expireMinutes + ); + + assertNull(resultWithoutType.get("businessType")); + System.out.println("不传BusinessType时的结果: " + resultWithoutType.get("businessType")); + } +} diff --git a/test_qr_business_type.md b/test_qr_business_type.md new file mode 100644 index 0000000..fa03352 --- /dev/null +++ b/test_qr_business_type.md @@ -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格式)