新冠肺炎服务预约类小程序在2020年之后集中出现了一波开发需求当时我正好在做一个社区健康服务平台的二次开发前后经手了三个类似的项目其中一个就是基于SSM框架加微信小程序的预约系统。这类项目的技术栈选择其实很集中——后端用SSMSpring SpringMVC MyBatis前端用微信小程序原生开发数据库用MySQL部署在Tomcat上。看起来是典型的课程设计配置但真正做下来会发现预约类业务里有几个坑是绕不开的时间槽位的并发控制、用户身份的绑定逻辑、以及后台管理端的数据导出。这篇文章我把整个项目的设计思路、核心代码实现和踩过的坑完整梳理一遍适合正在做类似课程设计或者需要快速搭建一个预约系统的开发者参考。文中涉及的技术选型和实现方案都来自实际项目源码结构也一并说明。1. 项目整体设计与技术选型拆解1.1 为什么是SSM而不是SpringBoot这个项目标题里明确写了SSM我一开始其实想用SpringBoot毕竟现在新项目基本都是SpringBoot起步。但后来改回SSM的原因有几个第一课程设计或毕设场景下很多学校的指导老师对SSM的接受度更高答辩时问的也是SpringMVC的请求流程、MyBatis的映射原理这些内容第二SSM的分层结构更“显式”对于刚接触Java Web的学生来说理解Controller→Service→Dao这条链路比SpringBoot的自动配置要直观得多第三项目本身业务不复杂不需要SpringBoot的自动装配和起步依赖带来的便利。具体版本选择上我用的组合是组件版本说明Spring5.2.x核心IOC和AOPSpringMVC5.2.xWeb层请求处理MyBatis3.5.x数据持久层MySQL5.7/8.0数据库Druid1.1.x连接池Tomcat8.5/9.0Web容器微信开发者工具稳定版小程序端开发调试这个组合在2020-2023年间的课程设计里非常常见稳定性没问题。需要注意的是Spring 5.x对JDK版本的要求是JDK8以上如果学校机房还在用JDK7需要降级到Spring 4.x。1.2 小程序端的技术决策微信小程序端我没有用uni-app或者Taro这类跨端框架而是选了原生开发。原因很直接项目只需要跑在微信端跨端框架带来的额外抽象层反而会增加调试成本。而且预约类小程序的交互不复杂原生的小程序框架完全够用。小程序端的页面结构大致如下首页展示预约项目列表核酸检测、健康咨询、疫苗接种预约等预约页选择日期、时间段、填写个人信息我的预约查看预约记录和状态个人中心登录授权、信息编辑后台管理Web端预约数据查看、导出、时段管理这里有个设计上的取舍——后台管理端我没有做成小程序页面而是单独用JSP/HTMLSSM做了个Web管理端。原因是后台涉及表格展示、数据导出这些操作在小程序里做体验很差而且管理员通常用电脑操作。1.3 数据库设计的核心思路数据库表的设计是整个项目的地基我在设计时主要考虑了三个维度用户信息、预约资源、预约记录。核心表结构-- 用户表 CREATE TABLE user ( id int(11) NOT NULL AUTO_INCREMENT, openid varchar(64) DEFAULT NULL COMMENT 微信openid, nickname varchar(64) DEFAULT NULL, phone varchar(20) DEFAULT NULL, id_card varchar(18) DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY idx_openid (openid) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 预约时段表 CREATE TABLE time_slot ( id int(11) NOT NULL AUTO_INCREMENT, date date NOT NULL, start_time time NOT NULL, end_time time NOT NULL, capacity int(11) DEFAULT 0 COMMENT 容量, booked int(11) DEFAULT 0 COMMENT 已预约数, PRIMARY KEY (id), KEY idx_date (date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 预约记录表 CREATE TABLE appointment ( id int(11) NOT NULL AUTO_INCREMENT, user_id int(11) NOT NULL, slot_id int(11) NOT NULL, status tinyint(4) DEFAULT 0 COMMENT 0待确认 1已确认 2已取消 3已完成, remark varchar(255) DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user (user_id), KEY idx_slot (slot_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有几个设计细节值得展开说。time_slot表里我用了capacity和booked两个字段来管理容量而不是每次查询时去appointment表里count。这样做的原因是预约高峰时段count操作会很频繁直接读字段性能更好。但代价是需要保证booked字段的准确性后面会讲怎么处理并发问题。另外user表的openid字段加了唯一索引这是必须的。微信授权登录时同一个用户可能多次点击授权如果没有唯一约束会插入多条重复记录。2. 核心功能模块的细节实现2.1 微信授权登录的完整流程小程序端的登录流程是整个系统的入口也是比较容易出问题的地方。我采用的是wx.logincode换openid的标准方案没有用手机号快速验证那个需要企业认证且额外收费。流程是这样的小程序端调用wx.login()获取临时code将code发送到后端/api/login接口后端用codeappidsecret调用微信的code2Session接口拿到openid和session_key查询数据库如果openid存在则返回用户信息不存在则创建新用户后端生成一个自定义的token我用的是JWT返回给小程序后续请求携带这个token做身份校验后端核心代码RestController RequestMapping(/api) public class LoginController { Value(${wechat.appid}) private String appid; Value(${wechat.secret}) private String secret; Autowired private UserService userService; PostMapping(/login) public Result login(RequestBody LoginDTO dto) { String url https://api.weixin.qq.com/sns/jscode2session ?appid appid secret secret js_code dto.getCode() grant_typeauthorization_code; String response HttpUtil.get(url); JSONObject json JSON.parseObject(response); String openid json.getString(openid); if (openid null) { return Result.error(微信授权失败); } User user userService.findByOpenid(openid); if (user null) { user new User(); user.setOpenid(openid); user.setNickname(dto.getNickname()); userService.insert(user); } String token JwtUtil.generateToken(user.getId()); MapString, Object data new HashMap(); data.put(token, token); data.put(user, user); return Result.success(data); } }注意session_key不要返回给小程序端它应该保存在后端可以存Redis或者直接放在JWT的payload里加密。很多教程会把session_key一起返回这是有安全风险的。2.2 预约时段的管理与展示预约时段的管理分两部分后台管理员配置时段小程序端展示可预约时段。后台配置时段时我加了一个批量生成的逻辑。管理员选择日期范围、每天的时间段划分比如8:00-9:00、9:00-10:00...、每个时段的容量然后一键生成。这个功能在实际使用中非常省事不然一天一天手动添加会疯掉。PostMapping(/admin/slot/batch) public Result batchCreateSlot(RequestBody BatchSlotDTO dto) { ListTimeSlot slots new ArrayList(); LocalDate start dto.getStartDate(); LocalDate end dto.getEndDate(); for (LocalDate date start; !date.isAfter(end); date date.plusDays(1)) { for (String[] range : dto.getTimeRanges()) { TimeSlot slot new TimeSlot(); slot.setDate(Date.valueOf(date)); slot.setStartTime(Time.valueOf(range[0] :00)); slot.setEndTime(Time.valueOf(range[1] :00)); slot.setCapacity(dto.getCapacity()); slot.setBooked(0); slots.add(slot); } } timeSlotService.batchInsert(slots); return Result.success(生成成功共 slots.size() 个时段); }小程序端展示时我按日期分组用横向滚动的日期选择器加上下方的时段列表。这里有个细节只展示当前时间之后的时段。比如现在是下午3点那么今天上午的时段就不应该再显示了。这个过滤逻辑放在SQL里做select idselectAvailableSlots resultTypeTimeSlot SELECT * FROM time_slot WHERE date gt; CURDATE() AND CONCAT(date, , start_time) gt; NOW() AND booked lt; capacity ORDER BY date, start_time /select2.3 预约提交的并发控制这是整个项目里最需要认真处理的地方。预约提交时如果两个用户同时预约同一个时段的最后一个名额不做并发控制就会出现超卖。我一开始用的是最简单的方案先查询booked capacity然后插入预约记录再更新booked 1。这个方案在测试环境没问题但用JMeter模拟50个并发请求时出现了booked超过capacity的情况。后来改成了数据库层面的乐观锁update idincrementBooked UPDATE time_slot SET booked booked 1 WHERE id #{slotId} AND booked lt; capacity /update然后在Service层判断影响行数Transactional public Result makeAppointment(Integer userId, Integer slotId) { int affected timeSlotMapper.incrementBooked(slotId); if (affected 0) { return Result.error(该时段已约满); } Appointment appointment new Appointment(); appointment.setUserId(userId); appointment.setSlotId(slotId); appointment.setStatus(0); appointmentMapper.insert(appointment); return Result.success(预约成功); }这个方案的好处是利用了MySQL的行锁UPDATE语句本身是原子操作WHERE booked capacity保证了不会超卖。affected 0说明要么时段不存在要么已经约满。配合Transactional注解如果后续插入预约记录失败booked的更新也会回滚。实操心得这个方案在并发量不是特别大的场景下完全够用。如果并发量很大比如秒杀级别可以考虑用Redis做预扣减但预约类业务一般不需要。2.4 小程序端请求封装小程序端的网络请求我用了一个统一的封装主要是处理token携带、错误码统一处理和loading状态。// utils/request.js const BASE_URL https://your-domain.com/api; function request(options) { return new Promise((resolve, reject) { const token wx.getStorageSync(token); wx.request({ url: BASE_URL options.url, method: options.method || GET, data: options.data || {}, header: { Content-Type: application/json, Authorization: token ? Bearer token : }, success(res) { if (res.statusCode 200) { if (res.data.code 200) { resolve(res.data.data); } else if (res.data.code 401) { // token过期重新登录 wx.removeStorageSync(token); wx.navigateTo({ url: /pages/login/login }); reject(res.data); } else { wx.showToast({ title: res.data.msg, icon: none }); reject(res.data); } } else { wx.showToast({ title: 网络异常, icon: none }); reject(res); } }, fail(err) { wx.showToast({ title: 请求失败, icon: none }); reject(err); } }); }); } module.exports { request };这个封装用了Promise调用时可以用async/await代码清爽很多。注意Authorization头的格式后端JWT拦截器里要对应解析Bearer前缀。3. 完整实操流程与关键配置3.1 后端项目搭建步骤从零开始搭建这个SSM项目我按实际操作顺序列一下关键步骤。第一步创建Maven项目pom.xml里引入核心依赖dependencies dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version5.2.8.RELEASE/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.5/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.21/version /dependency dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.1.23/version /dependency dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper/artifactId version5.2.0/version /dependency /dependencies第二步配置applicationContext.xml主要是数据源、MyBatis的SqlSessionFactory和事务管理bean iddataSource classcom.alibaba.druid.pool.DruidDataSource property namedriverClassName valuecom.mysql.cj.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/covid_appointment?useUnicodetrueamp;characterEncodingutf8amp;serverTimezoneAsia/Shanghai/ property nameusername valueroot/ property namepassword valueyour_password/ property nameinitialSize value5/ property namemaxActive value20/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath:mapper/*.xml/ property nametypeAliasesPackage valuecom.example.entity/ /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.example.mapper/ /bean bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean tx:annotation-driven transaction-managertransactionManager/第三步配置spring-mvc.xml处理请求映射和静态资源context:component-scan base-packagecom.example.controller/ mvc:annotation-driven/ mvc:resources mapping/static/** location/static// bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/views// property namesuffix value.jsp/ /bean第四步配置web.xml注册DispatcherServlet和字符编码过滤器servlet servlet-namedispatcher/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-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mapping filter filter-nameencodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param /filter这里有个容易踩的坑web.xml里配置了CharacterEncodingFilter之后POST请求的中文乱码问题解决了但GET请求的中文参数在某些Tomcat版本下仍然会乱码。解决办法是在server.xml的Connector标签里加上URIEncodingUTF-8。3.2 JWT拦截器的实现后端需要拦截需要登录的接口我用的是SpringMVC的拦截器public class JwtInterceptor implements HandlerInterceptor { Autowired private JwtUtil jwtUtil; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (request.getMethod().equals(OPTIONS)) { return true; } String authHeader request.getHeader(Authorization); if (authHeader null || !authHeader.startsWith(Bearer )) { response.setStatus(401); response.getWriter().write({\code\:401,\msg\:\未登录\}); return false; } String token authHeader.substring(7); try { Integer userId jwtUtil.getUserId(token); request.setAttribute(userId, userId); return true; } catch (Exception e) { response.setStatus(401); response.getWriter().write({\code\:401,\msg\:\登录已过期\}); return false; } } }在spring-mvc.xml里注册拦截器mvc:interceptors mvc:interceptor mvc:mapping path/api/**/ mvc:exclude-mapping path/api/login/ mvc:exclude-mapping path/api/slot/list/ bean classcom.example.interceptor.JwtInterceptor/ /mvc:interceptor /mvc:interceptorsexclude-mapping里的路径是不需要登录就能访问的比如登录接口和时段列表接口用户还没登录时也应该能看到有哪些时段可以预约。3.3 小程序端的页面跳转与数据传递小程序的页面跳转传参用的是URL query但有个限制传递对象时需要先JSON.stringify接收方再JSON.parse。预约页跳转// 从首页跳转到预约详情页 wx.navigateTo({ url: /pages/appointment/detail?slotId${slot.id}date${slot.date}time${slot.startTime}-${slot.endTime} });接收参数onLoad(options) { this.setData({ slotId: options.slotId, date: options.date, time: options.time }); }如果参数里包含特殊字符比如日期里的-建议用encodeURIComponent处理一下不然解析可能出问题。3.4 后台管理端的数据导出管理员需要把预约数据导出成Excel方便做统计和上报。我用的是Apache POI注意POI的版本和JDK版本的兼容性——POI 4.x需要JDK8以上POI 3.x对JDK6/7支持更好。GetMapping(/admin/export) public void export(HttpServletResponse response) throws IOException { ListAppointmentVO list appointmentService.listAll(); XSSFWorkbook workbook new XSSFWorkbook(); XSSFSheet sheet workbook.createSheet(预约记录); XSSFRow header sheet.createRow(0); String[] titles {预约编号, 姓名, 手机号, 预约日期, 时间段, 状态}; for (int i 0; i titles.length; i) { header.createCell(i).setCellValue(titles[i]); } int rowNum 1; for (AppointmentVO vo : list) { XSSFRow row sheet.createRow(rowNum); row.createCell(0).setCellValue(vo.getId()); row.createCell(1).setCellValue(vo.getNickname()); row.createCell(2).setCellValue(vo.getPhone()); row.createCell(3).setCellValue(vo.getDate().toString()); row.createCell(4).setCellValue(vo.getStartTime() - vo.getEndTime()); row.createCell(5).setCellValue(getStatusText(vo.getStatus())); } response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment;filenameappointments.xlsx); workbook.write(response.getOutputStream()); workbook.close(); }注意导出的文件名如果包含中文需要URL编码否则某些浏览器会乱码。可以用URLEncoder.encode(预约记录.xlsx, UTF-8)处理。4. 常见问题排查与避坑经验4.1 小程序端常见问题问题一wx.request请求返回404这个是最常见的问题排查顺序是先确认后端接口是否正常启动用Postman测一下然后检查小程序开发者工具里的“不校验合法域名”选项是否勾选。开发阶段这个选项必须勾上否则localhost的请求会被拦截。上线前需要在微信公众平台配置服务器域名。问题二真机调试时请求失败开发者工具里正常真机上报错。大概率是域名没有配置HTTPS。微信小程序要求所有请求域名必须走HTTPS且证书要有效。如果是测试阶段可以在开发者工具里设置“不校验合法域名”但真机上这个选项不生效。我当时的解决办法是给测试服务器配了一个免费的SSL证书。问题三openid获取失败code2Session接口返回errcode: 40029表示code无效。原因通常是小程序端调用wx.login()之后没有及时把code发给后端或者code被重复使用。code只能用一次且有效期5分钟。正确的做法是每次登录都重新调用wx.login()。错误码含义解决办法40029code无效重新调用wx.login获取新code45011频率限制每个用户每分钟100次降低频率40163code已被使用不要重复使用同一个code-1系统繁忙稍后重试4.2 后端常见问题问题一MyBatis映射失败报错Invalid bound statement (not found)排查顺序检查mapperLocations配置的路径是否匹配检查XML文件的namespace是否对应Mapper接口的全限定名检查方法名和XML里的id是否一致。这三个地方任何一个不对都会报这个错。问题二事务不生效Transactional注解不生效的常见原因有三种一是方法不是public的二是同类内部方法调用比如Service的A方法调用B方法B方法上有事务注解但不会生效三是没有配置tx:annotation-driven/。我遇到过第二种情况后来把B方法抽到另一个Service里解决。问题三日期类型转换异常前端传的日期是字符串2023-01-01后端实体类字段是java.util.DateSpringMVC默认转换会报错。解决办法是在实体类字段上加DateTimeFormat(pattern yyyy-MM-dd)注解。如果是JSON请求需要在RequestBody的DTO字段上加JsonFormat(pattern yyyy-MM-dd HH:mm:ss)。4.3 数据库层面的坑坑一时区问题MySQL 8.0的JDBC连接URL必须加serverTimezone参数否则会报The server time zone value 中国标准时间 is unrecognized。我一般统一用Asia/Shanghai。坑二中文乱码数据库、表、连接都要设置成utf8mb4。特别注意连接URL里的characterEncodingutf8要写另外MySQL的my.ini里character-set-serverutf8mb4也要确认。utf8mb4比utf8多支持emoji用户的昵称里如果有emoji用utf8会插入失败。坑三预约记录的时间显示不正确如果数据库存的datetime是UTC时间而用户看到的是北京时间会有8小时偏差。我统一在JDBC连接里指定serverTimezoneAsia/Shanghai同时在Java代码里用new Date()生成的时间也要确认JVM的时区设置。可以在启动参数里加-Duser.timezoneAsia/Shanghai。4.4 部署上线时的注意事项注意一Tomcat的context path本地开发时可能用的是/部署到服务器后如果加了context path比如/appointment小程序端的BASE_URL也要同步修改。我建议本地和线上保持一致直接用/省得改来改去。注意二静态资源缓存小程序的代码包有大小限制主包2M图片资源建议放CDN或者后端静态目录不要全部打包进小程序。注意三日志配置SSM项目默认用的是Log4j或者Logback上线前一定要把日志级别调整为INFO或WARNDEBUG级别的日志在线上会产生大量日志文件磁盘很快就满了。我在logback.xml里配置了按天滚动和最大保留天数appender nameFILE classch.qos.logback.core.rolling.RollingFileAppender rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePatternlogs/app.%d{yyyy-MM-dd}.log/fileNamePattern maxHistory30/maxHistory /rollingPolicy encoder pattern%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n/pattern /encoder /appender4.5 几个提升开发效率的小技巧技巧一用Postman维护接口集合后端接口写完后第一时间在Postman里维护好请求示例包括正确的请求参数和返回结果。这样前端联调时直接分享集合比口头描述或者写文档快得多。技巧二小程序开发者工具的“真机调试”有些问题只在真机上出现比如键盘弹起遮挡输入框、滚动穿透开发者工具的模拟器不一定能复现。真机调试功能可以在手机上实时看到console输出排查起来很方便。技巧三数据库备份脚本开发过程中数据经常被改乱建议写一个备份脚本每次修改表结构之前先备份mysqldump -uroot -p covid_appointment backup_$(date %Y%m%d_%H%M%S).sql这个习惯在后期改表结构时救过我好几次。技巧四接口返回统一格式后端所有接口的返回格式统一成{code, msg, data}前端只判断code 200。这样前端处理逻辑最简单也不容易出现某个接口返回格式不一致导致的解析错误。4.6 项目文档和源码的组织课程设计或毕设通常需要提交文档和源码。我的建议是源码目录结构保持清晰covid-appointment/ ├── src/main/java/ │ ├── com/example/controller/ │ ├── com/example/service/ │ ├── com/example/mapper/ │ ├── com/example/entity/ │ ├── com/example/dto/ │ ├── com/example/interceptor/ │ └── com/example/util/ ├── src/main/resources/ │ ├── mapper/ │ ├── applicationContext.xml │ ├── spring-mvc.xml │ └── logback.xml ├── src/main/webapp/ │ ├── WEB-INF/views/ │ └── static/ ├── miniprogram/ # 小程序端源码 │ ├── pages/ │ ├── utils/ │ └── app.js └── sql/ └── init.sql # 建表语句文档部分至少包含需求分析、系统设计含ER图和架构图、接口文档、测试用例、部署说明。接口文档可以直接从Postman导出不用手写。这套项目做下来整体代码量不算大后端核心代码大概3000行左右小程序端2000行左右。关键在于把预约的并发控制和身份认证这两块做扎实其他都是常规的增删改查。如果你正在做类似的项目建议先把数据库设计和接口定义确定下来再动手写代码不然后期改起来会非常痛苦。另外记得在开发阶段就把HTTPS和域名配置好不要等到最后才处理那个环节卡住的话很影响进度。