Skip to content

在开发黑马商城的过程中,积累了不少值得记录的技术实践。这篇文章梳理一下项目中几个比较有意思的技术点,包括安全认证、MyBatis-Plus 技巧、统一异常处理等,附上代码片段和设计思路。

一、基于 RSA + JWT 的安全认证方案 ​

项目没有直接使用 Spring Security 的全套过滤器链,而是只借用了它的密码加密能力(BCryptPasswordEncoder),认证逻辑自己用拦截器实现。这样做的好处是更灵活,也更容易理解整个认证流程。

1.1 RSA 密钥对配置 ​

JWT 的签名算法选用了 RS256(非对称加密),密钥对存储在 JKS 文件中:

java
@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:

java
@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 中的配置:

yaml
hm:
  jwt:
    location: classpath:hmall.jks
    alias: hmall
    password: hmall123
    tokenTTL: 30m

生成 JKS 密钥对的命令(记录一下,免得每次都去搜):

bash
keytool -genkeypair -alias hmall -keyalg RSA -keypass hmall123 -keystore hmall.jks -storepass hmall123

1.2 JWT 工具类 ​

使用 Hutool 的 JWT 工具封装了 token 的创建和解析,整个流程比较清晰:

java
@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,供后续业务使用:

java
@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 封装:

java
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 可配置的拦截路径 ​

拦截器的拦截/放行路径通过配置文件控制,而不是硬编码在代码里:

java
@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 中这样配置:

yaml
hm:
  auth:
    excludePaths:
      - /search/**
      - /users/login
      - /items/**
      - /hi

这个设计的思路是:默认拦截所有请求,只放行明确配置的路径。新增接口时如果需要登录保护,不需要改代码;如果需要放行,加一行配置就行。

二、统一异常处理和响应封装 ​

2.1 异常体系设计 ​

项目定义了一套语义清晰的异常层次结构,基类 CommonException 携带 HTTP 状态码:

java
@Getter
public class CommonException extends RuntimeException {
    private int code;

    public CommonException(String message, int code) {
        super(message);
        this.code = code;
    }
}

各业务异常继承它,预设好状态码:

异常类HTTP 状态码场景
BadRequestException400参数校验失败
UnauthorizedException401未登录 / token 失效
ForbiddenException403用户被冻结
BizIllegalException500业务逻辑异常(如余额不足)
DbException500数据库操作异常

使用时直接抛出,非常直观:

java
if (user.getStatus() == UserStatus.FROZEN) {
    throw new ForbiddenException("用户被冻结");
}

2.2 全局异常拦截 ​

通过 @RestControllerAdvice 统一捕获异常,返回格式化的错误响应:

java
@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 统一响应体 ​

java
@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 结构始终一致:

json
{"code": 200, "msg": "OK", "data": {...}}
{"code": 401, "msg": "未登录", "data": null}

三、MyBatis-Plus 使用技巧 ​

3.1 自动分页配置 ​

分页拦截器的配置放在 hm-common 模块中,通过条件注解保证只在引入了 MyBatis-Plus 的模块生效:

java
@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 封装了前端传来的分页参数,内置了默认值和排序支持:

java
@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 里用起来就很简洁:

java
@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 对象,提供了多种转换方式:

java
// 直接转换
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 的枚举处理器:

yaml
mybatis-plus:
  configuration:
    default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler

配合自定义的枚举设计,比如 PayStatus:

java
@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() 构建查询条件,代码可读性很好:

java
User user = lambdaQuery().eq(User::getUsername, username).one();

更新操作用 lambdaUpdate() 配合乐观锁:

java
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 基础上扩展了自定义转换器的能力:

java
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 类型序列化 ​

java
@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 方法处理了重复支付请求的问题:

java
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 订单创建的事务控制 ​

java
@Override
@Transactional
public Long createOrder(OrderFormDTO orderFormDTO) {
    // 1. 查询商品,计算总价
    // 2. 保存订单
    // 3. 保存订单详情
    // 4. 清理购物车
    // 5. 扣减库存
}

整个下单流程在一个事务中完成,任何一步失败都会回滚。不过扣减库存用了 try-catch,如果库存不足会抛出 RuntimeException,触发事务回滚。

5.3 批量库存扣减 ​

ItemServiceImpl 中使用了 MyBatis-Plus 的 executeBatch 方法:

java
@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 中应该用乐观锁(库存 >= 扣减数量)来保证不会超卖。

六、总结 ​

整个项目在架构设计上有几个值得学习的点:

  1. 模块拆分合理 -- hm-common 抽取通用配置和工具,hm-service 承载业务逻辑,后续拆微服务时 common 模块可以直接复用
  2. 配置优于硬编码 -- 拦截路径、JWT 参数等都通过配置文件管理,灵活且易于维护
  3. 防御性编程 -- JWT 解析的每一步都有校验,异常信息清晰,方便排查问题
  4. 利用框架特性 -- @ConditionalOnClass、@ConditionalOnMissingBean 让组件更通用,lambdaQuery/lambdaUpdate 让代码更简洁
  5. 关注细节 -- Long 序列化精度问题、ThreadLocal 内存泄漏防护、支付幂等性等,都是实际开发中容易踩的坑

这些实践虽然是在一个单体项目中,但设计思路是通用的,后续拆分成微服务时也能平滑迁移。