☰
基于微信小程序与SSM框架的会议发布预约系统设计与并发控制实践
2026/9/26 20:09:22 网站建设 项目流程

简介:这是一份面向毕业设计或课程设计的完整项目源码,基于微信小程序和SSM框架实现会议发布与预约系统,涵盖小程序用户端与后台管理端,适合Java学习者、小程序开发爱好者以及需要快速搭建会议管理系统的学生参考。压缩包共1139个文件,大小约14.9MB,主要包含Java后端源码(95个java)、Vue后台管理页面(121个vue)、微信小程序前端(wxml、wxss、js等)、SQL脚本及构建运行脚本,覆盖前后端与部署配置。系统实现了用户注册登录、会议信息浏览、热门推荐、预约申请、预约审批、提醒通知、预约统计等完整流程,前后端通过RESTful API交互,使用MyBatis持久化数据,功能模块划分清晰。当前已有89人学习下载;包内附有数据库初始化文件、启动脚本和静态资源,便于本地运行、二次开发或直接作为毕业设计展示,整体结构对二次开发友好。

1. 会议发布与预约系统+ssm框架:先想清楚这套系统在解决什么

“基于微信小程序的会议发布与预约系统的设计与开发”这个题目,往小了说,是给单位内部做一套会议室管理工具;往大了说,是把“会议资源”当作可发布、可查询、可抢占、可释放的数据来管。后端选 SSM(Spring + SpringMVC + MyBatis),前端用微信小程序,是这类课题里最经典、也最好落地的组合:用户不用装 App,组织者不用进 OA 系统,扫一眼小程序就能看到今天有哪些会议、还剩几个名额、自己约没约上。

这套系统适合两类人:一是正在做 Java Web 课程设计或毕业设计的同学,需要一个能讲清楚架构、能跑通演示的业务闭环;二是企业内部需要一套轻量会议工具,但又不想为一个小功能引入整套微服务中台。它真正的难点不在 CRUD,而在三个地方:并发预约时不超卖、会议状态和时间不乱、小程序弱网环境下不重复提交。下文会把这三条主线全部拆开,从后端骨架一直写到前端避坑。

2. SSM框架先立住:三个框架的分工与最小可运行骨架

2.1 为什么这个项目要选 SSM,而不是一上来就 Spring Boot

很多人问过我这个选择。SSM 确实是“老技术”,但到现在依然是教学体系里最常见的组合,原因很直接:它把框架之间的边界暴露得很清楚,适合理解 Java Web 的传统分工。Spring 管对象创建和事务,SpringMVC 管 HTTP 请求路由和 JSON 互转,MyBatis 管 SQL 与 Java 对象的映射。每一层都能在配置文件里明确看到,出了问题也知道去哪个文件翻。如果我完全按生产环境从零选型,会优先用 Spring Boot + MyBatis,少写一半 XML;但既然标题锁定 SSM,就把它作为主线讲透——它的分层思想迁到 Spring Boot 后依然成立,不算白学。

实际开发时,我用 Maven 管理依赖,整个后端分包大致是:controller 放接口入口,service 放业务逻辑,mapper 放 MyBatis 的数据库访问,entity 放与表对应的实体类。MyBatis 的 Mapper 接口只定义方法,SQL 写在 resources/mapper 下的 XML 文件中。这样做的好处是,后面换 Spring Boot 时 controller/service/entity 三层代码原样搬走,只需要替换掉配置和依赖。

2.2 用 Maven 搭出最小可运行骨架:pom、web.xml、spring-mybatis 配置

先给 pom.xml 里最核心的依赖做减法,只留六组,其余按需补。注意我这里不写具体版本号,而是建议你用一个统一的 Spring 版本管理,避免 Spring 5 和 Spring 4 的包混在一起导致启动时一堆 NoSuchMethodError。

