在开发黑马商城的过程中,积累了不少值得记录的技术实践。这篇文章梳理一下项目中几个比较有意思的技术点,包括安全认证、MyBatis-Plus 技巧、统一异常处理等,附上代码片段和设计思路。
一、基于 RSA + JWT 的安全认证方案
项目没有直接使用 Spring Security 的全套过滤器链,而是只借用了它的密码加密能力(BCryptPasswordEncoder),认证逻辑自己用拦截器实现。这样做的好处是更灵活,也更容易理解整个认证流程。
1.1 RSA 密钥对配置
JWT 的签名算法选用了 RS256(非对称加密),密钥对存储在 JKS 文件中:
@Configuration
@EnableConfigurationProperties(JwtProperties.class)
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
public KeyPair keyPair(JwtProperties properties){
KeyStoreKeyFactory keyStoreKeyFactory =
new KeyStoreKeyFactory(
properties.getLocation(),
properties.getPassword().toCharArray());
return keyStoreKeyFactory.getKeyPair(
properties.getAlias(),
properties.getPassword().toCharArray());
}
}对应的配置类通过 @ConfigurationProperties 绑定 YAML:
@Data
@ConfigurationProperties(prefix = "hm.jwt")
public class JwtProperties {
private Resource location; // classpath:hmall.jks
private String password; // hmall123
private String alias; // hmall
private Duration tokenTTL = Duration.ofMinutes(10);
}YAML 中的配置:
hm:
jwt:
location: classpath:hmall.jks
alias: hmall
password: hmall123
tokenTTL: 30m生成 JKS 密钥对的命令(记录一下,免得每次都去搜):
keytool -genkeypair -alias hmall -keyalg RSA -keypass hmall123 -keystore hmall.jks -storepass hmall1231.2 JWT 工具类
使用 Hutool 的 JWT 工具封装了 token 的创建和解析,整个流程比较清晰:
@Component
public class JwtTool {
private final JWTSigner jwtSigner;
public JwtTool(KeyPair keyPair) {
this.jwtSigner = JWTSignerUtil.createSigner("rs256", keyPair);
}
public String createToken(Long userId, Duration ttl) {
return JWT.create()
.setPayload("user", userId)
.setExpiresAt(new Date(System.currentTimeMillis() + ttl.toMillis()))
.setSigner(jwtSigner)
.sign();
}
public Long parseToken(String token) {
if (token == null) {
throw new UnauthorizedException("未登录");
}
JWT jwt;
try {
jwt = JWT.of(token).setSigner(jwtSigner);
} catch (Exception e) {
throw new UnauthorizedException("无效的token", e);
}
if (!jwt.verify()) {
throw new UnauthorizedException("无效的token");
}
try {
JWTValidator.of(jwt).validateDate();
} catch (ValidateException e) {
throw new UnauthorizedException("token已经过期");
}
Object userPayload = jwt.getPayload("user");
if (userPayload == null) {
throw new UnauthorizedException("无效的token");
}
try {
return Long.valueOf(userPayload.toString());
} catch (RuntimeException e) {
throw new UnauthorizedException("无效的token");
}
}
}parseToken 方法的防御性编程值得学习 -- 每一步校验都有对应的异常信息,排查问题时能快速定位是哪种 token 失效。
1.3 登录拦截器 + ThreadLocal
认证通过拦截器实现,解析 token 后把 userId 存入 ThreadLocal,供后续业务使用:
@RequiredArgsConstructor
public class LoginInterceptor implements HandlerInterceptor {
private final JwtTool jwtTool;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String token = request.getHeader("authorization");
Long userId = jwtTool.parseToken(token);
UserContext.setUser(userId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
UserContext.removeUser(); // 必须清理,防止内存泄漏
}
}UserContext 就是一个简单的 ThreadLocal 封装:
public class UserContext {
private static final ThreadLocal<Long> tl = new ThreadLocal<>();
public static void setUser(Long userId) { tl.set(userId); }
public static Long getUser() { return tl.get(); }
public static void removeUser() { tl.remove(); }
}1.4 可配置的拦截路径
拦截器的拦截/放行路径通过配置文件控制,而不是硬编码在代码里:
@Configuration
@RequiredArgsConstructor
@EnableConfigurationProperties(AuthProperties.class)
public class MvcConfig implements WebMvcConfigurer {
private final JwtTool jwtTool;
private final AuthProperties authProperties;
@Override
public void addInterceptors(InterceptorRegistry registry) {
LoginInterceptor loginInterceptor = new LoginInterceptor(jwtTool);
InterceptorRegistration registration = registry.addInterceptor(loginInterceptor);
List<String> includePaths = authProperties.getIncludePaths();
if (CollUtil.isNotEmpty(includePaths)) {
registration.addPathPatterns(includePaths);
}
List<String> excludePaths = authProperties.getExcludePaths();
if (CollUtil.isNotEmpty(excludePaths)) {
registration.excludePathPatterns(excludePaths);
}
// 额外放行 Swagger 等静态资源
registration.excludePathPatterns("/error", "/favicon.ico",
"/v2/**", "/v3/**", "/swagger-resources/**", "/webjars/**", "/doc.html");
}
}YAML 中这样配置:
hm:
auth:
excludePaths:
- /search/**
- /users/login
- /items/**
- /hi这个设计的思路是:默认拦截所有请求,只放行明确配置的路径。新增接口时如果需要登录保护,不需要改代码;如果需要放行,加一行配置就行。
二、统一异常处理和响应封装
2.1 异常体系设计
项目定义了一套语义清晰的异常层次结构,基类 CommonException 携带 HTTP 状态码:
@Getter
public class CommonException extends RuntimeException {
private int code;
public CommonException(String message, int code) {
super(message);
this.code = code;
}
}各业务异常继承它,预设好状态码:
| 异常类 | HTTP 状态码 | 场景 |
|---|---|---|
BadRequestException | 400 | 参数校验失败 |
UnauthorizedException | 401 | 未登录 / token 失效 |
ForbiddenException | 403 | 用户被冻结 |
BizIllegalException | 500 | 业务逻辑异常(如余额不足) |
DbException | 500 | 数据库操作异常 |
使用时直接抛出,非常直观:
if (user.getStatus() == UserStatus.FROZEN) {
throw new ForbiddenException("用户被冻结");
}2.2 全局异常拦截
通过 @RestControllerAdvice 统一捕获异常,返回格式化的错误响应:
@RestControllerAdvice
@Slf4j
public class CommonExceptionAdvice {
@ExceptionHandler(DbException.class)
public Object handleDbException(DbException e) {
log.error("mysql数据库操作异常 -> ", e);
return processResponse(e);
}
@ExceptionHandler(CommonException.class)
public Object handleBadRequestException(CommonException e) {
log.error("自定义异常 -> {} , 异常原因:{} ", e.getClass().getName(), e.getMessage());
log.debug("", e);
return processResponse(e);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public Object handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getAllErrors()
.stream().map(ObjectError::getDefaultMessage)
.collect(Collectors.joining("|"));
log.error("请求参数校验异常 -> {}", msg);
return processResponse(new BadRequestException(msg));
}
@ExceptionHandler(Exception.class)
public Object handleRuntimeException(Exception e) {
log.error("其他异常 uri : {} -> ", WebUtils.getRequest().getRequestURI(), e);
return processResponse(new CommonException("服务器内部异常", 500));
}
private ResponseEntity<R<Void>> processResponse(CommonException e){
return ResponseEntity.status(e.getCode()).body(R.error(e));
}
}几个值得注意的细节:
MethodArgumentNotValidException处理@Validated校验失败,用|拼接所有错误信息,前端可以一次性展示所有问题- 日志分了
error和debug两个级别 --error只打关键信息,debug打完整堆栈,生产环境可以只看 error 日志 processResponse方法把异常的 HTTP 状态码和业务错误码统一处理,保持了 HTTP 语义
2.3 统一响应体
@Data
public class R<T> {
private int code;
private String msg;
private T data;
public static <T> R<T> ok(T data) {
return new R<>(200, "OK", data);
}
public static <T> R<T> error(CommonException e) {
return new R<>(e.getCode(), e.getMessage(), null);
}
public boolean success(){
return code == 200;
}
}所有 Controller 的返回值都会被包装成 R<T> 格式(成功的响应通过 ResponseBodyAdvice 或直接返回),前端拿到的 JSON 结构始终一致:
{"code": 200, "msg": "OK", "data": {...}}
{"code": 401, "msg": "未登录", "data": null}三、MyBatis-Plus 使用技巧
3.1 自动分页配置
分页拦截器的配置放在 hm-common 模块中,通过条件注解保证只在引入了 MyBatis-Plus 的模块生效:
@Configuration
@ConditionalOnClass({MybatisPlusInterceptor.class, BaseMapper.class})
public class MyBatisConfig {
@Bean
@ConditionalOnMissingBean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
paginationInnerInterceptor.setMaxLimit(1000L); // 防止全表扫描
interceptor.addInnerInterceptor(paginationInnerInterceptor);
return interceptor;
}
}@ConditionalOnClass + @ConditionalOnMissingBean 的组合保证了:如果某个微服务模块有自己的分页配置,不会冲突;如果没有,就用这个默认的。这对后续拆分微服务很友好。
3.2 分页查询封装
PageQuery 封装了前端传来的分页参数,内置了默认值和排序支持:
@Data
@ApiModel(description = "分页查询条件")
@Accessors(chain = true)
public class PageQuery {
private Integer pageNo = 1;
private Integer pageSize = 20;
private Boolean isAsc = true;
private String sortBy;
public <T> Page<T> toMpPage(OrderItem... orderItems) {
Page<T> page = new Page<>(pageNo, pageSize);
if (orderItems != null && orderItems.length > 0) {
for (OrderItem orderItem : orderItems) {
page.addOrder(orderItem);
}
return page;
}
if (StrUtil.isNotEmpty(sortBy)){
OrderItem orderItem = new OrderItem();
orderItem.setAsc(isAsc);
orderItem.setColumn(sortBy);
page.addOrder(orderItem);
}
return page;
}
public <T> Page<T> toMpPageDefaultSortByCreateTimeDesc() {
return toMpPage("create_time", false);
}
}Controller 里用起来就很简洁:
@GetMapping("/page")
public PageDTO<ItemDTO> queryItemByPage(PageQuery query) {
Page<Item> result = itemService.page(query.toMpPage("update_time", false));
return PageDTO.of(result, ItemDTO.class);
}PageDTO 则封装了 MyBatis-Plus 的 Page 对象,提供了多种转换方式:
// 直接转换
PageDTO.of(page, ItemDTO.class);
// 带自定义转换器
PageDTO.of(page, ItemDTO.class, (source, target) -> {
target.setExtraField(source.getSomeField());
});
// 用 Function 转换
PageDTO.of(page, item -> convertToDTO(item));3.3 枚举类型处理
配置文件中开启了 MyBatis-Plus 的枚举处理器:
mybatis-plus:
configuration:
default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler配合自定义的枚举设计,比如 PayStatus:
@Getter
public enum PayStatus {
NOT_COMMIT(0, "未提交"),
WAIT_BUYER_PAY(1, "待支付"),
TRADE_CLOSED(2, "已关闭"),
TRADE_SUCCESS(3, "支付成功");
private final int value;
private final String desc;
PayStatus(int value, String desc) {
this.value = value;
this.desc = desc;
}
public boolean equalsValue(Integer value){
if (value == null) return false;
return getValue() == value;
}
}equalsValue 方法避免了直接用 == 比较可能出现的空指针问题。
3.4 LambdaQuery 的使用
Service 层大量使用 lambdaQuery() 构建查询条件,代码可读性很好:
User user = lambdaQuery().eq(User::getUsername, username).one();更新操作用 lambdaUpdate() 配合乐观锁:
return lambdaUpdate()
.set(PayOrder::getStatus, PayStatus.TRADE_SUCCESS.getValue())
.set(PayOrder::getPaySuccessTime, successTime)
.eq(PayOrder::getId, id)
.in(PayOrder::getStatus, PayStatus.NOT_COMMIT.getValue(), PayStatus.WAIT_BUYER_PAY.getValue())
.update();这里用 .in() 限制了支付单的当前状态,实现了乐观锁的效果 -- 只有处于"未提交"或"待支付"状态的订单才能被标记为成功,防止重复支付。
四、通用工具类设计
4.1 BeanUtils -- 带自定义转换的 Bean 拷贝
在 Hutool 的 BeanUtil 基础上扩展了自定义转换器的能力:
public class BeanUtils extends BeanUtil {
public static <R, T> T copyBean(R source, Class<T> clazz, Convert<R, T> convert) {
T target = copyBean(source, clazz);
if (convert != null) {
convert.convert(source, target);
}
return target;
}
public static <R, T> T copyBean(R source, Class<T> clazz){
if (source == null) return null;
return toBean(source, clazz);
}
public static <R, T> List<T> copyList(List<R> list, Class<T> clazz) {
if (list == null || list.size() == 0) return CollUtils.emptyList();
return copyToList(list, clazz);
}
}Convert 接口定义了一个函数式接口,用于处理字段名不一致或需要额外逻辑的场景。项目中 PO -> DTO 的转换基本都走这个工具,Controller 层代码非常干净。
4.2 JsonConfig -- Long 类型序列化
@Configuration
@ConditionalOnClass(ObjectMapper.class)
public class JsonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
return jacksonObjectMapperBuilder -> {
jacksonObjectMapperBuilder.serializerByType(Long.class, ToStringSerializer.instance);
jacksonObjectMapperBuilder.serializerByType(BigInteger.class, ToStringSerializer.instance);
};
}
}这是一个经典的坑:JavaScript 的 Number 类型精度只有 2^53,超过这个范围的 Long 值在前端会丢失精度。解决方案是在后端序列化时把 Long 转成 String,前端拿到的是字符串,展示时不受影响。
五、业务层的一些设计亮点
5.1 支付的幂等性处理
PayOrderServiceImpl 中的 checkIdempotent 方法处理了重复支付请求的问题:
private PayOrder checkIdempotent(PayApplyDTO applyDTO) {
PayOrder oldOrder = queryByBizOrderNo(applyDTO.getBizOrderNo());
if (oldOrder == null) {
// 首次请求,创建支付单
PayOrder payOrder = buildPayOrder(applyDTO);
payOrder.setPayOrderNo(IdWorker.getId());
save(payOrder);
return payOrder;
}
// 已支付 -> 抛异常
if (PayStatus.TRADE_SUCCESS.equalsValue(oldOrder.getStatus())) {
throw new BizIllegalException("订单已经支付!");
}
// 已关闭 -> 抛异常
if (PayStatus.TRADE_CLOSED.equalsValue(oldOrder.getStatus())) {
throw new BizIllegalException("订单已关闭");
}
// 支付渠道变更 -> 重置数据
if (!StringUtils.equals(oldOrder.getPayChannelCode(), applyDTO.getPayChannelCode())) {
PayOrder payOrder = buildPayOrder(applyDTO);
payOrder.setId(oldOrder.getId());
updateById(payOrder);
return payOrder;
}
// 其他情况 -> 返回旧数据
return oldOrder;
}这个方法覆盖了多种边界情况,思路很清晰:先查旧单 -> 判断状态 -> 根据不同状态做不同处理。
5.2 订单创建的事务控制
@Override
@Transactional
public Long createOrder(OrderFormDTO orderFormDTO) {
// 1. 查询商品,计算总价
// 2. 保存订单
// 3. 保存订单详情
// 4. 清理购物车
// 5. 扣减库存
}整个下单流程在一个事务中完成,任何一步失败都会回滚。不过扣减库存用了 try-catch,如果库存不足会抛出 RuntimeException,触发事务回滚。
5.3 批量库存扣减
ItemServiceImpl 中使用了 MyBatis-Plus 的 executeBatch 方法:
@Override
public void deductStock(List<OrderDetailDTO> items) {
String sqlStatement = "com.hmall.mapper.ItemMapper.updateStock";
boolean r = false;
try {
r = executeBatch(items, (sqlSession, entity) -> sqlSession.update(sqlStatement, entity));
} catch (Exception e) {
throw new BizIllegalException("更新库存异常,可能是库存不足!", e);
}
if (!r) {
throw new BizIllegalException("库存不足!");
}
}通过 executeBatch 实现批量操作,SQL 中应该用乐观锁(库存 >= 扣减数量)来保证不会超卖。
六、总结
整个项目在架构设计上有几个值得学习的点:
- 模块拆分合理 --
hm-common抽取通用配置和工具,hm-service承载业务逻辑,后续拆微服务时 common 模块可以直接复用 - 配置优于硬编码 -- 拦截路径、JWT 参数等都通过配置文件管理,灵活且易于维护
- 防御性编程 -- JWT 解析的每一步都有校验,异常信息清晰,方便排查问题
- 利用框架特性 --
@ConditionalOnClass、@ConditionalOnMissingBean让组件更通用,lambdaQuery/lambdaUpdate让代码更简洁 - 关注细节 -- Long 序列化精度问题、ThreadLocal 内存泄漏防护、支付幂等性等,都是实际开发中容易踩的坑
这些实践虽然是在一个单体项目中,但设计思路是通用的,后续拆分成微服务时也能平滑迁移。