新增二维码生成接口及工具类

This commit is contained in:
2025-08-19 00:05:14 +08:00
parent 3d33e42aae
commit bddda435de
18 changed files with 2382 additions and 5 deletions

View File

@@ -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<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());
}
}
}

View File

@@ -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 + '\'' +
'}';
}
}

View File

@@ -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 + '\'' +
'}';
}
}

View File

@@ -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 + '\'' +
'}';
}
}

View File

@@ -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 + '\'' +
'}';
}
}

View File

@@ -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 + '\'' +
'}';
}
}

View File

@@ -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 + '\'' +
'}';
}
}

View File

@@ -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<ConstraintViolation<?>> 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) {

View File

@@ -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()

View File

@@ -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());
}
}

View File

@@ -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);
}
}

View File

@@ -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像素"));
}
}

View File

@@ -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"));
}
}