1. 项目定位与技术选型:为什么这个系统值得做
宠物服务预约这个方向,说实话在近几年已经不算什么新鲜概念了。但真正有意思的点在于:市面上大多数同类系统要么做得太轻(只是一个预约表单),要么做得太重(复杂到门店根本用不起来)。我接手这个项目时,第一件事不是急着写代码,而是把整个业务链路重新梳理了一遍——宠物主人需要什么、门店需要什么、这个系统的边界应该划在哪里。
从标题就能看出来,这套系统的核心是两件事:服务预约和宠物用品。前者是典型的预约类业务,涉及服务项目、技师、时段、订单状态流转;后者是零售类业务,涉及商品库存、购物车、订单结算。两条业务线看似独立,实际共享同一个用户体系、订单体系和支付入口,这决定了后端必须有一个统一的订单服务来承接两条业务线的差异化诉求。
技术选型上,标题已经写得很明确:Java + SpringBoot + 微信小程序。为什么是这套组合?我个人的判断是三点:第一,SpringBoot 的生态成熟度摆在那里,从 JPA 到 MyBatis-Plus 再到 Spring Security,周边组件基本覆盖了中小型项目的全部诉求,不需要像用 Node.js 那样到处拼装第三方库;第二,Java 的类型系统和事务管理在订单、支付这类对数据一致性要求高的场景下更让人放心;第三,微信小程序作为 C 端入口几乎是国内宠物门店的标配,用户不需要下载 App,扫一扫就能用,获客成本低。
提示:如果你的项目时间紧、目标是快速演示完整业务闭环,SpringBoot 后端 + 小程序前端的组合是最稳妥的。如果需要做复杂的数据分析和推荐系统,再考虑引入微服务或大数据组件,现阶段不要过度设计。
这套系统的目标用户也很清晰:计划开发毕业设计或课程项目的在校生、想接私活的小团队、以及宠物门店的经营者。如果你是后者,看完这篇文章你至少能搞清楚一套预约系统到底应该包含哪些模块、数据怎么设计、部署需要什么成本。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计:从订单模型到库存扣减的完整思路
老规矩,写后端先设计数据库。我在这个项目里一共设计了 10 张核心表,分三个模块:用户与权限、服务预约、商品交易。下面把关键表逐一说明,并解释每个设计决策背后的理由。
2.1 用户相关表设计
用户体系这块,小程序端用户和门店管理员在业务上是两类完全不同的角色。小程序端用户通过微信登录静默获取 openid,不需要注册账号密码;门店管理员则通过账号密码登录后台管理端。所以我设计了两张表:pet_user(C 端用户表)和 staff(员工/管理员表)。
pet_user 表的关键字段包括:openid(微信唯一标识)、nickname、avatar、phone、pet_count(宠物数量)。这里有一个很多初学者容易忽略的点:openid 必须加唯一索引。一个微信用户对应一个 openid,但同一用户可能在不同小程序下有不同 openid,同一小程序下也可能用不同手机号登录——只有 openid 才能在小程序维度内唯一标识一个用户。
pet_user 的附加信息我拆了一张 pet 表(宠物档案表),专门存宠物信息:pet_name、pet_type(猫/狗/其他)、pet_breed、pet_birthday、weight、gender。为什么单独建表而不是直接塞在用户表里?因为预约服务时要按宠物类型匹配项目,比如"猫咪洗澡"和"狗狗洗澡"价格不同,如果用户档案里只存一个宠物,用户养了第二只猫时系统就废了。拆开建表,后续可以针对每只宠物做预约历史和健康记录,业务扩展性完全不一样。
2.2 服务预约模块表设计
服务预约是核心模块,涉及的表包括:service_item(服务项目表)、service_appointment(预约订单表)、service_schedule(排班时段表)。
service_item 表的关键字段:service_name、service_type(洗澡/美容/寄养/医疗等)、service_price、service_duration(服务时长,单位分钟)、pet_type(适配宠物类型)、service_img、service_status(上架/下架)。设计时建议对 pet_type 字段加一层约束,因为不同宠物类型的服务项目往往是独立的,不区分的话后续查询和库存计算都会出问题。
service_appointment 是整张业务的核心表,字段设计如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| appointment_id | bigint | 主键 |
| user_id | bigint | 用户ID |
| pet_id | bigint | 宠物档案ID |
| service_id | bigint | 服务项目ID |
| staff_id | bigint | 服务技师ID(门店可分配) |
| appointment_date | date | 预约日期 |
| appointment_time | varchar | 预约时段(如 10:00-11:00) |
| order_status | int | 0待支付 / 1待服务 / 2服务中 / 3已完成 / 4已取消 |
| remark | varchar | 备注 |
注意 appointment_time 我用了 varchar 而不是 datetime。为什么?因为这里存的是"时段"而不是"时刻",用户选择的时间段本来就是一个区间,用字符串存储更直观,也方便前端直接回显。判断时段是否冲突时再对字符串做区间比较即可。当然你也可以用开始时间和结束时间两个 datetime 字段,但那样前后端联调时反而多一步格式化工作,对中小型项目没必要。
service_schedule 表用于管理可预约时段,核心字段:schedule_date(日期)、time_slot(时段)、staff_id(可服务技师)、capacity(最大可预约数)、booked_count(已预约数)。预约时先判断 booked_count < capacity,满足才能生成订单,否则提示"该时段已约满"。
2.3 商品交易模块表设计
商品模块是独立的交易闭环:goods(商品表)、shopping_cart(购物车表)、goods_order(商品订单表)、goods_order_detail(订单明细表)。
goods 表关键字段:goods_name、goods_desc、goods_type(宠物食品/玩具/用品/药品)、goods_price、goods_stock(库存)、goods_img、sales_count(销量)、goods_status。这里重点说 goods_stock:库存字段是 int 类型,永远不要在数据库里做减法。正确做法是:查询时用 select ... where goods_stock >= ?,扣减时用 update goods set goods_stock = goods_stock - ? where goods_id = ? and goods_stock >= ?。这样可以避免并发超卖问题。
goods_order 和 goods_order_detail 是一对多关系。订单主表存总价、状态、用户ID、收货信息;明细表存具体商品ID、商品名称、单价、数量。明细表为什么要冗余商品名称和单价?因为商品信息是会变的——今天卖 35 元的狗粮,明天降价到 30 元,历史订单里的明细必须保持不变。这是电商订单设计的基本常识,但很多新手会忽略。
2.4 预约冲突与并发控制的实现要点
预约系统最容易出的问题就是同一时段被多人约满。我在项目里用了一种非常朴素但可靠的方案:在 service_appointment 表上建立唯一索引 (appointment_date, appointment_time, staff_id)。这样即使两个用户同时对同一技师发起了同一时段的预约,数据库层面也会拦截重复插入,从根上掐断冲突。
有同学可能会问:如果门店不止一个技师,一个时段可以同时服务多个用户怎么办?那就把 unique 索引去掉,改用 service_schedule.booked_count 做乐观锁更新。操作流程是:先查 booked_count < capacity,然后执行 update service_schedule set booked_count = booked_count + 1 where schedule_id = ? and booked_count < capacity,如果影响行数为 1 说明预约成功,否则提示已满。这个方案在低并发场景下完全够用,没必要上 Redis 分布式锁。
3. 核心接口设计:从微信登录到订单流转
数据库设计完后,下一步是接口设计。我按业务模块整理了核心接口清单,并给出关键接口的代码示例。
3.1 微信登录接口
微信小程序的登录流程是典型的 code2Session 流程:小程序端调用 wx.login() 获取临时 code,发送到后端,后端用 code 调用微信官方接口换取 openid 和 session_key。拿到 openid 后,先查 pet_user 表,如果用户已存在则直接返回登录态,否则创建新用户并返回。
核心代码如下:
java复制@RestController
@RequestMapping("/api/user")
public class UserController {
@Autowired
private UserService userService;
@PostMapping("/login")
public Result login(@RequestBody LoginDTO dto) {
// 1. 调用微信接口,使用code换取openid
String url = "https://api.weixin.qq.com/sns/jscode2session?appid="
+ appId + "&secret=" + appSecret
+ "&js_code=" + dto.getCode()
+ "&grant_type=authorization_code";
String response = restTemplate.getForObject(url, String.class);
JSONObject json = JSONObject.parseObject(response);
String openid = json.getString("openid");
// 2. 根据openid查询用户,不存在则注册
PetUser user = userService.findByOpenid(openid);
if (user == null) {
user = new PetUser();
user.setOpenid(openid);
user.setNickname("微信用户" + openid.substring(openid.length() - 4));
userService.save(user);
}
// 3. 生成Token返回给前端
String token = JwtUtil.generateToken(user.getUserId());
return Result.success(token);
}
}
这里有几个实战中会踩的坑:
一是 code 只能使用一次,用完后立即作废。如果前端因为网络问题重复发送同一个 code,后端会返回 invalid code 错误。前端的处理逻辑是登录失败后重新调用 wx.login() 获取新 code,而不是重试旧 code。
二是 session_key 需要妥善保存,它用于解密手机号和用户敏感信息,但不应该直接暴露给前端。我的做法是在后端用 Redis 缓存 session_key,key 为 "session:" + openid,值就是 session_key,有效期 7 天。只有需要解密手机号时才从 Redis 取出使用。
三是不要信任前端传过来的用户信息。昵称、头像这些字段,要么通过 wx.getUserProfile() 获取签名信息后由后端校验,要么干脆只存微信默认信息,等用户主动填写。很多项目为了省事,直接使用前端传递的昵称和头像,这在数据安全上是漏洞。
3.2 服务预约核心接口
预约流程分三步:查询可预约时段 → 提交预约单 → 支付/确认。这里贴一下提交预约单的核心代码逻辑:
java复制@Transactional
public AppointmentOrder createAppointment(AppointmentDTO dto) {
// 1. 检查时间段是否可约
ServiceSchedule schedule = scheduleMapper.findByDateAndTime(
dto.getAppointmentDate(), dto.getAppointmentTime());
if (schedule == null || schedule.getBookedCount() >= schedule.getCapacity()) {
throw new BusinessException("该时段已约满,请更换时间");
}
// 2. 创建预约订单
AppointmentOrder order = new AppointmentOrder();
order.setUserId(dto.getUserId());
order.setPetId(dto.getPetId());
order.setServiceId(dto.getServiceId());
order.setAppointmentDate(dto.getAppointmentDate());
order.setAppointmentTime(dto.getAppointmentTime());
order.setOrderStatus(0); // 待支付
order.setOrderNo(generateOrderNo()); // 生成唯一订单号
appointmentMapper.insert(order);
// 3. 更新时段已预约数量
int updated = scheduleMapper.increaseBookedCount(schedule.getScheduleId());
if (updated == 0) {
throw new BusinessException("该时段预约人数已满");
}
return order;
}
注意第 3 步使用的 increaseBookedCount 方法,SQL 语句是:
sql复制update service_schedule
set booked_count = booked_count + 1
where schedule_id = #{scheduleId}
and booked_count < capacity
这条语句是防超卖的关键,务必加上 booked_count < capacity 条件。执行后检查影响行数,如果为 0 则预约失败并回滚事务——注意这里必须抛出运行时异常才能触发事务回滚,如果只是返回一个错误对象,前面已经插入的订单记录是不会被自动回滚的。
3.3 商品订单与库存处理
商品订单的创建逻辑比服务预约多了一个购物车维度。前端将购物车中选中的商品 ID 列表和数量发送到后端,后端批量查询商品、计算总价、扣减库存。核心代码如下:
java复制@Transactional
public GoodsOrder createGoodsOrder(OrderDTO dto) {
// 1. 计算总价
List<Goods> goodsList = goodsMapper.selectBatchIds(dto.getGoodsIds());
BigDecimal totalPrice = BigDecimal.ZERO;
List<GoodsOrderDetail> details = new ArrayList<>();
for (Goods goods : goodsList) {
totalPrice = totalPrice.add(goods.getGoodsPrice());
GoodsOrderDetail detail = new GoodsOrderDetail();
detail.setGoodsId(goods.getGoodsId());
detail.setGoodsName(goods.getGoodsName());
detail.setGoodsPrice(goods.getGoodsPrice());
details.add(detail);
}
// 2. 扣减库存(带乐观锁条件)
for (Goods goods : goodsList) {
int updated = goodsMapper.reduceStock(goods.getGoodsId());
if (updated == 0) {
throw new BusinessException("商品库存不足:" + goods.getGoodsName());
}
}
// 3. 创建订单和明细
GoodsOrder order = new GoodsOrder();
order.setUserId(dto.getUserId());
order.setTotalPrice(totalPrice);
order.setOrderStatus(0);
goodsOrderMapper.insert(order);
details.forEach(detail -> {
detail.setOrderId(order.getOrderId());
goodsOrderDetailMapper.insert(detail);
});
return order;
}
这里有两个细节值得注意:
第一,先扣库存再创建订单。有人会先创建订单再扣库存,但如果在扣库存时发现不足,订单已经落库了,还要再删除或标记失效,凭空多了很多异常处理逻辑。先扣库存、失败就抛异常回滚,整个流程更干净。
第二,下单和支付是分离的。createGoodsOrder 只负责锁定库存、生成待支付订单,支付成功后通过微信支付回调更新订单状态。如果用户下单后 15 分钟未支付,需要有个定时任务把超时订单取消并恢复库存。这个我放在第 5 章展开讲。
3.4 订单状态机的完整定义
两种订单(服务预约和商品)都存在状态流转,我直接定义了订单状态机,后端代码中统一使用状态枚举:
| 状态值 | 服务预约订单 | 商品订单 |
|---|---|---|
| 0 | 待支付 | 待支付 |
| 1 | 待服务 | 待发货 |
| 2 | 服务中 | 已发货 |
| 3 | 已完成 | 已完成 |
| 4 | 已取消 | 已取消 |
| 5 | - | 退款中 |
订单状态只能按定义好的方向流转:待支付 → 待服务/待发货,待服务 → 服务中 → 已完成,待发货 → 已发货 → 已完成。任何状态只能从当前状态修改为下一个合法状态,建议在后端做一个状态流转校验:
java复制private static final Map<Integer, Set<Integer>> ALLOWED_TRANSITIONS = Map.of(
0, Set.of(1, 4),
1, Set.of(2, 4),
2, Set.of(3),
3, Set.of()
);
public void validateTransition(int from, int to) {
if (!ALLOWED_TRANSITIONS.getOrDefault(from, Set.of()).contains(to)) {
throw new BusinessException("非法的状态流转:" + from + " -> " + to);
}
}
这个校验看着简单,实际能堵住不少问题。比如用户重复点击"取消订单"按钮,如果没有状态校验,状态可能从"已取消"变成"已完成",这在业务上完全说不通。
4. 微信小程序端的关键页面与交互设计
小程序端是用户直接接触的入口,体验好坏直接影响系统评价。这里重点说几个关键页面的实现思路和一些容易被忽略的交互细节。
4.1 首页模块与分类导航
首页不是简单地堆砌商品列表,而是通过接口聚合的方式返回多个模块的数据:轮播图、服务项目推荐、热销商品、分类导航。这里我用了一个聚合接口:
java复制@GetMapping("/home")
public Result getHomeData() {
HomeVO vo = new HomeVO();
vo.setBanners(bannerService.getActiveBanners());
vo.setServiceItems(serviceItemService.getRecommendItems());
vo.setHotGoods(goodsService.getHotGoods());
vo.setCategories(categoryService.getAll());
return Result.success(vo);
}
为什么要聚合而不是分多个接口?因为小程序端首页一次进入就要并发发起四五个请求,每个请求都有网络延迟,串行渲染会让首页加载时间超过 3 秒,用户早就划走了。聚合接口一次拉回来,前端只需要渲染一次数据即可。如果你的小程序首页一直感觉很慢,先看看是不是请求数太多。
4.2 服务预约页面的交互逻辑
预约页面是这个系统体验最重的页面,用户至少做四步操作:选服务项目 → 选宠物 → 选日期 → 选时段。每一段切换都会触发一次价格和可约状态刷新。
日期选择这里我说个建议:不要自己写日历组件,直接用小程序内置的 picker 组件,mode="date",再配合一个时段列表。如果自己写日历,不仅代码量大,而且月份切换、禁用过去日期等边界问题处理起来非常费时间,对项目演示来说性价比不高。
时段选择的显示逻辑是:已满时段置灰,不可点击;未满时段正常展示并标注剩余名额。判断依据是后端返回的 service_schedule 列表:
javascript复制fetchSchedule(date) {
wx.request({
url: `/api/schedule/list?date=${date}&serviceId=${this.data.serviceId}`,
success: (res) => {
const schedules = res.data.map(item => ({
...item,
disabled: item.bookedCount >= item.capacity
}));
this.setData({ schedules });
}
});
}
选完日期时段后,点击"提交预约"会先弹窗确认服务项目和价格,然后调用创建订单接口。注意这里不能让用户自己填价格,前端展示的价格只做展示,后端下单时会根据 service_id 重新查一次价,防止用户篡改请求参数。
4.3 登录态的维护与请求拦截
小程序端的登录态维护是一个老生常谈但很容易做错的事情。我的做法是:
- 用户第一次打开小程序时,调用
wx.login()获取 code,发送到后端换取 token,存到wx.setStorageSync('token', token)。 - 后续所有请求在 header 中携带
Authorization: Bearer token。 - 后端用拦截器统一校验 token 有效性,无效则返回 401 状态码。
- 小程序端在响应拦截器中判断 401,自动重新登录并重放请求。
小程序端的请求封装核心代码:
javascript复制function request(url, method, data) {
const token = wx.getStorageSync('token');
return new Promise((resolve, reject) => {
wx.request({
url: `${BASE_URL}${url}`,
method,
data,
header: { 'Authorization': `Bearer ${token}` },
success: (res) => {
if (res.statusCode === 401) {
// token失效,重新登录
login().then(() => {
request(url, method, data).then(resolve).catch(reject);
});
return;
}
resolve(res.data);
},
fail: reject
});
});
}
这里有个容易踩的坑:token 过期后重新登录,需要重放原始请求,而不是直接提示用户重新打开小程序。上面的递归调用就是解决这个问题,但要注意防止无限循环——如果重新登录后依然返回 401,就说明后端登录接口出了问题,需要跳转错误页面而不是继续重试。
4.4 购物车与商品列表的本地缓存策略
购物车的实现方式有两种:纯后端 Redis 存储和小程序本地缓存。我选的是本地缓存方案,原因很简单:购物车是轻交互、高频率的数据,每次增减都发请求会显著增加页面卡顿感。本地缓存方案下,购物车数据存储在小程序的 Storage 中,提交订单时一次性将购物车数据发送到后端。
但本地缓存方案有一个坑:用户换设备登录后购物车数据会丢失。解决方案是:用户登录成功后,将本地购物车同步到后端(merge 到 Redis),下次再登录时从 Redis 拉取合并。这个逻辑其实不难,但注意一定要做数量合并而不是覆盖——同一件商品,A 设备购物车里有 2 件,B 设备里有 1 件,合并后应该是 3 件才对。
5. 订单超时处理与微信支付对接中的坑
订单模块开发到一半,真正让人头疼的不是 CRUD,而是异常场景的处理。这里单独分享两个我踩过最深的技术坑。
5.1 订单超时自动取消的实现方案
用户支付有 15 分钟时限,超时未支付需要自动取消订单并恢复库存和服务时段名额。实现方案不用上消息队列,SpringBoot 自带的 @Scheduled 定时任务就够了。
核心逻辑是:定时扫描待支付且创建时间超过 15 分钟的订单,批量改变状态并恢复库存。但直接扫全库的性能很差,更优的实践是分页批量处理:
java复制@Component
public class OrderTimeoutTask {
@Autowired
private GoodsOrderMapper goodsOrderMapper;
@Autowired
private ServiceAppointmentMapper appointmentMapper;
@Scheduled(fixedDelay = 60000) // 每60秒执行一次
public void cancelTimeoutOrders() {
LocalDateTime deadline = LocalDateTime.now().minusMinutes(15);
// 商品订单
List<GoodsOrder> timeoutGoodsOrders = goodsOrderMapper.selectTimeoutOrders(deadline);
for (GoodsOrder order : timeoutGoodsOrders) {
cancelGoodsOrder(order);
}
// 服务预约
List<ServiceAppointment> timeoutAppointments = appointmentMapper.selectTimeoutOrders(deadline);
for (ServiceAppointment appointment : timeoutAppointments) {
cancelAppointment(appointment);
}
}
}
实现定时任务的几个注意点:
一是 时间窗口控制。扫描时加上 create_time < deadline 和 order_status = 0 两个条件,避免全表扫描。
二是 取消服务预约时需要恢复时段名额。booked_count 要减 1,否则用户不下单也会占着时段名额,时间长了所有时段都会被占满。恢复完成后建议给用户发一条订阅消息,告知预约已取消。
三是定时任务在多实例部署下会重复执行。如果你只在一台服务器上运行,这个不是问题;如果将来部署了多个实例,需要用分布式锁(Redis 或者数据库锁)保证只有一个实例执行定时任务。现阶段单实例部署完全够用。
5.2 微信支付回调的验签与幂等处理
微信支付对接是很多初学者的噩梦,但其实原理并不复杂。用户在小程序端调用 wx.requestPayment 发起支付后,微信服务端会异步回调后端通知支付结果。回调地址是你在商户平台配置的接口,比如 https://yourdomain.com/api/pay/notify。
回调处理的核心逻辑:
java复制@PostMapping("/notify")
public String payNotify(@RequestBody String xmlData) throws Exception {
// 1. 解析微信回调的XML报文
Map<String, String> resultMap = WxPayUtil.xmlToMap(xmlData);
if (!"SUCCESS".equals(resultMap.get("return_code"))) {
return WxPayUtil.responseXml("FAIL", "参数错误");
}
// 2. 验签
if (!WxPayUtil.verifySign(resultMap, apiKey)) {
return WxPayUtil.responseXml("FAIL", "验签失败");
}
// 3. 处理业务:更新订单状态
String orderNo = resultMap.get("out_trade_no");
String transactionId = resultMap.get("transaction_id");
boolean success = orderService.paySuccess(orderNo, transactionId);
return success ? WxPayUtil.responseXml("SUCCESS", "OK")
: WxPayUtil.responseXml("FAIL", "订单处理失败");
}
这个接口有三个必须注意的细节:
- 验签必须严格。微信回调可能被伪造,验签失败一定要返回 FAIL 并记录日志。
- 必须幂等。微信支付回调可能由于网络原因重复发送多次,后端的
paySuccess方法要判断订单状态——如果订单已经是"已支付",直接返回成功,不要重复处理。 - 回调处理必须快速响应。如果后端因为异常导致响应超时,微信会多次重试,加重后端压力。所以回调接口内部不要做耗时的外部调用,比如不要发短信、不要推送消息,这些异步化处理。
6. 项目部署与运行环境配置的完整记录
项目开发完成后,如何让它在本地和服务器上正常跑起来,是很多同学最头疼的部分。这一节把整个过程完整记录下来,按下面步骤操作基本不会出问题。
6.1 本地运行环境准备
第一步是准备开发环境。我用的版本组合如下:
| 组件 | 版本 | 备注 |
|---|---|---|
| JDK | 1.8 | 稳定,兼容性好 |
| Maven | 3.6.3 | 依赖管理 |
| MySQL | 5.7 | 数据存储 |
| Redis | 5.0 | 缓存、session_key 存储 |
| 微信开发者工具 | 最新稳定版 | 小程序调试 |
| IDEA | 2023.2 | Java 开发 IDE |
这里特别提醒一个容易踩的坑:SpringBoot 的版本和 JDK 版本必须匹配。如果你用 JDK 1.8,SpringBoot 最好选择 2.3.x~2.7.x 之间的版本,不要用 SpringBoot 3.0+,因为 SpringBoot 3.0 强制要求 JDK 17+,装完之后一堆莫名其妙的报错会让你怀疑人生。标题里既然说了是 SpringBoot 项目,我的建议是直接用 SpringBoot 2.7.x + JDK 1.8 的黄金组合,教程多、问题少、跑起来最省心。
6.2 项目配置文件的正确写法
本地开发和服务器部署的配置一般不同,建议用 application-dev.yml 和 application-prod.yml 做环境隔离。application-dev.yml 的配置如下:
yaml复制server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/pet_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
redis:
host: localhost
port: 6379
wechat:
appid: 你的小程序appid
secret: 你的小程序secret
mch-id: 你的商户号
api-key: 你的API密钥
这里有一个安全细节:不要把 appsecret 和 api-key 明文写在配置文件里并推到 GitHub。尤其是做完项目写博客、上传源码的时候,记得排除 application-prod.yml,或者用环境变量占位:
yaml复制wechat:
secret: ${WECHAT_SECRET}
否则很容易被人扫到仓库里的密钥,盗刷你的支付账户。
6.3 微信小程序端的前置配置
小程序端跑起来之前,有两处地方必须配置。第一处是小程序项目的 app.js 里的后端接口地址,改成你电脑的局域网 IP + 端口(如果真机调试)或 http://localhost:8080(如果开发者工具模拟器调试)。要注意开发者工具模拟器里 localhost 指向的是开发机,真机预览时 localhost 指向的是手机本身,所以真机调试必须用局域网 IP。
第二处是小程序后台的服务器域名配置。微信小程序要求所有请求的域名必须在后台配置合法域名,否则请求会直接被拦截。开发阶段可以在开发者工具里勾选"不校验合法域名",但上线前必须配置好 https 域名并备案。
还有一个小程序端非常常见的报错:wx1cb4398e1413dce7 这种错误码,通常是 wx.login 返回的 code 已经失效,或者后端拿 code 换 openid 时的接口地址写错了。排查思路是:先看后端日志里是否收到登录请求,收到后看是否有返回的 openid,如果没有说明 code2Session 接口的 appid 或 secret 填错了,或者小程序 appid 和 secret 不是同一个账号的。
6.4 生产环境的部署步骤
本地环境跑通之后,部署到服务器相对简单。我习惯用 Docker 部署,原因是可以把 MySQL、Redis、后端应用统一管理,换服务器时一条命令就能恢复环境。
后端部署的核心 Dockerfile:
dockerfile复制FROM maven:3.6.3-jdk-8 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests
FROM openjdk:8-jre-alpine
WORKDIR /app
COPY --from=build /app/target/pet-system.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar", "--spring.profiles.active=prod"]
第一次构建会下载依赖,时间会比较久,但后续修改代码后只重新构建后一段,速度会快很多。
部署时还有两个细节:MySQL 的时区设置要加 serverTimezone=Asia/Shanghai,否则日期时间会差 8 小时;防火墙要放行 80、443、8080 端口的入站规则,否则外部访问不到。
7. 项目演示与文档交付中的经验补充
这个项目标题里包含了"源码+文档+运行视频+讲解视频"的交付形态,说明用户很可能是要拿来做毕业设计或者项目展示。这里补充几点关于如何让项目演示更顺畅、更专业的经验。
7.1 演示数据要提前准备好
很多同学在答辩或演示时翻车,最大的原因不是代码写错了,而是演示数据没准备好。建议提前往数据库里灌一批"看起来真实"的数据,比如:
- 服务项目:猫咪基础洗护 88 元、金毛精洗 128 元、泰迪美容造型 158 元、猫咪疫苗套餐 199 元等。
- 服务时段:当天到未来 7 天,每天从 9:00 到 18:00,每 1 小时一个时段。
- 商品:宠物粮、猫砂、牵引绳、宠物玩具等,每种搭配几张清晰的图片。
- 用户:用你自己的微信扫码登录,预设 2-3 只宠物档案。
演示数据的数量不用太多,但每类数据都要覆盖到不同状态。订单列表里最好同时有待支付、已支付、已完成、已取消四种状态的订单,这样展示时可以完整展示各种业务场景。
7.2 演示流程的编排
按我自己的演示习惯,通常会按"注册登录 → 首页浏览 → 预约服务 → 商城购物 → 订单管理 → 后台管理"的顺序走一遍。演示时不要一口气点完所有功能,建议在每个页面停留 3-5 秒,讲清楚这个页面的核心业务逻辑,让评审老师或观众看清你的设计思路。
比如预约服务页面,不要只点一次就完事。可以演示两种场景:正常预约成功,以及选一个"已约满"的时段看系统的提示效果。这种细节处理能明显提升项目的完成度。
7.3 讲解视频的录制要点
如果你需要录制讲解视频,我有几条经验供参考:
- 视频分辨率选择 1080P、帧率 30fps 即可,过高的码率会让文件很大,上传分享平台反而卡顿。
- 每一段视频控制在 10-15 分钟,一个视频从头讲到尾很容易让观众疲劳。建议拆分为:项目介绍(5 分钟)、数据库设计讲解(10 分钟)、后端接口讲解(10 分钟)、小程序页面演示(10 分钟)、部署演示(5 分钟)。
- 录制前准备好脚本,但录的时候不要逐字念,而是用口语表达。逐字念的语音一听就很僵,大多数观众会直接跳走。
- 讲代码时,打开 IDEA 的字体放大到 18 号以上,方便手机端观看。代码讲解不建议逐行念,讲关键方法的设计思路即可。
7.4 文档写作中的图表与代码规范
项目文档建议包含以下几部分:项目背景与需求分析、技术选型说明、数据库设计文档(含 ER 图)、接口文档、系统测试报告、部署文档。其中最容易出彩的是数据库设计文档和接口文档。
数据库设计文档最好画一张清晰的 ER 图,标出主外键关系,配合每张表的字段说明表。接口文档推荐用 Swagger 自动生成,再手动补充每个接口的请求示例和返回示例。如果你用的是 SpringBoot,加一个 springfox-swagger2 依赖就能自动生成接口文档,非常省力。
代码里还有一个容易被扣分的细节:命名规范。数据库表名统一小写下划线(service_appointment、goods_order),Java 类名用驼峰(ServiceAppointment、GoodsOrder),字段名保持和数据库字段一一对应。这些细节看起来微不足道,但评阅老师在审阅代码时,规范命名的代码观感上就会加分不少。
8. 实际开发中最高频的十个错误与解决方案
写在最后。坦白说,项目的核心开发周期我只用了不到两周,但调试这些乱七八糟的问题断断续续花了快一周。我把高频出现的十个问题整理成表格,给正在做类似项目的你做一个参考速查表。
| 序号 | 错误现象 | 根因分析 | 解决方案 |
|---|---|---|---|
| 1 | 小程序请求后端提示 404 | 后端 Controller 路径和小程序请求路径不一致 | 检查 @RequestMapping 的路径是否完全匹配,注意大小写 |
| 2 | 后端跨域报错 | 小程序域名访问后端被浏览器安全策略拦截 | 配置 @CrossOrigin 或实现 WebMvcConfigurer 的 addCorsMappings |
| 3 | 微信登录 code 失效 | code 是一次性的,重复使用会失效 | 前端确保每次登录都调用 wx.login() 获取最新 code |
| 4 | 数据库插入中文乱码 | MySQL 连接字符串缺少 characterEncoding 参数 | 在 JDBC URL 中加 characterEncoding=utf8 |
| 5 | 时间字段显示相差 8 小时 | 服务器时区和数据库时区不一致 | JDBC URL 加 serverTimezone=Asia/Shanghai |
| 6 | goods_stock 变负数 |
并发下单未加库存条件判断 | 使用 update goods set stock = stock - 1 where id = ? and stock >= 1 |
| 7 | 订单支付回调无限重试 | 回调处理抛异常导致未返回 SUCCESS | 回调接口做好幂等处理,已支付订单直接返回 SUCCESS |
| 8 | 上传的文件无法访问 | 上传路径未做静态资源映射 | 配置 WebMvcConfigurer 将本地目录映射为静态资源路径 |
| 9 | 定时任务在多个实例重复执行 | 多实例部署未加锁 | 单实例部署或接入分布式锁 |
| 10 | 本地运行正常部署后接口 404 | 打包时漏掉了静态资源或配置文件 | 检查 target 目录下 jar 包是否包含 resources 下的文件 |
这些问题如果你遇到了,优先对照表格排查,大部分都能在十分钟内解决。如果还有别的疑难杂症,一般走三步排查法:先看后端日志(tail -f /app/logs/app.log),再看数据库(数据是否写入正确),最后看网络(微信平台接口是否是通的),基本跑不出这个范围。
做这类全栈项目,最大的感受是:业务功能谁都能写出来,区别在于你愿不愿意把异常场景、边界条件、部署细节这些"看不见的地方"做好。系统正常跑通的那一刻,你会觉得之前调试的所有 bug 都值得。希望这篇分享能帮你少走一些弯路,用更短的时间做出一个逻辑完整、能拿得出手的宠物服务预约系统。
