详解Java项目中积分商城的实现方案

68 阅读5分钟

场景描述:Java单体架构项目。积分商城:积分可以在积分商城消费,消费积分的时候优先使用快过期的积分,需要建立积分记录表(id,用户id,积分数量,过期时间),积分商城订单表(id,订单号,商品id,用户id,消耗积分总量,订单状态(0已支付,1已发货,2已签收,3已评价,4已退款)),积分商城订单明细表(id,积分商城订单id,积分数量,过期时间)。因为积分商城不存在购物车合并支付,这里花费积分后就会创建订单,用户一次只能买一个商品。明细表记录积分详情是的作用是在用户选择退货时退回积分,退回积分就是在积分记录表中插入新数据,继承过期时间。花费积分时删除积分记录表中对应数据,如果某一条数据的积分数量用了一部分,则将它的积分数量字段更新成剩余积分量。SpringTask定期删除积分记录表中的过期积分。

以下是基于Spring Boot + MyBatis Plus的技术方案:

一、数据库表结构

-- 积分记录表
CREATE TABLE points_record (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  user_id BIGINT NOT NULL COMMENT '用户ID',
  points INT NOT NULL COMMENT '积分数量',
  expire_time DATETIME NOT NULL COMMENT '过期时间'
);

-- 积分商城订单表
CREATE TABLE points_order (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  order_sn VARCHAR(64) NOT NULL COMMENT '订单号',
  goods_id BIGINT NOT NULL COMMENT '商品ID',
  user_id BIGINT NOT NULL COMMENT '用户ID',
  total_points INT NOT NULL COMMENT '消耗积分总量',
  status TINYINT NOT NULL COMMENT '订单状态(0已支付,1已发货,2已签收,3已评价,4已退款)'
);

-- 积分商城订单明细表
CREATE TABLE points_order_detail (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  order_id BIGINT NOT NULL COMMENT '积分商城订单ID',
  points INT NOT NULL COMMENT '积分数量',
  expire_time DATETIME NOT NULL COMMENT '过期时间'
);

二、核心功能实现

1. 消费积分(创建订单)

核心逻辑:优先使用快过期的积分,扣减时可能涉及部分使用或整条删除

@Service
@Transactional
public class PointsOrderService {

    @Autowired
    private PointsRecordMapper pointsRecordMapper;
    
    @Autowired
    private PointsOrderMapper pointsOrderMapper;
    
    @Autowired
    private PointsOrderDetailMapper pointsOrderDetailMapper;

    public void createOrder(OrderCreateDTO dto) {
        Long userId = dto.getUserId();
        int pointsToDeduct = dto.getRequiredPoints();
        
        // 1. 查询可用积分(按过期时间升序)
        List<PointsRecord> availablePoints = pointsRecordMapper.selectAvailablePoints(userId);
        
        // 2. 验证积分是否足够
        int totalAvailable = availablePoints.stream().mapToInt(PointsRecord::getPoints).sum();
        if (totalAvailable < pointsToDeduct) {
            throw new BusinessException("可用积分不足");
        }
        
        // 3. 创建订单
        PointsOrder order = new PointsOrder();
        order.setOrderSn(generateOrderSn());
        order.setGoodsId(dto.getGoodsId());
        order.setUserId(userId);
        order.setTotalPoints(pointsToDeduct);
        order.setStatus(0); // 已支付
        pointsOrderMapper.insert(order);
        
        // 4. 扣减积分并创建明细
        int remaining = pointsToDeduct;
        List<PointsOrderDetail> details = new ArrayList<>();
        
        for (PointsRecord record : availablePoints) {
            if (remaining <= 0) break;
            
            int pointsUsed;
            if (record.getPoints() > remaining) {
                // 部分使用
                pointsUsed = remaining;
                record.setPoints(record.getPoints() - remaining);
                pointsRecordMapper.updateById(record);
                remaining = 0;
            } else {
                // 整条使用
                pointsUsed = record.getPoints();
                pointsRecordMapper.deleteById(record.getId());
                remaining -= pointsUsed;
            }
            
            // 创建明细记录
            PointsOrderDetail detail = new PointsOrderDetail();
            detail.setOrderId(order.getId());
            detail.setPoints(pointsUsed);
            detail.setExpireTime(record.getExpireTime());
            details.add(detail);
        }
        
        // 5. 批量保存明细
        pointsOrderDetailMapper.batchInsert(details);
    }
    
    private String generateOrderSn() {
        // 订单号生成逻辑(时间戳+随机数)
        return "PO" + System.currentTimeMillis() + ThreadLocalRandom.current().nextInt(1000, 9999);
    }
}

