多门店营销中台设计与落地:LBS围栏、效果追踪与分布式架构复盘

发布时间:2026/10/10 3:19:30
多门店营销中台设计与落地:LBS围栏、效果追踪与分布式架构复盘
去年我们研发小组接手了某连锁品牌的新零售中台改造项目核心诉求就一句话总部要做几十家门店的统一营销管控每家门店又不能被管死还得有差异化活动的空间更重要的是每一次活动的效果必须能追踪到店、到人、到核销。这个项目我们内部代号叫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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT活动主表;门店配置表则采用“一活动一门店一行记录”的模型每行记录代表某个活动在某门店的执行参数。比如同样一个满减活动A门店可以设置满300减50B门店设置满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: GeoRoot#2024 MYSQL_DATABASE: geo_core TZ: Asia/Shanghai command: - --character-set-serverutf8mb4 - --collation-serverutf8mb4_unicode_ci - --default-time-zone8: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-zone8: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, ListLong regionIds) { Activity activity activityMapper.selectById(activityId); if (!ActivityStatus.DRAFT.equals(activity.getStatus())) { throw new BizException(只有草稿状态的活动才能下发); } ListStoreRegion regions regionMapper.selectBatchIds(regionIds); ListActivityStoreConfig 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, ListLatLng 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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%的误差日终数据以T1全量重算为准。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。开发阶段还有一个让我印象深刻的小技巧千万要给你的所有配置表加上操作日志表。有一次运营觉得某个区域的门店配置被改错了大家吵了两天最后靠操作日志定位到是一个区域管理员误操作五分钟就解决了争议。这种日志表前期看着没什么用后期就是整个系统的保命符。