简介:一套面向美容院门店的会员管理系统源码,附带微信端,适合需要快速搭建会员体系的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?useUnicode=true&characterEncoding=utf8 username: root password: your-password redis: host: localhost port: 6379配置文件不复杂,但wx.appid和wx.secret是小程序登录的关键凭据,如果留空,前端登录按钮点击后会直接报错。逻辑说明:spring.datasource.url里的characterEncoding=utf8是为了防止中文乱码,MySQL 8.0 以上还需要加serverTimezone=Asia/Shanghai,不然时间字段会差 8 小时。常见做法:开发阶段可以把日志级别调到 DEBUG,专门看 SQL 和请求参数,排查时效率高很多。# 启动命令示例,按你的环境调整 mvn clean package -DskipTests java -jar target/beauty-member-server-1.0.0.jar启动成功后,先用浏览器打开http://localhost:8080/api/member/list?page=1&size=10,看能不能返回 JSON。如果这一步通了,说明数据库连接、MyBatis 映射、基础表结构都没问题,再继续配微信端。参数说明:page和size是分页参数,后端会返回一个带 total 的对象,小程序端列表页直接渲染即可。4.3 联调时先看网络面板,再看代码逻辑联调微信端和后端时,我建议不要猜,直接看微信开发者工具里的 Network 面板。请求红色基本是后端没启动或者路径不对;请求蓝色但返回报错,那就是接口内部逻辑问题。按这个顺序排查最快:# 后端常见排查命令 curl http://localhost:8080/api/member/info?id=1 # {"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' doesn't 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 + 造数验证”三步,再谈业务二开。希望帮到你。本文还有配套的精品资源,点击获取
简介:一套面向美容院门店的会员管理系统源码,附带微信端,适合需要快速搭建会员体系的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?useUnicode=true&characterEncoding=utf8 username: root password: your-password redis: host: localhost port: 6379配置文件不复杂,但wx.appid和wx.secret是小程序登录的关键凭据,如果留空,前端登录按钮点击后会直接报错。逻辑说明:spring.datasource.url里的characterEncoding=utf8是为了防止中文乱码,MySQL 8.0 以上还需要加serverTimezone=Asia/Shanghai,不然时间字段会差 8 小时。常见做法:开发阶段可以把日志级别调到 DEBUG,专门看 SQL 和请求参数,排查时效率高很多。# 启动命令示例,按你的环境调整 mvn clean package -DskipTests java -jar target/beauty-member-server-1.0.0.jar启动成功后,先用浏览器打开http://localhost:8080/api/member/list?page=1&size=10,看能不能返回 JSON。如果这一步通了,说明数据库连接、MyBatis 映射、基础表结构都没问题,再继续配微信端。参数说明:page和size是分页参数,后端会返回一个带 total 的对象,小程序端列表页直接渲染即可。4.3 联调时先看网络面板,再看代码逻辑联调微信端和后端时,我建议不要猜,直接看微信开发者工具里的 Network 面板。请求红色基本是后端没启动或者路径不对;请求蓝色但返回报错,那就是接口内部逻辑问题。按这个顺序排查最快:# 后端常见排查命令 curl http://localhost:8080/api/member/info?id=1 # {"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' doesn't 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 + 造数验证”三步,再谈业务二开。希望帮到你。本文还有配套的精品资源,点击获取