<properties> <spring.version>5.3.x</spring.version> <mybatis.version>3.5.x</mybatis.version> <mysql.version>8.0.x</mysql.version> </properties> <dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>${mybatis.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.x</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>${mysql.version}</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.x</version> </dependency> </dependencies>

spring-webmvc 是 SpringMVC 的入口,spring-jdbc 提供事务管理器,mybatis-spring 负责把 MyBatis 的 SqlSessionFactory 交给 Spring 容器管理。这里的版本号我给的是主版本区间,具体到某一版要看你的 JDK 版本:JDK 8 用 Spring 5.3 没问题,JDK 17 也可以,但要注意把 javax.servlet 换成 jakarta.servlet 相关坐标,否则 DispatcherServlet 起不来。

然后是 web.xml,这是 SSM 项目的入口配置。它的作用是把所有 HTTP 请求交给 DispatcherServlet 转发:

<servlet> <servlet-name>springmvc</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring/spring-mvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>springmvc</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping>

load-on-startup 填 1,表示 Tomcat 启动时就初始化这个 Servlet,而不是等第一个请求进来才初始化。url-pattern 写成/,意思是所有请求都进 SpringMVC,静态资源要单独配 mvc:resources 放行,不然小程序端拉不到图片或 HTML 页面。

接下来是最容易出问题的 spring-mybatis 配置。SSM 整合 MyBatis 时,核心是 SqlSessionFactoryBean 和 MapperScannerConfigurer:

<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/meeting_sys?useUnicode=true&amp;characterEncoding=utf8&amp;serverTimezone=Asia/Shanghai&amp;useSSL=false"/> <property name="username" value="root"/> <property name="password" value="你的密码"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.meeting.mapper"/> </bean>

JDBC URL 里的 serverTimezone=Asia/Shanghai 必须带,否则 MySQL 8 连接时会报时区错误。useSSL=false 是本地开发用的,生产环境如果数据库没有配 SSL 证书也建议保持 false。MapperScannerConfigurer 的作用是扫描 com.meeting.mapper 包,把里面的接口自动注册成 Spring 管理的 Bean,这样 service 里直接用 @Resource 注入 Mapper 接口就行,不需要手写实现类。

2.3 第一个接口跑通:Controller 到 Service 到 Mapper 的完整链路

骨架搭起来后,先跑通一个最简单的查询接口验证配置是否正确。我在 MeetingController 里写一个分页查询入口,前端小程序后续所有请求都照这个模式扩展:

@RestController @RequestMapping("/api/meeting") public class MeetingController { @Resource private MeetingService meetingService; @GetMapping("/list") public Result list(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size) { return Result.success(meetingService.page(page, size)); } }

这里有几个细节值得说。@RestController 是 @Controller 和 @ResponseBody 的合并,方法返回值直接序列化成 JSON,不用再在方法上重复加 @ResponseBody。Result 是我自定义的统一返回结构,里面包含 code、message、data 三个字段,小程序端就靠 code 判断业务成功还是失败。分页参数用 defaultValue 指定默认值,前端不传 page 和 size 时不会报错。

Service 层要加 @Service 注解并处理事务。分页查询是只读操作,不需要开启事务;但后面的预约、取消、发布接口,我会在每个写方法上加 @Transactional(rollbackFor = Exception.class),保证多条 SQL 要么全部成功、要么全部回滚。这一点放在下一章结合预约场景具体讲,因为它是整个系统最容易翻车的地方。

@Service public class MeetingService { @Resource private MeetingMapper meetingMapper; public PageResult page(int page, int size) { int offset = (page - 1) * size; List<Meeting> list = meetingMapper.page(offset, size); long total = meetingMapper.count(); return new PageResult(total, list); } }
<select id="page" resultType="com.meeting.entity.Meeting"> SELECT id, title, location, start_time, end_time, capacity, remain_count, status FROM meeting WHERE status IN (0, 1) ORDER BY start_time ASC LIMIT #{offset}, #{size} </select>

分页的 offset 在 Service 里算好,SQL 里直接用 LIMIT 接收,这是最简单、也最好调试的分页方式。不要在小程序端传页码给 SQL 做 offset,因为前端传过来的 page 是 1 开始的,数据库是从 0 开始的,很容易查错数据。这个接口跑通后,说明从 HTTP 到 SpringMVC 到 Spring 再到 MyBatis 这条链路是通的,后面所有业务功能都在这个链路上叠加。

3. 会议与预约的数据模型:两张核心表和四个关键接口

3.1 meeting 与 reservation 表:字段设计、状态机和唯一索引

会议发布与预约的业务核心是两张表:会议表和预约表。会议表存“场次信息”,预约表存“谁约了哪场”。设计表时我优先考虑查询效率和三张状态流,而不是追求字段多。

CREATE TABLE meeting ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL COMMENT '会议标题', location VARCHAR(100) NOT NULL COMMENT '会议地点', start_time DATETIME NOT NULL COMMENT '开始时间', end_time DATETIME NOT NULL COMMENT '结束时间', capacity INT NOT NULL DEFAULT 0 COMMENT '可预约总名额', remain_count INT NOT NULL DEFAULT 0 COMMENT '剩余名额', status TINYINT NOT NULL DEFAULT 0 COMMENT '0未开始 1进行中 2已结束 3已取消', creator_id BIGINT NOT NULL COMMENT '发布人ID', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_start_time (start_time), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE reservation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, meeting_id BIGINT NOT NULL, user_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0正常 1已取消', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_meeting_user (meeting_id, user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

status 字段单独建索引,是因为会议列表页最常见操作就是按状态过滤。预留 remain_count 字段虽然可以用 capacity 减去预约数实时算,但实时统计在列表页会变成一个子查询,数据量上来后很伤性能。对于中小型系统,直接冗余一个剩余数字段,预约成功时减一、取消时加一,代价最小。后面要讲的超卖问题,重点也是保证这个字段不被并发请求扣成负数。

reservation 表上的唯一索引 uk_meeting_user 是整个预约防重的关键。没有它,同一用户在同一场会议下可以插入多条记录;有了它,数据库层面就拦住了重复预约。这个索引在下面的预约接口和最后的进阶验证里都会用到。

3.2 发布会议接口:时间校验与状态机推进

发布会议是管理员的动作。接口接收标题、地点、开始时间、结束时间、总名额,后端要做两个校验:开始时间必须晚于当前时间,结束时间必须晚于开始时间。

@Transactional(rollbackFor = Exception.class) public Long publish(Meeting meeting) { LocalDateTime now = LocalDateTime.now(); if (meeting.getStartTime().isBefore(now)) { throw new BizException("会议开始时间不能早于当前时间"); } if (!meeting.getEndTime().isAfter(meeting.getStartTime())) { throw new BizException("结束时间必须晚于开始时间"); } meeting.setRemainCount(meeting.getCapacity()); meeting.setStatus(0); meetingMapper.insert(meeting); return meeting.getId(); }

为什么发布时要校验时间?因为会议状态机完全由时间驱动:status=0 表示未开始,到了开始时间自动切换成进行中,过了结束时间切换成已结束。这个切换可以靠定时任务扫描,也可以约定前端根据当前时间判断显示什么状态。如果发布时允许开始时间在过去,状态机初始状态就是错的,后续所有列表和预约逻辑都会乱套。

这里有个链路上的细节:前端小程序传过来的时间是字符串,比如 "2024-06-01 09:30:00",SpringMVC 默认不会自动把它转成 LocalDateTime。常见做法是在 controller 里用 @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") 注解参数,或者在全局配置一个 Jackson 的日期反序列化器。我一般直接在实体类的 startTime 字段上加 @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss"),这样从 JSON 反序列化和向 JSON 序列化时都能统一格式,避免前端拿到带 T 的 ISO 字符串。

