资讯详情 SpringMVC构建早教小程序后端:从工程搭建到登录鉴权实战
📅 2026/10/24 7:48:21
做过几年Java后端一直想把手头一个真实项目踩过的坑系统性地记录下来。今年以来帮一家连锁早教机构做小程序后端基于SpringMVC整了一套接口服务从环境搭建到上线维护中间绕了不少弯路。这个系列我打算分几篇写第一篇先把项目背景、技术选型、工程搭建、数据库设计和登录鉴权讲清楚。后面再逐步展开课程预约、课时消扣、签到打卡这些核心业务。早教机构和普通电商小程序差别挺大因为它面向的不是单一用户而是家长孩子双重角色业务上有排课、约课、消课、请假、续费这些特殊流程。做之前如果对这些场景没概念数据库设计和接口划分很容易跑偏。这篇文章完全按照我实际开发时的顺序来写所有配置和代码都是项目里跑过的版本不是网上拼凑的demo。1. 需求拆解与应用场景分析1.1 早教机构的业务特点早教机构这个行业有个很典型的特点用户与客户分离。来上课的是孩子掏钱做决策的是家长。这直接决定了系统在设计时必须区分家长端和管理端两套视角同一份数据要能支撑两个角色的不同诉求。从我接触的这家机构来看核心业务可以拆成这么几块课程管理早教课程按年龄段划分比如0-1岁、1-2岁、2-3岁、3-6岁每个年龄段对应的课程内容、课时价格都不一样。课程还有独立的品牌名称用于市场招生。班级与排课同一个课程会开多个班每个班有固定人数上限、固定的上课时段。排课涉及每周几、几点开始、持续多久、哪个教室。课时包与消课家长报名时购买的是一定期数的课时包比如48课时包。孩子每上一次课扣掉一个课时。这是早教行业最核心的计费模型和传统电商完全不同。预约与签到选定班级后每次上课前家长需要在小程序里预约确认到店后由老师扫码或手动签到。请假需要提前申请爽约的课时通常会按规则扣除。家长信息管理一个家长账户下可能关联多个孩子一个孩子可能同时报名多个班级。这个一对多关系在数据库设计时就要考虑清楚。提示做早教机构小程序别直接套用电商项目的思路。电商是人找货早教是人选课、课约人。业务主线条是家长→孩子→报名班级→约课→消课整个表结构设计都围绕这条主线来。1.2 小程序需要承载的核心能力和机构内部使用的管理后台不同家长端小程序不需要、也不应该覆盖全业务流程。我当时梳理需求时和机构负责人反复确认了四五轮最终圈定的核心范围只有四块机构展示首页展示机构介绍、师资团队、校区环境、课程列表相当于一个轻量官网。这是吸引新客的入口。选课与报名家长浏览课程查看班级剩余名额在线报名并支付。支付这里我建议直接走微信支付不要再自建支付体系。约课与请假已报名家庭可以预约具体上课日期临时有事需要请假系统自动把名额释放回班级。课时与成长记录家长随时查看剩余课时、消课明细、孩子每节课的课堂反馈和照片。这套范围定下来之后后端接口的边界就非常清晰了。每个功能模块对应一组REST接口不会出现接口越写越多、控制器越来越乱的问题。2. 技术栈确认为什么在这个阶段仍然选择SpringMVC2.1 技术选型的真实考量现在新项目普遍都是Spring Boot起步我这个项目却用了SpringMVC评论区肯定有人问。理由其实很现实团队现有的后管理端系统就是基于SpringMVC的核心代码运行稳定线上的机构管理端、财务系统都在上面跑。如果小程序单独拉一套Spring Boot技术栈意味着团队要同时维护两套体系运维成本翻倍。这里有一个关键判断小程序后端本质上就是一个JSON接口服务只要能满足请求路由、参数绑定、数据校验、事务管理这些基础能力用SpringMVC完全做得了。Spring Boot固然有自动配置、内嵌容器这些便利但在一个已经有成熟SSM架构的团队里增量项目的技术选型首先要考虑存量系统能不能统一维护而不是追新。SpringMVC处理这种需求绰绰有余。它的核心DispatcherServlet负责统一分发请求RequestMapping做路由映射ResponseBody自动处理JSON序列化配合Jackson就能覆盖小程序接口的所有使用场景。小程序端是纯前后端分离的根本不关心你后端是SpringMVC还是Spring Boot只要接口契约稳定就行。2.2 前后端分离的整体架构整个系统的分工是这样的小程序端微信小程序作为客户端负责页面渲染和交互通过wx.request发起网络请求请求地址指向后端接口域名。后端服务SpringMVC架构的三层结构——Controller层接收参数Service层处理业务逻辑Mapper层通过MyBatis完成数据持久化。数据存储MySQL作为主库Redis缓存热点数据。前后端交互统一使用JSON格式消息结构固定为代码(code)、消息(msg)、数据(data)三段式。小程序端拿到的永远是这套结构不管成功还是失败逻辑层解析起来非常省心。从部署架构看后端打成War包部署在Tomcat中通过域名暴露HTTPS接口。小程序平台强制要求请求地址必须是HTTPS且域名已备案这一点做之前一定要确认好不然后端写完了小程序连不上白白浪费时间。3. 开发环境与工程骨架搭建3.1 环境版本说明这一步我踩过一个版本相关的坑所以先把版本摆出来讲清楚。项目用的是经典SSM组合经过生产环境验证过的版本搭配如下组件版本说明JDK1.8Spring 4.x对JDK8支持最完善Maven3.6统一依赖管理Tomcat8.5Servlet 3.1规范配合Spring 4.x较稳定Spring4.3.18.RELEASE经典稳定版MyBatis3.4.6配合mybatis-spring 1.3.2MySQL5.7业务数据存储Redis3.x存储登录Token后续业务缓存这套组合是团队踩了一年多坑之后的最优解。Spring 4.3.18和JDK8、Tomcat8.5的兼容性非常好基本不会出现类库加载冲突的问题。如果你本地装的是新版Tomcat比如Tomcat 10以上里面的javax.servlet要迁移到jakarta.servletSpring 4.x跑不了这个要特别注意。3.2 创建Maven Web工程工程创建不啰嗦直接用IDEA新建Maven项目打包方式选war。关键是把pom.xml里几个核心依赖配正确这里给出精简后的配置properties spring.version4.3.18.RELEASE/spring.version mybatis.version3.4.6/mybatis.version /properties dependencies !-- Spring核心 -- dependency groupIdorg.springframework/groupId artifactIdspring-context/artifactId version${spring.version}/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version${spring.version}/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-jdbc/artifactId version${spring.version}/version /dependency !-- MyBatis -- dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version${mybatis.version}/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version1.3.2/version /dependency !-- MySQL驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version5.1.47/version /dependency !-- JSON处理 -- dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.9.10/version /dependency !-- 其他 -- dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version3.1.0/version scopeprovided/scope /dependency /dependencies注意MySQL驱动版本要和数据库版本匹配。MySQL 5.7用5.1.47没问题如果你用的是MySQL 8.x需要换成8.x版本的驱动否则会报通讯链路异常。工程目录结构上我习惯按技术分层分包不按业务模块分包。早期项目可以这样一旦业务复杂起来这种结构会暴露出类文件分散的问题但初期开发效率确实高com.example.earlyedu ├── controller # 接口控制器 ├── service # 业务逻辑层 │ └── impl ├── mapper # MyBatis Mapper接口 ├── entity # 数据库实体 ├── dto # 接口传输对象 ├── common # 统一返回、常量、异常处理 └── config # 配置类3.3 Spring与SpringMVC核心配置SSM组合最让人头疼的就是配置文件。三个配置文件各管一摊职责必须分清楚混在一起后面排查问题会痛不欲生。applicationContext.xmlSpring容器总配置管理数据源、事务、MyBatis。核心内容是把Mapper扫描和数据库连接池放进来。!-- 数据库配置加载 -- context:property-placeholder locationclasspath:jdbc.properties/ !-- 数据源用的Druid连接池 -- bean iddataSource classcom.alibaba.druid.pool.DruidDataSource property namedriverClassName value${jdbc.driver}/ property nameurl value${jdbc.url}/ property nameusername value${jdbc.username}/ property namepassword value${jdbc.password}/ property nameinitialSize value5/ property namemaxActive value20/ /bean !-- MyBatis配置 -- bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ !-- XML映射文件位置 -- property namemapperLocations valueclasspath:mapper/*.xml/ !-- 实体类别名包 -- property nametypeAliasesPackage valuecom.example.earlyedu.entity/ /bean !-- 扫描Mapper接口 -- bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.example.earlyedu.mapper/ /bean !-- 事务管理 -- bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean tx:annotation-driven transaction-managertransactionManager/spring-mvc.xml只管Controller层和请求分发相关的东西。!-- Controller所在包 -- context:component-scan base-packagecom.example.earlyedu.controller/ !-- 注解驱动支持JSON转换 -- mvc:annotation-driven/ !-- 静态资源放行 -- mvc:default-servlet-handler/web.xml负责装配上面两个配置文件注册DispatcherServlet。这里有个细节SpringMVC的ContextLoaderListener和DispatcherServlet加载配置文件的路径要对应好少配一个都会出现Bean找不到的报错。context-param param-namecontextConfigLocation/param-name param-valueclasspath:applicationContext.xml/param-value /context-param listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener servlet servlet-namespringMVC/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namespringMVC/servlet-name url-pattern//url-pattern /servlet-mapping这三份配置配合好工程的基础骨架就立住了。接下来就是数据库设计这一步直接决定了后面接口写起来顺不顺畅。4. 数据库模型设计早教业务的表结构4.1 核心数据关系梳理数据库设计这一步我在项目初期就犯了想当然的毛病。一开始按普通培训机构的模式设计表结构机构、课程、学生各一张表搞定。等真正开始做排课和消课时才发现根本没有考虑家长与孩子分离和课时包消课这两个核心场景。后来推翻重来最终整理的实体关系是这样的家长表(parent)一个家长账户存微信号OpenId、手机号、姓名。家长可以关联多个孩子。孩子表(child)孩子的姓名、性别、出生日期、年龄分组。一个孩子可以被多个家长关联爸爸、妈妈也可以报名多个班级。课程表(course)课程名称、课程封面、简介、适用年龄段、总课时数、价格区间。班级表(course_class)课程下的具体班级关联课程ID、上课时段、教室、人数上限、当前人数。课时包表(course_package)家长给孩子报名后生成的课时账户关联孩子ID、购买课时数、剩余课时数。预约记录表(appointment)每次上课预约关联孩子ID、班级ID、上课日期、状态。消课记录表(class_record)每次实际消课明细关联预约记录、扣了几课时、上课老师。表与表之间的关系搞清楚了接口逻辑就顺了。我见过不少半路接手项目的同事第一件事就是画ER图画不明白后面必然反复返工。4.2 建表SQL与字段设计要点核心几张表的建表SQL写出来字段注释我尽量写清楚后面维护的人会感谢你。下面是家长表、孩子表和班级表的关键设计-- 家长表 CREATE TABLE t_parent ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键, openid varchar(64) NOT NULL COMMENT 微信OpenId, nickname varchar(64) DEFAULT NULL COMMENT 家长昵称, real_name varchar(32) DEFAULT NULL COMMENT 家长姓名, mobile varchar(20) DEFAULT NULL COMMENT 手机号, create_time datetime DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_openid (openid) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT家长(用户)表; -- 孩子表 CREATE TABLE t_child ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键, parent_id bigint(20) NOT NULL COMMENT 家长ID, name varchar(32) NOT NULL COMMENT 孩子姓名, gender tinyint(1) DEFAULT NULL COMMENT 性别 1男 2女, birthday date DEFAULT NULL COMMENT 出生日期, age_group varchar(16) DEFAULT NULL COMMENT 年龄段标记 0-1,1-2,2-3,3-6, avatar varchar(255) DEFAULT NULL COMMENT 头像, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_parent (parent_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT孩子表; -- 班级表 CREATE TABLE t_course_class ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键, course_id bigint(20) NOT NULL COMMENT 课程ID, class_name varchar(64) NOT NULL COMMENT 班级名称, teacher_name varchar(32) DEFAULT NULL COMMENT 上课老师, week_day tinyint(1) NOT NULL COMMENT 星期几 1-7, start_time varchar(8) NOT NULL COMMENT 开始时间 HH:mm, end_time varchar(8) NOT NULL COMMENT 结束时间 HH:mm, classroom varchar(32) DEFAULT NULL COMMENT 教室, max_student int(11) NOT NULL DEFAULT 10 COMMENT 人数上限, current_student int(11) NOT NULL DEFAULT 0 COMMENT 已报名人数, status tinyint(1) NOT NULL DEFAULT 1 COMMENT 状态 1启用 0停用, PRIMARY KEY (id), KEY idx_course (course_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT班级表;字段设计的细节上有几个容易忽略的经验时间字段的类型上课时间不用datetime存就用varchar存HH:mm排序和比较都方便。日期用date类型。用datetime存纯时间查询时还得函数处理纯属给自己找麻烦。金额字段用decimal别用float/double涉及钱的都有精度问题。软删除业务表尽量加deleted字段做逻辑删除不要物理删数据。课时、消课这些记录后面随时要核对物理删除容易吃大亏。冗余字段该留就留班级表里的course_name、teacher_name我都冗余了一份。这是为了小程序列表页少做几次关联查询。电商大厂都靠冗余换性能咱们小项目更不用纠结规范化理论。写Mapper查询的时候用Select注解还是XML文件我的习惯是简单的单表查询用注解直接写在接口上多表关联、动态条件多的查询放XML。SpringMVC项目里Mapper接口和XML在同一包路径下mybatis-spring会自动识别不需要额外配置mapper-locations。5. 小程序登录与用户体系实现5.1 登录流程拆解小程序的登录和传统Web登录完全是两个套路。传统网站输用户名密码小程序不需要。微信已经帮我们解决了身份认证的问题整个登录流程可以抽象成三步小程序端调用wx.login()拿到临时code。把这个code通过后端接口传到服务器后端拿code appid secret 去微信接口服务换取openid和session_key。后端用openid去查家长表如果没注册过就自动创建新用户然后签发一个自定义的Token返回给小程序。这个过程中的几个关键点我要详细说一下code换取openid。这个请求必须由后端发出绝不能在小程序端完成。因为Secret是敏感信息一旦泄露到前端代码里等于把后门打开给别人了。微信接口地址如下https://api.weixin.qq.com/sns/jscode2session?appidAPPIDsecretSECRETjs_codeCODEgrant_typeauthorization_code后端拿到返回的JSON里面有openid、session_key。注意openid是每个用户在每个小程序下的唯一标识同一个用户在不同小程序下的openid不一样。所以表设计时用openid做唯一索引是完全没问题的。为什么还要自建Token。小程序官方其实提供了token管理的能力但直接用官方能力在业务上会遇到问题官方token的有效期管理比较绕而且它不关联你自己的业务用户ID。我自建的方案是登录成功后生成UUID作为token以token:用户ID的形式存进Redis设置7天过期。小程序端每次请求把token放到Header里后端通过拦截器统一校验。Token存Redis的好处是后台可以随时踢人下线用户改密码后立刻失效这些用纯JWT是做不到的。JWT是无状态的发出去了就没法主动吊销。小程序这种低频高安全性的场景Redis Token更稳妥。5.2 统一返回结构与Token签发无论登录成功还是业务出错后端返回给小程序的数据结构必须统一。我定义的Result类public class ResultT implements Serializable { private Integer code; // 状态码 0成功非0为业务错误码 private String msg; // 提示信息 private T data; // 数据体 public static T ResultT success(T data) { ResultT result new Result(); result.code 0; result.msg ok; result.data data; return result; } public static T ResultT error(Integer code, String msg) { ResultT result new Result(); result.code code; result.msg msg; return result; } }登录接口的实现Controller层代码如下RestController RequestMapping(/api/user) public class UserController { Autowired private UserService userService; PostMapping(/login) public ResultLoginVO login(RequestBody LoginRequest request) { // 调用Service完成微信登录返回Token和用户信息 LoginVO loginVO userService.wxLogin(request.getCode()); return Result.success(loginVO); } }Service层是整个登录逻辑的关键做了三件事调微信接口换取openid。查询家长表不存在就注册新用户。生成Redis Token绑定用户ID。public LoginVO wxLogin(String code) { // 1. 调微信接口 WxSessionResult wxResult wxClient.code2Session(code); if (wxResult.getOpenid() null) { throw new BizException(微信登录失败请重试); } String openid wxResult.getOpenid(); // 2. 查询或创建用户 Parent parent parentMapper.selectByOpenid(openid); if (parent null) { parent new Parent(); parent.setOpenid(openid); parentMapper.insert(parent); } // 3. 签发Token有效期7天 String token UUID.randomUUID().toString().replace(-, ); redisTemplate.opsForValue().set(login:token: token, String.valueOf(parent.getId()), 7, TimeUnit.DAYS); LoginVO vo new LoginVO(); vo.setToken(token); vo.setHasChild(childMapper.countByParentId(parent.getId()) 0); return vo; }RestController是Controller和ResponseBody的组合注解。Spring 4.x开始支持Controller类上直接加它所有方法默认返回JSON省得每个方法都写ResponseBody。5.3 第一个完整业务接口课程列表登录搞定之后先拿一个最简单的课程列表接口把SpringMVC处理请求的完整链路走通。小程序首页展示课程列表按年龄段筛选接口设计如下GetMapping(/course/list) public ResultListCourseVO list(RequestParam(required false) String ageGroup) { // Service层调用Mapper查询课程组装成VO返回 ListCourseVO list courseService.getCourseList(ageGroup); return Result.success(list); }Service实现里做两件小事查课程表数据把数据库字段转成前端需要的字段名。这一步我单独定义了CourseVO有人会觉得字段能对上直接用不就行了何必多套一层。但前端需要的字段和后端表字段其实经常不一样比如数据库中is_hot前端希望看到isHot与其让前端迁就不如在Service层做一层转换接口契约由后端直接控制。public ListCourseVO getCourseList(String ageGroup) { ListCourse courseList courseMapper.selectByAgeGroup(ageGroup); return courseList.stream().map(course - { CourseVO vo new CourseVO(); vo.setCourseId(course.getId()); vo.setCourseName(course.getCourseName()); vo.setCoverUrl(course.getCoverUrl()); vo.setAgeGroup(course.getAgeGroup()); vo.setTotalHours(course.getTotalHours()); vo.setPriceRange(course.getPriceRange()); return vo; }).collect(Collectors.toList()); }课程列表用的是GET请求参数通过RequestParam接收SpringMVC自动绑定。小程序端用wx.request发请求时把token放在Header里后端用拦截器统一校验。这里要注意课程列表这种信息查询的接口小程序端也需要登录态才能访问因为首页要展示剩余课时这样的个性化信息。到现在为止工程能跑通小程序发起请求→SpringMVC接收→Service处理业务→Mapper操作数据库→返回JSON这条完整链路了。第一版的基础功能已经立起来下一篇可以开始深入课程预约和消课这个核心业务了。先把当前这一步遇到的问题做个汇总。6. 常见问题与调试实录6.1 小程序请求失败的三大主因小程序和后端联调的时候遇到最多的问题就是请求发不出去或者发出了没响应。我总结下来无外乎三个原因域名白名单没配。小程序后台管理页要对request、uploadFile合法域名做配置。开发时可以勾选不校验合法域名真机预览必须用HTTPS正式域名。别看完文档就以为环境配好了实际操作中漏掉的十有八九是这一步。HTTPS证书问题。微信小程序强制要求HTTPS协议而且对证书要求比较严格。国内云服务商默认提供的免费证书基本都能过自己用openssl签的证书大概率不行。接口返回数据格式不对。小程序端对Content-Type很敏感后端接口返回的必须是application/json。如果返回text/html小程序端解析直接报错。检查方法很简单浏览器里直接访问接口看响应头。6.2 数据访问层的几个坑数据库连接乱码。MySQL连接串上必须加上characterEncodingutf8和useSSLfalse不然后端存中文数据乱码排查起来极度浪费时间。jdbc.urljdbc:mysql://127.0.0.1:3306/earlyedu?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiMyBatis驼峰映射没开。数据库字段是下划线风格create_timeJava实体是驼峰风格createTime。如果不开启驼峰映射查询结果里这些字段全是null还排查不出原因。在MyBatis全局配置里加上configuration settings !-- 开启驼峰命名自动映射 -- setting namemapUnderscoreToCamelCase valuetrue/ /settings /configurationLocalDateTime无法序列化。Spring 4.x默认的Jackson不支持Java 8时间类型解决方法是注册一个JavaTimeModule。这个坑很隐蔽接口单独访问返回报错但日志里看不出所以然。6.3 关键经验与项目结构维护建议做到这一步系列第一篇的核心内容已经完成。项目从零到工程启动、从空库到业务表、从没有用户到登录体系跑通这是整个项目里最花时间的一段路也是后续一切开发的地基。几个经验再沉淀一遍写接口文档比写代码重要。前后端分离的项目里接口契约必须在一开始就用文档固定下来。不要指望联调时靠沟通解决问题把每个接口的路径、请求参数、返回结构先对清楚能让联调时间缩短一半以上。控制器要保持薄。我在项目里坚持Controller只做参数接收和结果封装业务逻辑全部下沉到ServiceService之间可以互相调用。这样代码逻辑保留在业务层后期加缓存、加分布式锁都有清晰的位置。日志打点从第一天做起。每个接口的入口和出口都打日志包含业务ID和耗时。这套习惯在项目初期看着多余等上线遇到问题需要排查时你会庆幸当初多写了这几十行代码。第一篇就到这里。项目工程搭建和基础链路已经立起来了数据库表结构也跑通了登录鉴权可以正常工作。下一篇我会写课程预约和消课这个核心业务模块包括排课的数据结构、课时扣除的事务一致性处理以及家长请假流程怎么做才不产生数据混乱。这一块是我这次项目里真正烧脑的部分也是早教机构小程序区别于普通业务系统的关键所在。