在私域运营的留存工具里,积分体系是普及率最高、但也最容易做"塌"的一个。
很多系统的积分模块做得很简单:用户表加个积分余额字段,下单加一点、消费扣一点。跑起来之后才发现问题:重复签到重复发积分、退款了积分没扣回、积分对不上账找不到原因、用户积分无限累积最后变成财务包袱。
本质上,积分不是一个"字段",是一套微型账户系统------有账户、有流水、有获取规则、有消耗场景、有有效期、有对账依据。LikeShop 单商户标准版的积分模块就是按这个思路设计的:底层统一账户与流水,上层封装签到、消费返、兑换、抵现等业务场景,所有变动可追溯、可对账、可回退。
这篇文章从表结构设计、三个核心业务场景、关键设计原则三个层面拆解实现思路,附核心代码片段。
一、整体架构:账户-流水-业务三层设计
积分体系从上到下分三层,底层稳了,上层加多少场景都不会乱。
业务场景层:签到、消费返现、积分抵现、积分兑换、新人奖励
↓
流水记录层:每一笔积分变动都生成流水,类型、来源、金额、余额快照
↓
账户底层:用户积分账户、余额、累计收支、有效期
核心原则只有一条:永远不直接修改积分余额,所有变动都通过流水驱动。余额只是冗余展示字段,真实账目以流水为准。这样出了问题可以逐笔对账,也能随时按维度统计获取、消耗的构成。
二、核心数据库设计
2.1 用户积分账户表
每个用户一条账户记录,存当前余额和累计统计,不存明细。
sql
CREATE TABLE ls_user_integral_account (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL UNIQUE COMMENT '用户ID',
balance INT NOT NULL DEFAULT 0 COMMENT '当前可用积分',
total_earned INT NOT NULL DEFAULT 0 COMMENT '累计获取积分',
total_spent INT NOT NULL DEFAULT 0 COMMENT '累计消耗积分',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
update_time DATETIME NOT NULL,
create_time DATETIME NOT NULL,
INDEX idx_user_id(user_id)
);
2.2 积分流水表(核心)
所有积分变动都必须落流水,这是对账和追溯的唯一依据。
sql
CREATE TABLE ls_integral_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
type TINYINT NOT NULL COMMENT '类型:1获取 2消耗',
source VARCHAR(32) NOT NULL COMMENT '来源:sign/order/refund/exchange/deduct',
amount INT NOT NULL COMMENT '变动积分(正数获取,负数消耗)',
balance_after INT NOT NULL COMMENT '变动后余额快照',
biz_no VARCHAR(64) COMMENT '关联业务单号:订单号、签到日期等',
remark VARCHAR(255) COMMENT '备注',
create_time DATETIME NOT NULL,
INDEX idx_user_id(user_id),
INDEX idx_biz_no(biz_no),
INDEX idx_create_time(create_time)
);
2.3 签到记录表
专门记录每日签到,用来计算连续天数和断签重置。
sql
CREATE TABLE ls_user_sign_in (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
sign_date DATE NOT NULL COMMENT '签到日期',
continuous_days INT NOT NULL COMMENT '连续签到天数',
reward_points INT NOT NULL COMMENT '本次获得积分',
create_time DATETIME NOT NULL,
UNIQUE KEY uk_user_date(user_id, sign_date),
INDEX idx_user_id(user_id)
);
2.4 积分兑换订单表
积分商城兑换商品/优惠券时生成,记录兑换明细与发货状态。
sql
CREATE TABLE ls_integral_exchange_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
goods_id BIGINT COMMENT '兑换商品ID',
goods_name VARCHAR(128),
need_points INT NOT NULL COMMENT '消耗积分',
status TINYINT NOT NULL COMMENT '0待发货 1已发货 2已完成',
express_no VARCHAR(64),
create_time DATETIME NOT NULL,
INDEX idx_user_id(user_id)
);
三、核心场景一:每日签到与连续奖励
签到是最低成本的日活工具。规则通常是:每日签到得基础积分,连续天数越多奖励越高,断签重置回第一天。
3.1 规则设计
- 第 1 天 5 积分,第 2 天 8 积分,第 7 天 20 积分,7 天一个周期循环;
- 自然日判断,一天只能签一次;
- 当天没签就算断签,连续天数重置为 1;
- 签到奖励积分实时到账。
3.2 核心实现代码
java
@Service
public class SignInService {
@Autowired
private UserSignInMapper signInMapper;
@Autowired
private IntegralAccountService accountService;
/**
* 用户签到
*/
@Transactional(rollbackFor = Exception.class)
public SignInResult signIn(Long userId) {
LocalDate today = LocalDate.now();
// 1. 幂等校验:今天是否已签到
UserSignIn todayRecord = signInMapper.getByDate(userId, today);
if (todayRecord != null) {
throw new BizException("今日已签到");
}
// 2. 查询昨天签到记录,计算连续天数
UserSignIn yesterdayRecord = signInMapper.getByDate(userId, today.minusDays(1));
int continuousDays = yesterdayRecord == null
? 1
: yesterdayRecord.getContinuousDays() + 1;
// 3. 根据连续天数计算奖励积分(7天一个周期)
int rewardPoints = calcReward(continuousDays);
// 4. 写入签到记录
UserSignIn record = new UserSignIn();
record.setUserId(userId);
record.setSignDate(today);
record.setContinuousDays(continuousDays);
record.setRewardPoints(rewardPoints);
signInMapper.insert(record);
// 5. 增加积分,写流水
accountService.addIntegral(
userId,
rewardPoints,
IntegralSource.SIGN,
"签到奖励",
"sign_" + today
);
return new SignInResult(continuousDays, rewardPoints);
}
/**
* 阶梯奖励计算
*/
private int calcReward(int continuousDays) {
int[] rewards = {5, 8, 10, 12, 15, 18, 20};
int index = (continuousDays - 1) % rewards.length;
return rewards[index];
}
}
积分增减统一走 IntegralAccountService,不在业务代码里直接改余额,保证流水和余额一致。
四、核心场景二:消费返积分与退款回退
消费返积分是最主要的积分获取渠道,规则简单但细节多,尤其是退款反向处理很容易遗漏。
4.1 规则设计
- 按实付金额比例返还,例:1 元 = 1 积分;
- 仅商品实付部分计算,运费、优惠券抵扣部分不计入;
- 支付成功后发放,还是确认收货后发放,可配置;
- 发生退款时,按退款比例扣回对应积分。
4.2 支付成功返积分
通过订单支付事件驱动,不侵入主订单流程。
java
@Service
public class OrderIntegralService {
@Autowired
private IntegralAccountService accountService;
/**
* 订单支付成功,发放消费积分
*/
@EventListener
public void onOrderPaid(OrderPaidEvent event) {
Order order = event.getOrder();
Long userId = order.getUserId();
// 1. 计算可返积分的金额(实付 - 运费)
BigDecimal payableAmount = order.getPayAmount()
.subtract(order.getFreightAmount());
if (payableAmount.compareTo(BigDecimal.ZERO) <= 0) {
return;
}
// 2. 按比例计算积分
BigDecimal rate = getIntegralRate(); // 配置化,如 1:1
int points = payableAmount.multiply(rate).intValue();
if (points <= 0) {
return;
}
// 3. 幂等校验:同一订单只发一次
if (accountService.existsBizNo(userId, "order_" + order.getOrderSn())) {
return;
}
// 4. 发放积分
accountService.addIntegral(
userId,
points,
IntegralSource.ORDER,
"购物返积分",
"order_" + order.getOrderSn()
);
}
}
4.3 退款扣回积分
退款是最容易漏的环节。全额退款扣回全部积分,部分退款按比例扣回。
java
@EventListener
public void onOrderRefund(OrderRefundEvent event) {
OrderRefund refund = event.getRefund();
Order order = event.getOrder();
Long userId = order.getUserId();
// 按退款金额占比,计算应扣回积分
BigDecimal ratio = refund.getRefundAmount()
.divide(order.getPayAmount(), 4, RoundingMode.HALF_UP);
// 查询该订单发放的总积分
int totalPoints = accountService.getPointsByBizNo(
userId, "order_" + order.getOrderSn());
int deductPoints = new BigDecimal(totalPoints)
.multiply(ratio).intValue();
if (deductPoints > 0) {
accountService.deductIntegral(
userId,
deductPoints,
IntegralSource.REFUND_DEDUCT,
"订单退款扣回积分",
"refund_" + refund.getRefundSn()
);
}
}
五、核心场景三:积分消耗的两种主流模式
积分只有能花出去,用户才会在乎赚积分。常见的消耗场景有两种:下单积分抵现 和积分商城兑换。
5.1 下单积分抵现
用户提交订单时可勾选使用积分抵扣现金,规则可配置抵扣比例和上限。
java
@Service
public class IntegralDeductService {
/**
* 下单时计算可抵扣金额
*/
public BigDecimal calcDeductAmount(Long userId, BigDecimal orderAmount) {
int userBalance = accountService.getBalance(userId);
if (userBalance <= 0) {
return BigDecimal.ZERO;
}
// 规则:100积分 = 1元,单笔抵扣不超过订单金额的20%
BigDecimal maxDeductByRatio = orderAmount.multiply(new BigDecimal("0.2"));
BigDecimal maxDeductByBalance = new BigDecimal(userBalance)
.divide(new BigDecimal(100), 2, RoundingMode.DOWN);
return maxDeductByBalance.min(maxDeductByRatio);
}
/**
* 提交订单时扣减积分
*/
@Transactional(rollbackFor = Exception.class)
public void deductOnOrder(Long userId, String orderSn, int usePoints) {
// 1. 校验余额
int balance = accountService.getBalance(userId);
if (balance < usePoints) {
throw new BizException("积分不足");
}
// 2. 扣减积分
accountService.deductIntegral(
userId,
usePoints,
IntegralSource.ORDER_DEDUCT,
"订单抵扣",
"deduct_" + orderSn
);
}
}
注意:订单取消或退款时,抵扣的积分要原路退回,和消费返积分的反向逻辑同理。
5.2 积分商城兑换
用户在积分商城用纯积分或"积分+现金"兑换商品、优惠券、周边实物。核心逻辑是校验余额 → 扣积分 → 生成兑换订单 → 发货/发放。
java
@Transactional(rollbackFor = Exception.class)
public ExchangeResult exchangeGoods(Long userId, Long goodsId) {
IntegralGoods goods = goodsMapper.selectById(goodsId);
if (goods.getStock() <= 0) {
throw new BizException("库存不足");
}
// 1. 扣减积分
accountService.deductIntegral(
userId,
goods.getNeedPoints(),
IntegralSource.EXCHANGE,
"积分兑换:" + goods.getName(),
"exchange_" + IdGenerator.nextId()
);
// 2. 扣兑换库存
int affected = jdbcTemplate.update(
"UPDATE ls_integral_goods SET stock = stock - 1 WHERE id = ? AND stock > 0",
goodsId
);
if (affected == 0) {
throw new BizException("手慢了,库存不足");
}
// 3. 生成兑换订单
IntegralExchangeOrder order = new IntegralExchangeOrder();
order.setUserId(userId);
order.setGoodsId(goodsId);
order.setGoodsName(goods.getName());
order.setNeedPoints(goods.getNeedPoints());
order.setStatus(0);
exchangeOrderMapper.insert(order);
return new ExchangeResult(order.getId(), goods.getNeedPoints());
}
六、账户层的关键设计原则
所有业务场景最终都落到账户层,这一层做对了,上层怎么加场景都稳。
6.1 流水驱动,余额冗余
永远不直接 update 余额字段完事。每一次变动都先生成流水,再更新余额,余额是流水的快照。对账时以流水为准,余额只做展示优化。
6.2 幂等控制,按业务单号去重
同一笔订单、同一天签到、同一个退款单,只能产生一次积分变动。用 biz_no 做唯一约束,防止重试、重复事件导致积分多发或多扣。
6.3 并发安全,乐观锁防护
高并发下直接改余额会出现超扣。用版本号乐观锁或者条件更新保证原子性:
java
// 条件更新扣积分:只有余额足够时才扣成功
int update = jdbcTemplate.update(
"UPDATE ls_user_integral_account " +
"SET balance = balance - ?, version = version + 1 " +
"WHERE user_id = ? AND balance >= ? AND version = ?",
amount, userId, amount, version
);
if (update == 0) {
throw new BizException("积分不足或状态已变更,请重试");
}
6.4 有效期管理,避免无限累积
积分设置有效期,例如滚动 12 个月有效或每年年底清零。到期前发消息提醒用户消耗,既提升活跃度,也避免积分池无限膨胀变成财务负担。
七、常见坑与最佳实践
-
只存余额不存流水
坑:积分对不上账,查不到哪笔多了哪笔少了。
最佳实践:流水必存,业务号必录,可追溯可对账。
-
退款不回退积分
坑:用户退款了,积分还在手里,等于白嫖。
最佳实践:退款事件驱动积分扣回,全额部分额都要处理。
-
签到无幂等
坑:网络重试、多点几次,重复发积分。
最佳实践:用户+日期唯一索引,业务号去重。
-
并发扣减负积分
坑:同时两笔订单抵扣,余额扣成负数。
最佳实践:乐观锁或条件更新,数据库层面保证原子性。
-
积分无有效期
坑:积分越积越多,最后平台背不动。
最佳实践:设置有效期,到期清零,提前提醒。
结语
积分体系看起来是个很简单的小功能,真要做扎实,本质上是一套微型账务系统。
LikeShop 单商户标准版的这套设计,核心就是把账户层和业务层分开:底层账户统一管流水、管幂等、管并发安全;上层签到、消费、兑换、抵现等场景只管业务规则,不用重复造轮子。这样不仅上线时稳,后续二开加新的积分玩法,也只需要在业务层扩展,不会动到底层账户逻辑。
对做电商系统的团队来说,积分模块属于"不起眼但很容易埋雷"的部分。底层按账户系统的标准做扎实了,后面运营怎么玩花样都不会出账务事故。