3.3 预约会议接口:原子扣减 + 事务回滚,根治超卖

预约是整套系统最核心的接口。需求是:用户选择一场未开始的会议,点击预约,如果还有剩余名额就成功,同时生成一条预约记录。最容易想到的写法是“先查余量,再判断,再更新”,但并发场景下这是错的:

// 反面例子,不要这样写 Meeting meeting = meetingMapper.selectById(id); if (meeting.getRemainCount() > 0) { meetingMapper.decreaseRemain(id); reservationMapper.insert(meetingId, userId); }

两个用户同时读到 remain_count=1,都进入 if 分支,两个人都扣减成功,余额变成 -1。这就是典型的超卖。正解是把“判断余量”和“扣减”合并到一条 UPDATE 语句里,让数据库在行锁层面保证原子性:

@Transactional(rollbackFor = Exception.class) public void reserve(Long meetingId, Long userId) { int rows = meetingMapper.deductRemain(meetingId); if (rows == 0) { throw new BizException("名额不足或会议已不可预约"); } try { reservationMapper.insert(meetingId, userId); } catch (DuplicateKeyException e) { throw new BizException("你已经预约过这场会议,不要重复提交"); } }

对应的 MyBatis SQL 这样写:

<update id="deductRemain"> UPDATE meeting SET remain_count = remain_count - 1 WHERE id = #{meetingId} AND remain_count > 0 AND status = 0 </update>

