去年我们研发小组接手了某连锁品牌的新零售中台改造项目,核心诉求就一句话:总部要做几十家门店的统一营销管控,每家门店又不能被管死,还得有差异化活动的空间,更重要的是,每一次活动的效果必须能追踪到店、到人、到核销。这个项目我们内部代号叫GEO系统,全称是 Geographic Engagement Optimization,简单说就是“地理化营销活动优化与效果追踪平台”。
这篇文章我打算把整个系统的设计与落地过程完整复盘一遍,从业务矛盾拆解到表结构设计、分布式技术选型、LBS围栏判定、效果追踪数据链路,再到上线后踩过的坑,全部摊开来讲。内容适合在做连锁品牌数字化、多门店营销中台、LBS场景应用开发的团队参考,也适合刚接触营销系统开发的后端同学拿来理解“从业务到技术”的完整推导过程。
1. 项目边界与业务痛点拆解
1.1 门店营销管理的三对核心矛盾
做多门店营销系统之前,很多团队会先问“为什么不用现成的SCRM”或者“为什么不用表格管理”。我们把业务部门的需求吃透之后,发现真正的问题不是没有工具,而是存在三对根本性矛盾。
第一对矛盾是总部管控和门店自主之间的矛盾。总部希望统一品牌口径、统一活动节奏,但每家门店所处商圈、客流结构、库存情况完全不同,一刀切的活动方案在部分门店完全跑不动。区域经理需要一个“总部定规则、门店做微调”的机制,而不是总部把每个字段都锁死。
第二对矛盾是活动配置的灵活性和财务核算的严肃性之间的矛盾。营销活动涉及优惠券、满减、折扣分摊、导购提成比例。同一个满减活动,A门店可能参加,B门店不参加;同一个券,C门店核销率很高,D门店核销率几乎为零。之前用Excel管理,每次活动结束都要人工对账,误差大而且扯皮多。
第三对矛盾是活动响应速度和效果反馈滞后之间的矛盾。传统做法是活动做完了等半个月出报表,这期间问题根本无法纠正。业务部门要求实时看到曝光、点击、核销的漏斗数据,以便活动进行中就能调整投放策略。
这三对矛盾决定了系统不能只是“活动的增删改查”,它必须同时具备规则引擎、差异化配置能力、实时数据追踪能力。GEO系统的整体架构设计,本质上就是围绕这三对矛盾的逐一解耦。
1.2 效果追踪到底追踪什么
很多团队做效果追踪,上来就埋点、就画报表,但漏掉了最关键的环节:定义清楚“效果”的粒度和口径。在GEO项目里,我们和业务部门开了三轮会才把这个问题对齐。
我们把效果拆成了五个关键事件:曝光、点击、报名参与、到店核销、离店后回流复购。曝光指的是用户在地理围栏范围内看到活动卡片;点击表示用户点进了活动详情页;报名参与是用户主动领取优惠券或预约了到店服务;到店核销是用户到达门店并使用了券码;回流复购则是核销后30天内再次到店的记录。
这里最大的争议在于“到店”怎么判断。仅凭用户手动打卡不靠谱,仅凭GPS上报坐标误差又太大。最终我们采用了混合判定策略:用户授权位置后,服务端实时计算坐标与门店围栏的包含关系,同时结合门店Wi-Fi探针、核销订单的POS时间戳做交叉验证。也就是说,位置围栏是主信号,核销事件是强确认信号,两者同时命中,才标记为一次完整到店转化。
1.3 GEO系统的定位与模块边界
理清业务矛盾后,系统的边界就清晰了。GEO系统不是一个从零到一的 CRM,也不是一个纯粹的BI报表工具,而是一个“总部侧统一策划、门店侧差异化执行、数据侧全链路追踪”的营销活动管理中台。
系统划分成五个核心模块:活动配置中心负责总部创建活动模板和规则;门店差异化中心负责门店维度的参数调整与审批;LBS围栏引擎负责地理围栏的创建和坐标判定;效果追踪中心负责事件上报、数据清洗、漏斗计算;财务对账模块则负责费用分摊和核销订单核对。
模块边界的划分原则只有一个:凡是多门店共享的,归总部中心统一管理;凡是门店单独执行的,归门店差异化中心管理;凡是跨模块流转的数据,必须走统一事件总线。这个原则看起来简单,实际执行起来能省掉后面大量无休止的接口扯皮。
2. 技术选型:从“能用”到“好用”的关键取舍
2.1 为什么选Java体系做分布式服务
技术选型阶段,我们内部争论过要不要用Node.js或者Python快速搭建。最终统一选了Java Spring Boot + Spring Cloud Alibaba这套体系,不是因为它最时髦,而是因为几个非常现实的原因。
第一,团队人才结构决定了维护成本。Java开发者在市场上最充裕,后续接手维护的人员不用重新学语言。第二,营销系统核心是强一致性的数据操作,涉及资金、券码、库存扣减,Java在事务处理、成熟框架、连接池生态上沉淀了十几年,稳定压倒一切。第三,多门店并发访问场景需要分布式事务、消息队列、分布式锁这些能力,Java生态里的Seata、RocketMQ、Redisson都是久经考验的组件。
团队不是选最热的技术,而是选最不容易翻车、后续最方便招人维护的技术。Spring Cloud Alibaba体系里,我们用Nacos做注册中心和配置中心,Sentinel做流量防护,RocketMQ做事件异步流转。整套下来,基础设施部分几乎没有踩坑,因为都是高度成熟的组件。
2.2 MySQL的职责边界与数据模型设计
数据存储方案上,我们采用MySQL作为核心业务库,但严格控制它的职责范围。MySQL只保存活动元数据、门店配置、订单核销流水和系统账号权限。所有需要高性能聚合分析的统计数据,比如漏斗报表、门店趋势图、渠道转化对比,都放到后面专门讲的效果数据中心。
核心表设计上,最关键的是活动表要“平”,不要做太多范式的拆分。我们实际生产环境里有这样一张活动主表,字段我简化了,但结构是真实的。
CREATE TABLE `t_activity` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '活动ID', `activity_no` VARCHAR(32) NOT NULL COMMENT '活动编号,格式:ACT+日期+序列', `activity_name` VARCHAR(128) NOT NULL COMMENT '活动名称', `activity_type` TINYINT NOT NULL COMMENT '活动类型:1-满减 2-折扣 3-买赠 4-新客立减', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-草稿 1-待审核 2-已发布 3-已下线 4-已结束', `channel_id` BIGINT NOT NULL COMMENT '渠道ID,区分小程序/APP/H5', `start_time` DATETIME NOT NULL COMMENT '活动开始时间', `end_time` DATETIME NOT NULL COMMENT '活动结束时间', `budget_limit` DECIMAL(12,2) DEFAULT NULL COMMENT '预算上限', `creator_id` BIGINT NOT NULL COMMENT '创建人', `auditor_id` BIGINT DEFAULT NULL COMMENT '审核人', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_activity_no` (`activity_no`), KEY `idx_status_time` (`status`, `start_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动主表';门店配置表则采用“一活动一门店一行记录”的模型,每行记录代表某个活动在某门店的执行参数。比如同样一个满减活动,A门店可以设置满300减50,B门店设置满200减30,各自拥有独立的库存券池。这种设计在写入的时候会多一些行数,但读取的时候特别清晰,不用做复杂的JSON解析,排错时一眼就能定位问题。
注意:不要为了省事把门店差异化参数塞进一个JSON字段里,比如
{"store_1": {...}, "store_2": {...}}。前期确实方便,后期做财务对账和权限控制时会想哭。每一个需要独立审批、独立核算的差异化参数,都应该有独立的行。
2.3 部署形态:本机安装MySQL和Docker启动怎么选
GEO项目开发过程中,组里来了两个新同事,环境搭建就出了分歧,一个习惯在本机直接装MySQL,一个坚持用Docker。这里顺便把这个很多团队都会遇到的问题说透。
本机直接安装MySQL的优势是:性能损耗最低、调试的时候可以直接看datadir下的binlog文件、Navicat连接也最简单,适合个人学习或者单机测试环境。但缺点是:版本切换麻烦、系统升级或Python等环境冲突时容易把数据库搞挂、团队协作时每个人的安装路径和字符集设置都不一致,“在我电脑上是好的”这类问题频发。
我们团队最终统一选择了Docker启动MySQL,不是因为它性能更好,而是因为它把不确定性锁住了。一份docker-compose.yml定义好版本、端口、字符集、时区,任何新同学拉起来就是一模一样的环境。
version: '3.8' services: mysql: image: mysql:8.0.32 container_name: geo-mysql restart: always environment: MYSQL_ROOT_PASSWORD: 'Geo@Root#2024' MYSQL_DATABASE: geo_core TZ: 'Asia/Shanghai' command: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_unicode_ci - --default-time-zone=+8:00 ports: - "3306:3306" volumes: - geo_mysql_data:/var/lib/mysql - ./sql:/docker-entrypoint-initdb.d healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 10s timeout: 5s retries: 5 volumes: geo_mysql_data: driver: local这个配置里有几个细节值得说一下。TZ环境变量只对容器内系统时区生效,MySQL内部时区还得靠--default-time-zone=+8:00来指定。数据目录必须挂载到宿主机volume里,否则容器删除后数据全部消失,这是新手最容易翻车的点。sql目录挂载的是初始化脚本,首次启动时自动建表,后续不要再往里放改动脚本,因为只在volume为空时才执行。
实际用下来,开发阶段用Docker跑MySQL,测试阶段用Docker跑整套中间件,生产环境再用云数据库或物理机高可用方案。这个组合兼顾了开发效率和交付稳定性,是投入产出比最高的方式。
3. 核心模块与实操实现
3.1 活动统一创建与门店差异化下发的完整流程
GEO系统里最核心的业务流程是总部创建活动、区域调整参数、门店确认执行的三级联动。这个流程如果只用状态机硬编码,后期加一个“区域退回修改”的节点就会很痛苦。我们采用配置中心加操作日志的方式实现。
总部运营登录管理端,创建一个活动模板。模板里包含活动名称、适用品牌、时间范围、预算上限、优惠类型等公共约束。创建时状态为“草稿”,此时门店侧还看不到任何信息。总部提交后,状态变为“待区域配置”,系统通过Nacos配置中心广播一条事件,通知所有区域管理员进行差异化参数设置。
区域管理员的配置页面只暴露允许调整的字段,比如门店券池数量、满减门槛金额。不可调整的字段在服务端做校验,不能只靠前端隐藏。每个区域确认后,活动实例下发到该区域下所有门店,生成t_activity_store_config记录。门店店长登录后,只能看到本门店的配置单,可以申请修改,但修改必须走审批流。
这个流程里最容易被忽略的是“操作留痕”。我们规定所有配置修改必须写入t_activity_operation_log表,哪怕只是把满减金额从50改成40,也要记录操作人、操作前后值、审批人等。后面财务对账或者运营复盘时有分歧,直接甩日志出来,谁改的、改了什么一目了然。
核心下发逻辑的简化伪代码如下:
public void publishActivityToStores(Long activityId, List<Long> regionIds) { Activity activity = activityMapper.selectById(activityId); if (!ActivityStatus.DRAFT.equals(activity.getStatus())) { throw new BizException("只有草稿状态的活动才能下发"); } List<StoreRegion> regions = regionMapper.selectBatchIds(regionIds); List<ActivityStoreConfig> configs = new ArrayList<>(); for (StoreRegion region : regions) { for (Store shop : region.getStores()) { ActivityStoreConfig config = new ActivityStoreConfig(); config.setActivityId(activityId); config.setStoreId(shop.getId()); config.setRegionId(region.getId()); config.setBudgetLimit(activity.getBudgetLimit()); // 继承总部默认值 config.setStatus(ConfigStatus.PENDING_CONFIRM); config.setConfigSnapshot(buildSnapshot(activity)); configs.add(config); } } activityStoreConfigMapper.batchInsert(configs); activity.setStatus(ActivityStatus.REGION_CONFIGURING); activityMapper.updateById(activity); eventPublisher.publish(new ActivityPublishedEvent(activityId, configs)); }注意configSnapshot这个字段,它保存的是活动模板在该门店实例化时的“参数快照”。这样做的好处是:哪怕后续总部修改了活动模板,已经下发给门店的参数不会被连带改动,保证活动执行期间数据的一致性。这个设计是从“发布即冻结”的思路来的。
3.2 地理围栏引擎:LBS触发与到店判定的实现
地理围栏是GEO系统的技术亮点,也是最容易出bug的地方。我们实现了一个轻量级的LBS围栏服务,没有用第三方地图平台的高阶API,而是自己写了一套基于射线法的多边形包含判定。
整个流程是这样的:门店创建时,店长在管理端画出本店的围栏区域,可以是一个圆形或多边形,存入库中的是经纬度坐标集合。用户携带小程序或APP进入门店周边时,客户端SDK按一定频率上报GPS坐标,服务端拿到坐标后,先做粗筛:计算用户坐标与围栏中心点的球面距离,如果距离大于围栏外接圆半径,直接跳过多边形精确判定。粗筛通过后,再走射线法做精确的包含判断。
射线法思路不复杂,从用户坐标点向任意方向做一条射线,计算这条射线与多边形各条边的交点个数,交点数为奇数则点在多边形内,偶数则在外部。我们基于Java实现时,核心代码如下:
public boolean contains(LatLng point, List<LatLng> polygon) { int intersectCount = 0; for (int i = 0; i < polygon.size(); i++) { LatLng p1 = polygon.get(i); LatLng p2 = polygon.get((i + 1) % polygon.size()); // 判断射线是否穿越边p1-p2 if (point.getLng() < Math.min(p1.getLng(), p2.getLng())) { continue; } if (point.getLng() > Math.max(p1.getLng(), p2.getLng())) { continue; } // 计算交点的纬度(简化公式,生产环境有更精确处理) double intersectLat = p1.getLat() + (point.getLng() - p1.getLng()) / (p2.getLng() - p1.getLng()) * (p2.getLat() - p1.getLat()); if (intersectLat > point.getLat()) { intersectCount++; } } return (intersectCount % 2) == 1; }项目上线后我们才发现,射线法在GPS漂移明显的地方误判率比较高,尤其是门店处于高架桥、地铁站附近时。后续迭代我们引入了“连续三点命中”规则:用户上报的连续三个坐标点都被判定为围栏内,才确认进入门店范围。这个规则牺牲了一点点实时性,但换来了误报率的大幅下降。经验是:纯算法判定必须有业务规则兜底。
围栏判定结果不是终点,它要触发后续事件。每次命中围栏,服务端生成一条t_geo_hit_log记录,包含用户ID、门店ID、命中时间、坐标精度、所属活动ID。这个日志表是效果追踪的源头数据,量级最大,我们按天分表存储,并保留90天,满足活动复盘周期。
3.3 效果追踪数据链路和归因口径
效果追踪部分,我们设计了完整的事件流处理链路。这个链路最核心的设计原则是:前端只上报原始行为事件,所有业务归因和指标计算全部在服务端完成。这样有效避免了不同客户端版本上报口径不一致的问题。
移动端和H5端通过统一的埋点SDK上报五类事件:exposure曝光、click点击、enroll报名、verify核销、return回流。客户端每次上报都携带全局唯一的event_id,以及用户ID、门店ID、活动ID、时间戳和位置坐标。经过API网关进入RocketMQ,再消费写入t_event_detail事件明细表。
真正的难点在于归因。一个用户可能先路过A门店看到活动曝光,后来又跑到B门店核销了券,那这次核销应该算在哪个门店头上?我们去业务部门讨论后确定了归因规则:核销事件优先归属到实际发生核销的POS门店;如果核销记录缺失,则按最后一次围栏命中记录归属;时间窗口设定为曝光后72小时内的后续行为归因到同一活动。
归因完成后,定时任务每五分钟跑一次聚合,把明细数据写入效果汇总表,也就是下面这张表。
CREATE TABLE `t_effect_summary` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `activity_id` BIGINT NOT NULL, `store_id` BIGINT NOT NULL, `channel` VARCHAR(20) NOT NULL, `exposure_cnt` INT NOT NULL DEFAULT 0, `click_cnt` INT NOT NULL DEFAULT 0, `enroll_cnt` INT NOT NULL DEFAULT 0, `verify_cnt` INT NOT NULL DEFAULT 0, `return_cnt` INT NOT NULL DEFAULT 0, `verify_amount` DECIMAL(12,2) NOT NULL DEFAULT 0, `stat_date` DATE NOT NULL, `updated_at` DATETIME NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_activity_store_channel_date` (`activity_id`, `store_id`, `channel`, `stat_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动效果日汇总表';有了这张日汇总表,运营人员查报表时不再直接扫事件明细表,而是按天查汇总记录,查询性能提升了几个数量级。报表层面,我们通过一个Spring Boot定时任务在每天凌晨两点全量重算昨天的数据,并做数据质量校验,比如曝光数等于各渠道曝光之和、核销数不超过报名数等规则,保证报表层的数字可信。
4. 上线过程中踩过的坑与排查实录
4.1 重复数据与幂等机制
系统刚上线第一周,运营反馈某门店的核销数字比POS系统多了一倍。排查之后发现是我们自己的bug,不是数据造假,而是客户端断网重试导致同一次核销请求被提交了多次,服务端没有做幂等处理。
这个问题经典且隐蔽。网关层或服务层必须对写操作做幂等校验。我们的解决方案是为五类事件都增加了唯一键校验:uk_event_biz,由事件类型加业务ID加发生时间戳拼接成唯一字符串。核销事件用“用户ID+券码+核销时间”作为唯一键。重复上报时,数据库唯一索引直接拒绝插入,然后接口返回成功,避免客户端无限重试。
4.2 门店时区与活动时间窗口的错乱
GEO系统涉及跨地域门店,部分门店位于不同时区。最初开发时图省事,所有时间字段都用北京时间存储,结果导致西部门店的活动实际开始时间比计划晚了两个小时。导购在早上开门前还没收到活动生效通知,错过了一波早市客流。
吸取教训后,所有时间字段统一为UTC时间戳存储,接口层根据门店所属时区做转换。活动时间窗口比较时,统一转换为门店本地时间。这个改造工作量不大,但需要DBA配合把存量数据刷一遍。时间处理上还有一个细节点:MySQL连接串里的serverTimezone参数不要写Asia/Shanghai,直接用UTC,避免Java应用和MySQL会话时区不一致导致的时间偏移。
4.3 MySQL统计慢的历史教训
项目迭代到第二个版本时,运营增加了“实时看板”需求,希望看到每个活动的实时参与人数。我们最开始直接在t_event_detail明细表上做条件count查询,结果活动上线高峰期,一张明细表的数据量迅速突破千万,count查询耗时达到三四秒,严重拖慢主库。
后来把这个统计需求从业务链路里剥离,单独建立一个统计库,采用队列异步写入加聚合表的方式。明细表落在OLTP库,统计库接收MQ数据后实时累加Redis计数,每五分钟落一次汇总表。这个方案把实时看板的查询时间稳定在500毫秒以内,代价是牺牲了一点精确度。我们跟运营明确约定,看板的分钟级数据允许有不超过5%的误差,日终数据以T+1全量重算为准。
4.4 多门店活动互斥与排期冲突
活动配置中心上线后出现了一个业务层面的坑,两家门店在同一个时段都策划了大型促销活动,用的却是同一个优惠券批次,导致券池提前被核销完,后开始活动的门店被迫下线,消费者投诉增加。
解法是在活动配置中心增加排期冲突校验模块。每次创建活动时,系统自动检测新活动的时间段和券池资源是否与同门店其他活动冲突。冲突检测不是简单的start_time和end_time比较,还要考虑活动预热期和核销期。比如一个活动虽然三天后才开始,但预热期已经发放优惠券,那么预热期内其他活动就不能占用同一个券池。
校验算法做一次SQL判断:给定时间区间[newStart, newEnd],查询同门店、同券池、状态不为已结束的活动,判断区间重叠条件。逻辑上就是一条语句:
SELECT COUNT(*) FROM t_activity WHERE store_id = #{storeId} AND coupon_batch_id = #{batchId} AND status IN (2, 3) AND (preheat_start_time <= #{newEnd} AND preheat_end_time >= #{newStart});这个校验规则写清楚后,排期冲突的工单量几乎降为零。业务上还有一个心得:冲突校验的结果不能只弹警告,一定要强制阻断保存,除非有总部高权限账号审批解锁,不然总有运营人员会忽略警告强行提交。
5. 效果追踪的指标体系与可视化复盘
5.1 门店ROI计算口径的统一
效果追踪做完了,最后一个大问题是如何算ROI。不同门店对ROI的理解完全不同,有的门店只算券核销金额,有的门店把广告投放也算进去。我们在报表模块里做了一次全公司范围的指标口径统一,这也是系统推广过程中阻力最大的一个环节。
系统里定义的标准ROI公式是:门店活动ROI = (核销订单关联的客单价增量 + 拉新客户预估生命周期价值) / (门店承担费用 + 总部分摊费用)。这里每个分项都有明确的字段来源,核销订单关联的是POS同步数据,拉新客户数来自用户注册表过滤出首次到店标签,费用则来自活动预算表中的实际消耗。
指标口径一旦确定,前端报表里所有门店看到的数字都是同一个逻辑计算出来的。门店之间不再因为算法不同而打架,这比技术本身还要产生更大的业务价值。如果你们的系统也存在多门店比数据的情况,建议优先组织业务方把口径固化下来,然后再开发报表功能。
5.2 报表里哪些信号说明数据有问题
最后分享一些我们在效果追踪数据复核时的经验。如果看到下面这几种情况,不用急着分析业务效果,首先要怀疑数据本身有bug。
曝光数极高但点击数极低,且广告素材没有变动,大概率是曝光埋点上报了非可见区域的展示,比如页面预加载就触发了曝光事件。核销数大于报名数,一定是重复上报或归因错误,正常逻辑下不可能出现这个比例。某个门店的后台核销金额持续异常增长,可以考虑检查这台POS机的数据同步任务是否产生了重复插入。活动刚结束那天的回流复购数异常攀升,要检查归因时间窗口是否跨活动周期串数据。
这些检查规则我们都做成了数据质量监控任务,每天跑一遍,发现问题直接钉钉告警到数据负责人。效果追踪系统的价值一半在于算得准,另一半在于发现哪里算错了。
写在最后的一点个人体会
这套系统从立项到稳定运行,前后将近四个月。我个人最大的感受是:多门店营销中台最难的不是高并发,也不是复杂的算法,而是把业务规则的每个分支都想清楚,并在代码里显式表达出来。很多系统后期维护痛苦,都是因为在“活动参数差异化”这种细节上选择了妥协,用JSON字段凑合,最后写出了一堆无法维护的if-else。
开发阶段还有一个让我印象深刻的小技巧,千万要给你的所有配置表加上操作日志表。有一次运营觉得某个区域的门店配置被改错了,大家吵了两天,最后靠操作日志定位到是一个区域管理员误操作,五分钟就解决了争议。这种日志表前期看着没什么用,后期就是整个系统的保命符。