做早教机构的小程序,这个需求放在2024年来看,技术本身并不稀奇,真正容易被忽略的是业务链路。我接手这个项目的时候,客户是一家跨区域的早教连锁品牌,线下有十几家门店,每天排课和消课全靠前台在Excel里手工记账。家长约课靠微信群喊话,顾问排课靠脑子记,月底核销课时的时候经常对不上账。这种状态下,小程序要解决的根本不是"线上有个东西能用",而是把约课、排课、扣课时、签到这一整套流程从线下搬到线上,并且保证账目不出错。
这个系列笔记会完整记录我从0到100的开发过程,包括业务梳理、技术选型、数据库设计、接口开发、小程序端对接,以及上线后遇到的各种坑。第一篇先讲清楚整体思路和基建部分:为什么用SpringMVC,数据结构怎么设计,核心接口怎么做,以及小程序端怎么对接最省事。如果你手头也接了类似的机构预约类项目,或者想把一个传统线下业务做成小程序,这篇笔记应该能帮你少走不少弯路。
1. 早教机构需要什么小程序?先把业务逻辑捋清楚
1.1 从线下运营痛点反推需求清单
很多人接这类项目上来就打开设计工具画页面,然后写接口,这是最容易翻车的顺序。我在和客户聊完两个下午之后,发现早教机构的运营流程比想象中要复杂得多,核心痛点集中在四件事:
第一是约课混乱。家长在微信群喊"周六上午的创意美术还有位子吗",顾问得翻Excel表格、翻聊天记录,甚至靠脑子回忆。一旦两个顾问同时回消息,同一个名额可能被约出去两次,最后上课当天才发现有家长撞课。
第二是扣课时不透明。早教机构的收费模式是卖课时包,比如"96节课的亲子课包",每上一次课消一个课时。线下手工消课,记错、漏记、晚记的情况特别多,月底家长来查课时余额,前台永远在道歉。
第三是签到靠纸。很多机构上课前让家长签字,签了不代表孩子真的来了,也不代表扣了课时。纸质单据后面还要人工录入系统,一个月的单据积压下来,补录效率极低。
第四是家长没有自助渠道。家长想知道孩子还剩多少课时、下周有什么课,只能打电话问前台,前台忙起来电话根本接不完。
基于这四个痛点,我给客户列了一个需求优先级清单。第一阶段先做家长端约课和签到消课,这是核心闭环。第二阶段再做管理后台的排课和报表。第三阶段再考虑营销工具比如优惠券、转介绍裂变。千万不要一上来就什么都做,尤其是营销功能,会拖慢主链路的开发进度。客户最痛的是教材对不上账,先把课约清楚、把课时扣明白,信任感就建立起来了。
| 优先级 | 功能模块 | 解决痛点 | 复杂度 |
|---|---|---|---|
| P0 | 课程列表与详情 | 家长不知道有什么课 | 低 |
| P0 | 在线预约/取消预约 | 约课靠人工、易撞课 | 中 |
| P0 | 签到与自动扣课时 | 消课手工记账、对不上账 | 高 |
| P1 | 课时包与余额查询 | 家长查余课困难 | 中 |
| P1 | 机构通知与课表提醒 | 家长容易忘课 | 中 |
| P2 | 分享裂变与优惠券 | 获客难、老客带新客 | 高 |
1.2 确定"课"为核心主数据
需求分析阶段最大的收获,是明确了整个系统的核心主数据是"课",不是"商品"。早教机构和电商不一样,家长买的不是一件实物,而是一个个课时段。
一次完整的业务链路是这样走的:机构在后台创建课程,然后排课,生成一个带具体时间段、带容量上限的课次。家长在小程序看到课次,点击预约,系统检查剩余名额和家长课时余额,两者都满足就预约成功并锁定名额。上课当天,家长到店扫码签到,系统确认到场,这才扣除一个课时。如果家长没签到,课时保留,但名额已经占掉,所以最好还要有请假规则。
这一条链路上有两个关键点:预约成功不扣课时,签到才扣课时;取消预约要释放名额,但距离上课太近的取消要有惩罚规则或者禁止取消。这些业务规则直接决定后面数据库表和接口怎么设计,所以一定要在动手前和客户反复确认。
这里我特别提醒一点:早教行业存在"占位不扣费"模式,也存在"预约即扣费"模式。两种模式的接口逻辑完全不同。我们这次采用前者,因为家长对"约了没去还要扣课时"非常敏感,但这也带来了一个副作用:会有家长恶意占位不来上课,导致名额被浪费。所以在后面的表结构设计里,我加了一个课程状态流转字段,支持顾问后台手动取消和补位。
2. 技术选型:为什么还用SpringMVC?
2.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 class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter"> <property name="objectMapper" ref="customObjectMapper"/> </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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这个表设计有三个关键细节。第一个是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/list | GET | 课程列表(按门店筛选) | 否 |
| /api/course/detail | GET | 课程详情与最近排课 | 否 |
| /api/schedule/list | GET | 某课程的可约课次列表 | 是 |
| /api/appointment/create | POST | 提交预约 | 是 |
| /api/appointment/cancel | POST | 取消预约 | 是 |
| /api/appointment/my | GET | 我的预约列表 | 是 |
| /api/sign/checkin | POST | 扫码签到并扣课时 | 是 |
| /api/package/my | GET | 我的课时包与余额 | 是 |
| /api/consume/list | GET | 课时消费流水 | 是 |
| /api/member/update | POST | 更新家长及孩子信息 | 是 |
接口设计上我遵循一个原则:写接口返回前端的视图模型,而不是直接返回数据库实体。比如课程详情的返回值,需要包括课程名、封面图、年龄段文案、剩余名额、下一节课时间。这些信息散落在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 id="customObjectMapper" class="org.springframework.http.converter.json.Jackson2ObjectMapperFactoryBean"> <property name="simpleDateFormat" value="yyyy-MM-dd HH:mm"/> <property name="timeZone" value="Asia/Shanghai"/> </bean>5.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,比如status=0 OR status=1,导致索引条件下推失败。改掉这个SQL逻辑之后,数据库活跃连接数立刻降下来了。这个案例给我的教训是:上线前一定要拿预估的最大并发量做压测,而不是在测试环境随便点点就以为没问题。服务能跑起来和扛得住流量是两回事。
6.3 从"能跑"到"好用"的优化方向
第一个版本上线稳定运行两周之后,我开始梳理第二阶段的优化清单,核心方向有三个。第一个是Redis缓存课次列表。目前课程列表接口每次都实时查MySQL,虽然加了索引之后快了不少,但这类读多写少的接口天然适合加缓存。缓存更新策略用的是更新排课时主动删除缓存,下次请求回源并重建。第二个是后台管理端的排课冲突检测。目前排课只做到了同一教室同一时间段不能重复排,但还没有做到授课老师在两个教室同时段撞课。第三个是报表模块。客户月底需要一个课时核销汇总表,机构运营也要看每个课程的上座率,这部分在第二阶段会开放给客户的管理后台。
优化方向里最需要注意的是,不要为了技术炫技引入多余的组件。我当时也考虑过引入消息队列做预约流量削峰,但和客户确认后,预约高峰其实只有每次活动推广的几分钟,数据库加上连接池控制完全扛得住。消息队列带来的运维成本对客户来说是一种负担,不划算的架构调整宁可砍掉。
7. 系列第一篇的收尾与新坑预告
第一篇笔记写到这里,项目已经从零到一跑通了核心链路:家长看课、预约、签到、消课对账。我做这个项目最大的体会是,技术难点其实不在SpringMVC或者小程序本身,而在于你对业务链条的理解有多深。把约课和消课的账理清楚,让每一项数据的变动都有据可查,系统的基本盘就稳了。
最后分享一个踩过两次坑才记住的经验:开发阶段的测试数据一定要模拟真实业务,不要只是各建几条订单就算完。我在测试环境里准备了一个"月底对账"脚本,模拟某个月有100个会员、600次上课、40次请假、20次退费的情况,每次改完数据库表结构就全量跑一遍这套数据,确认所有余额和流水对得上再上线。这个习惯帮我挡掉了很多线上的低级错误。
系列第二篇我会重点写后台管理端的排课和报表开发,以及微信支付对接和退款流程的踩坑记录。如果你也正在做类似的预约类小程序,欢迎在评论区聊聊你踩过的坑,也许下一篇文章就会把你的问题拆开来一起复盘。