这段 SQL 的巧妙之处在于把 remain_count > 0 写进 WHERE。MySQL 执行 UPDATE 时会锁住命中的行,同一时刻只有一个事务能修改这条记录。如果剩余名额为 0,影响行数是 0,Java 里 rows == 0 就抛异常,不会继续插入预约记录。

重复预约的拦截也值得说清楚。reservation 表有唯一索引 uk_meeting_user,当同一个用户再次插入时会触发 DuplicateKeyException。在 @Transactional 的方法里,这个异常发生后,Spring 会标记整个事务为 rollback-only——也就是说,前面那次 deductRemain 的扣减也会跟着回滚,不会出现“扣了名额但预约记录没插进去”的半成品状态。这里我不需要手动补回余量,事务机制帮我做了。

3.4 取消预约与列表查询:事务边界和排序规则

取消预约是预约的反向操作。用户取消时,先将 reservation 记录置为已取消,再把 meeting 表的 remain_count 加一:

@Transactional(rollbackFor = Exception.class) public void cancel(Long meetingId, Long userId) { int rows = reservationMapper.cancel(meetingId, userId); if (rows == 0) { throw new BizException("预约记录不存在或已取消"); } meetingMapper.increaseRemain(meetingId); }

这里要特别说明:我用的是“更新状态”而不是“删除记录”。之所以保留预约记录,是为了让组织者能统计“谁约过又取消了”,这是会议管理里的常见需求。cancel 方法里先更新 reservation 再增加余量,如果 increaseRemain 失败,整个事务回滚,预约记录不会被置成已取消。这样即使后一条 SQL 报错,数据也不会处于“状态取消但名额没还回来”的状态。

列表查询同样要围绕状态和时间组织。三条规则我比较常用:默认只看未开始和进行中的会议,已经结束的默认不出现在主列表;按开始时间升序排列,越早开始的越靠前;每页固定返回 total,方便小程序端做上拉加载。

<select id="page" resultType="com.meeting.entity.Meeting"> SELECT id, title, location, start_time, end_time, capacity, remain_count, status FROM meeting WHERE status IN (0, 1) ORDER BY start_time ASC LIMIT #{offset}, #{size} </select> <select id="count" resultType="long"> SELECT COUNT(*) FROM meeting WHERE status IN (0, 1) </select>

分页查询和 count 查询分开写,看起来多了一段 SQL,但比 MyBatis 插件自动生成 count 更可控。count 里没有 ORDER BY,MySQL 执行时会省掉排序开销,数据量大了以后差异很明显。至于“进行中”的会议要不要允许预约,产品上通常不允许,因为会议已经开始了,预约没有意义。所以我上面的 SQL 里预约条件是 status = 0,而列表展示条件放宽到 status IN (0, 1)。这两个状态判断不要混在一起,否则会出现“列表能看到进行中的会议,但预约时报名额不足”的困惑。

4. 小程序端对接:登录、请求封装、页面状态控制

4.1 登录态怎么打通:wx.login 换 code,后端换 openid

小程序端不能直接拿到用户身份,标准流程是:小程序调用 wx.login 获取一个临时 code,把 code 传给后端,后端用 code 加上小程序的 appid 和 secret,向微信官方接口换 openid。换到 openid 后,后端把它当作用户唯一标识,生成一个自己的 token 返回给小程序,之后所有请求都带这个 token。

小程序端登录代码:

wx.login({ success: async (res) => { if (res.code) { const data = await request('/api/auth/login', 'POST', { code: res.code }) wx.setStorageSync('token', data.token) wx.setStorageSync('userId', data.userId) } } })

后端 authService 里调用微信接口,注意 GET 请求的四个参数一个都不能少:

String url = "https://api.weixin.qq.com/sns/jscode2session" + "?appid=" + appid + "&secret=" + secret + "&js_code=" + code + "&grant_type=authorization_code";

