一、原生能力的"半自动":有定时任务,但没有活动调度
LikeShop 的定时任务基础设施是完备的。入口是项目根目录的 think 文件,通过 php think crontab 一条命令驱动所有子任务,服务器 Crontab 只需配置每分钟执行一次即可。后台"系统设置 → 系统维护 → 定时任务"提供了完整的 CRUD 和开关,每个子任务在数据库中有自己的 cron 表达式和执行状态,Crontab 命令每分钟遍历所有已开启任务,匹配时间后执行。
这套机制里已经有一条"活动自动开启/结束"的子任务,负责检查营销活动的开始和结束时间。营销中心的基表 ls_market_activity 也确实有 start_time、end_time 和 status 字段(0未开始、1进行中、2已结束、3关闭),并建立了 idx_time(start_time, end_time) 索引。
但这条原生能力停留在"状态翻转"层面:到点了把 status 从 0 改成 1,或者从 1 改成 2。它没有回答几个运营真正关心的问题------活动结束后,效果数据谁来出?每周循环的秒杀,难道要运营每周手动新建一遍?活动开始前,有没有预热提醒? 自动启停只解决了"开关"的动作,没有解决"开关前后的一整段运营链路"。
二开的核心策略是:不重写原生的 Crontab 调度器,不修改 ls_market_activity 的基表结构,在原生"活动自动开启/结束"子任务的扩展点上,增加预热提醒、效果快照、报表生成和循环复刻四类动作。活动状态流转仍然由原生字段承载,二开新增的是围绕状态变更的"前后置业务"。
二、自动启停的调度架构:两阶段状态机 + 预热窗口
2.1 原生状态流转的缺口:开始前没有"预热"
原生状态只有"未开始 → 进行中 → 已结束"三段,活动在 start_time 到达的瞬间直接从 0 跳到 1。但运营需要的是预热期:活动开始前 24 小时或 1 小时,给收藏了活动商品、或者属于目标人群的用户推送提醒;活动结束前,也要有一次"即将结束"的触达。这些动作发生在状态翻转之前,原生状态机没有承载点。
二开不改变 status 的原生枚举,而是在活动调度表上增加一个独立的 schedule_stage 字段,用来记录预热、进行、收尾的调度阶段:
sql
ALTER TABLE `ls_market_activity`
ADD COLUMN `preheat_start_time` int UNSIGNED NOT NULL DEFAULT 0
COMMENT '预热开始时间,0为不预热' AFTER `start_time`,
ADD COLUMN `schedule_stage` tinyint NOT NULL DEFAULT 0
COMMENT '调度阶段:0-待调度,1-预热中,2-进行中,3-收尾中,4-已完成' AFTER `status`,
ADD COLUMN `loop_config` text COMMENT '循环配置,JSON格式,非循环活动为空' AFTER `schedule_stage`,
ADD COLUMN `report_status` tinyint NOT NULL DEFAULT 0
COMMENT '效果报表状态:0-未生成,1-生成中,2-已生成,3-生成失败' AFTER `loop_config`;
preheat_start_time 由运营配置或按 start_time 自动推导(如提前 24 小时)。schedule_stage 是二开自己的调度阶段,与原生 status 解耦------原生 status 继续表达"活动是否对外生效",schedule_stage 表达"调度器执行到了哪一步"。两者分离的好处是,即使调度器在预热阶段出现异常,原生活动状态仍然可以按时间正常翻转,不会因为二开逻辑阻塞核心交易。
2.2 调度子任务的执行逻辑
在 Crontab.php 中新增一个 ActivitySchedule 子任务,每分钟执行,按 schedule_stage 分阶段推进:
php
public function activitySchedule()
{
$now = time();
// 阶段0→1:到达预热时间,触发预热提醒
$this->advanceStage(0, 1,
['preheat_start_time', '>', 0],
['preheat_start_time', '<=', $now],
function ($activity) { $this->sendPreheatNotice($activity); }
);
// 阶段1→2:到达开始时间,原生status已翻转,这里只推进调度阶段
$this->advanceStage(1, 2,
['start_time', '<=', $now],
[],
function ($activity) { $this->onActivityStart($activity); }
);
// 阶段2→3:到达结束前收尾窗口(如结束前1小时)
$this->advanceStage(2, 3,
['end_time', '<=', $now + 3600],
['end_time', '>', $now],
function ($activity) { $this->sendEndingNotice($activity); }
);
// 阶段3→4:活动已结束,生成报表、触发复刻
$this->advanceStage(3, 4,
['end_time', '<=', $now],
[],
function ($activity) { $this->onActivityEnd($activity); }
);
}
每个阶段的推进必须用条件更新做幂等 。advanceStage 内部的更新是 UPDATE ... WHERE schedule_stage = ? AND ...,影响行数为 0 即说明其他实例已经推进过,直接跳过。Crontab 每分钟执行一次,多实例部署时同一条活动可能被多个进程同时扫描到,没有条件更新会导致预热通知重复发送、报表重复生成。
预热和收尾触达走队列,不在调度子任务中同步执行。调度子任务本身的职责是"判断时间是否到达、推进阶段",触达动作(站内信、模板消息、短信)是外部调用,延迟不可控。推入队列异步执行,保证调度器每分钟的扫描不被单个活动的触达耗时拖垮。
2.3 活动类型差异的处理
秒杀、拼团、优惠券的"开始"和"结束"语义不同,调度阶段推进后的业务动作也不同,但调度骨架是共用的 。差异收敛在 onActivityStart 和 onActivityEnd 两个钩子方法中,按 activity_type 分支:
-
秒杀:开始时通常需要把活动商品的价格快照刷新到最新;结束时需要处理未支付订单的关单和库存回退。
-
拼团:开始时检查虚拟成团开关是否生效;结束时按原生规则处理未成团订单的退款。
-
优惠券:开始时如果券是"到点自动发放"的,触发批量发放;结束时关闭领取入口。
调度层不直接写业务表,只调用对应活动类型的 Service。这样新增活动类型时,调度逻辑不需要改,只需要实现新的钩子。
三、效果自动复盘:活动结束后自动生成报表
3.1 报表的数据口径必须在活动开始时锁定
活动效果报表最容易出的问题是"口径漂移"。活动结束时按当时的订单表统计 GMV,但用户可能在活动结束后退款,退款后的 GMV 和活动结束那一刻的 GMV 是不同的。如果报表在活动结束后实时生成,且没有冻结口径,运营每次打开报表看到的数字都可能不一样。
正确的做法是在活动结束的调度节点(阶段3→4)生成一份口径快照,把活动期间的核心指标固化下来,后续的退款只在报表上做"退款调整"的增量标注,不修改原始快照:
sql
CREATE TABLE `ls_activity_effect_report` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT,
`activity_id` int UNSIGNED NOT NULL COMMENT '活动ID',
`activity_type` tinyint NOT NULL COMMENT '活动类型',
`period_start` int UNSIGNED NOT NULL COMMENT '活动开始时间',
`period_end` int UNSIGNED NOT NULL COMMENT '活动结束时间',
`order_count` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '活动订单数',
`order_amount` decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT '活动订单金额',
`pay_user_count` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '支付人数',
`goods_sold_count` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '售出件数',
`refund_count` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '退款单数',
`refund_amount` decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT '退款金额',
`conversion_rate` decimal(6,2) NOT NULL DEFAULT 0.00 COMMENT '转化率(支付人数/活动曝光UV)',
`stock_used_rate` decimal(6,2) NOT NULL DEFAULT 0.00 COMMENT '活动库存消耗率',
`report_data` text COMMENT '完整报表数据快照,JSON格式',
`create_time` int UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_activity_id` (`activity_id`),
KEY `idx_type_period` (`activity_type`, `period_start`, `period_end`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动效果报表快照表';
uk_activity_id 唯一约束保证一个活动只有一份原始快照。退款调整写入独立的调整记录表,报表展示时用"快照 + 调整"合成最终数值,而不是覆盖快照。
3.2 报表生成的异步化
报表生成涉及订单表、订单商品表、活动库存表的多表聚合,不能在调度子任务的同步链路中执行。阶段3→4 推进后,将 report_status 置为"生成中",推入队列,由消费者执行聚合计算并写入快照表,完成后置为"已生成"。生成失败时置为"生成失败",后台提供手动重试入口。
报表的"曝光 UV"口径需要提前明确。LikeShop 原生的活动页面 PV/UV 可能没有独立埋点,如果报表中强行包含曝光 UV 和转化率,会得到空值或错误值。更务实的做法是:首期报表只包含订单侧指标(订单数、GMV、支付人数、退款),曝光和转化率作为"待接入"字段,等前端埋点补齐后再启用。
四、循环活动自动复刻:从"手动新建"到"模板化滚动"
4.1 秒杀的原生"每日循环"是时段循环,不是活动复刻
LikeShop 的秒杀模块本身就支持"每天循环开展的时段"------设置"14:00-16:00",每天这个时间段都会自动开启秒杀。但这是同一个活动记录内的时间段循环 ,活动的 start_time 和 end_time 是覆盖整个循环周期的,而不是每周生成一个新的活动记录。
对于拼团、优惠券,或者需要"每周一期、每期独立报表、每期独立库存"的秒杀场景,原生的时段循环不够用。需要的是活动实例的滚动复刻:一个活动模板定义"每周一 10:00 开始,持续 2 小时,循环 8 期",调度器在每期结束后自动创建下一期的活动记录。
4.2 复刻的配置与执行
在 loop_config 中定义循环规则:
json
{
"enabled": true,
"frequency": "weekly",
"day_of_week": 1,
"start_hour": 10,
"duration_hours": 2,
"total_periods": 8,
"cloned_periods": 2
}
调度器在活动进入阶段4(已完成)时,检查 loop_config.enabled 和 cloned_periods < total_periods,如果满足条件,创建下一期的活动记录。复刻的不是"活动数据的全量拷贝",而是"模板 + 新时间窗口" :商品、价格、库存规则从模板继承,start_time 和 end_time 按循环规则重新计算,status 重置为 0,schedule_stage 重置为 0,sold_count 归零。
复刻时必须重置库存 。如果直接拷贝模板的 activity_stock 和 sold_count,新一期的活动会继承上一期的已售数量,导致可售库存被错误扣减。新一期的 sold_count 必须为 0,activity_stock 从模板的"每期库存"字段读取,而不是上一期的剩余库存。
复刻的幂等锚点是"期号" 。在活动表中增加 period_no 字段,复刻时用 (template_id, period_no) 做唯一约束,防止调度器重复执行产生两期相同的活动。
五、后台配置与开发注意事项
5.1 后台配置清单
| 配置项 | 类型 | 说明 |
|---|---|---|
| 预热提醒开关 | 开关 | 活动开始前是否推送提醒 |
| 预热提前时长 | 数字(小时) | 默认 24 小时 |
| 收尾提醒开关 | 开关 | 活动结束前是否推送"即将结束" |
| 收尾提前时长 | 数字(小时) | 默认 1 小时 |
| 效果报表自动生成 | 开关 | 活动结束后是否自动生成快照 |
| 报表生成失败重试次数 | 数字 | 默认 3 次 |
| 循环复刻开关 | 开关 | 是否允许活动模板滚动复刻 |
| 复刻频率 | 单选 | 每日 / 每周 / 每月 |
| 每期库存来源 | 单选 | 模板固定值 / 上期剩余 / 手动指定 |
5.2 开发注意事项
调度阶段与原生 status 必须解耦 。schedule_stage 是二开自己的字段,用于控制预热、收尾、报表等扩展动作;原生 status 继续由原生定时任务按时间翻转,表达活动是否对外生效。两者混用会导致"预热阶段活动提前生效"或"报表生成失败阻塞活动关闭"的问题。
每个阶段的推进必须用条件更新做幂等 。Crontab 每分钟执行,多实例部署时同一条活动可能被多个进程同时扫描。UPDATE ... WHERE schedule_stage = 当前阶段 的影响行数为 0 即跳过,是防止预热通知重复、报表重复生成的唯一可靠方式。
报表快照必须在活动结束时生成,后续退款以调整形式增量记录。每次打开报表实时聚合订单表,退款发生后历史数字会"漂移",运营无法对同一份报表进行前后对比。快照 + 调整的模型保证了报表的可比性。
循环复刻必须重置 sold_count 和库存。直接拷贝上一期的活动记录是复刻功能最危险的实现方式,已售数量、锁定库存、关联订单都会串到新一期。复刻的本质是"模板继承 + 时间窗口重算 + 销售数据归零",三者缺一不可。
兼容性 。新增的 preheat_start_time、schedule_stage、loop_config、report_status 字段全部设置默认值(0 或空),存量活动的 schedule_stage = 0,调度器不会对它们执行任何扩展动作,行为与原生完全一致。效果报表表和循环复刻作为独立模块,不修改原生 ls_market_activity 的状态流转逻辑,也不侵入 OrderLogic 和 PayLogic 的核心链路。