简介:这是一款面向实体零售场景的全栈式会员营销系统,适用于汽车4S店、餐饮、花店、甜品店等线下门店及线上电商,解决传统收银与会员运营割裂问题。系统基于Java SpringBoot+MySQL构建,完整覆盖微信小程序前台、H5轻应用、后端API服务及后台管理四端,集成优惠券、储值卡、集次卡、电子券、积分体系与支付收款等核心营销能力。资源包共531个文件,含422个Java业务逻辑与控制器类、50个XML配置与Mapper映射文件、33个PNG/13个JPG界面资源图,以及SQL建表脚本、Shell部署脚本和License说明,整体仅5.5MB,结构清晰、开箱即用。已有696人学习下载,开发者可直接获取完整可运行源码、标准化分层架构(Controller-Service-Mapper)、典型业务模块实现(如CouponServiceImpl、MemberServiceImpl、WeixinServiceImpl)及前后端联调范例,快速复用或二次开发。
1. 实体店铺会员系统:为什么90%的“小程序+H5+后台”项目在第三个月就卡在数据不同步上?
你刚上线一套「微信小程序+H5页面+后台管理」的会员系统,顾客扫个码就能领券、积分、查余额;店员在后台改个折扣,前端秒变;老板手机点开H5报表,昨天哪位会员复购了3次一目了然——听起来很稳?但真实情况是:小程序里显示积分+100,H5页面刷新后还是+80,后台管理端导出的Excel里却是+120。三端数据对不上,不是bug,是架构设计时没把「状态一致性」当第一优先级来压测。这不是小作坊的通病,连锁茶饮、社区生鲜、美业门店的SaaS服务商,每年都有3~5个同类项目因会员等级错乱、优惠券重复核销、库存超卖被客户投诉下线。本文不讲概念,只拆解一个能跑通100家实体门店的真实路径:用统一用户中心兜底身份,用事件驱动解耦营销动作,用幂等+本地缓存+服务端校验三层防线守住数据底线。适合正在选型、已启动开发但卡在联调、或刚上线正被老板追问“为什么小程序和H5优惠券不一致”的一线开发者与技术负责人。
2. 前端三端(小程序/H5/后台管理)如何共用同一套用户体系而不翻车?
实体门店场景下,“微信小程序”和“H5页面”本质是两个独立运行环境:小程序走微信授权体系,H5走浏览器Cookie+Token,后台管理又是另一套RBAC权限系统。强行让三端共享session或硬塞同一套JWT,不出三天就会遇到“H5登录后小程序自动登出”“后台改了会员等级,H5页面缓存没刷新”这类玄学问题。真正可靠的解法不是统一登录态,而是统一用户身份标识+状态决策权。
2.1 用户中心必须独立部署,且只暴露ID映射与基础属性
不要把用户表放在订单服务或营销服务里。必须拆出独立的user-center微服务(哪怕单体架构也按模块物理隔离),它只做三件事:
- 绑定微信OpenID / UnionID 与内部
member_id(主键) - 存储基础属性:手机号、昵称、头像、注册时间、最后登录时间
- 提供
/v1/member/info?member_id=xxx接口,返回脱敏后的只读字段
提示:
member_id必须是业务自增ID或Snowflake生成,绝不能直接用微信OpenID当主键。原因:OpenID在不同公众号/小程序下不互通,且微信可能调整规则;而member_id是你所有业务的锚点,订单、积分、优惠券都关联它。
# user-center服务核心逻辑示例(FastAPI) from fastapi import Depends, HTTPException from sqlalchemy import select async def get_member_info(member_id: str, db: AsyncSession = Depends(get_db)): stmt = select(Member).where(Member.member_id == member_id) result = await db.execute(stmt) member = result.scalar_one_or_none() if not member: raise HTTPException(status_code=404, detail="Member not found") # 严格过滤返回字段,不暴露敏感信息 return { "member_id": member.member_id, "nickname": member.nickname or "匿名用户", "avatar": member.avatar or "https://example.com/default.png", "level": member.level, # 会员等级(数字,非文字) "points": member.points, # 当前积分 "created_at": member.created_at.isoformat() }这段代码的关键不在语法,而在返回字段的克制性:不返回手机号(除非带权限校验)、不返回OpenID(前端不需要)、不返回密码哈希。所有业务方(小程序/H5/后台)调用此接口拿用户快照,但修改操作必须走各自领域服务——比如小程序调用/marketing/coupon/use,H5调用/order/create,后台调用/admin/member/update-level,它们各自校验权限后,再通过内部RPC或消息队列通知user-center更新points或level。
2.2 小程序端:用UnionID打通多入口,避免OpenID碎片化
实体门店常同时运营公众号、小程序、视频号小店。如果只存小程序的OpenID,用户从公众号菜单进H5,就变成新用户。必须在首次授权时,用jscode2session拿到unionid(需公众号和小程序同主体),并以此为依据合并用户:
// 小程序前端获取unionid逻辑(wx.login + wx.getUserInfo 合并) wx.login({ success: res => { // 1. 用code向后端换取session_key和unionid wx.request({ url: 'https://api.yourdomain.com/auth/wx-login', method: 'POST', data: { code: res.code }, success: authRes => { const { unionid, openid, session_key } = authRes.data; // 2. 如果unionid存在,说明该用户已在其他渠道注册过 if (unionid) { // 直接绑定到现有member_id bindToExistingMember(unionid); } else { // 新用户,用openid创建member_id createNewMember(openid); } } }); } });注意:
wx.getUserInfo已废弃,必须用wx.login+ 后端jscode2session。unionid仅在用户关注过公众号或使用过同主体小程序时才返回,因此要设计降级逻辑:无unionid时先用openid创建临时用户,后续用户主动绑定手机号时再合并。
2.3 H5端:用localStorage+Token双保险维持会话,但绝不信任前端Token
H5没有微信环境,必须自己管登录态。常见错误是把Token存在localStorage里,然后每次请求都带上——这会导致XSS攻击时Token被盗。正确做法是:
- 登录成功后,后端下发
access_token(JWT)和refresh_token(长时效,存HttpOnly Cookie) - 前端将
access_token存localStorage,用于API请求Header access_token过期(如2小时)后,用refresh_token自动续期,无需用户重新登录- 所有敏感操作(如核销优惠券、修改手机号)必须二次校验短信验证码或密码
// H5前端请求拦截器(Axios) axios.interceptors.request.use( async config => { const token = localStorage.getItem('access_token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }, error => Promise.reject(error) ); // 刷新Token逻辑(单独封装) const refreshToken = async () => { try { const res = await axios.post('/auth/refresh', {}, { withCredentials: true // 携带HttpOnly Cookie }); localStorage.setItem('access_token', res.data.access_token); return res.data.access_token; } catch (e) { // refresh失败,强制跳转登录页 window.location.href = '/login'; } };关键点:withCredentials: true确保Cookie能发送,而access_token只存前端,即使被XSS窃取,有效期也极短;refresh_token存在服务端可控的Cookie里,攻击者无法读取。
3. 营销活动如何做到“一次配置,三端生效”,且不引发并发超发?
门店搞“满100减20”活动,小程序首页弹窗、H5活动页轮播、后台管理端实时看核销数——这三处UI可以不同,但活动规则、库存、状态必须绝对一致。很多团队用“后台改数据库字段,前端定时轮询”来同步,结果就是:轮询间隔2秒,10个用户同时点击,第11个用户看到“库存充足”却提示“已抢光”。真正的解法是规则中心化 + 库存预占 + 状态广播。
3.1 活动配置必须抽象为JSON Schema,禁止硬编码规则
别在代码里写if (orderAmount > 100) discount = 20。所有营销规则(满减、折扣、赠品、阶梯价)必须定义成可配置的JSON结构,由marketing-rule-engine服务解析执行:
{ "activity_id": "act_20240601_001", "name": "夏日冰饮满减", "status": "active", "scope": ["store", "online"], // 适用范围:门店or线上 "rules": [ { "type": "order_amount_threshold", "threshold": 100, "discount": 20, "max_discount": 50 } ], "quota": { "total": 1000, "used": 0, "per_user_limit": 1 } }血泪经验:
per_user_limit字段必须存在,且校验逻辑放在rule-engine里,而不是前端。曾有项目把“每人限领1张”逻辑放小程序JS里,被用户用抓包工具反复提交,3分钟刷走200张券。
3.2 优惠券发放:用Redis原子操作防超发,而非MySQL行锁
MySQLUPDATE coupon SET used_count = used_count + 1 WHERE activity_id = 'xxx' AND used_count < total在高并发下会失效。正确姿势是:
- 优惠券库存存Redis Hash,
HINCRBY coupon:act_20240601_001:stock 1 - 若返回值 ≤ 总库存,则发放成功;否则返回“已发完”
- 成功后,异步写MySQL记录明细(最终一致性)
# Python示例(redis-py) def issue_coupon(activity_id: str, member_id: str) -> bool: key = f"coupon:{activity_id}:stock" # 原子递增,返回递增后的新值 current_used = redis.hincrby(key, "used", 1) total = int(redis.hget(key, "total")) if current_used <= total: # 记录发放明细(异步任务) save_coupon_issue.delay(activity_id, member_id, current_used) return True else: # 回滚:递减计数(因为HINCRBY不可逆) redis.hincrby(key, "used", -1) return False这里HINCRBY是Redis原生命令,毫秒级原子性,比MySQL事务快10倍以上。save_coupon_issue.delay用Celery异步落库,避免阻塞主流程。
3.3 三端状态同步:用WebSocket推送,而非轮询或Polling
小程序/H5/后台管理都需要实时知道“活动还剩几张券”“当前核销率”。轮询浪费带宽,Polling延迟高。直接上WebSocket:
- 后端用
FastAPI + WebSockets或Socket.IO建立连接 - 用户进入活动页时,前端建立WS连接,并传
activity_id rule-engine每次发放/核销成功,触发publish("activity:act_20240601_001", {"used": 127, "remain": 873})- 所有订阅该channel的客户端实时收到更新
// 小程序端WebSocket连接(uni-app) const socket = uni.connectSocket({ url: 'wss://api.yourdomain.com/ws/activity', header: { 'Authorization': 'Bearer ' + token } }); socket.onMessage(res => { const data = JSON.parse(res.data); if (data.channel === `activity:${this.activityId}`) { this.couponRemain = data.remain; // 直接更新UI } });避坑:WebSocket连接必须带鉴权(如JWT),且服务端要校验
member_id是否有权订阅该活动。曾有项目未校验,导致A店员工能监听B店活动数据。
4. 后台管理系统的三个致命陷阱:权限失控、操作不可逆、数据导出失真
后台管理是老板和店长天天盯着的地方,但90%的同类系统在这里埋雷:店长删了会员数据没人知道;财务导出的销售报表和实际到账差2毛;区域经理能看到隔壁区的门店数据。这些不是功能缺陷,是权限模型和审计机制缺失。
4.1 RBAC必须细化到“数据行级”,而非仅菜单级
“门店管理员”角色不能只控制“能否看到会员列表”,而要控制“只能看到本店会员”。常见错误是:
-- ❌ 错误:只校验角色,不校验数据归属 SELECT * FROM member WHERE role = 'store_admin'; -- ✅ 正确:JOIN门店表,强制WHERE store_id = ? SELECT m.* FROM member m JOIN store s ON m.store_id = s.id WHERE s.id = ? AND s.manager_id = ?;后端API必须在每个查询中注入store_id条件。更安全的做法是:所有涉及门店数据的表,都加store_id字段,并在ORM层全局拦截(如SQLAlchemy的before_compile事件)。
4.2 关键操作必须留“后悔药”,且日志可追溯到人
“删除会员”“清空优惠券”“重置积分”这类操作,不能直接执行DELETE。标准流程是:
- 第一步:标记为
deleted_at = NOW()(软删除) - 第二步:写审计日志,包含:操作人ID、IP、时间、影响行数、原始SQL参数
- 第三步:提供“恢复最近7天软删除数据”按钮(仅限超级管理员)
# 审计日志表结构(关键字段) class AuditLog(Base): id = Column(String(32), primary_key=True) # Snowflake ID operator_id = Column(String(32)) # 操作人member_id operator_ip = Column(String(45)) # IPv4/IPv6 action = Column(String(50)) # "delete_member", "reset_points" target_type = Column(String(30)) # "member", "coupon" target_id = Column(String(32)) # 被操作对象ID params = Column(JSON) # JSON序列化的操作参数 created_at = Column(DateTime, default=func.now())提示:
params字段必须存原始参数(如{"member_id": "m1001", "reason": "测试误操作"}),而不是“操作成功”这种废话。某次生产事故中,正是靠这条日志定位到是测试账号误点了清空按钮。
4.3 数据导出必须走“离线任务”,且文件内容与页面展示完全一致
后台点击“导出本月销售报表”,如果直接SELECT ... INTO OUTFILE,会遇到:
- 导出时数据还在变更,文件里金额和页面看到的不一致
- 大数据量时请求超时,用户以为失败,其实后台还在跑
- CSV中文乱码、Excel公式被破坏
正确方案:
- 点击导出 → 后端生成任务ID,立即返回
{task_id: "task_20240601_abc"} - 前端轮询
/export/status?task_id=xxx,直到状态为completed - 下载链接指向Nginx静态文件(如
/exports/task_20240601_abc.xlsx),文件由Celery异步生成
# 异步导出任务(Celery) @shared_task def export_sales_report(task_id: str, store_id: str, start_date: str, end_date: str): # 1. 查询时加WITH FOR UPDATE锁定关键数据(如订单汇总) # 2. 用openpyxl生成Excel,设置单元格格式(金额保留2位小数) # 3. 文件存入MinIO,生成预签名URL # 4. 更新audit_log表,记录导出详情 pass注意:Excel必须用
openpyxl而非pandas.to_excel,后者对样式支持弱,且大文件易内存溢出。某次导出10万行订单,pandas吃光2GB内存,openpyxl稳定在300MB。
5. 避坑指南:三端联调时最常踩的5个深坑及根治方案
联调阶段,小程序/H5/后台管理三端互相调用API,看似简单,实则暗礁密布。以下5条是我在12个同类项目中反复验证过的“必踩坑”,每条都附带现象、根因和可落地的解决方案。
5.1 现象:小程序调用API返回401,但H5同样Token却正常
原因:小程序request默认不携带Cookie,而后端Session依赖Cookie校验;H5用axios默认withCredentials: true。
解决:
- 后端API统一改用Token认证(Header
Authorization: Bearer xxx),弃用Cookie Session - 或小程序端显式开启
withCredentials: true(需后端CORS配置Access-Control-Allow-Credentials: true且Origin不能为*)
5.2 现象:H5页面在微信内置浏览器打开时,部分CSS失效或白屏
原因:微信iOS版WebView对flex: 1、vh单位兼容性差,且禁用部分CSS动画。
解决:
- 使用
postcss-pxtorem将px转rem,配合lib-flexible适配 vh单位替换为JavaScript动态计算:document.documentElement.clientHeight- 关键动画用
transform替代top/left,避免触发微信WebView重绘Bug
5.3 现象:后台管理端修改会员等级后,小程序端仍显示旧等级,刷新也不变
原因:小程序端缓存了/member/info接口响应(HTTP Cache或Taro/Uniapp的request缓存)。
解决:
- 后端
/member/info接口强制加Cache-Control: no-cache, no-store - 小程序端请求时加时间戳参数:
/member/info?member_id=xxx&t=${Date.now()} - 更优方案:会员等级变更时,通过WebSocket推送
{type: "member_update", data: {member_id, level}},前端收到后主动刷新
5.4 现象:优惠券核销后,H5页面显示“已使用”,但小程序端仍显示“可使用”
原因:三端各自缓存了优惠券列表,且没有统一的缓存失效机制。
解决:
- 所有优惠券相关接口(列表、详情)响应头加
ETag,前端根据ETag做条件请求 - 核销成功后,后端调用
redis.del("coupon:list:member_id_123")主动失效缓存 - 小程序/H5在核销成功回调里,手动清除本地缓存并重新拉取
5.5 现象:后台导出Excel,金额列出现199.99999999999997而非200.00
原因:JavaScript/Python浮点数精度丢失,数据库存的是DECIMAL,但中间计算用了float。
解决:
- 后端所有金额运算用
decimal.Decimal(Python)或BigDecimal(Java) - 前端展示金额时,用
Number(val).toFixed(2)而非val.toFixed(2)(避免字符串隐式转换) - Excel导出时,openpyxl设置单元格格式为
numbers.FORMAT_NUMBER_00
6. 一个让老板当场拍板的技巧:用“门店热力图”把后台数据变成门店增长引擎
后台管理的价值不该止于“查数据”,而要成为门店经营的决策中枢。我给3家连锁烘焙品牌落地的方案是:把后台的会员消费数据、优惠券核销数据、到店频次数据,实时渲染成“门店热力图”,并叠加天气、竞品促销、周边地铁客流等外部因子,生成可执行的《门店行动建议》。
6.1 热力图不是炫技,而是解决三个真实问题
| 问题场景 | 传统后台做法 | 热力图驱动方案 |
|---|---|---|
| A店连续两周销售额下滑 | 导出Excel,人工对比上周数据 | 热力图显示A店周边3km内新增2家竞品,系统自动推送“针对竞品用户定向发5折券”策略 |
| B店周末客流暴增但转化率低 | 查“到店人数”和“成交人数”两个孤立数字 | 热力图发现B店周末10-12点排队超20人,系统建议“增开1个收银台+推预约免排队H5页” |
| C店新会员增长慢 | 看“新增会员数”柱状图 | 热力图结合LBS数据,发现C店3km内有3所大学开学,系统生成“学生证享首单8折”活动模板 |
6.2 技术实现:用GeoHash+Redis+Canvas,零成本接入
不用买商业BI工具。核心逻辑只有三步:
- 坐标打点:用户到店时,小程序上报经纬度,后端用
geohash.encode(lat, lng, precision=7)生成wx8g3d这样的短码,存Redis Set:SADD store:1001:geohash wx8g3d wx8g3e - 热力聚合:每5分钟,用
GEOPOS批量查出所有门店周边坐标,用Canvas绘制热力图(颜色深浅=点密度) - 策略生成:当某区域热力值突增(如环比+50%),触发规则引擎,匹配预设策略库
# 热力值计算(Redis Lua脚本,保证原子性) local count = redis.call('SCARD', 'store:' .. ARGV[1] .. ':geohash') local last_count = tonumber(redis.call('GET', 'store:' .. ARGV[1] .. ':heat:last')) local ratio = count / (last_count + 1) if ratio > 1.5 then -- 触发策略推送 redis.call('PUBLISH', 'strategy:trigger', ARGV[1] .. '|' .. ratio) end redis.call('SET', 'store:' .. ARGV[1] .. ':heat:last', count)关键细节:
precision=7的GeoHash精度约100m,足够覆盖单个门店辐射范围;SCARD比KEYS store:*:geohash快100倍;策略推送用Redis Pub/Sub,避免轮询。
6.3 落地效果:从“数据看板”到“增长中台”的最后一公里
我们给某社区生鲜品牌上线后,店长每天打开后台第一件事不再是看销售额,而是看热力图——红色区块代表“高潜力未触达人群”,蓝色区块代表“高流失风险老客”。系统自动生成的《行动建议》直接嵌入企业微信,店长一键转发给店员执行。三个月后,试点门店的会员复购率提升27%,优惠券核销率从31%升至68%。老板在季度会上说:“这个后台,终于不是摆设了。”
我坚持在每个新项目启动时,花2天时间把热力图框架搭出来。不是为了炫技,而是让数据从“老板问我要什么”变成“系统告诉我该做什么”。它逼着团队把会员、营销、门店数据真正打通,而不是各管一摊。当你看到店长拿着手机对着热力图说“东门那片红得发亮,今天加推试吃装”,你就知道这套系统真的活了。
希望帮到你。
本文还有配套的精品资源,点击获取