多商户平台的营销中心,和单商户完全不是一个概念。
单商户的营销工具是商家自己配置、自己玩;多商户的营销中心是平台提供标准化工具,商家自主创建/报名、平台统一管控,同时还要和商家分账、商品库存、订单流转深度联动。很多做多商户系统的团队,一开始把营销做成「每个活动各写一套逻辑」,结果库存乱、分账错、活动状态不同步,维护成本极高。
LikeShop 多商户高级版的营销中心采用的是「基础活动抽象 + 子类型扩展 + 统一库存分账联动」的架构:拼团、砍价、秒杀共享底层能力,各自实现自身业务规则。这篇文章从通用架构、三个活动的核心实现、共通技术点三个层面拆解,附核心代码与表设计。
一、营销中心通用底层架构
所有营销活动本质上都有共性:绑定商品、设置活动价、占用库存、有时间周期、有状态流转、和订单挂钩。把这些共性抽到底层,子活动只做差异化扩展,是整套架构的核心思路。
1.1 活动抽象基表 + 子类型扩展
采用「基表存公共字段 + 扩展表存类型字段」的设计,避免每个活动重复写一遍商品、时间、状态、商家关联。
-- 营销活动基表(所有类型共用)
CREATE TABLE ls_market_activity (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
merchant_id BIGINT NOT NULL COMMENT '商家ID',
activity_type TINYINT NOT NULL COMMENT '活动类型:1拼团 2砍价 3秒杀',
activity_name VARCHAR(64) NOT NULL,
goods_id BIGINT NOT NULL,
sku_id BIGINT DEFAULT NULL,
original_price DECIMAL(10,2) NOT NULL COMMENT '商品原价',
activity_price DECIMAL(10,2) NOT NULL COMMENT '活动价',
activity_stock INT NOT NULL COMMENT '活动库存',
sold_count INT NOT NULL DEFAULT 0 COMMENT '已售数量',
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
audit_status TINYINT NOT NULL DEFAULT 0 COMMENT '审核状态:0待审 1通过 2驳回',
status TINYINT NOT NULL DEFAULT 0 COMMENT '0未开始 1进行中 2已结束 3关闭',
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
INDEX idx_merchant_id(merchant_id),
INDEX idx_status(status),
INDEX idx_time(start_time, end_time)
);
每个活动类型再建一张扩展表,存自身特殊字段:
ls_market_groupbuy:拼团人数、团长优惠、成团时效;ls_market_bargain:底价、助力次数、每人砍价区间;ls_market_seckill:限购数量、秒杀场次、限流阈值。
这样设计的好处是:公共逻辑(库存扣减、状态流转、订单关联、分账计算)只写一遍,子活动只实现自身规则,新增活动类型时不用重复造轮子。
1.2 活动库存独立池
营销活动最容易出事故的就是库存。如果活动直接扣普通商品库存,要么活动卖超了影响正常发货,要么活动结束剩余库存没回退,商家后台对不上账。
通用底层采用活动独立库存池机制:
- 活动审核通过时,从普通 SKU 库存中划拨对应数量到活动库存;
- 活动下单只扣活动库存,不影响正常销售库存;
- 订单取消、退款、活动结束剩余,自动回退到普通库存。
库存扣减用条件更新保证原子性,这是防超卖的基础:
@Service
public class ActivityStockService {
/**
* 扣减活动库存(原子操作)
* @return 受影响行数 > 0 表示扣减成功
*/
public int deductStock(Long activityId, int num) {
// 关键:WHERE 带上 stock >= num 条件
// 数据库行锁保证并发安全
return jdbcTemplate.update(
"UPDATE ls_market_activity " +
"SET activity_stock = activity_stock - ?, sold_count = sold_count + ? " +
"WHERE id = ? AND activity_stock >= ?",
num, num, activityId, num
);
}
/**
* 回退活动库存
*/
public int refundStock(Long activityId, int num) {
return jdbcTemplate.update(
"UPDATE ls_market_activity " +
"SET activity_stock = activity_stock + ?, sold_count = sold_count - ? " +
"WHERE id = ?",
num, num, activityId
);
}
}
1.3 订单标记与分账联动
活动订单必须在订单表打标记,否则后续结算、退款、统计都会乱。
订单表扩展字段:activity_id、activity_type、activity_price。下单时写入,全程跟随订单流转。
分账联动规则:
- 平台佣金按订单实付金额计算,不按原价;
- 活动让利部分由商家承担,平台不损失佣金;
- 退款时按实付比例回退分账,和普通订单逻辑一致。
这样设计避免了一个常见坑:活动订单按原价分账,导致平台佣金减少、财务对账对不上。
1.4 活动状态机
所有活动共用一套状态流转,事件驱动,不允许非法跳转。
public enum ActivityStatus {
DRAFT(0, "草稿"),
PENDING_AUDIT(1, "待审核"),
AUDIT_PASS(2, "审核通过"),
NOT_START(3, "未开始"),
RUNNING(4, "进行中"),
ENDED(5, "已结束"),
CLOSED(6, "已关闭");
}
标准流转路径:
草稿 → 提交审核 → 审核通过 → 未开始 → 时间到自动开始 → 进行中 → 时间到自动结束
↓
审核驳回 → 商家修改重提
状态变更统一走 ActivityStateMachine,写日志、触发事件、联动库存,业务代码不允许直接改状态字段。
二、拼团活动的核心实现
拼团是社交属性最强的活动,核心是「成团机制」——人够就成,不够就散,自动退款。
2.1 核心表设计
-- 拼团活动扩展表
CREATE TABLE ls_market_groupbuy (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
activity_id BIGINT NOT NULL UNIQUE,
group_size INT NOT NULL COMMENT '成团人数',
leader_discount DECIMAL(5,2) COMMENT '团长额外折扣比例',
group_timeout INT NOT NULL COMMENT '成团超时时间(小时)',
INDEX idx_activity_id(activity_id)
);
-- 拼团记录(每个团一条)
CREATE TABLE ls_groupbuy_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
activity_id BIGINT NOT NULL,
group_no VARCHAR(32) NOT NULL COMMENT '团号',
leader_user_id BIGINT NOT NULL COMMENT '团长用户ID',
current_num INT NOT NULL DEFAULT 1 COMMENT '当前人数',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1进行中 2已成团 3已解散',
create_time DATETIME NOT NULL,
success_time DATETIME COMMENT '成团时间',
expire_time DATETIME NOT NULL,
INDEX idx_activity_id(activity_id),
INDEX idx_status(status)
);
-- 拼团成员
CREATE TABLE ls_groupbuy_member (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
group_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
order_id BIGINT NOT NULL,
is_leader TINYINT NOT NULL DEFAULT 0 COMMENT '0成员 1团长',
join_time DATETIME NOT NULL,
UNIQUE KEY uk_group_user(group_id, user_id)
);
2.2 参团与成团逻辑
用户下单时选择「去开团」或「去参团」,支付成功后加入对应团:
@Service
public class GroupbuyService {
@Transactional(rollbackFor = Exception.class)
public void joinGroup(Long activityId, Long groupId, Long userId, Long orderId) {
// 1. 校验活动状态
MarketActivity activity = activityService.getById(activityId);
if (activity.getStatus() != ActivityStatus.RUNNING.getCode()) {
throw new BizException("活动未开始或已结束");
}
// 2. 扣活动库存
int ok = activityStockService.deductStock(activityId, 1);
if (ok == 0) {
throw new BizException("活动库存不足");
}
// 3. 加入团
GroupbuyRecord group = recordMapper.selectById(groupId);
if (group.getStatus() != 1) {
activityStockService.refundStock(activityId, 1);
throw new BizException("该团已结束或已解散");
}
int affected = jdbcTemplate.update(
"UPDATE ls_groupbuy_record SET current_num = current_num + 1 WHERE id = ? AND status = 1",
groupId
);
if (affected == 0) {
activityStockService.refundStock(activityId, 1);
throw new BizException("加入失败,请重试");
}
// 4. 写入成员记录
memberMapper.insert(/* 成员数据 */);
// 5. 判断是否成团
group = recordMapper.selectById(groupId);
GroupbuyActivity ext = extMapper.getByActivityId(activityId);
if (group.getCurrentNum() >= ext.getGroupSize()) {
// 成团:更新状态、通知所有成员
successGroup(groupId);
}
}
}
2.3 超时自动解散
定时任务每小时扫描超时未成团的团:
- 团状态改为已解散;
- 所有成员订单自动退款;
- 活动库存回退;
- 通知用户拼团失败已退款。
三、砍价活动的核心实现
砍价的核心是「裂变助力」——用户发起,好友帮忙砍,砍到低价后成交。难点在防刷和助力规则。
3.1 核心表设计
-- 砍价活动扩展表
CREATE TABLE ls_market_bargain (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
activity_id BIGINT NOT NULL UNIQUE,
floor_price DECIMAL(10,2) NOT NULL COMMENT '砍后底价',
max_help_count INT NOT NULL COMMENT '最多助力人数',
min_cut DECIMAL(10,2) NOT NULL COMMENT '每人最少砍',
max_cut DECIMAL(10,2) NOT NULL COMMENT '每人最多砍',
record_timeout INT NOT NULL COMMENT '砍价记录有效期(小时)',
INDEX idx_activity_id(activity_id)
);
-- 砍价记录(每个用户每次发起一条)
CREATE TABLE ls_bargain_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
activity_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
current_price DECIMAL(10,2) NOT NULL COMMENT '当前价格',
help_count INT NOT NULL DEFAULT 0 COMMENT '已助力次数',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1进行中 2已下单 3已过期',
create_time DATETIME NOT NULL,
expire_time DATETIME NOT NULL,
INDEX idx_activity_id(activity_id),
INDEX idx_user_id(user_id)
);
-- 助力记录
CREATE TABLE ls_bargain_help (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
record_id BIGINT NOT NULL,
helper_user_id BIGINT NOT NULL,
cut_amount DECIMAL(10,2) NOT NULL COMMENT '砍掉金额',
create_time DATETIME NOT NULL,
UNIQUE KEY uk_record_helper(record_id, helper_user_id)
);
3.2 助力与防刷逻辑
助力是最容易被薅羊毛的环节,必须做多维度校验:
@Service
public class BargainHelpService {
@Transactional(rollbackFor = Exception.class)
public void help(Long recordId, Long helperUserId) {
BargainRecord record = recordMapper.selectById(recordId);
if (record.getStatus() != 1) {
throw new BizException("砍价已结束或已过期");
}
// 1. 防重复:同一个人不能帮同一个砍价记录砍两次
if (helpMapper.exists(recordId, helperUserId)) {
throw new BizException("您已经帮TA砍过啦");
}
// 2. 防次数超限
BargainActivity ext = extMapper.getByActivityId(record.getActivityId());
if (record.getHelpCount() >= ext.getMaxHelpCount()) {
throw new BizException("已达最大助力次数");
}
// 3. 随机砍价金额(区间内浮动,防固定金额脚本刷)
BigDecimal cutAmount = randomCut(ext.getMinCut(), ext.getMaxCut());
// 4. 不能砍到低于底价
BigDecimal newPrice = record.getCurrentPrice().subtract(cutAmount);
if (newPrice.compareTo(ext.getFloorPrice()) < 0) {
cutAmount = record.getCurrentPrice().subtract(ext.getFloorPrice());
newPrice = ext.getFloorPrice();
}
// 5. 更新砍价记录
jdbcTemplate.update(
"UPDATE ls_bargain_record SET current_price = ?, help_count = help_count + 1 WHERE id = ?",
newPrice, recordId
);
// 6. 写助力记录
helpMapper.insert(/* 助力数据 */);
}
private BigDecimal randomCut(BigDecimal min, BigDecimal max) {
// 区间内随机,保留两位小数
double range = max.subtract(min).doubleValue();
double random = Math.random() * range + min.doubleValue();
return BigDecimal.valueOf(random).setScale(2, RoundingMode.HALF_UP);
}
}
3.3 过期与下单
- 砍价记录有有效期,超时未下单自动失效;
- 用户砍到心仪价格后,可直接下单,下单后扣活动库存,订单标记砍价活动。
四、秒杀活动的核心实现
秒杀的核心是高并发下的超卖防护。瞬间流量是平时的几十上百倍,库存扣减、接口限流、订单时效都要做专门设计。
4.1 秒杀库存池
秒杀采用独立预占库存:活动开始前,从普通库存划拨到秒杀库存池,活动结束剩余自动回退。
好处:
- 不影响正常销售库存;
- 超卖只在活动池内,风险可控;
- 库存数量少,扣减性能更高。
4.2 限流与原子扣减
秒杀的技术重点在防超卖,两层防护:
第一层:入口限流
用令牌桶或计数器限流,超过阈值直接返回“活动火爆,请稍后再试”,避免大量请求打到数据库。
第二层:数据库条件更新
库存扣减用单行条件更新,数据库行锁保证原子性,这是最后一道防线。
@Service
public class SeckillService {
/**
* 秒杀下单扣库存
*/
@Transactional(rollbackFor = Exception.class)
public boolean seckillOrder(Long activityId, Long userId, int num) {
// 1. 限流校验(令牌桶)
if (!rateLimiter.tryAcquire()) {
throw new BizException("活动太火爆了,请稍后再试");
}
// 2. 限购校验
if (getUserBuyCount(activityId, userId) + num > getLimitNum(activityId)) {
throw new BizException("超出限购数量");
}
// 3. 原子扣库存(核心)
int affected = jdbcTemplate.update(
"UPDATE ls_market_activity " +
"SET activity_stock = activity_stock - ?, sold_count = sold_count + ? " +
"WHERE id = ? AND activity_stock >= ? AND status = ?",
num, num, activityId, num,
ActivityStatus.RUNNING.getCode()
);
if (affected == 0) {
throw new BizException("手慢了,库存不足");
}
// 4. 创建秒杀订单
createSeckillOrder(activityId, userId, num);
return true;
}
}
4.3 订单超时释放
秒杀下单后必须设置短超时(通常 5~15 分钟),未付款自动取消,释放库存给其他人抢。
定时任务扫描超时未支付的秒杀订单:取消订单 → 回退活动库存 → 释放限购名额。
五、共通技术点与避坑
5.1 库存幂等与回滚
所有库存操作都要幂等,按订单维度去重。订单取消、退款、活动关闭,都要自动回退对应活动库存,避免库存不一致。
5.2 平台审核机制
商家创建活动后必须走平台审核:
- 校验原价真实性,禁止虚高原价再打折;
- 校验活动商品合规,禁售品不能做活动;
- 校验活动库存,不能大于实际可用库存。
没做审核的话,商家乱标价格、虚假活动,最后投诉和合规风险都是平台的。
5.3 数据统计隔离
每个活动的销量、GMV、参与人数、转化率单独统计,商家后台只看自己的活动数据,平台后台看全平台。数据按 merchant_id 隔离,不能串。
5.4 常见坑
活动库存和普通库存混淆
坑:活动直接扣普通库存,结束没回退,商家库存对不上。
最佳实践:独立活动库存池,全链路闭环。拼团/砍价退款不同步
坑:订单退款了,活动库存没加回去,团人数没减。
最佳实践:退款事件驱动库存回退、团记录更新。秒杀没做原子扣减
坑:先查后改,并发下超卖。
最佳实践:条件更新 SQL,把判断和扣减合并成一条语句。活动订单分账按原价
坑:平台佣金按原价算,商家活动让利平台买单。
最佳实践:一律按实付金额分账,让利商家承担。活动状态不做校验
坑:未开始、已结束的活动也能下单。
最佳实践:所有操作前置状态校验,状态机统一流转。
结语
多商户营销中心看起来是三个独立的活动功能,本质是一套底层架构 + 三套业务规则。
通用的库存、订单、分账、状态机做扎实了,上面的拼团、砍价、秒杀都只是基于这套底座的规则扩展。LikeShop 多商户高级版的这套设计,核心就是把共性的东西抽到底层,子活动只实现自身差异化逻辑,无论是维护成本还是扩展效率,都比每个活动各写一套高很多。
对做多商户平台的团队来说,营销模块属于「看起来简单,做好很难」的部分。底层架构对了,加活动、加玩法都是顺理成章的事;底层乱了,每个活动都是坑。