场景描述: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);
}
三、关键设计说明
-
积分扣减策略:
- 查询语句:
ORDER BY expire_time ASC确保优先使用最早过期的积分 - 扣减时遍历积分记录:
- 当记录积分 > 需扣减量:更新剩余积分(
update) - 当记录积分 ≤ 需扣减量:删除记录(
delete)
- 当记录积分 > 需扣减量:更新剩余积分(
- 查询语句:
-
事务控制:
@Transactional注解保证:- 订单创建:积分扣减+订单创建+明细创建的原子性
- 退款操作:积分返还+订单状态更新的原子性
-
积分返还特性:
- 退货时从
points_order_detail读取原始过期时间 - 创建新积分记录时继承原过期时间
- 保证积分有效期不变
- 退货时从
-
防并发处理:
- 在积分扣减的Service方法添加
synchronized关键字 - 或使用数据库悲观锁:
SELECT * FROM points_record WHERE user_id = #{userId} FOR UPDATE
- 在积分扣减的Service方法添加
-
性能优化:
- 批量插入操作使用
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();
}
五、注意事项
- 积分一致性:通过数据库事务保证积分操作原子性
- 过期策略:定时任务需配置分布式锁(如Redis锁)防止集群重复执行
- 并发控制:高并发场景建议:
- 使用Redis分布式锁
- 数据库行级锁(
SELECT ... FOR UPDATE)
- 日志记录:关键操作记录操作日志,便于对账
- 索引优化:
-- 积分记录表 CREATE INDEX idx_user_expire ON points_record(user_id, expire_time); -- 订单明细表 CREATE INDEX idx_order_detail ON points_order_detail(order_id);
该方案完整实现了积分消费、过期优先、退货返还、定期清理等核心功能。