做早教机构的小程序这个需求放在2024年来看技术本身并不稀奇真正容易被忽略的是业务链路。我接手这个项目的时候客户是一家跨区域的早教连锁品牌线下有十几家门店每天排课和消课全靠前台在Excel里手工记账。家长约课靠微信群喊话顾问排课靠脑子记月底核销课时的时候经常对不上账。这种状态下小程序要解决的根本不是线上有个东西能用而是把约课、排课、扣课时、签到这一整套流程从线下搬到线上并且保证账目不出错。这个系列笔记会完整记录我从0到100的开发过程包括业务梳理、技术选型、数据库设计、接口开发、小程序端对接以及上线后遇到的各种坑。第一篇先讲清楚整体思路和基建部分为什么用SpringMVC数据结构怎么设计核心接口怎么做以及小程序端怎么对接最省事。如果你手头也接了类似的机构预约类项目或者想把一个传统线下业务做成小程序这篇笔记应该能帮你少走不少弯路。1. 早教机构需要什么小程序先把业务逻辑捋清楚1.1 从线下运营痛点反推需求清单很多人接这类项目上来就打开设计工具画页面然后写接口这是最容易翻车的顺序。我在和客户聊完两个下午之后发现早教机构的运营流程比想象中要复杂得多核心痛点集中在四件事第一是约课混乱。家长在微信群喊周六上午的创意美术还有位子吗顾问得翻Excel表格、翻聊天记录甚至靠脑子回忆。一旦两个顾问同时回消息同一个名额可能被约出去两次最后上课当天才发现有家长撞课。第二是扣课时不透明。早教机构的收费模式是卖课时包比如96节课的亲子课包每上一次课消一个课时。线下手工消课记错、漏记、晚记的情况特别多月底家长来查课时余额前台永远在道歉。第三是签到靠纸。很多机构上课前让家长签字签了不代表孩子真的来了也不代表扣了课时。纸质单据后面还要人工录入系统一个月的单据积压下来补录效率极低。第四是家长没有自助渠道。家长想知道孩子还剩多少课时、下周有什么课只能打电话问前台前台忙起来电话根本接不完。基于这四个痛点我给客户列了一个需求优先级清单。第一阶段先做家长端约课和签到消课这是核心闭环。第二阶段再做管理后台的排课和报表。第三阶段再考虑营销工具比如优惠券、转介绍裂变。千万不要一上来就什么都做尤其是营销功能会拖慢主链路的开发进度。客户最痛的是教材对不上账先把课约清楚、把课时扣明白信任感就建立起来了。优先级功能模块解决痛点复杂度P0课程列表与详情家长不知道有什么课低P0在线预约/取消预约约课靠人工、易撞课中P0签到与自动扣课时消课手工记账、对不上账高P1课时包与余额查询家长查余课困难中P1机构通知与课表提醒家长容易忘课中P2分享裂变与优惠券获客难、老客带新客高1.2 确定课为核心主数据需求分析阶段最大的收获是明确了整个系统的核心主数据是课不是商品。早教机构和电商不一样家长买的不是一件实物而是一个个课时段。一次完整的业务链路是这样走的机构在后台创建课程然后排课生成一个带具体时间段、带容量上限的课次。家长在小程序看到课次点击预约系统检查剩余名额和家长课时余额两者都满足就预约成功并锁定名额。上课当天家长到店扫码签到系统确认到场这才扣除一个课时。如果家长没签到课时保留但名额已经占掉所以最好还要有请假规则。这一条链路上有两个关键点预约成功不扣课时签到才扣课时取消预约要释放名额但距离上课太近的取消要有惩罚规则或者禁止取消。这些业务规则直接决定后面数据库表和接口怎么设计所以一定要在动手前和客户反复确认。这里我特别提醒一点早教行业存在占位不扣费模式也存在预约即扣费模式。两种模式的接口逻辑完全不同。我们这次采用前者因为家长对约了没去还要扣课时非常敏感但这也带来了一个副作用会有家长恶意占位不来上课导致名额被浪费。所以在后面的表结构设计里我加了一个课程状态流转字段支持顾问后台手动取消和补位。2. 技术选型为什么还用SpringMVC2.1 这个项目为什么不用SpringBoot再炫技一把坦率地说现在新起项目用SpringBoot是主流SpringMVC确实有点老。但在接手具体项目的时候技术选型从来不是新不新能完全解释的。客户原有的管理后台是一个跑了三年的老工程基于SpringMVC加MyBatis部署在客户指定的私有服务器上整个运维体系围绕Tomcat和手动打war包运转。如果强行引入SpringBoot意味着要并行维护两套技术栈要么花时间把老工程迁过去而业务方不愿意为这个内部升级承担风险。另外还有一个现实原因SpringMVC对于这类以接口服务为主的中小型项目完全够用。注解开发、参数绑定、拦截器、JSON序列化这些能力一样不缺。技术债务要看场景如果一个项目的瓶颈不在框架能力而在业务规则复杂度上那换框架解决不了业务问题反而增加迁移风险。所以我最终的方案是沿用SpringMVC作为Web层保持原有工程结构统一后面如果真有性能和开发效率的硬需求再考虑渐进式引入其他组件。2.2 SpringMVC项目的工程结构组织工程结构上我没有用传统的三层分包硬套而是在controller、service、dao的基础上增加了一层facade。原因很简单小程序端的接口粒度比PC管理端更细前后端需要对齐的是一个个原子操作而不是service层的业务方法。facade层专门负责接收前端参数、组装服务调用、返回视图模型controller只做最薄的参数校验和路由转发。整个工程按功能域分包而不是按技术分层分包。比如appointment包下面有AppointmentController、AppointmentService、AppointmentMapper、AppointmentQuery这样找代码的时候按业务域进去直接能找到完整链路。刚开始团队有人不习惯觉得包名重复度高但跑过两个迭代之后新同事上手找代码的效率明显提升。2.3 关键依赖与配置文件要点既然是SpringMVC老工程就没有SpringBoot那种自动装配的便利所有配置都要手动挂载。我梳理了一份最精简的依赖清单Spring核心、SpringMVC、MyBatis、MyBatis-Spring、Jackson、Druid连接池、Servlet API。这个组合可以覆盖全部需求再多加框架就是增加管理负担。MVC配置里有几个容易漏的细节。第一个是注解驱动必须开启否则controller里的注解全部不生效mvc:annotation-driven mvc:message-converters bean classorg.springframework.http.converter.json.MappingJackson2HttpMessageConverter property nameobjectMapper refcustomObjectMapper/ /bean /mvc:message-converters /mvc:annotation-driven第二个是static资源放行小程序的接口都在 /api 前缀下所以拦截器只拦 /api/ 就行静态资源和后台上传图片的路径要提前在SpringMVC配置里排除不然后台上传的图片在小程序端永远加载不出来。第三个是统一编码过滤器这个老生常谈但如果忘了配POST请求中文乱码排查起来特别耽误时间。2.4 基于拦截器的登录态设计小程序的登录态和后端Session天然不匹配所以我没有启用Spring Session而是自己写了一套基于Token的拦截器方案。小程序调用wx.login拿到code后端用code去微信服务端换openid然后生成一个32位的随机token把这个token和openid、用户信息一起放进Redis同时把token返回给小程序。小程序后续每次请求都在header里带X-Token后端拦截器从Redis查token查到就放行并设置用户上下文查不到就返回401。这个方案比单纯把openid当凭证安全得多也比JWT好在token过期和服务端踢人非常方便。拦截器里有个细节值得注意放行白名单不要只写登录接口还要把课程列表这类不需要登录就能看的接口单独列出来不然家长还没登录就看不到课程预约转化率会打折扣。public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(X-Token); if (StringUtils.hasText(token)) { UserContext current tokenService.getUserByToken(token); if (current ! null) { UserContextHolder.set(current); return true; } } response.setStatus(401); return false; } }3. 数据库设计8张核心表撑起整个预约业务3.1 核心表全景概览数据库设计是整个项目最值得花时间的部分。我第一版设计只有5张表后来评审的时候发现取消预约、签到历史、请假记录无处存放硬生生扩展到了8张。这8张表构成了整个业务闭环的数据底座表名功能核心字段说明member家长/会员账户openid、手机号、姓名一个家长可以绑定多个孩子child孩子信息昵称、生日、性别课时按孩子维度消耗course课程信息课程名、适用年龄、单课时价相当于课程字典course_schedule排课实例课程ID、门店ID、上课时间、容量、已约人数每次上课的具体安排appointment预约记录会员ID、孩子ID、课次ID、状态预约/取消/已上课/已请假course_package课时包模板名称、总课时、有效期相当于收费套餐member_package会员持有课时包会员ID、剩余课时、到期时间关键的业务账本consume_record消课流水预约ID、课时包ID、变动数量每一笔课时进出都有据可查这里最想强调的一点是预约记录和消课流水必须分开。预约记录管约没约消课流水管扣没扣。如果合在一张表里签到、请假、取消这些状态变更会把表弄成一团乱麻。分开之后appointment记录约课状态consume_record只记录签到扣课时这一笔账两边用appointment_id关联职责清晰排查问题的时候也只要顺着一条链路查。3.2 表结构细节设计与建表SQL以最核心的appointment预约表为例我列出关键字段的设计思路CREATE TABLE appointment ( id bigint(20) NOT NULL AUTO_INCREMENT, appointment_no varchar(32) NOT NULL COMMENT 预约单号, member_id bigint(20) NOT NULL COMMENT 预约家长ID, child_id bigint(20) NOT NULL COMMENT 上课孩子ID, schedule_id bigint(20) NOT NULL COMMENT 课次ID, package_id bigint(20) DEFAULT NULL COMMENT 锁定课时包ID, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0已预约 1已签到 2已取消 3已请假, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, cancel_time datetime DEFAULT NULL, cancel_reason varchar(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_member_schedule (member_id,schedule_id), KEY idx_schedule_status (schedule_id,status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这个表设计有三个关键细节。第一个是appointment_no预约单号所有和前端交互的地方都用这个业务号不把自增id暴露给外部避免被遍历。第二个是唯一索引uk_member_schedule同一个家长对同一个课次只能有一条预约记录这个唯一索引是防止重复下单的兜底。第三个是status字段配合schedule_id的复合索引因为查询某课次下有哪些状态的人是所有后台列表页都要用的高频查询。课时包和消课流水这两张表同样有讲究。member_package里有total_count和remaining_count两个字段每次课时包发放初始余额写total_count每次消课更新remaining_count。注意这里不能用total_count减去一条聚合查询来算余额因为历史表数据可能被清理或调整余额必须作为冗余字段实时维护。consume_record里变更类型用一个正负号表示正的1代表发放课时包负的1代表消课扣减这样月底对账的时候只要sum一下流水就能和member_package里的remaining_count对上。3.3 排课容量与名额锁定的设计课程容量控制是这个系统最容易出现并发问题的地方。一个课次最多10个孩子但可能有30个家长同时抢。我设计了两个字段courseschedule 表里的 total_capacity 和 booked_count。每次预约成功booked_count 加1同时预约记录变成已预约状态取消预约或者请假booked_count 减1。这里必须配合事务和行锁来保证不超卖。我在service层做了一个update语句让数据库帮我们把关名额是否满员UPDATE course_schedule SET booked_count booked_count 1 WHERE id #{scheduleId} AND booked_count total_capacity这个语句返回受影响的行数如果返回1说明名额抢到了返回0说明已经满员。因为有行锁的存在并发请求到达数据库时会被串行化天然避免了超卖。这是我整个项目里最满意的一个设计没有用分布式锁也没有用Redis计数器单纯依靠SQL的原子性就把问题解决了。4. 后端核心接口设计从课程展示到签到消课的完整链路4.1 接口清单与角色权限小程序端的接口不能设计得太重每个接口只做一件事。我最终梳理出的核心接口一共19个第一优先级是下面这十个接口路径方法功能登录要求/api/course/listGET课程列表按门店筛选否/api/course/detailGET课程详情与最近排课否/api/schedule/listGET某课程的可约课次列表是/api/appointment/createPOST提交预约是/api/appointment/cancelPOST取消预约是/api/appointment/myGET我的预约列表是/api/sign/checkinPOST扫码签到并扣课时是/api/package/myGET我的课时包与余额是/api/consume/listGET课时消费流水是/api/member/updatePOST更新家长及孩子信息是接口设计上我遵循一个原则写接口返回前端的视图模型而不是直接返回数据库实体。比如课程详情的返回值需要包括课程名、封面图、年龄段文案、剩余名额、下一节课时间。这些信息散落在course、course_schedule和门店表里我在facade层组装好之后再吐给小程序。直接返回实体类虽然省事但前端拿到一堆用不上的字段后续改接口字段两边都要同步太狼狈了。4.2 预约接口的幂等与事务控制预约是整个系统里最容易出问题的接口因为一个动作同时涉及三张表的变更检查名额、写预约记录、加booked_count。任何一个环节失败都可能导致数据不一致。我把这一整个操作封装在一个事务方法里Transactional(rollbackFor Exception.class) public AppointmentResult createAppointment(AppointmentRequest request) { CourseSchedule schedule scheduleMapper.lockById(request.getScheduleId()); if (schedule.getBookedCount() schedule.getTotalCapacity()) { throw new BusinessException(该课次已约满); } MemberPackage memberPkg packageMapper.lockByMemberAndChild(request.getMemberId(), request.getChildId()); if (memberPkg.getRemainingCount() 0) { throw new BusinessException(课时余额不足); } if (appointmentMapper.selectUnique(request.getMemberId(), request.getScheduleId()) ! null) { throw new BusinessException(您已预约过该课次); } Appointment appointment new Appointment(); appointment.setMemberId(request.getMemberId()); appointment.setScheduleId(request.getScheduleId()); appointment.setStatus(0); appointmentMapper.insert(appointment); scheduleMapper.increaseBookedCount(request.getScheduleId()); return new AppointmentResult(appointment.getAppointmentNo()); }lockById和lockByMemberAndChild都是在查询语句后面加了FOR UPDATE这样同一个课次和同一个家长在多线程并发操作时后面的请求会阻塞等待前面的锁释放。事务的隔离级别用的是MySQL默认的RR级别在这个场景下不会出现幻读问题。这里有一个容易踩坑的细节事务内尽量不要捕获异常后吞掉。如果我在createAppointment里catch住BusinessException然后返回一个错误对象Spring的事务代理会失效前面的写操作不会回滚就会出现预约记录插进去了、booked_count也加了但用户收到的是预约失败的尴尬情况。正确做法是让异常抛出去统一由全局异常处理器处理。4.3 签到消课的正确打开方式签到是另一个核心接口它和预约不同预约不扣课时签到了才扣。正常流程是家长到店前台在小程序管理端点击签到或者家长自己扫码后端把appointment状态从已预约改成已签到同时扣减会员课时包的剩余课时写入consume_record消课流水。实现的要点在于这三步同样要放在一个事务里。很多人在这一步图省事直接在controller里先更新预约状态再调service扣课时结果更新预约成功了扣课时失败了孩子来上了一节课但账上没扣。这个问题的根源就是把跨表操作拆到了方法外面事务边界没兜住。Transactional(rollbackFor Exception.class) public void checkin(String appointmentNo) { Appointment appointment appointmentMapper.selectByNo(appointmentNo); if (appointment null) { throw new BusinessException(预约单不存在); } if (appointment.getStatus() ! 0) { throw new BusinessException(当前预约状态不可签到); } appointmentMapper.updateStatus(appointmentNo, 1); MemberPackage memberPkg packageMapper.lockByMemberAndChild(appointment.getMemberId(), appointment.getChildId()); memberPkg.setRemainingCount(memberPkg.getRemainingCount() - 1); packageMapper.updateRemaining(memberPkg); ConsumeRecord record new ConsumeRecord(); record.setAppointmentId(appointment.getId()); record.setPackageId(memberPkg.getId()); record.setChangeAmount(-1); consumeRecordMapper.insert(record); }签到接口还有两个业务细节需要和客户明确第一如果家长预约了但没来课时要不要扣。我们最后的规则是不扣但名额释放或者记录爽约这个由后台顾问手动操作。第二签到的操作人和时间必须记录。appointment表里我额外加了sign_user和sign_time字段方便后续出现投诉时回溯到底是谁签的、为什么签。5. 小程序端对接三天跑通核心流程5.1 登录与授权最容易被低估的环节小程序端的登录流程我单独拿出来说因为这里坑最多。很多新手以为小程序登录就是调用wx.login然后拿openid就行实际上微信在2021年之后收紧了用户信息授权不能再直接通过login接口拿到用户的手机号和个人信息。想要手机号必须用button组件的open-type\getPhoneNumber\让用户主动授权。我采用了两阶段方案。第一阶段用户进来只需要通过wx.login拿到code后端换openid建一个匿名账户这时候用户可以浏览课程。第二阶段用户想预约了就必须完善孩子信息和手机号这一步会弹窗授权拿到信息后和匿名账户合并。这个方案的好处是第一次打开小程序的流失率不会那么高毕竟看课不需要那么多隐私权限。小程序的请求封装也是一个细节活。除了基础的request下载拦截我还做了两件小事统一在header里附加X-Token以及统一处理401状态码跳转登录页。登录页和课程详情页的跳转去重非常容易忘记处理用户在弹窗授权时反复点击会触发多个wx.login并发请求最终导致token错乱。5.2 首页课程展示与课次卡片聚合课程列表页的接口如果直接按数据库的schedule记录逐条返回前端就疯了因为一个课程可能有未来两周的二十多个课次。我设计了两个粒度的接口课程维度只返回下一场可约课次作为展示信息课次维度返回完整的可约时间列表。前端首页只需要调一次列表接口就能渲染出课程卡片加最近可约时间。小程序端的渲染没有太多技术含量唯一要注意的是时间格式处理。后端的datetime返回到前端变成了UTC格式的字符串如果不做格式化界面直接显示一大串带T和Z的内容家长根本看不懂。我在后端Jackson配置里统一设置了日期格式同时指定了时区。这个坑非常典型我见过太多项目在联调阶段花两天调时区最终原因就是后端默认时区不是Asia/Shanghai。bean idcustomObjectMapper classorg.springframework.http.converter.json.Jackson2ObjectMapperFactoryBean property namesimpleDateFormat valueyyyy-MM-dd HH:mm/ property nametimeZone valueAsia/Shanghai/ /bean5.3 预约交互里的状态保护预约按钮的点击是一个高频操作用户可能因为网络慢或者手抖连续点击好几次。我除了在后端加唯一索引兜底之外前端也做了三层保护按钮点击后立刻置灰并显示loading用setTimeout限制5秒内不可重复点击以及预约成功后将课程卡片状态置为已约。这三层配合下来我在测试环境用移动网络模拟低网速连续点击十几下也只产生了一条预约记录。这里强烈建议在小程序端做预约结果页而不是toast提示。预约成功之后家长需要看到预约单号、上课时间、门店地址、剩余课时这些信息放在一个成功页里比toast弹一下有价值得多。而且成功页可以顺便放一个加入日历的按钮调用wx.addPhoneCalendar直接帮家长把上课时间加到手机日历这个功能虽然只有一行代码但家长对这种贴心体验的感知非常强。6. 上线前的高频问题与排查实录6.1 问题速查表开发过程中积累了不少问题我把最高频的几个整理成了排查速查表后续维护的同学照着这个表能解决掉80%的线上问题问题现象根本原因解决方案课程列表接口返回特别慢排课查询没有按schedule_time建索引加复合索引(store_id, course_id, schedule_time)用户重复提交生成两条预约前端未做防抖后端无唯一约束前端按钮置灰后端加唯一索引签到成功但课时没扣扣课时方法在事务外被调用把状态更新和扣课时放进同一事务方法小程序请求报跨域未配置CORS放行特定域名在SpringMVC里配置跨域过滤器课时余额偶尔变负数并发签到同一课时包扣减时加FOR UPDATE行锁后台修改排课时间前端不生效小程序端缓存了旧数据在课次数据变更时主动清理缓存家长取消预约后名额未恢复取消和释放名额不在同一事务恢复booked_count与更新状态一起提交图片上传成功但无法访问Tomcat静态资源路径未映射配置servlet的静态资源映射到上传目录6.2 一次并发抢课的复盘上线第二天客户做了一场朋友圈推广同时有接近200个家长涌入小程序抢一个只有15个名额的体验课。课程列表接口的QPS瞬间到了40多数据库连接数飙到上百。我在监控面板上看到预约接口的TPS并不高但数据库的活跃连接数一直在涨排查下来发现是两条慢SQL拖垮了连接池一条是查询预约记录时的索引失效另一条是查课时包余额时没走主键索引。预约记录那条SQL我写了一个member_id和status的复合索引但排查执行计划发现MySQL仍然走了全表扫描。原因是查询条件里用了OR比如status0 OR status1导致索引条件下推失败。改掉这个SQL逻辑之后数据库活跃连接数立刻降下来了。这个案例给我的教训是上线前一定要拿预估的最大并发量做压测而不是在测试环境随便点点就以为没问题。服务能跑起来和扛得住流量是两回事。6.3 从能跑到好用的优化方向第一个版本上线稳定运行两周之后我开始梳理第二阶段的优化清单核心方向有三个。第一个是Redis缓存课次列表。目前课程列表接口每次都实时查MySQL虽然加了索引之后快了不少但这类读多写少的接口天然适合加缓存。缓存更新策略用的是更新排课时主动删除缓存下次请求回源并重建。第二个是后台管理端的排课冲突检测。目前排课只做到了同一教室同一时间段不能重复排但还没有做到授课老师在两个教室同时段撞课。第三个是报表模块。客户月底需要一个课时核销汇总表机构运营也要看每个课程的上座率这部分在第二阶段会开放给客户的管理后台。优化方向里最需要注意的是不要为了技术炫技引入多余的组件。我当时也考虑过引入消息队列做预约流量削峰但和客户确认后预约高峰其实只有每次活动推广的几分钟数据库加上连接池控制完全扛得住。消息队列带来的运维成本对客户来说是一种负担不划算的架构调整宁可砍掉。7. 系列第一篇的收尾与新坑预告第一篇笔记写到这里项目已经从零到一跑通了核心链路家长看课、预约、签到、消课对账。我做这个项目最大的体会是技术难点其实不在SpringMVC或者小程序本身而在于你对业务链条的理解有多深。把约课和消课的账理清楚让每一项数据的变动都有据可查系统的基本盘就稳了。最后分享一个踩过两次坑才记住的经验开发阶段的测试数据一定要模拟真实业务不要只是各建几条订单就算完。我在测试环境里准备了一个月底对账脚本模拟某个月有100个会员、600次上课、40次请假、20次退费的情况每次改完数据库表结构就全量跑一遍这套数据确认所有余额和流水对得上再上线。这个习惯帮我挡掉了很多线上的低级错误。系列第二篇我会重点写后台管理端的排课和报表开发以及微信支付对接和退款流程的踩坑记录。如果你也正在做类似的预约类小程序欢迎在评论区聊聊你踩过的坑也许下一篇文章就会把你的问题拆开来一起复盘。