简介:这份美容院会员管理系统源码包面向中小型美容门店的数字化管理需求,也适合PHP开发者学习会员系统与微信端的业务实现。系统围绕会员档案维护、门店日常业务和权限管理展开,权限可细分到每个功能点,增删改查模块较为齐全,并整合Excel导出,便于搭建内部管理后台或进行二次开发。压缩包共1601个文件、约23.4MB,以662个PHP程序文件和272个HTML模板为主体,辅以JavaScript、CSS、图片及字体等前端资源,并包含配置、公共函数库、SQL脚本等支撑文件,整体目录组织较完整,便于按模块查找和替换。已有3259人学习,读者可从中了解微信端与管理端的协作方式、细粒度权限控制、数据增删改查以及报表导出的实现思路。对于需要快速搭建门店会员管理后台、研究管理端与微信端数据交互的开发者,这套源码具有直接的参考和复用意义。
1. 美容院会员管理系统:前台那本手写卡册,其实三天就能搬进微信端
接手一个三家门店的美容院项目时,前台桌上还摆着一本手写卡册:谁办了补水疗程、剩几次、卡里还有多少余额,全靠一支笔,换个店员就得翻半天。老板想上系统,又不想被按年收费的 SaaS 绑住,最后选了一套美容院会员管理系统源码自己部署,微信端让顾客自己查卡、约时间、看余额。这套代码的定位很清楚:把会员档案、卡项、疗程扣次、员工提成、店铺流水这几件事管住,后台给店长和前台用,微信端给顾客用。它适合手里有台服务器、愿意自己维护的小连锁和单店,不适合指望装上就自动拉客的人——会员是要靠门店服务一点点攒的。
2. 技术栈与目录结构:一套源码怎么同时喂饱后台、门店端和微信端
这类系统的并发量其实很低,一家店一天几十单,三家店加起来上百单,用不着微服务那一套。但它对“钱”和“次”的准确度要求很高:扣一次疗程、扣一笔余额、算一笔提成,三件事要么全成要么全不成。所以这套源码走的是单体 + 分模块的路子,事务边界清楚,部署也就一个 jar 加一个前端目录。
2.1 技术选型:为什么这类项目总是 Spring Boot + MyBatis-Plus + Vue
我见过的美容院会员管理系统源码,绝大多数是 Java 路线:Spring Boot 起服务,MyBatis-Plus 做数据访问,MySQL 存业务数据,Redis 放登录令牌和门店排班缓存,后台前端 Vue + Element UI,微信端 uni-app 一套代码同时编译小程序和 H5。这个组合不新,但它有一个很实际的好处——交付形态简单,打成 jar 扔到服务器上就能跑,客户环境里不用额外装运行时,出问题也好远程看日志。
| 层次 | 常见选型 | 这套业务里为什么这么选 |
|---|---|---|
| 服务端 | Spring Boot + MyBatis-Plus | 扣次扣款能塞进一个方法里,事务边界一目了然 |
| 数据库 | MySQL | 会员、卡项、流水都是强关系数据,约束能兜底 |
| 缓存 | Redis | 登录态、门店排班、卡项字典,读多写少 |
| 后台前端 | Vue + Element UI | 表格表单多,组件现成,加字段改得快 |
| 微信端 | uni-app | 一套代码出小程序和 H5,顾客不用装 App |
| 定时任务 | Spring Task 或 XXL-JOB | 凌晨算提成、发卡项到期提醒、清过期券 |
前后端分离是标配,但不是前后端各写各的。后台接口前缀一般是/admin,微信端接口走/api,两套接口分别做鉴权:后台走账号密码 + 门店权限,微信端走 openid 换来的令牌。这个分离要在目录上就体现出来,别等到后期才拆。
2.2 目录结构:按业务域分包,别按技术分层平铺
很多源码包一打开是这样的:controller一个包,service一个包,mapper一个包。小项目看着整齐,一旦要改“卡项到期提醒”这种横跨多层的功能,你会在三个包里来回跳。我更推荐按业务域分包,这套源码大体也是这个思路:
beauty-member/ ├── beauty-admin/ 后台管理端接口,给店长和前台用 ├── beauty-wechat/ 微信端接口,给小程序和 H5 用 ├── beauty-common/ 公共模块:统一返回、异常、工具类、常量 ├── beauty-system/ 业务主模块:会员、卡项、疗程、订单、提成 └── sql/ ├── beauty_schema.sql 建表脚本 └── beauty_init_data.sql 初始化数据:门店、角色、卡项模板beauty-admin和beauty-wechat拆成两个可独立启动的模块,不是过度设计。微信端接口要做签名校验、频率限制、返回体精简,后台要接门店数据权限,混在一起后期很难剥。beauty-common里放Result、BizException、雪花 ID、日期工具,所有模块都依赖它,它不依赖任何业务模块,这个方向不能反。
业务主模块内部再按域分:
beauty-system/src/main/java/com/example/beauty/ ├── member/ 会员档案、标签、等级 ├── card/ 卡项模板、会员持卡、余额 ├── treatment/ 疗程、扣次记录 ├── order/ 收银台、充值、退款 ├── commission/ 员工提成规则与结算 ├── store/ 门店、员工、排班 └── wechat/ 微信登录、预约、卡项展示按域分包之后,改一个功能基本只在一个包里动手,也不容易在member包里随手引用order的类,造成循环依赖。实体命名统一用业务词:Member、MemberCard、TreatmentRecord、CommissionRule,别出现TbUser1这种导入工具自动生成的表名映射。
2.3 数据模型:会员、持卡、流水三张表撑起全部业务
看这类系统值不值得接手,我第一件事是看流水表怎么设计。会员可以改,卡项可以下架,流水一旦能被覆盖,对账就是一笔糊涂账。核心三张表我一般这么定:
-- 会员主表:一个手机号一个会员,跨门店共享 CREATE TABLE `member` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '会员ID', `store_id` BIGINT NOT NULL COMMENT '首次建档门店', `mobile` VARCHAR(20) NOT NULL COMMENT '手机号,全局唯一', `name` VARCHAR(50) NOT NULL COMMENT '姓名/昵称', `level_id` BIGINT DEFAULT NULL COMMENT '会员等级', `balance` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '储值余额,单位元', `points` INT NOT NULL DEFAULT 0 COMMENT '积分', `last_visit_at` DATETIME DEFAULT NULL COMMENT '最后到店时间', `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除', PRIMARY KEY (`id`), UNIQUE KEY `uk_mobile` (`mobile`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员档案'; -- 会员持卡:一张卡对应一个会员 + 一个卡项模板 CREATE TABLE `member_card` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `member_id` BIGINT NOT NULL COMMENT '会员ID', `store_id` BIGINT NOT NULL COMMENT '售卡门店', `card_tpl_id` BIGINT NOT NULL COMMENT '卡项模板ID', `remain_count` INT NOT NULL DEFAULT 0 COMMENT '剩余次数,储值卡为0', `remain_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '剩余金额', `expire_at` DATE DEFAULT NULL COMMENT '到期日,NULL 表示长期', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 2用完 3过期 4退卡', PRIMARY KEY (`id`), KEY `idx_member` (`member_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员持卡'; -- 扣次流水:只增不改,对账全靠它 CREATE TABLE `treatment_record` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `card_id` BIGINT NOT NULL COMMENT '会员持卡ID', `member_id` BIGINT NOT NULL, `store_id` BIGINT NOT NULL COMMENT '实际核销门店', `staff_id` BIGINT NOT NULL COMMENT '服务员工', `count` INT NOT NULL DEFAULT 1 COMMENT '本次扣减次数,退单为负', `amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '本次扣减金额快照', `biz_no` VARCHAR(40) NOT NULL COMMENT '幂等号,防重复提交', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_biz_no` (`biz_no`), KEY `idx_card_time` (`card_id`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='扣次/核销流水';三个设计点值得单独说。第一,余额和次数分开存。储值卡看remain_amount,疗程卡看remain_count,不要用一张表加个type字段混着塞,后期算提成时基数的取法完全不同。第二,流水表带biz_no唯一索引,这是防重复扣次的最后一道闸,前端置灰只是体验,数据库约束才是保障。第三,store_id在会员表上是“建档门店”,在持卡表和流水表上是“售卡/核销门店”,两个含义别混——会员跨店可用,卡归属于售出的那家店,提成也归那家店,这是分成规则的地基。
| 表 | 作用 | 容易出错的地方 |
|---|---|---|
member | 会员档案 | 手机号没做唯一,同一人建三次档 |
member_card | 会员持卡 | 状态没维护,过期卡还能核销 |
treatment_record | 扣次流水 | 允许 update,对账时查不到原始值 |
commission_rule | 提成规则 | 比例写死在代码 if-else 里 |
store | 门店 | 缺少营业时间与结账截止点 |
3. 核心链路拆解:一次「到店扣次 + 算提成」到底走了哪些代码
后台菜单再多,真正跑在业务主干上的只有三条链路:建档、扣次、结算。把这三段读透,剩下的会员标签、生日提醒、优惠券都是挂在上面的枝叶。
3.1 会员建档:先查手机号,再决定是新建还是合并
前台最常干的事就是“给这位姐建个档”。这段代码看着简单,坑不少:
@Transactional(rollbackFor = Exception.class) public Long createOrMerge(MemberCreateDTO dto) { // 1. 手机号是全局唯一键,先查再插,避免收银台重复建档 Member exist = memberMapper.selectByMobile(dto.getMobile()); if (exist != null) { // 2. 老会员只补全空字段,不动 level 和 balance if (StringUtils.isBlank(exist.getName())) { exist.setName(dto.getName()); memberMapper.updateById(exist); } return exist.getId(); } // 3. 新会员:余额、积分一律从 0 开始,不接受前端传值 Member member = new Member(); member.setStoreId(dto.getStoreId()); member.setMobile(dto.getMobile()); member.setName(dto.getName()); member.setBalance(BigDecimal.ZERO); member.setPoints(0); memberMapper.insert(member); return member.getId(); }dto里只放门店 ID、手机号、姓名、来源渠道这几个字段,余额和等级坚决不放进 DTO——前端能传余额,等于把收银台的钱袋子敞开。第 3 步之后如果还要发新人券,放在事务外的AFTER_COMMIT事件里做,别让发券失败把建档整体回滚。
为什么不“先插入、撞唯一键再捕获异常”?这是我有过一次血泪经验的地方:在同一个事务里捕获DuplicateKeyException之后,事务已经被标记成 rollback-only,后面哪怕不再抛异常,提交时照样炸。要么先查,要么把插入放到独立事务里。
3.2 疗程卡扣次:一个事务里必须做完的四件事
扣次是整个系统最不能出错的地方,写法上我坚持“条件更新 + 流水兜底”:
@Transactional(rollbackFor = Exception.class) public void consume(TreatmentConsumeDTO dto) { // 1. 幂等:biz_no 有唯一索引,重复提交直接返回成功 if (recordMapper.existsByBizNo(dto.getBizNo())) { return; } // 2. 带条件扣减:把 remain_count >= ? 放进 where,靠数据库保证不超扣 int rows = cardMapper.deductCount(dto.getCardId(), dto.getCount()); if (rows == 0) { throw new BizException("卡项次数不足或卡已失效"); } // 3. 写流水,金额按卡项单价做个快照,退单写负数冲正 TreatmentRecord record = new TreatmentRecord(); record.setCardId(dto.getCardId()); record.setMemberId(dto.getMemberId()); record.setStoreId(dto.getStoreId()); record.setStaffId(dto.getStaffId()); record.setCount(dto.getCount()); record.setAmount(dto.getUnitPrice() .multiply(BigDecimal.valueOf(dto.getCount())) .setScale(2, RoundingMode.HALF_UP)); record.setBizNo(dto.getBizNo()); recordMapper.insert(record); // 4. 更新最后到店时间,沉睡会员唤醒名单靠这个字段筛 memberMapper.touchLastVisit(dto.getMemberId()); }配套的更新语句是关键:
<update id="deductCount"> UPDATE member_card SET remain_count = remain_count - #{count} WHERE id = #{cardId} AND status = 1 AND remain_count >= #{count} </update>rows == 0就代表三种情况之一:卡不存在、卡状态不对、次数不够。业务上不需要区分得太细,给前台的提示统一成“次数不足或卡已失效”更好用,具体原因写进日志。biz_no一般由前端生成 UUID,前端重试时带同一个值;如果前端没传,后端用“门店 ID + 时间戳 + 三位随机数”补一个,但绝不能每次请求都重新生成,那样幂等就白做了。
3.3 提成结算:规则落表,别写死在一串 if-else 里
提成规则是店长最爱改的东西,这个月疗程卡提 8%,下个月做活动提到 12%,写死在代码里的结果就是每次改都要重新发版。做法是把规则落成表,按“门店 + 卡项 + 岗位”匹配,取优先级最高的一条:
public BigDecimal calcCommission(TreatmentRecord r, MemberCard card) { // 1. 按门店、卡项、员工岗位匹配规则,priority 大的优先 CommissionRule rule = ruleMapper.match(r.getStoreId(), card.getCardTplId(), r.getStaffId()); if (rule == null) { return BigDecimal.ZERO; // 没配规则就是 0,不默认给比例 } // 2. 基数:疗程卡按单价乘次数,储值卡按本次实际消费金额 BigDecimal base = rule.getBaseType() == 1 ? card.getUnitPrice().multiply(BigDecimal.valueOf(r.getCount())) : r.getAmount(); // 3. 比例存 12.50 这种百分比数值,先保留 4 位再收敛到分 return base.multiply(rule.getRate()) .divide(new BigDecimal("100"), 4, RoundingMode.HALF_UP) .setScale(2, RoundingMode.HALF_UP); }第 3 步的两级舍入不能省。直接multiply完setScale(2),遇到 0.5 分这种边界值会和财务算出来的差一分钱,一个月几百单攒下来就是几十块的窟窿。结算结果写进commission_record,明细按流水一行一条,汇总按月按员工一条,月底导出时两边能对上。
真正跑的时候,提成计算放在凌晨的定时任务里,不在扣次事务里同步做。扣次是顾客在前台等着的,必须快;提成是老板月底才看的,慢一点没关系,失败了还能补跑。
4. 环境搭建与跑通:从导入 SQL 到微信端扫码能看到自己的卡
源码拿到手,最想知道的其实是“我这台机器上能不能跑起来”。这一章按我实际操作的顺序走一遍。
4.1 本地环境与配置项:先改这四处
环境我一般用 JDK 8、Maven 3.6 以上、MySQL 5.7 以上、Redis 5 以上,前端 Node 14 以上,微信端装开发者工具。装完先别急着编译,把配置文件里这四处改掉:
server: port: 8080 spring: datasource: # 时区必须显式指定,不写的话跨时区服务器上时间会漂 url: jdbc:mysql://127.0.0.1:3306/beauty?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 redis: host: 127.0.0.1 port: 6379 database: 3 # 别和其他项目共用 0 号库,清缓存会误伤 beauty: upload-path: /data/beauty/upload/ # 会员头像、卡项封面,别放 jar 同级目录 wechat: app-id: wx0000000000000000 # 换成自己的小程序 AppID app-secret: 00000000000000000000000000000000 mch-id: "" # 不接微信支付可以先留空upload-path单独拎出来是有原因的:打成 jar 之后,jar 同级目录在重新部署时经常被整个覆盖,顾客头像一夜间全变成叉号。database: 3也是经验,多个项目共用 0 号库,某天随手一个flushdb就把别人的登录态清了。
4.2 建库导数据与启动后端
库一定要用utf8mb4。会员昵称里带个表情符号,utf8三字节存不进去,插入直接报错,前台会以为系统坏了。
# 1. 建库,字符集必须是 utf8mb4 mysql -uroot -p -e "CREATE DATABASE beauty DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci;" # 2. 先导结构再导初始化数据,顺序反了会缺菜单和角色 mysql -uroot -p beauty < sql/beauty_schema.sql mysql -uroot -p beauty < sql/beauty_init_data.sql # 3. 打包,第一次跑先跳过测试,别被环境问题卡住 mvn clean package -DskipTests # 4. 启动后台服务,profile 指向本地 java -jar beauty-admin/target/beauty-admin.jar --spring.profiles.active=dev前端单独起:
cd beauty-ui npm install --registry=https://registry.npmmirror.com npm run dev启动后先看控制台有没有打印出接口前缀和端口,后台默认账号密码在beauty_init_data.sql的sys_user表里,第一次登录完记得改。如果后台页面能打开但接口全 404,八成是前端.env.development里的VUE_APP_BASE_API没对上后端端口,这个错我见过太多次。
4.3 微信端联调:AppID、请求域名和一次性的 code
微信端的请求封装是第一个要动的地方:
// utils/request.js const BASE_URL = 'http://127.0.0.1:8081/api'; // 微信端接口走 beauty-wechat 模块 export function request(options) { return new Promise((resolve, reject) => { uni.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data || {}, header: { 'content-type': 'application/json', 'X-Token': uni.getStorageSync('token') || '' // 登录后写入的令牌 }, success: (res) => { if (res.data.code === 200) { resolve(res.data.data); } else if (res.data.code === 401) { uni.reLaunch({ url: '/pages/login/login' }); // 令牌过期直接回登录页 reject(res.data); } else { uni.showToast({ title: res.data.msg, icon: 'none' }); reject(res.data); } }, fail: reject }); }); }后端的登录换 openid 这一段,写法上有几个不能让步的点:
public String login(String code) { // 1. code 只能用一次,五分钟内有效,绝对不能缓存复用 String url = "https://api.weixin.qq.com/sns/jscode2session" + "?appid=" + appId + "&secret=" + secret + "&js_code=" + code + "&grant_type=authorization_code"; JSONObject json = restTemplate.getForObject(url, JSONObject.class); String openid = json.getString("openid"); if (StringUtils.isBlank(openid)) { throw new BizException("微信登录失败:" + json.getString("errmsg")); } // 2. sessionKey 只留在服务端,一律不下发给前端 Member member = memberMapper.selectByOpenid(openid); if (member == null) { member = new Member(); // 首次进入:先建一个只有 openid 的空档案 member.setOpenid(openid); member.setStoreId(0L); memberMapper.insert(member); } String token = UUID.randomUUID().toString().replace("-", ""); redis.opsForValue().set("wx:token:" + token, member.getId(), 7, TimeUnit.DAYS); return token; }首次进入的顾客只有 openid,没有手机号,这时查不到任何持卡记录。我的处理是页面照常展示,卡列表返回空并提示“到店报手机号即可绑定”,绑定动作放在后台收银台做,由前台确认本人后手动关联。让顾客自己在小程序里输手机号也行,但要加短信验证码,不然有人拿别人手机号试一遍就能看到别人的消费记录。
4.4 上线前的配置切换清单
本地能跑通和能上线是两件事,下面这几项我在每次部署前都会过一遍:
| 检查项 | 本地 | 线上 | 不改的后果 |
|---|---|---|---|
| 接口域名 | 127.0.0.1 | HTTPS 域名并在小程序后台配好 | 真机请求全部失败 |
| AppID/Secret | 测试号 | 正式小程序 | 拿不到 openid |
| 数据库时区 | Asia/Shanghai | Asia/Shanghai | 流水时间和实际差 8 小时 |
| 上传目录 | jar 同级 | 独立挂载目录 | 重新部署后图片全丢 |
| 定时任务 | 可以关 | 多实例只能开一个 | 提成算两遍 |
| 日志级别 | debug | info,按天切割 | 磁盘被日志写满 |
最后一行不是危言耸听,日志级别留在 debug 跑一个月,磁盘写满导致服务起不来,是我遇到过最没技术含量但最难第一时间想到的故障。
5. 避坑与排查:门店上线后最容易出问题的五个点
代码能跑起来只是开始,真正的问题都在门店真实环境里冒出来。下面五条是我在不同项目里反复遇到的,按“现象 → 原因 → 解决”记下来。
5.1 报表金额差一分钱
现象:收银台充 500 送 50,会员卡余额显示 550.00,月底报表合计 549.99。差一分钱,财务就是不签字。
原因:实体字段用了Double,或者数据库字段定义成float。二进制浮点表示不了 0.1 这种十进制小数,几百次加减之后误差累积,最后在汇总时暴露出来。
解决:数据库一律DECIMAL(10,2),Java 一律BigDecimal,构造时用字符串new BigDecimal("0.01"),别用new BigDecimal(0.01)。比较大小用compareTo而不是equals,因为1.0和1.00的equals返回 false。除法必须指定精度和舍入模式,缺一个参数就可能抛ArithmeticException。
5.2 连点两次核销,次数少了两
现象:前台网络有点慢,店员以为没点上,又点了一次“确认核销”,会员的疗程次数从 8 变成 6,顾客当场就不高兴了。
原因:幂等只做在页面层——按钮置灰 800 毫秒。网络重试、用户双击、浏览器回退再提交,任何一种都能绕过这层。
解决:接口层用biz_no唯一索引兜底,前端每次打开核销页生成一个 UUID,重试时带同一个值;扣减用带条件的UPDATE ... WHERE remain_count >= ?,不要“先 select 查剩余、再 if 判断、最后 update”。这两道加起来,重复请求最多是查一次幂等表然后返回,不会真的扣两次。
5.3 真机上微信登录拿不到 openid
现象:开发者工具里一切正常,真机扫码后一直卡在登录页,日志里能看到errcode 40163或者40029。
原因:code被用了两次。常见于前端把code存进缓存,登录失败时原样重发;或者后端做了接口重试,第一次其实已经成功。另一种情况是appid和secret不是同一个小程序,测试阶段复制粘贴最容易搞混。
解决:code一次性使用,失败就让前端重新调一次uni.login拿新的。在code2session那一步加日志,把errcode、errmsg原样打出来,不要只抛一句“登录失败”,否则排查时完全是黑匣子。secret一旦怀疑泄露,去后台重置,别心存侥幸。
5.4 门店数据串了
现象:A 店店长登录后,会员列表里出现了 B 店的顾客,甚至有 B 店的消费流水。
原因:SQL 忘了带store_id条件,或者用了 MyBatis-Plus 的selectList直接查全表。还有一种更隐蔽的:会员本身是跨店共享的,但卡不是,结果按会员维度查卡时把别的店的卡也带出来了。
解决:先把业务边界写死在文档里——会员共享、持卡归售卡门店、流水归核销门店、提成归核销门店。然后所有列表查询强制带门店条件,能上租户插件就上,让框架统一拼store_id。测试时至少造两家店的数据,只造一家店的测试环境永远发现不了这个问题。
5.5 跨天营业,半夜的单子查不到
现象:晚上十一点半做的核销,第二天上午查昨天的报表,这笔单子不见了。
原因:营业日不等于自然日。很多门店凌晨一两点才结账,用DATE(created_at) = CURDATE()按自然日切,零点之后的单子就被算到今天了。
解决:加一个business_date字段,写库时按门店配置的结账截止时间算营业日:
/** 门店营业日:截止时间之前算前一天 */ public static LocalDate businessDate(LocalDateTime now, LocalTime cutOff) { return now.toLocalTime().isBefore(cutOff) ? now.toLocalDate().minusDays(1) : now.toLocalDate(); }cutOff存在门店表里,默认02:00,报表、提成、日结全部按business_date分组,不要再用created_at做日期维度。这个改动越早做越好,等到有了历史数据再补,就得回头刷全表。
6. 二次开发:加一套「门店分账」,从加表开始而不是改主流程
这套源码最常被提的改造需求是多门店分账:顾客在 A 店买卡,可以去 B 店消费,收的钱和做的服务不是同一家店,怎么分。我的原则是主流程一行不改。
先加两张表:store_share_rule存分配规则(按售卡店和核销店的比例),share_record存每次消费产生的分账明细。然后在扣次成功之后不去直接调用分账逻辑,而是发一个事件:
// 扣次事务内只负责发事件,不关心谁监听 applicationEventPublisher.publishEvent(new CardConsumedEvent(record)); // 分账监听器:主事务提交后才执行,失败只记日志不影响核销 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onConsumed(CardConsumedEvent event) { shareService.calculate(event.getRecord()); // 内部自己开新事务 }这样写有两个好处。核销不会因为分账规则没配好而失败,前台该扣的次照扣;分账逻辑出问题只需要补跑,不用去修流水。验证方法也简单:造三家门店、两种卡项、各做一笔跨店核销,跑一段对账 SQL,把share_record里两边的金额加起来,必须等于流水的amount,差一分都算没过。
还有个小技巧:给member和member_card各留一个ext_json字段,二次开发要加的临时字段(比如顾客的肤质备注、卡的赠送来源)先塞进去,别急着改表结构。等需求稳定了再抽成正式字段,这样升级源码时冲突最少。
从那以后,我每次接这类美容院会员管理系统源码,都强制先把扣次流水和分账金额跑一遍对账脚本,再谈功能加不加——账对不上,功能做得再花哨也是白搭。希望帮到你。
本文还有配套的精品资源,点击获取