2. 退货退款(积分返还)

核心逻辑:根据订单明细原样返还积分

@Service
@Transactional
public class PointsRefundService {

    @Autowired
    private PointsOrderMapper pointsOrderMapper;
    
    @Autowired
    private PointsOrderDetailMapper pointsOrderDetailMapper;
    
    @Autowired
    private PointsRecordMapper pointsRecordMapper;

    public void refundOrder(Long orderId) {
        // 1. 验证订单状态
        PointsOrder order = pointsOrderMapper.selectById(orderId);
        if (order == null || order.getStatus() != 0) {
            throw new BusinessException("订单不可退款");
        }
        
        // 2. 查询订单明细
        List<PointsOrderDetail> details = pointsOrderDetailMapper.selectByOrderId(orderId);
        
        // 3. 返还积分
        List<PointsRecord> refundRecords = details.stream().map(detail -> {
            PointsRecord record = new PointsRecord();
            record.setUserId(order.getUserId());
            record.setPoints(detail.getPoints());
            record.setExpireTime(detail.getExpireTime());
            return record;
        }).collect(Collectors.toList());
        
        // 4. 批量插入积分记录
        pointsRecordMapper.batchInsert(refundRecords);
        
        // 5. 更新订单状态
        order.setStatus(4); // 已退款
        pointsOrderMapper.updateById(order);
    }
}

3. 定期清理过期积分

使用Spring Task定时任务

@Component
public class PointsCleanTask {

    @Autowired
    private PointsRecordMapper pointsRecordMapper;

    // 每天凌晨3点执行
    @Scheduled(cron = "0 0 3 * * ?")
    public void cleanExpiredPoints() {
        int count = pointsRecordMapper.deleteExpiredPoints(new Date());
        log.info("已清理过期积分记录:{}条", count);
    }
}
<!-- MyBatis Mapper 接口方法 -->
public interface PointsRecordMapper extends BaseMapper<PointsRecord> {
    
    // 查询可用积分(按过期时间升序)
    @Select("SELECT * FROM points_record " +
            "WHERE user_id = #{userId} AND expire_time > NOW() " +
            "ORDER BY expire_time ASC")
    List<PointsRecord> selectAvailablePoints(Long userId);
    
    // 删除过期积分
    @Delete("DELETE FROM points_record WHERE expire_time < #{now}")
    int deleteExpiredPoints(@Param("now") Date now);
}

三、关键设计说明

  1. 积分扣减策略

    • 查询语句:ORDER BY expire_time ASC 确保优先使用最早过期的积分
    • 扣减时遍历积分记录:
      • 当记录积分 > 需扣减量:更新剩余积分(update
      • 当记录积分 ≤ 需扣减量:删除记录(delete
  2. 事务控制

    • @Transactional注解保证:
      • 订单创建:积分扣减+订单创建+明细创建的原子性
      • 退款操作:积分返还+订单状态更新的原子性
  3. 积分返还特性

    • 退货时从points_order_detail读取原始过期时间
    • 创建新积分记录时继承原过期时间
    • 保证积分有效期不变
  4. 防并发处理

    • 在积分扣减的Service方法添加synchronized关键字
    • 或使用数据库悲观锁:
      SELECT * FROM points_record 
      WHERE user_id = #{userId} 
      FOR UPDATE
      
  5. 性能优化

    • 批量插入操作使用INSERT INTO ... VALUES (...), (...)语法
    • 定期清理任务在业务低峰期执行

四、接口设计示例

创建订单DTO

public class OrderCreateDTO {
    private Long userId;
    private Long goodsId;
    private Integer requiredPoints; // 商品所需积分
    
    // getters/setters
}

退款请求

@PostMapping("/refund")
public Result refundOrder(@RequestParam Long orderId) {
    pointsRefundService.refundOrder(orderId);
    return Result.success();
}

五、注意事项

  1. 积分一致性:通过数据库事务保证积分操作原子性
  2. 过期策略:定时任务需配置分布式锁(如Redis锁)防止集群重复执行
  3. 并发控制:高并发场景建议:
    • 使用Redis分布式锁
    • 数据库行级锁(SELECT ... FOR UPDATE
  4. 日志记录:关键操作记录操作日志,便于对账
  5. 索引优化
    -- 积分记录表
    CREATE INDEX idx_user_expire ON points_record(user_id, expire_time);
    
    -- 订单明细表
    CREATE INDEX idx_order_detail ON points_order_detail(order_id);
    

该方案完整实现了积分消费、过期优先、退货返还、定期清理等核心功能。