secret 要从服务端拿,不能写在小程序代码里,否则等于把密钥公开出去了。我一般把 appid 和 secret 放进 properties 配置文件,部署时用环境变量覆盖。拿到微信返回的 openid 后,先查用户表有没有这个 openid,没有就自动注册一条新用户记录,再生成 token 存 Redis 或数据库。token 的有效期建议设成一个长效值,比如 7 天,因为会议预约场景不需要特别高的安全性,频繁重新登录会影响体验。

4.2 request 统一封装:token 注入、错误码分流、超时兜底

小程序原生 wx.request 用起来太裸,每个页面都写一遍会非常散。我会在 utils/request.js 里封装一层,统一做四件事:拼接 BASE_URL、注入 token、按业务 code 分流、处理 HTTP 层异常。

const BASE_URL = 'https://meeting.example.com' function request(path, method = 'GET', data = {}) { return new Promise((resolve, reject) => { wx.request({ url: `${BASE_URL}${path}`, method: method, data: data, timeout: 10000, header: { 'Content-Type': 'application/json', 'Authorization': wx.getStorageSync('token') || '' }, success(res) { if (res.statusCode === 200 && res.data.code === 0) { resolve(res.data.data) } else if (res.data.code === 401) { wx.removeStorageSync('token') // 这里统一跳转登录页,避免每个页面自己处理 token 过期 } else { reject(res.data) } }, fail(err) { reject(err) } }) }) } module.exports = { request }

timeout 我习惯设 10 秒,会议列表接口通常需要返回较多数据,5 秒在大网络环境下偶尔不够。Authorization 放在 header 里,后端 SpringMVC 用拦截器从 header 里取 token 解析用户身份,这样每个业务接口不需要手动接收 token 参数,代码干净很多。

后端拦截器只是个人人都会配的基础设施,但要注意一个坑:小程序预览时第一次请求可能会走 OPTIONS 预检,如果后端没有放行 OPTIONS 请求,小程序端会一直报“跨域请求失败”。在拦截器里加一句:如果请求方法是 OPTIONS,直接返回 200。

4.3 会议列表和详情页:时间格式化与剩余名额渲染

会议列表页是小程序的主页面。一组典型的返回数据里包含 startTime 和 endTime,页面渲染时不能直接用后端字符串。最常见的场景是列表显示“06月01日 09:30”,详情页显示“剩余名额 8/20”。

function formatMeetingTime(value) { const date = new Date(value.replace(/-/g, '/')) const month = date.getMonth() + 1 const day = date.getDate() const hours = String(date.getHours()).padStart(2, '0') const minutes = String(date.getMinutes()).padStart(2, '0') return `${month}月${day}日 ${hours}:${minutes}` }

这里必须解释 value.replace(/-/g, '/') 这段:iOS 的 JavaScriptCore 不认 “2024-06-01 09:30:00” 这种带横杠的日期字符串,直接 new Date() 会得到 Invalid Date,页面显示 “NaN月NaN日”。把横杠换成斜杠之后,iOS 和 Android 都能正确解析。这个坑我在联调时踩过,后来所有日期字段统一走后端格式化、前端只做显示,没有再出问题。

剩余名额的渲染也要做前置判断。如果为 0,按钮置灰并显示“已满”;如果当前时间已经超过会议的结束时间,显示“已结束”,按钮不可点击。列表页做一次当前时间对比即可,详情页可以用 setInterval 每分钟刷新一次状态。

4.4 预约按钮的防连点:loading 状态必加

小程序里用户连点两次预约按钮,会发出两个相同请求。如果后端没有幂等处理,就可能出现“一次点击占两个名额”的情况。后端靠唯一索引能拦住重复预约,但前端仍然要加一层锁,避免用户看到“请求中”时还能再点。

Page({ data: { reserving: false }, async onReserveTap() { if (this.data.reserving) return this.setData({ reserving: true }) try { await request('/api/meeting/reserve', 'POST', { meetingId: this.data.meetingId }) wx.showToast({ title: '预约成功', icon: 'success' }) } catch (err) { wx.showToast({ title: err.message || '预约失败', icon: 'none' }) } finally { this.setData({ reserving: false }) } } })

reserving 这个布尔值就是前端防连点开关。注意 finally 里每次都要复位,不能只在校验失败时复位,否则一次失败后按钮就永久不可点了。这里用 setData 而不是直接修改 this.data.reserving,是为了让按钮的 disabled 属性与状态同步,避免出现“逻辑上已锁定但按钮外观没有变化”的误导。按钮上加 disabled="{{reserving}}" 后,用户点击时会直接走微信组件的禁用态,双重保险。

5. 避坑指南:新手最容易翻车的五个点

5.1 真机访问不了本地后端

现象:微信开发者工具里接口正常,一用真机预览就报 request:fail,页面白屏,后端日志里一个请求都没有。

原因:小程序真机环境要求所有 request 请求必须走微信后台配置的合法域名,并且必须是 HTTPS。开发时用局域网 IP 加端口访问本机 Tomcat,这个地址不在合法域名列表里,微信直接拦截。

解决:开发阶段在微信开发者工具右上角“详情 → 本地设置”里勾选“不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”。这是官方提供的开发选项,只对开发版和体验版生效,发布上线前必须把后端接口部署到 HTTPS 域名,并在小程序后台“开发管理 → 服务器域名”里加入 request 合法域名。如果你用的是云开发,可以直接调 wx.cloud 的相关 API,绕开域名校验,但题目既然锁定 SSM 后端,还是走传统 request 更贴近业务。

5.2 顶部导航栏在不同机型上错位

现象:自定义导航栏在 iPhone 8 上按钮居中正常,换到 iPhone 14 Pro 或带挖孔的安卓机上,胶囊按钮挤压标题,右侧按钮被裁掉。

原因:状态栏高度和胶囊按钮位置在不同机型上差异很大,固定值 64 或 44 只对部分机型有效。一旦项目里选择了自定义导航栏,导航栏高度就必须动态计算。

解决:用胶囊按钮的位置反推导航栏高度。给小程序根组件绑定一个 style,把高度设为变量:

const winInfo = wx.getWindowInfo() const menu = wx.getMenuButtonBoundingClientRect() const statusBarHeight = winInfo.statusBarHeight const navBarHeight = (menu.top - statusBarHeight) * 2 + menu.height

这段公式的逻辑是:菜单按钮顶部到状态栏底部的距离,乘以 2,加上按钮本身高度,就是导航栏的总高度。它对大多数机型都成立,因为胶囊按钮是微信自己渲染的,它的位置天然适配了当前机型的刘海和挖孔。用这个值去设置 navigationStyle: custom 页面里的占位视图高度,标题就不会跑偏。

5.3 iOS 上时间显示 NaN,相差 8 小时

现象:会议列表页在 iOS 真机上显示 “NaN月NaN日 09:30”,但同一份代码在开发者工具里一切正常。还有另一种情况,页面显示的时间比实际时间快了或慢了 8 小时。

原因:第一个坑是 iOS 的 Date 解析不认 “2024-06-01 09:30:00” 这种带横杠的字符串;第二个坑是后端的 DateTime 通过网络传输到前端时,被 JSON 库按 UTC 时区序列化,前端没做时区转换直接 new Date,导致偏移。

解决:前端统一用 replace(/-/g, '/') 转成斜杠格式再解析,这在上一章已经写过。后端确保 Jackson 的 ObjectMapper 配置了 timezone 为 GMT+8,并且在 JDBC URL 里已经写上 serverTimezone=Asia/Shanghai。这样从数据库到后端再到 JSON,全程同一个时区,前端渲染时不需要再加减 8 小时。最保险的做法是后端直接返回格式化好的字符串,前端把字符串当纯文本显示,彻底跳过 Date 解析。缺点是如果要计算“距开始还有多久”,还得拿字符串再解析一次,这时记得用斜杠兼容写法。

5.4 预约超卖:并发请求把剩余名额打成负数

现象:组织者设置一场会议名额为 1,两个用户同时点击预约,两个人都收到“预约成功”提示,数据库里 remain_count 变成了 -1,预约记录却有两条。

原因:Service 层写成“查询余量 → 判断 > 0 → 扣减”三步操作。两个请求同时读到 remain_count=1,同时通过判断,同时执行扣减,最终结果就是负值。这是典型的 check-then-act 竞态条件,Java 代码不保证两条线程之间的先后顺序,必须靠数据库的行锁来兜底。

解决:把判断条件写进 UPDATE 的 WHERE 子句,用影响行数判断是否成功。SQL 就是我在 3.3 节给出的 deductRemain,核心是 AND remain_count > 0。如果影响行数为 0,说明这个时刻已经没名额了,直接抛“名额不足”。这条 SQL 执行时,MySQL 会锁定对应的 meeting 行,第二个请求必须等第一个事务提交后才能执行,因此不会出现两个请求同时扣成功的情况。上线前建议用 JMeter 或 Postman 并发脚本验证一次:设置名额为 1,十线程同时请求预约接口,最终只有一个人成功,其余全部返回“名额不足”。

5.5 数据库 8 小时断连,接口突然报错

现象:系统跑了两周都很正常,某天早上第一个访客打开小程序,列表接口报 “Connection is not available, request timed out”,刷新一次又恢复正常。

原因:MySQL 默认的连接空闲超时时间是 8 小时,也就是 wait_timeout=28800。当天晚上没有请求,连接池里的连接一直空闲,到早上被 MySQL 服务端主动断开。但连接池不知道连接已经失效,仍把旧连接分配给第一个请求,于是读操作直接超时。等连接池检测到异常并重建连接后,第二个请求就正常了。

解决:连接池配置里开启空闲连接检测。以 Druid 为例,这几项参数直接写进 spring 的 dataSource bean:

<property name="testWhileIdle" value="true"/> <property name="validationQuery" value="SELECT 1"/> <property name="timeBetweenEvictionRunsMillis" value="60000"/> <property name="minEvictableIdleTimeMillis" value="300000"/>

testWhileIdle 设为 true 表示空闲连接在归还时会执行一次 validationQuery 验证是否存活,timeBetweenEvictionRunsMillis 是每 60 秒扫描一次空闲连接,minEvictableIdleTimeMillis 表示连接空闲超过 5 分钟才被回收。这套配置加上后,长期无人访问的系统再也不会出现“每天早上第一次请求必挂”的玄学问题。

6. 进阶:把预约做成一次真正幂等的操作,并验证并发

预约接口的并发问题,前面的方案已经解决了“超卖”,但还剩下一个边界:同一用户同一场会议的重复请求,从前端防连点、后端唯一索引、事务回滚三个层面都做了拦截,是不是就够了?够是够了,但我想在这个基础上再补最后一道保险——把接口设计成幂等:无论同一个请求发多少次,最终的数据状态都只有一份。

具体做法分三步。第一步,reservation 表保留唯一索引 uk_meeting_user,这一步数据库层已经挡住了重复数据。第二步,在 reserve 方法里捕获 DuplicateKeyException,但不要吞掉异常,而是把它翻译成用户可理解的错误信息;同时因为整个方法在事务里,扣减操作会自动回滚。第三步,前端把预约按钮的请求加一个业务幂等键,比如每次进入详情页生成一个随机串,第一次预约成功后把键存起来,按钮置灰。这样即使用户杀掉小程序再重新打开,看到的状态也是“已预约”,不会产生歧义。

验证这套方案有没有问题,我一般不用 UI 手工点,而是直接压接口。拿 JMeter 建一个线程组,十个线程同时调 /api/meeting/reserve,参数是同一个 meetingId 和不同 userId,断言表现按三种预期看:成功数不超过剩余名额;每个 userId 最多成功一次;remain_count 最终等于 capacity 减去成功数。如果第一条不满足,说明 UPDATE 的原子扣减没生效;如果第二条不满足,说明唯一索引或事务回滚配错了;两条都满足,这个预约接口就扛得住真实场景的上线流量。

我自己的习惯是,凡是涉及“剩余名额”的业务,都把唯一索引和原子扣减放在同一个事务里,并且把 SQL 的武器说明写在代码注释里,防止后来维护的人觉得“先查再改”更方便就顺手改成反面写法。曾经有一次我图省事,在另一个活动报名系统里用了先查后改,结果用户连点两次预约,一个名额占了两条记录,最后只能写脚本手动清数据。那次教训之后,我再没有在这种场景下相信程序员的自觉——所有能靠数据库约束解决的并发问题,都不要指望代码顺序。

这套会议发布与预约系统,技术上没有高深的东西,难点全在数据边界和用户重复操作上。把并发扣减、时间格式化、导航栏适配这三关过了,项目就完成了八成。剩下的靠小程序开发者工具和真机分别跑一遍,把日志看熟,你会比照抄代码的人更快理解框架之间是怎么协作的。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询