资讯详情 Spring Boot + 小程序宠物领养预约系统:接口设计与并发防重实战
📅 2026/10/28 8:26:33
简介一套基于JavaSpringBoot的微信小程序宠物领养医院系统开发源码同时包含网页管理端与小程序端面向正在做课程设计、期末大作业或毕业设计的计算机相关专业学生也适合希望提升前后端协作开发能力的中级学习者。压缩包内共543个文件体积约38.04MB涵盖90个Java后端源文件、90个JS逻辑脚本、70个TypeScript文件、68个WXSS样式、64个JSON配置、59个WXML页面模板以及HTML管理页面、XML映射、图标图片素材等从服务端接口到小程序界面均完整覆盖目录结构清晰便于按模块快速查阅。项目内附带说明文档源码经过严格调试配置好环境后可直接启动运行读者可从中学习SpringBoot后端如何与小程序端及网页端协同工作理解宠物领养、医院预约等业务模块的实现思路并可在现有代码基础上进行二次功能扩展。目前已有164人学习该资源对需要快速获得可运行项目并系统梳理开发流程的学习者来说具备不错的参考价值。1. 一个 Spring Boot 小程序双端项目从源码到接口设计的拆解很多人拿到宠物领养医院系统的源码压缩包看到一长串index.html、appointment.html、oneClient.html和 Java 文件第一反应是找个启动脚本直接双击。实际上这套基于 Java Spring Boot 的微信小程序项目业务上分两半小程序端给用户浏览宠物、提交领养申请、预约宠物门诊网页管理端给运营人员处理预约列表、审核申请、维护宠物档案。两端共用同一套 REST API所以后端只要调通网页和小程序都能跑出真实数据。它不太适合零基础直接当黑盒运行但非常适合已经写过 Spring Boot 基础工程、正在做课程设计或期末项目的学生。下面从数据模型开始拆最后把并发抢领养和预约冲突的坑补上。2. Spring Boot 后端分层与双端共用的数据模型2.1 为什么选 MyBatis-Plus 当持久层框架Spring Boot 对接 MySQL常用三条路线Spring Data JPA 全自动但复杂条件查询容易绕原生 MyBatis 灵活但每个表都要维护 XMLMyBatis-Plus 介于两者之间内置BaseMapper提供了insert、deleteById、selectById、updateById这些常用方法同时用LambdaQueryWrapper拼 where 条件时几乎不写 SQL。这套系统选了 MyBatis-Plus理由是课程设计和毕设场景下实体类写好以后增删改查直接继承接口就能用不用把时间耗在 XML 映射上。pom.xml 里我一般不写裸版本号让 Spring Boot 父工程统一管理只把 MyBatis-Plus 的 starter 单独声明parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency参数说明Spring Boot 版本固定在 2.7.x原因是 3.0 之后强制要求 JDK 17 和jakarta.*命名空间很多课程设计环境还在用 JDK 8/11贸然升级会带来大量编译期报错。MyBatis-Plus 3.5.x 与 2.7 兼容性最好同时支持新版分页插件。mysql-connector-java的 scope 是 runtime说明只在运行期需要。2.2 数据库表领养记录与预约挂号放在一张表里还是拆开资源包里的业务文件包含appointment.html和y-appointmentList.html说明预约是独立流程。一般表结构按业务边界拆成四张用户表、宠物表、领养申请表、预约挂号表。这里给出宠物表和预约表的最小建表语句CREATE TABLE pet ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT 宠物昵称, type VARCHAR(20) NOT NULL COMMENT dog/cat, gender TINYINT DEFAULT 0 COMMENT 0公 1母, vaccine_status TINYINT DEFAULT 0 COMMENT 0未接种 1已接种, adopt_status TINYINT DEFAULT 0 COMMENT 0可领养 1审核中 2已领养, version INT DEFAULT 0 COMMENT 乐观锁版本号, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE appointment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, doctor_id BIGINT NOT NULL, pet_id BIGINT DEFAULT NULL, appointment_date DATE NOT NULL, time_slot VARCHAR(10) NOT NULL COMMENT AM/PM, status VARCHAR(20) DEFAULT PENDING COMMENT PENDING/APPROVED/COMPLETED, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_doctor_time (doctor_id, appointment_date, time_slot) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;建表时的几个关键点adopt_status不能用字符串枚举因为整数状态在 Java 端映射更省心appointment表的联合索引idx_doctor_time是为了后面做预约冲突查询和唯一性约束准备pet.version字段先留在这里第 6 章会把它变成乐观锁。各表职责可以用下面这张表概括表名核心字段用途useropenid, nickname, phone保存小程序授权用户petname, type, adopt_status, version宠物档案与领养状态adopt_recorduser_id, pet_id, status领养申请记录appointmentuser_id, doctor_id, date, time_slot预约挂号记录2.3 实体类与通用出参封装实体类直接映射表结构用 Lombok 减少 getter/setter 噪音Data TableName(pet) public class Pet { TableId(type IdType.AUTO) private Long id; private String name; private String type; private Integer gender; private Integer vaccineStatus; private Integer adoptStatus; Version private Integer version; private LocalDateTime createTime; private LocalDateTime updateTime; }说明TableName指定表名TableId(type IdType.AUTO)表示主键自增Version是 MyBatis-Plus 乐观锁注解更新时插件会自动在 SQL 里拼接version ?条件。createTime和updateTime由数据库维护Java 端只负责读。接口返回统一用ResultT包装避免小程序端每次接收时还需要解析不同的 json keyData public class ResultT { private Integer code; private String msg; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.code 0; r.msg success; r.data data; return r; } public static T ResultT fail(String msg) { ResultT r new Result(); r.code 1; r.msg msg; return r; } }这里把code0定义为成功code1定义为业务失败。这样做的好处是小程序端封装 request 时只需要判断code一个字段不需要捕获 Spring 默认的 500 异常信息。2.4 双端如何共享同一套 API网页版和小程序端本质是同一个后端的两个前端壳。接口路径统一以/api开头小程序端通过wx.request访问网页端通过 fetch 或 jQuery 访问。小程序端通常抽一个统一请求文件// /utils/request.js const BASE_URL http://localhost:8080/api; function request(path, method GET, data {}) { return new Promise((resolve, reject) { wx.request({ url: BASE_URL path, method: method, data: data, header: { Authorization: wx.getStorageSync(token) || }, success: (res) { if (res.statusCode 200 res.data.code 0) { resolve(res.data.data); } else { wx.showToast({ title: res.data.msg || 请求失败, icon: none }); reject(res); } }, fail: reject }); }); } module.exports { request };BASE_URL按环境修改开发环境用http://localhost:8080真机联调时改成电脑局域网 IP。Authorization头里放登录后拿到的 token后端统一从请求头取出用户身份不依赖 Cookie。网页管理端如果用 Bootstrap 写列表页面直接在同一域名下请求/api路径即可不需要重复写小程序端这套封装。3. 领养与预约接口实现从 Controller 到 Service 的链路3.1 接口清单与状态约定后端接口不是随意定义的需要先明确状态流转。领养申请的状态是PENDING - APPROVED/REJECTED预约状态是PENDING - APPROVED/COMPLETED。接口设计如下方法路径入参说明GET/api/pet/listtype, adoptStatus查询可领养宠物列表POST/api/adopt/applypetId提交领养申请POST/api/appointment/createdoctorId, petId, date, timeSlot创建预约GET/api/appointment/my-当前用户预约列表这里的adoptStatus用整数表示0 表示可领养1 表示审核中2 表示已被领养。小程序首页通常只请求adoptStatus0的数据管理端请求全部状态。3.2 Controller 层参数校验与统一响应Controller 层不应该写业务逻辑只做两件事参数收口、调用 service。以领养接口为例RestController RequestMapping(/api/adopt) public class AdoptController { Resource private AdoptService adoptService; PostMapping(/apply) public ResultVoid apply(RequestBody Valid ApplyRequest req, RequestHeader(Authorization) String token) { Long userId UserContext.getUserId(token); adoptService.apply(userId, req.getPetId()); return Result.ok(null); } }ApplyRequest是 DTO里面只需要一个字段Data public class ApplyRequest { NotNull(message petId不能为空) private Long petId; }关键点Valid触发 DTO 校验NotNull好在入参缺失时就返回错误而不是进到 service 再判断空指针。RequestHeader(Authorization)从请求头拿 tokenUserContext是由拦截器先行写入的 ThreadLocal 工具这里不展开第 4 章会解释。3.3 Service 层事务与状态位流转领养申请的难点是状态并发同一只宠物不能被两个用户同时领养。Service 层使用条件更新替代先查后改Service public class AdoptServiceImpl implements AdoptService { Resource private PetMapper petMapper; Resource private AdoptRecordMapper adoptRecordMapper; Transactional(rollbackFor Exception.class) public void apply(Long userId, Long petId) { Pet pet petMapper.selectById(petId); if (pet null || pet.getAdoptStatus() ! 0) { throw new BusinessException(该宠物不可领养); } // 关键带条件更新只有当前状态为0时才能更新成功 LambdaUpdateWrapperPet uw new LambdaUpdateWrapper(); uw.eq(Pet::getId, petId).eq(Pet::getAdoptStatus, 0); pet.setAdoptStatus(1); if (petMapper.update(pet, uw) 0) { throw new BusinessException(手慢了该宠物已被其他用户提交); } AdoptRecord record new AdoptRecord(); record.setUserId(userId); record.setPetId(petId); record.setStatus(PENDING); adoptRecordMapper.insert(record); } }解释selectById先检查业务状态但检查和更新之间有空窗所以真正写库时使用update的 where 条件adopt_status0。当两个请求并发进入MySQL 的行锁保证只有一个 update 返回 1另一个 update 影响行数为 0由此抛出异常。Transactional保证如果 record 插入失败前面 update 的领养状态会回滚到 0数据一致性才成立。3.4 小程序端调用领养接口小程序页面里的按钮绑定方法直接走统一request.js// pages/pet/detail.js const { request } require(../../utils/request); Page({ data: { petId: null }, submitAdopt() { request(/adopt/apply, POST, { petId: this.data.petId }) .then(() { wx.showToast({ title: 申请已提交, icon: success }); this.setData({ applied: true }); }) .catch(() {}); } });这里没有写/api前缀因为BASE_URL已经拼接。如果后端返回code1统一封装的fail会直接 reject页面不需要重复写错误弹窗。4. 网页管理端与小程序端的登录态与跨域处理4.1 小程序登录流程code 换取 openid小程序端不能直接存用户的 openid 明文标准流程是先让用户点击登录通过wx.login拿到临时 code然后传给后端后端拿 code 调用微信官方接口换取 openid。这一步必须在后端完成因为调用时需要使用小程序的 appSecret不能暴露在小程序包里。RestController RequestMapping(/api/auth) public class AuthController { Value(${wx.appid}) private String appid; Value(${wx.secret}) private String secret; Resource private RestTemplate restTemplate; PostMapping(/login) public ResultLoginVO login(RequestBody WxLoginDTO dto) { String url https://api.weixin.qq.com/sns/jscode2session?appid appid secret secret js_code dto.getCode() grant_typeauthorization_code; String json restTemplate.getForObject(url, String.class); JSONObject obj JSONObject.parseObject(json); String openid obj.getString(openid); if (openid null) { return Result.fail(微信登录失败); } User user userMapper.selectOne( new LambdaQueryWrapperUser().eq(User::getOpenid, openid)); if (user null) { user new User(); user.setOpenid(openid); user.setNickname(微信用户); userMapper.insert(user); } String token UUID.randomUUID().toString().replace(-, ); tokenService.put(token, user.getId()); LoginVO vo new LoginVO(); vo.setToken(token); vo.setUserId(user.getId()); return Result.ok(vo); } }参数说明wx.appid和wx.secret放在application.yml里真实部署时应当用环境变量覆盖不要提交到 git 仓库。RestTemplate需要显式注入Spring Boot 不会自动创建。tokenService.put可以把 token 放内存 Map 或 Redis源码中最简单的做法是ConcurrentHashMap超时清理由定时任务完成。4.2 基于 Token 的登录校验而不是 Session小程序端无法可靠依赖 Cookie尤其真机调试时 Cookie 策略不稳定。更稳妥的方案是登录成功后在请求头带Authorization后端用拦截器统一拦截。Component public class TokenInterceptor implements HandlerInterceptor { Resource private TokenService tokenService; Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) { String token req.getHeader(Authorization); if (token null || !tokenService.exists(token)) { resp.setStatus(401); return false; } UserContext.setUserId(tokenService.getUserId(token)); return true; } Override public void afterCompletion(...) { UserContext.clear(); } }配置注册拦截器时需要把登录接口/api/auth/login排除掉否则会循环跳转。同时静态资源路径也要放行网页端的login.html才不会被 401 挡住。4.3 网页管理端跨域配置网页管理端如果直接双击打开index.html请求会从file://或本地静态服务器发到http://localhost:8080触发跨域。Spring Boot 端只加一个 CorsFilter 就能解决Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }网上很多配置用addAllowedOrigin(*)但allowCredentials(true)时*会被拒绝改用addAllowedOriginPattern(*)才是 Spring Boot 2.7 推荐写法。如果管理端和小程序共用同一套后端/api/**路径都会被这个配置覆盖小程序端本身不触发 CORS所以不影响。4.4 网页端与预约列表对接源码里有appointment.html和y-appointmentList.html前者是用户下单页后者是管理员查看预约列表页。管理端页面打开后进来先调登录接口拿管理员 token再请求预约列表fetch(/api/appointment/list, { headers: { Authorization: localStorage.getItem(admin-token) } }) .then(r r.json()) .then(res { if (res.code 0) { const rows res.data.map(item tr td${item.userId}/td td${item.appointmentDate}/td td${item.timeSlot}/td td${item.status}/td /tr).join(); document.querySelector(#appointment-tbody).innerHTML rows; } });这里用模板字符串拼tr配合 Bootstrap 表格样式就能直接看到数据。注意localStorage只在同源页面可用所以网页端必须通过 HTTP 服务访问不能直接双击打开 html 文件否则 token 取不到。常见做法是用npx serve -l 8081或者 VSCode Live Server 起个静态服务。5. 打包部署与常见运行故障定位5.1 本地打包启动后端是标准 Maven 工程打包命令和启动命令都很直接# 跳过测试打包 mvn clean package -DskipTests # 启动后端 java -jar target/*.jar --spring.profiles.activedev--spring.profiles.activedev指定读取application-dev.yml文件里配好数据库地址、用户名、密码。如果使用 JDK 17记得在pom.xml里把maven.compiler.source改成 17否则会出现无效的目标发行版报错。5.2 常见运行错误对照表我整理了一份高频报错表适合课程设计和毕设答辩前速查报错信息原因处理方案Failed to configure a DataSource数据库连接没配置检查application.yml的 url/username/passwordAccess denied for user root密码错误或权限不足在 MySQL 里执行GRANT ALL PRIVILEGES ON *.* TO root%;Communications link failureMySQL 未启动或端口错误netstat -ano查看 3306 端口确认 MySQL 服务状态request:fail url not in domain list小程序合法域名未配置开发环境勾选“不校验合法域名”正式上线时配置 request 合法域名401 Unauthorized请求头缺少 Authorization 或 token 过期在wx.request的 header 中打印 token确认拦截器排除路径Invalid bound statement (not found)Mapper 接口与 XML 不匹配检查MapperScan路径和 XML 资源目录5.3 调试技巧把 SQL 打出来MyBatis-Plus 在运行期生成的 SQL 默认不打印遇到条件查不对的问题很难定位。可以在application.yml中开 DEBUG 日志logging: level: com.petadopt.mapper: debug将com.petadopt.mapper替换成项目里 mapper 接口所在的包名。开启后控制台会显示 Preparing:和 Parameters:两行内容能直观看到 where 条件是不是多拼了一个字段。接口调不通时我一般先用 Postman 或 curl 直接打后端不急着跑小程序curl -X POST http://localhost:8080/api/adopt/apply \ -H Content-Type: application/json \ -H Authorization: test-token \ -d {petId: 1}如果返回401检查 token 是否有效如果返回业务异常看异常信息是宠物状态不对还是并发条件没满足。这样能把问题边界缩小到后端逻辑排除小程序端渲染干扰。6. 防止重复领养与预约冲突给业务加一个乐观锁兜底6.1 并发场景拆解第 3 章的领养逻辑用了条件更新这已经能挡住大部分重复提交。但预约场景相对复杂两个用户同时提交同一医生、同一日期的预约如果只在 Service 里先selectCount再插入依然会存在时间窗口。最终可靠的方案是“代码里做预校验 数据库唯一索引兜底”。6.2 预约创建时排他检查创建预约的 Service 里先查同一时间槽位是否已有生效记录Transactional(rollbackFor Exception.class) public void create(Long userId, AppointmentDTO dto) { Long cnt appointmentMapper.selectCount( new LambdaQueryWrapperAppointment() .eq(Appointment::getDoctorId, dto.getDoctorId()) .eq(Appointment::getAppointmentDate, dto.getDate()) .eq(Appointment::getTimeSlot, dto.getTimeSlot()) .in(Appointment::getStatus, PENDING, APPROVED)); if (cnt 0) { throw new BusinessException(该医生当前时间段已被预约); } Appointment appointment new Appointment(); appointment.setUserId(userId); appointment.setDoctorId(dto.getDoctorId()); appointment.setPetId(dto.getPetId()); appointment.setAppointmentDate(dto.getDate()); appointment.setTimeSlot(dto.getTimeSlot()); appointment.setStatus(PENDING); appointmentMapper.insert(appointment); }这段逻辑里有两个重要参数getDoctorId和getAppointmentDate决定查的是哪个医生哪一天timeSlot区分上午/下午in(PENDING, APPROVED)保证已经取消或完成的预约不占冲突名额。注意查询和插入不是原子操作必须配合第 2 章建表时提到的联合索引做兜底否则极端并发下还是会插两条记录。6.3 压测验证验证并发是否兜住不需要复杂的压测工具用 shell 循环就能模拟for i in 1 2 3; do curl -X POST http://localhost:8080/api/appointment/create \ -H Content-Type: application/json \ -H Authorization: test-token \ -d {doctorId:1,date:2024-06-01,timeSlot:AM} done wait三个请求同时打到后端最终只有一条会返回code0另外两条会收到 “该医生当前时间段已被预约”。去数据库执行SELECT COUNT(*) FROM appointment WHERE doctor_id1 AND appointment_date2024-06-01 AND time_slotAM;应该得到 1。如果得到大于 1说明联合索引没生效检查表结构里是否确实建了idx_doctor_time也可以在application.yml中开启ddl-auto: update让 Hibernate 自动建索引但 MyBatis-Plus 场景下更推荐手工执行建表 SQL。最后再给一个工程化建议把唯一索引建好后即使代码里漏了预校验数据库也会抛DuplicateKeyException所以 Service 层还要捕获这个异常并转换为友好提示。这样的双层防护才是生产环境里拿得出手的写法。本文还有配套的精品资源点击获取