美容院会员管理系统源码解析:储值、计次与预约实战
简介一套面向美容院门店的会员管理系统源码附带微信端适合需要快速搭建会员体系的PHP开发者、二次开发人员及有信息化需求的美容行业商家。系统在权限设计上支持功能级细粒度控制可拆分到新增、编辑、删除、导出等具体操作会员档案的增删改查功能一应俱全并整合电子表格导出能力便于日常运营与数据交接。资源为RAR压缩包共1601个文件大小约23.4MB其中662个PHP文件承担后端业务逻辑272个HTML、148个JS与35个CSS组成前端页面与交互多张GIF、PNG、JPG图片提供界面素材还包含SQL数据库脚本、config配置、functions函数等文件便于部署调试和学习源码结构。已有3258人学习可用于研究PHP项目分层、权限控制思路也可在此基础上改造为美容院或类似会员制门店的实际管理工具。1. 美容院会员管理系统源码先搞清楚这套东西到底值不值得本地跑做店铺管理系统的渗透测试和源码复现这几年我拆过不少号称“全功能”的会员系统大部分是拿开源商城改的界面花哨一查数据库连会员储值字段都没有。这套美容院会员管理系统源码含微信端算是少见的“业务闭环”型项目后端管会员档案、储值、计次、预约微信端管会员自助查余额、约项目、收营销推送。不用自己再从零搭权限模型和支付回调对想接门店会员系统做二开的人来说起步成本能省一大截。它适合三类人给美容院做定制系统接单的开发者需要一个能讲清“储值怎么冻结、次卡怎么扣次”的参考实现想快速给自家门店上个会员后台的老板或运营至少能看懂数据怎么流转以及打算拿这套源码入门微信小程序前后端联调的学习者。但先说清楚这套不是装完就能开业的商用成品它更像一个“基础框架 业务样例”支付、短信这类外部依赖留了接口得自己接。2. 先从数据库设计看门店会员业务储值、计次、到期提醒是怎么落表的2.1 会员主表不存余额余额是算出来的很多新手拆这种系统上来就翻 controller 看接口其实最先该看的是数据库脚本。这套系统的会员设计核心是“余额不落主表”主表只存会员基础信息和状态。-- 会员主表关键字段 CREATE TABLE member ( id BIGINT PRIMARY KEY AUTO_INCREMENT, mobile VARCHAR(20) NOT NULL UNIQUE COMMENT 手机号登录凭据, name VARCHAR(50) COMMENT 会员姓名, level TINYINT DEFAULT 0 COMMENT 会员等级0普通 1银卡 2金卡 3钻石, status TINYINT DEFAULT 1 COMMENT 1正常 0冻结, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); -- 储值流水表资金变动的唯一凭证 CREATE TABLE member_balance_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, member_id BIGINT NOT NULL, change_amount DECIMAL(10,2) NOT NULL COMMENT 正数为充值负数为消费, balance_after DECIMAL(10,2) NOT NULL COMMENT 变动后余额, biz_type VARCHAR(20) COMMENT recharge/consume/refund/adjust, remark VARCHAR(255), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_member_id (member_id) );这套设计的逻辑在于如果直接在会员表更新余额字段一旦出现重复支付回调或者手动调账数据错了很难回溯。而所有金额变动都走流水表余额永远是流水表的累计值。我一般会在查询会员详情时用SUM(change_amount)算出当前余额而不是读一个冗余字段。参数说明change_amount用 DECIMAL(10,2) 而不是 FLOAT是避免浮点精度误差美容院储值金额单笔几万封顶10 位整数加 2 位小数完全够用。biz_type建议保持英文小写枚举前端展示时再映射中文这样以后做数据统计可以直接 GROUP BY biz_type。2.2 计次卡和储值卡是两套逻辑不能混在一张表美容院业务里常见的坑是把次卡和储值卡混为一谈。这套系统的做法是分开建模储值卡走金额扣减次卡走剩余次数扣减两者互不干扰。-- 次卡订单表会员购买了“10次背部护理”这种卡项 CREATE TABLE card_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, member_id BIGINT NOT NULL, card_id BIGINT NOT NULL COMMENT 关联卡项定义表, total_count INT DEFAULT 0 COMMENT 总次数, used_count INT DEFAULT 0 COMMENT 已用次数, expire_date DATETIME COMMENT 到期时间NULL为永久, status TINYINT DEFAULT 1 COMMENT 1生效 0失效, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 次卡使用记录表每做一次项目插一条 CREATE TABLE card_usage_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, card_order_id BIGINT NOT NULL, member_id BIGINT NOT NULL, item_id BIGINT NOT NULL COMMENT 服务项目ID, used_at DATETIME DEFAULT CURRENT_TIMESTAMP, remark VARCHAR(255) );这里的业务关键在于每次消耗次卡时不能只让used_count加一还要记录一条card_usage_log方便以后核对会员说“我明明只做了三次”时能查到底。常见做法是事务里同时更新订单表的used_count和插入使用记录缺一不可。扩展思路如果门店想做“过期次卡自动转赠”或“剩余次数折现”基于这套表结构加一个定时任务扫描expire_date即可不需要动核心表。2.3 会员到期提醒用状态机而不是定时任务硬算这套源码里会员到期处理用了一个状态字段这是我希望新手多留意的设计。ALTER TABLE member ADD COLUMN danger_type TINYINT DEFAULT 0 COMMENT 0正常 1即将到期 2已过期 3余额不足;不是每个会员都要天天查他的到期日而是在每日定时任务里扫一遍expire_date距今小于 7 天则置为 1超过则置为 2余额低于某个阈值则置为 3。前端首页、微信端弹窗都读这个字段不用每次查询时实时计算。这个思路对门店运营很实用因为美容院不可能天天人工翻会员列表看谁快过期。避坑提醒这个状态字段一定要在会员资料变更续卡、充值时即时重置否则会出现“会员刚续费状态还是即将到期”的尴尬情况。我一般会在续卡接口里手动 UPDATE 这个字段为 0而不是等定时任务慢慢扫。3. 核心接口的业务逻辑储值、扣款、预约这些边界条件最容易翻车3.1 储值接口为什么要有幂等处理先看储值接口这算会员系统的资金入口做不好会出大问题——用户充值支付成功结果余额没到账客诉直接上门。Transactional public RechargeResult recharge(RechargeRequest req) { // 1. 幂等校验同一笔支付订单号不允许重复处理 if (rechargeLogMapper.existsByPaymentNo(req.getPaymentNo())) { return RechargeResult.fail(该支付单已处理); } // 2. 生成储值流水 MemberBalanceLog log new MemberBalanceLog(); log.setMemberId(req.getMemberId()); log.setChangeAmount(req.getAmount()); log.setBalanceAfter(balanceService.getBalance(req.getMemberId()) req.getAmount()); log.setBizType(recharge); balanceLogMapper.insert(log); // 3. 记录充值订单用于对账 rechargeLogMapper.insert(new RechargeLog(req.getPaymentNo(), req.getMemberId(), req.getAmount())); return RechargeResult.ok(log.getBalanceAfter()); }这段代码的核心不是“加余额”这个动作而是第 1 步的幂等判断。支付回调可能因为网络原因投递两次甚至三次如果没有paymentNo唯一约束一笔钱会被加两次。参数说明RechargeRequest里的amount建议在 Controller 层就校验最大值比如单笔不超过 50000防止恶意传参。balanceAfter这里直接查了一次当前余额再相加并发情况下可能算错严谨做法是改用SELECT balance_after FROM member_balance_log WHERE member_id? ORDER BY id DESC LIMIT 1或者用数据库行锁。经验参考这套代码的幂等方案是“先查后插”我一般会加一层唯一索引兜底否则并发请求同时插两条相同payment_no记录时会漏判。3.2 消费扣款事务、余额校验、流水记录三位一体扣款接口比充值更容易出边界问题因为充值只加钱扣款涉及到“够不够扣”的判断。Transactional public ConsumeResult consume(ConsumeRequest req) { // 1. 锁住会员行防止并发扣款 Member member memberMapper.selectByIdForUpdate(req.getMemberId()); BigDecimal balance balanceService.getBalance(member.getId()); if (balance.compareTo(req.getAmount()) 0) { return ConsumeResult.fail(余额不足); } // 2. 写入扣款流水 MemberBalanceLog log new MemberBalanceLog(); log.setMemberId(member.getId()); log.setChangeAmount(req.getAmount().negate()); // 负数 log.setBalanceAfter(balance.subtract(req.getAmount())); log.setBizType(consume); balanceLogMapper.insert(log); // 3. 写消费记录关联员工、项目 consumeRecordMapper.insert(buildConsumeRecord(req, member.getId())); return ConsumeResult.ok(log.getBalanceAfter()); }注意这行memberMapper.selectByIdForUpdate这是典型的悲观锁用法。美容院收银台同时有两台电脑都在给同一个会员做结算时如果不锁行两个请求都读到余额 500然后各扣 300最后余额剩 200实际应该剩 -100。用FOR UPDATE让第二个请求等待第一个事务提交后再读问题就避免。参数说明negate()是把消费金额转成负数这样在流水表里SUM(change_amount)正好等于余额不需要额外区分收付方向。consumeRecordMapper.insert里至少要有项目 id、员工 id、门店 id、消费金额、支付方式后续做业绩提成和门店报表都靠这张表。3.3 预约模块状态流转设计别用布尔值表示取消/完成预约在这套系统里状态字段用的是整数枚举不是布尔值。这一点值得学习因为预约生命周期比较复杂待确认、已确认、已取消、已完成、爽约。// 预约状态机定义前端 const APPOINTMENT_STATUS { PENDING: 0, // 待确认 CONFIRMED: 1, // 已确认 CANCELLED: 2, // 已取消 COMPLETED: 3, // 已完成 MISSED: 4 // 爽约 }; // 允许的状态迁移 const ALLOWED_TRANSITIONS { [APPOINTMENT_STATUS.PENDING]: [APPOINTMENT_STATUS.CONFIRMED, APPOINTMENT_STATUS.CANCELLED], [APPOINTMENT_STATUS.CONFIRMED]: [APPOINTMENT_STATUS.CANCELLED, APPOINTMENT_STATUS.COMPLETED, APPOINTMENT_STATUS.MISSED], [APPOINTMENT_STATUS.CANCELLED]: [], [APPOINTMENT_STATUS.COMPLETED]: [], [APPOINTMENT_STATUS.MISSED]: [] };如果不做这样的状态限制会出现“已完成之后又被取消”这种逻辑矛盾。常见做法是后端接口里也维护一份同样的迁移表前端只是展示友好提示真正的校验在后端做。参数改动建议PENDING 状态是否要自动流转看门店业务。有的店不需要用户确认下单即确认那就直接把初始状态设为 CONFIRMED。有的店每个项目都要人工排班才需要保留 PENDING。这套源码默认是 PENDING 设计二开时按需调整初始值即可。3.4 微信端登录openid 是唯一键手机号只作绑定微信端登录逻辑是另一个常见翻车点。初学者容易把手机号当成用户唯一标识这套源码的正确做法是用微信登录后的openid做唯一键手机号只作为绑定信息。public String wxLogin(String code, String encryptedData, String iv) { // 1. 用 code 换 openid 和 session_key WxSession session wxApi.code2Session(code); // 2. 查会员是否存在 Member member memberMapper.selectByOpenid(session.getOpenid()); if (member null) { // 3. 不存在则创建新会员记录openid 为主 member new Member(); member.setOpenid(session.getOpenid()); member.setMobile(); // 待绑定 memberMapper.insert(member); } // 4. 生成自定义登录态 token String token JwtUtil.createToken(member.getId()); return token; }这里的核心是 openid 与会员的绑定关系手机号绑定是后续行为不是登录的必要条件。逻辑说明步骤 2 里查一下 openid 是否存在是为了区分老用户和新用户步骤 4 生成 token 后后续请求头里带这个 token后端解析出 member_id 即可。坑点提醒session_key不要入库也不要下发到前端它只能用于解密微信手机号等敏感信息泄露后可以被用来伪造解密数据。有的开发者会把整个 session_key 存进 session 表这是安全问题不建议学。这个源码在这点上的处理是正常的开箱即用但二开时千万别手痒把 session_key 打到日志里。4. 微信端源码的落地细节小程序前后端联调、配置、部署边界4.1 小程序端目录结构与请求封装这套源码的微信端是原生小程序没有用 uni-app 或 Taro所以读起来很直白。拿到的压缩包里一般有pages/、utils/、components/三大目录核心在utils/request.js。这里把请求封装做好后面所有页面的接口调用都干净。// utils/request.js 核心封装 const BASE_URL https://your-domain.com/api; // 上线时改成你的 HTTPS 域名 function request(path, method GET, data {}) { return new Promise((resolve, reject) { wx.request({ url: ${BASE_URL}${path}, method: method, data: data, header: { Content-Type: application/json, // 从本地缓存取 token登录后写入 Authorization: wx.getStorageSync(token) || }, success: (res) { if (res.statusCode 200 res.data.code 0) { resolve(res.data.data); } else if (res.statusCode 401) { // token 过期跳转登录页 wx.navigateTo({ url: /pages/login/login }); reject(res); } else { wx.showToast({ title: res.data.msg || 请求失败, icon: none }); reject(res); } }, fail: (err) { wx.showToast({ title: 网络异常, icon: none }); reject(err); } }); }); } module.exports { request, BASE_URL };封装的核心价值是统一处理 401 跳转和错误提示不用每个页面写一遍wx.showToast。参数说明请求体里把 token 放在Authorization头是标准做法后端用一个拦截器统一解析比每次手动传member_id安全得多。经验补充小程序端所有请求必须走 HTTPS而且在微信公众平台后台要把域名加到“request 合法域名”列表里否则真机调试会直接报url not in domain list。这个坑几乎每个新手都会踩一次后面避坑章再细说。file 目录源码包里可能还有个project.config.json用微信开发者工具导入项目时它自动读取 appid如果没有真实 appid 就用测试号但涉及登录接口必须要真实小程序 appid。4.2 后端配置给微信端准备好对应的接口路径微信端请求的大多数接口后端源码里都已经有了关键是要对齐 URL 路径。这里建议第一步先跑通一个接口再逐步调通其他功能不必一上来就把整个项目跑起来。# application.yml 关键配置 server: port: 8080 wx: appid: your-wx-appid secret: your-wx-secret spring: datasource: url: jdbc:mysql://localhost:3306/beauty_member?useUnicodetruecharacterEncodingutf8 username: root password: your-password redis: host: localhost port: 6379配置文件不复杂但wx.appid和wx.secret是小程序登录的关键凭据如果留空前端登录按钮点击后会直接报错。逻辑说明spring.datasource.url里的characterEncodingutf8是为了防止中文乱码MySQL 8.0 以上还需要加serverTimezoneAsia/Shanghai不然时间字段会差 8 小时。常见做法开发阶段可以把日志级别调到 DEBUG专门看 SQL 和请求参数排查时效率高很多。# 启动命令示例按你的环境调整 mvn clean package -DskipTests java -jar target/beauty-member-server-1.0.0.jar启动成功后先用浏览器打开http://localhost:8080/api/member/list?page1size10看能不能返回 JSON。如果这一步通了说明数据库连接、MyBatis 映射、基础表结构都没问题再继续配微信端。参数说明page和size是分页参数后端会返回一个带 total 的对象小程序端列表页直接渲染即可。4.3 联调时先看网络面板再看代码逻辑联调微信端和后端时我建议不要猜直接看微信开发者工具里的 Network 面板。请求红色基本是后端没启动或者路径不对请求蓝色但返回报错那就是接口内部逻辑问题。按这个顺序排查最快# 后端常见排查命令 curl http://localhost:8080/api/member/info?id1 # {code:0,data:{id:1,name:张三,balance:1234.50}}这一步能直接确认后端接口是否健康。前端如果报 500去后端控制台看异常堆栈如果报跨域检查有没有配置 CORS 过滤器。这套源码后端一般会有一个全局 CORS 配置类但开发时用的端口是 8080微信开发者工具不校验跨域所以问题不大。到真机预览时才需要确认域名备案和 HTTPS 证书。经验参考我先会用 Postman 调一遍后端所有接口把参数和返回都录下来再导给小程序的request.js用这样联调时大部分问题都已经被过滤掉了。5. 避坑指南五个高频问题源码本地化部署时最容易踩5.1 数据库初始化脚本不完整缺表或缺字段类型对不上现象导入 SQL 后启动项目后端报Table member_balance_log doesnt exist但明明导入成功了。原因源码包里的 SQL 脚本可能分多个版本有的是增量更新脚本只含新增表基础表在另一个文件里。新手只导了其中一个。解决先把db目录下的所有.sql文件按文件名排序全部看一遍优先执行init.sql或者schema.sql之类的全量脚本如果是空库注意建库语句是否正确。我一般会把所有脚本在一个临时库里跑一遍确认无报错后再往正式库里导。5.2 微信端真机调试白屏报url not in domain list现象开发者工具里页面正常手机预览时所有请求失败控制台报域名不合法。原因微信小程序真机环境强制校验 request 合法域名本地调试用的是localhost或者局域网的 HTTP 地址微信不允许。解决开发阶段在微信公众平台后台把https://你的域名加到 request 合法域名如果只是本地调试可以在开发者工具里勾选“不校验合法域名”选项但仅限开发用。上线前必须换成 HTTPS 正式域名。从那以后我每次接手小程序项目都先检查这一项配置省得后面反复翻车。5.3 储值后余额没变但流水表多了记录现象用户充值 500支付回调返回成功流水表新增一条记录但会员端余额还是原来的数。原因这套逻辑里余额是查流水表算出来的如果缓存了member.balance字段那就出现数据不一致。有些二开版本在会员表加了冗余余额字段但更新代码没写全。解决检查查询余额的 SQL 是SUM(balance_log.change_amount)还是直接查 member 表的字段如果确实有冗余字段确认所有充值/扣款/退款接口都同步更新了它。建议还是去掉冗余字段统一按流水表算最省心。5.4 计次卡扣次数后会员还能继续做项目现象次卡显示剩余 0 次但前端预约或者到店核销时居然还能通过。原因核销接口只判断了卡项存在没有校验used_count total_count或者用判断导致边界失误。解决后端在扣减次数的接口里强制加一行if (cardOrder.getUsedCount() cardOrder.getTotalCount()) { return fail(次数已用完); }数据库层面再给card_order加一个CHECK (used_count total_count)约束双保险。这种逻辑问题代码层面容易漏数据库兜底能拦一波。5.5 定时任务把会员状态批量置为过期续费后状态没恢复现象会员昨天刚续费今天后台显示还是“已过期”首页营销推送也照常发给人家。原因定时任务每天扫描到期日并更新danger_type但续费接口没有把危险状态重置回 0。解决续费/充值成功后统一执行一条UPDATE member SET danger_type 0 WHERE id ?过期卡重新激活同理。这类状态一致性问题的基本原则是任何修改到期时间或余额的操作都要顺带重置状态字段而不是依赖定时任务来纠正。6. 进阶验证技巧用一套模拟数据把整套系统跑出真实感源码到手后直接拿空库跑通还不够建议自己造一套接近真实美容院业务的数据验证逻辑是否可靠。我一般会造 20 个会员、5 类次卡、3 个门店、若干服务项目然后按一个月的业务节奏模拟充值、消费、预约、取消、过期这样能看到系统在“真实流量”下的表现。造数最简单的方式不是手工插入而是写一个批量脚本。以 MySQL 为例用存储过程或一个简单的 Java 脚本批量生成 20 个会员每个会员绑定 1 到 2 张次卡再按正态分布生成 30 天内的消费流水。-- 批量插入测试会员示例造 20 个 DELIMITER $$ CREATE PROCEDURE generate_test_members() BEGIN DECLARE i INT DEFAULT 1; WHILE i 20 DO INSERT INTO member (mobile, name, level, status, created_at) VALUES (CONCAT(13, LPAD(i, 9, 0)), CONCAT(测试会员, i), i % 4, 1, NOW()); SET i i 1; END WHILE; END$$ DELIMITER ; CALL generate_test_members();这段脚本的价值在于让会员手机号以13开头加 9 位数字符合国内手机号的格式微信端能正常绑定。参数说明CONCAT(13, LPAD(i, 9, 0))意思是生成13000000001到13000000020这种账号i % 4控制等级分布让各等级都有样本。脚本跑完后用前端页面逐页翻看会员列表检查姓名、等级、余额是否正常显示这一步就能暴露不少前后端字段名不对齐的问题。另一个值得做的验证是“并发扣款压力测试”。用一个简单的并发脚本同时发起 20 个扣款请求会员余额只有 500每个请求扣 100预期结果应该是“成功 5 个、失败 15 个”。如果是这样说明事务锁生效。如果结果出现 6 个甚至 8 个成功那说明锁失效需要立刻检查 mapper 里的FOR UPDATE是否被Transactional包住。这套源码我在本地从建库到微信端跑通大概花了半天时间主要时间耗在数据库脚本排序和域名配置上真正代码层面的坑不多。适合作为接门店项目时的基座也适合拿来学习会员系统的建模思路。几个血泪教训SQL 脚本一定要看全别默认一个文件就是全部秒杀类的并发场景直接用行锁别过度设计状态字段改起来一定要顺着所有入口搜一遍漏一个就是线上事故。从那以后我每次接这类系统都强制先走一遍“全量 SQL 接口 Mock 造数验证”三步再谈业务二开。希望帮到你。本文还有配套的精品资源点击获取