前阵子帮一个做毕设的同学把过关——项目标题写的是“uniapp微信小程序 + php / python django 咖啡博物馆预约系统”。乍一看又是小程序又是双后端技术栈,信息量不小,但拆开之后发现这项目的定位非常清晰:围绕“咖啡博物馆”这个细分场景,做一套线上预约参观的闭环系统。这种题目在实际项目里特别常见,也特别锻炼人,因为它不是一个纯粹的后台CRUD,而是把微信生态、前后端分离、预约业务逻辑、管理端和用户端串在一起的完整产品。
我以A同学这个项目为主线,把整个系统的设计思路、技术选型、核心功能实现和大大小小的坑完整梳理一遍。这套内容对正在准备类似系统的人很有参考价值,哪怕你手上不是咖啡博物馆,而是美术馆、科技馆、自习室或者宠物店预约,逻辑都是通用的。
1. 内容整体设计与思路拆解
1.1 表面是预约,实际是一套C端产品闭环
先说清楚这个系统到底要解决什么问题。咖啡博物馆不是普通咖啡馆,它的核心服务是“看”:看咖啡发展史、看不同产区的豆子样本、看烘焙设备、可能还包含手冲体验课和杯测活动。这种场景天然需要分时预约,因为展馆空间有限,讲解员和体验课席位也有限。
用户端的需求其实可以拆成四块:浏览博物馆基本信息、查看可预约时段并提交预约、查看自己的预约记录、到馆后出示凭证核销。管理端的需求稍微重一些:维护展览内容和活动场次、设置每日预约时段和人数上限、查看全部预约并操作核销、统计预约数据。
这个项目就是典型的前后端分离架构。前端用uniapp写一套代码,编译成微信小程序,同时以后想要H5或App版本也不用重新开发。后端用Django提供RESTful接口,管理MySQL或SQLite里的业务数据,用户在小程序里的所有操作都通过接口完成。
1.2 标题里同时出现PHP和Python Django,是怎么回事
这个双后端技术栈一开始看起来有点怪,但深挖一下就知道大概率是两种来路:要么是课题文档在列举可选技术方案,要求学生在两者之间选一个;要么是早期原型用PHP快速做验证,后面为了应付更复杂的业务逻辑切到了Django。A同学的项目就是后者,先用PHP搭了个能跑的最小原型,后来发现预约并发控制、用户认证、后台管理这些模块用Django做更顺手,才把主后端定成了Python Django。
我在后面第5节会专门讲,如果硬要用PHP实现同一套业务逻辑,该怎么映射过去。
1.3 这套系统的价值点和适合人群
预约系统的难点从来不在“增删改查”,而在于时间、人数、状态这三者的联动。选了日期要看当日有没有满约,选了时段要扣减剩余名额,提交时要防止同一个人重复占坑,到馆核销时要确认凭证的归属。这套业务逻辑在咖啡博物馆项目里一条都不会少,学好之后可以平移到无限多的场景。
适合参考这套内容的读者:准备做微信小程序相关课题的学生,工作中需要快速交付小程序+后端接口的开发者,还有想从零了解预约类系统设计的爱好者。本文会按完整项目的顺序来讲:技术选型、数据库设计、核心流程实现、部署联调和问题排查。
2. 技术选型解析:为什么是uniapp、微信小程序和Django
2.1 前端用uniapp而不是原生小程序,图的是什么
微信原生小程序当然可以做项目,但选uniapp有几个实打实的理由。
第一是跨端复用。uniapp基于Vue语法,一套代码可以编译到微信小程序、支付宝小程序、H5,甚至可以打包成Android和iOS的App。对于毕设或产品初期来说,这等于上了双保险——万一导师或产品经理临时说“能不能做一个手机网页版”,你只需要多跑一条编译命令,而不是重写一个项目。
第二是开发效率。uniapp的组件和API都是跨端封装过的,比如uni.request统一处理网络请求,uni.login封装了各平台的登录逻辑。写过原生小程序的人都知道,wx.request每开一个新页面都要重复配置,代码量不小;uniapp这边可以把请求逻辑封装成一个模块,全局调用,清爽很多。
第三是Vue语法的心智负担低。如果你平时用Vue写后台管理系统甚至简单的网页,那上手uniapp几乎没有额外成本。它在HBuilderX里可以直接创建项目,实时预览到微信开发者工具,改动即时生效,调试体验流畅。
这个项目的页面结构大致是这样的:
- 首页:博物馆简介、热门活动、预约入口
- 展区与活动列表:展示可预约的内容,比如常设展、手冲体验课
- 预约页:选择日期、时段、人数,提交预约
- 我的预约:查看当前和历史预约状态
- 核销页:管理端小程序里扫二维码完成核销
2.2 后端为什么落定Django,PHP输在哪
前面提到A同学先用PHP写过一版原型。为什么最终主后端选了Python Django,这个取舍很值得展开。
PHP本身不弱,尤其用ThinkPHP或Laravel这类框架,开发效率也很高。但放到这个项目的具体诉求里对比,Django有几个明显优势:
- Django自带的ORM足够强大,预约查询这种带条件筛选、聚合统计的操作,用Python写起来比原生SQL或Query Builder更直观
- Django内置Admin后台。预约管理、展区信息维护、时段额度调整,只要简单配置就能获得一个可用的管理界面。这在小程序管理端还没完善的前期,是特别宝贵的兜底方案
- Django的认证体系成熟,配合微信登录的逻辑很容易扩展
- Python生态里处理日期时间的库很顺手,而预约系统恰恰离不开日期时间运算
而且Django的MTV架构和REST framework搭配,能在很短时间内提供一套完整、规范的JSON接口。对于需要写开发文档的课题来说,这种接口结构也更好解释。
2.3 核心依赖清单和版本组合
A同学最终使用的核心依赖如下,给读者做个参考:
| 模块 | 选型 | 说明 |
|---|---|---|
| 前端框架 | HBuilderX + uni-app | Vue3版本,编译到微信小程序 |
| 后端框架 | Django 4.x + Django REST framework | 提供RESTful API |
| 数据库 | SQLite(开发)/ MySQL(生产) | 通过Django ORM切换 |
| 认证 | 微信小程序登录 code2session | 获取openid,用于用户身份识别 |
| 消息通知 | 微信小程序订阅消息 | 预约成功、核销成功的状态通知 |
| 部署 | nginx + uwsgi(生产环境) | 静态资源与接口分离 |
这个组合看起来平平无奇,但它非常稳定。Django4.x配合DRF的序列化器处理JSON,schema直观,A同学从零到跑通前后端联调大概用了几天时间。注意Django和Python的版本要匹配,比如Django 4.2配Python 3.10或3.11都很稳妥,别一上来就上Django 5.x配合老版本Python,容易踩兼容性的坑。
3. 数据库设计与接口约定:预约系统的地基
3.1 核心表结构设计
我在数据库设计上踩过不少次坑,所以看到A同学的设计时格外关注这块。预约系统的地基不是一张预约表就够的,至少要拆成角色、内容、预约、核销四类数据。下面这些表基本就是项目的全部数据模型:
| 表名 | 核心字段 | 作用 |
|---|---|---|
| User | openid, nickname, avatar, phone | 小程序用户身份 |
| Exhibition | name, cover, description, start_date, end_date | 常设展或临时展信息 |
| Activity | title, type, capacity, price, start_time, end_time | 体验课或限定活动场次 |
| Tutor | name, intro, photo, specialty | 讲解员或体验课导师信息 |
| TimeSlot | date, start_time, end_time, max_people | 某一日期下可预约的时段 |
| Appointment | user, slot, date, people_count, status, code | 一条预约记录 |
| CheckinLog | appointment, operator, checkin_time | 核销记录 |
这里要特别强调TimeSlot和Appointment的关系。最忌讳的是直接在Appointment里写死“2025-01-20 10:00-11:00”这样的字符串,后续做剩余名额计算会非常痛苦。正确的做法是时段是独立的资源,Appointment通过外键引用它。计算某个时段是否约满,就是对Appointment按TimeSlot分组做COUNT,再和TimeSlot.max_people比较。
3.2 预约时段与库存的冲突处理
咖啡博物馆的预约和电影订票有一个非常相似的地方:每个时段的名额是有限的,用户可能选不同日期、不同时段,提交瞬间可能两个人盯上同一个时段。如果并发控制做得不好,就会出现“明明超员了,大家却都显示预约成功”的尴尬。
处理办法我从一开始就建议用三层保险:
- 第一层是预约插入时在应用层查询时段剩余额度,如果剩余额度大于请求人数才允许提交
- 第二层是数据库约束,TimeSlot表里设置一个appointed_count实时字段,每次预约成功就原子性地UPDATE。Django的F表达式可以避免读到脏数据
- 第三层是对同一用户设置唯一约束,比如unique_together=[user, time_slot, date],从数据库层面挡住重复下单
A同学在开发阶段直接用了SQLite,到部署前才切到MySQL,Django ORM的迁移脚本很好地处理了数据库切换问题。生产环境下依靠MySQL的InnoDB行锁,UPDATE语句天然串行化,高峰期也不会出现名额超卖。
3.3 接口约定:RESTful风格的API设计
后端接口建议统一用RESTful风格。这个项目的核心接口大致如下:
POST /api/wx/login # 微信登录,用code换openid GET /api/exhibitions # 展区列表 GET /api/activities # 可预约活动列表 GET /api/slots?date=2025-01-20 # 某一天的可约时段 POST /api/appointments # 提交预约 GET /api/appointments # 我的预约列表 POST /api/appointments/{id}/cancel # 取消预约 POST /api/checkin # 扫码核销前端统一用uni.request封装请求,携带token作为身份凭证。Django里用DRF的JWTAuth做登录态管理,前端把token存到uni.setStorage,每次请求塞进Authorization头。这套在uniapp里只需要封装一个几十行的request模块就能搞定,非常省事。
接口设计的时候要注意一个细节:前端拿到的时段数据必须包含slot_id、start_time、end_time、剩余名额,不要只拿时间字符串然后让前端自己算状态。剩余名额的计算是自动化的,接口返回“剩余名额”字段:
{ "id": 12, "date": "2025-01-20", "start_time": "10:00", "end_time": "11:30", "max_people": 50, "appointed_count": 36, "remaining": 14 }前端拿到remaining后,直接判断是否为0,决定按钮是否可以点击。这种字段设计让小程序端逻辑非常简单。
4. 核心功能实现:从微信登录到二维码核销
4.1 微信登录:code换openid
微信小程序登录的标准流程是:小程序端uni.login拿code,把code发给Django后端,后端拿code + appid + secret去微信接口换openid和session_key,然后用openid作为用户标识,自己签一个token返回给小程序。
A同学在这里遇到的一个典型困惑是:用户是不是必须手动授权手机号?不是。基础登录只需要点击“微信一键登录”,小程序会弹出授权窗,用户同意后就能拿到code。手机号授权是可选的,如果预约系统只用来记录预约人身份,不入库手机号也完全可以,只要在“我的”页面里让用户自己补充姓名和手机号就好。
Django端的登录接口核心代码大致是:
class WxLoginView(APIView): def post(self, request): code = request.data.get('code') appid = settings.WX_APPID secret = settings.WX_SECRET url = ( 'https://api.weixin.qq.com/sns/jscode2session' f'?appid={appid}&secret={secret}' f'&js_code={code}&grant_type=authorization_code' ) resp = requests.get(url).json() openid = resp.get('openid') if not openid: return Response({'code': 40001, 'msg': '登录失败'}) user, _ = User.objects.get_or_create(openid=openid) token = jwt_encode({'user_id': user.id}) return Response({'token': token, 'user': {'nickname': user.nickname}})这里要注意,appid和secret绝对不要写死在代码里,更不要打包进小程序前端。secret是后端凭据,泄露出去任何人都能冒充你的小程序调微信接口。放到环境变量或Django的settings配置中,并且加入.gitignore。
4.2 预约流程:选日期、时段、人数、提交
预约流程是用户路径最长的核心环节。A同学实现的方式如下:
用户进入首页,点击“立即预约”,进入预约页。预约页第一步让用户选日期,前端根据日期拉取当日的时段列表,同时展示每个时段的剩余人数。第二步选时段,第三步选择参观人数,第四步填写联系人姓名和手机号(非必填但建议填),最后提交。
提交时前端把slot_id、date、people_count一并传给后端。后端要做三件事:判断该时段是否存在,判断请求人数是否大等于1且不超过上限,判断该时段的剩余名额是否满足本次预约人数。如果通过就创建预约记录,返回预约单号和核销码。
这里要提醒一个坑:很多人在前端把date和slot里的日期混为一谈。TimeSlot本身已经绑定了date,前端提交时其实不需要再传date,直接传slot_id就够了。冗余传date只会增加校验成本。A同学最初也踩了这个坑,前端日期选择器和时段列表各维护了一份日期数据,结果切换日期后偶发提交的数据不一致。后面改成“时段就是日期绑定的资源”,问题直接消失。
4.3 防重复预约与并发控制
在预约系统里,防重复比防超卖更隐蔽。一个用户一天可能预约同一个展馆两次吗?业务上通常允许,但同一时段同一用户重复预约就不合理,会让时段名额被无意义地占用。
Django层面的处理方式是:
with transaction.atomic(): slot = TimeSlot.objects.select_for_update().get(id=slot_id) if slot.appointed_count + people_count > slot.max_people: return Response({'code': 40002, 'msg': '当前时段已满'}) Appointment.objects.update_or_create( user=user, slot=slot, date=slot.date, defaults={'people_count': people_count, 'status': 'confirmed'}, ) slot.appointed_count += people_count slot.save()select_for_update会锁住这一行,另一个请求必须等事务结束才能读到最新值。这把“查-改-存”的过程串行化,杜绝了超卖。同时Appointment表里user、slot、date的组合唯一约束,可以从根上防止同人同时段重复预约。
实际联调的时候,A同学用两个小程序账号模拟同时提交,发现即使锁了行,如果前端没禁用按钮,用户连点三次提交还是会生成三条预约。这个问题不是后端并发,而是前端重复提交。解决办法是在小程序里加一个submitting状态,提交期间禁用按钮,并且网络层做幂等控制——预约接口收到同一个request_id就直接返回上一次的结果。
4.4 订阅消息通知:预约成功要让人知道
小程序端给用户发通知,用的是微信的订阅消息能力,这个和以前模板消息不一样的是:用户必须主动点击“允许”订阅,你才能发一次消息。也就是说,不能想当然地调一个接口就推消息,必须设计好交互时机。
A同学的方案是:在用户提交预约成功后,弹出一个小弹窗“预约成功后是否接收通知”,用户点击允许,前端调用uni.requestSubscribeMessage拿到订阅授权结果,后端在合适的时间通过access_token调订阅消息接口推送“预约成功通知”。
订阅消息一天只能发一次,而且授权后只能用一次。这意味着如果你的预约流程里有“预约成功通知”和“到馆提醒”两个消息,就要在交互时让用户分别订阅两次。别试图绕过这个限制,微信对这个的审核非常严格,用户也可感知到小程序是不是总在弹授权。
4.5 二维码核销:预约凭证的生成与验证
二维码核销是管理端的核心功能。预约成功之后,前端应该展示一个预约详情页,上面有一个专属二维码,二维码的内容一般是一个带签名参数的预约ID。到馆之后,工作人员用管理端小程序扫这个码完成核销。
二维码的生成不需要特殊的SDK,uniapp里可以直接用开源库生成base64图片,或者后端生成二维码图片返回给前端展示。重要的是签名校验。A同学直接把预约ID放在二维码里,结果发现只要把ID改成别人的就能伪造凭证。后来我让他加了一层签名:二维码内容变成appointment_id + "|" + signature,signature是预约ID、预约状态和固定盐值一起做MD5的结果,核销的时候先验签,再查库校验状态,序列一致才能核销成功。
核销的接口逻辑:
def checkin(request): data = json.loads(request.body) code = data.get('code') appointment_id, signature = code.split('|') if signature != generate_sign(appointment_id): return JsonResponse({'code': 40003, 'msg': '二维码无效'}) appt = Appointment.objects.select_related('slot').get(id=appointment_id) if appt.status != 'confirmed': return JsonResponse({'code': 40004, 'msg': '该预约已使用或已取消'}) appt.status = 'used' appt.save() CheckinLog.objects.create(appointment=appt, operator=request.user) return JsonResponse({'code': 0, 'msg': '核销成功'})这里有个业务上的细节:核销之后不应把时段名额加回去,因为预约名额是“锁定”不是“临时占用”。A同学最开始写反了,核销之后把appointed_count减回去了,结果出现一个时段被核销两次仍然显示可约的BUG。
5. 实操过程与联调记录:Django后端、uniapp前端和微信后台的配合
5.1 Django跨域和微信后台配置
前后端分离开发时,跨域问题绕不开。但小程序端的请求其实没有浏览器的同源限制,只要后端把CORS放行即可,真正要小心的反而是域名白名单配置。微信小程序在真机上请求后端接口时,要求后台配置的合法域名必须是HTTPS且已备案。开发阶段可以在微信开发者工具里勾选“不校验合法域名”,但上线前一定得把域名和SSL证书配好。
A同学第一次真机预览时,发现所有请求都走不通,控制台报错显示是“不在以下request合法域名列表中”。其实问题不在Django,而在小程序的运营设置。解决办法是在微信公众平台的后台“开发-开发设置-服务器域名”里添加request合法域名,格式为https://api.example.com。注意千万别把本地IP写进去,真机访问不到局域网IP的。
5.2 前端联调时的几个老坑
第一个坑是时间格式化。Django返回的时间字段默认是ISO格式,比如2025-01-20T10:00:00+08:00,uniapp的iOS端对T分隔符解析偶尔会出现NaN。稳妥做法是后端直接返回格式化后的字符串,或者前端封装一个formatTime函数统一处理。A同学最后选择在后端序列化器里把时间转成了2025-01-20 10:00这种格式,一劳永逸。
第二个坑是图片域名。小程序端展示图片时,downloadFile的合法域名也要单独配置。如果后端图片放在media.example.com,必须把这个域名加到downloadFile合法域名列表里,否则只配置了request域名是没用的。
第三个坑是列表页的会话过期。token失效后,前端经常没做统一的401拦截,导致用户预约成功后却跳转到登录失效提示。正确的做法是在request封装里加一个响应拦截器:接收到401,清掉本地token,跳转到登录页。这个过程应该对用户透明。
5.3 管理端和统计功能
管理端不一定要做很重,但两项功能必须有。一个是预约列表和筛选,按日期、状态、活动类型过滤,让管理员快速看到谁约了哪个时段;另一个是核销记录查询,出现纠纷时能查谁在什么时间核销了哪张预约单。
Django自带的Admin后台可以先用起来,但说实话在小程序管理端开发出来之前,后台优先满足的是数据可查。A同学给Admin注册了模型,配合list_filter和search_fields,管理员在Web端就能按日期时段搜索预约记录。二维码核销则放在小程序管理端里,实现一个“扫一扫”按钮,调用微信的扫码能力,拿到码内容后请求核销接口。
5.4 如果还是想用PHP,业务逻辑怎么映射
如果你需要在课题里体现PHP技术栈,完全可以不推翻前端的uniapp和微信小程序,只把后端换成PHP框架。这里以ThinkPHP 6为例,把Django版的三个核心逻辑翻译过去。
微信登录的PHP实现:
public function login(Request $request) { $code = $request->param('code'); $url = "https://api.weixin.qq.com/sns/jscode2session?appid={$this->appid}&secret={$this->secret}&js_code={$code}&grant_type=authorization_code"; $res = json_decode(file_get_contents($url), true); $openid = $res['openid']; $user = User::where('openid', $openid)->find(); if (!$user) { $user = User::create(['openid' => $openid]); } $token = Jwt::createToken(['uid' => $user->id]); return json(['token' => $token]); }预约提交的PHP实现要注意事务和锁。ThinkPHP里可以用Db::startTrans()和Db::table('time_slot')->lock(true)模拟同样的行锁效果。整体业务逻辑完全一致,差异只是框架的API风格不同。如果你两套都写一遍,反而能在文档里多一段“双技术栈对比分析”,对课题评分有加分效果。
6. 常见问题与排查技巧实录
把开发过程中整理过的问题清单放在这里,按出现频率排序,基本覆盖了同类项目的大多数坑。
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 真机请求接口失败,开发工具正常 | 未配置域名白名单或使用了HTTP | 配置HTTPS合法域名,开发时勾选不校验域名 |
| 登录一直失败,后端日志显示code无效 | code只能使用一次,前端重复调用uni.login | 确认每次登录只请求一次code,不要缓存code |
| 预约超卖,剩余名额变负数 | 应用层没有加锁,并发同时写入 | 用select_for_update或数据库行锁 |
| 用户重复提交生成了多条预约 | 前端未禁用按钮,后端无幂等控制 | 前端加submitting状态,后端校验唯一约束 |
| 核销码扫了无反应 | 二维码内容格式不对或签名缺失 | 统一二维码生成逻辑,核销时先验签 |
| 订阅消息发送失败,报invalid form id | 用户未授权订阅或模板ID配置错误 | 检查模板ID,且在授权后短时间内发送 |
| 图片打不开 | downloadFile域名未配置 | 在公众平台添加downloadFile合法域名 |
| 管理员核销了已取消的预约 | 状态判断顺序不对 | 先查状态,状态不是confirmed则拒绝核销 |
额外再分享一个调试技巧:微信小程序和Django联调时,一定要打开开发者工具里的“不校验合法域名”,同时在后端日志里打印每次请求的入参和出参。A同学排查预约超卖问题时,靠的就是后端日志里的完整请求记录,否则很难复现两个账号同时提交时的交错过程。
7. 一些实在的收尾经验
这项目做到最后,A同学最大的感受是:预约系统看起来是给用户用的,真正费心思的反而是那些用户看不到的边界场景。同一个用户能不能约两场、满了的时段展示还是不展示、核销了一半还能不能取消预约、管理端误操作核销了怎么补偿——这些规则都比CRUD本身更耗时,也是这类项目拿来区分“作业”和“真系统”的分水岭。
如果让我给后来者一个建议,那就是上来先画时序图和状态流转图,把用户从进入小程序到核销离场的路径完整走一遍,确认每一步的背后数据和状态变化,再动手写代码。预约系统不复杂,但它对状态一致性要求很高,一旦状态逻辑理不清,后端的坑会一个接一个冒出来。
最后再说一句:像我前面提到的,这个项目里的每个模块拿出去都能单独扩展。时段资源可以扩展成场地租赁预约,订阅消息可以扩展到活动提醒,二维码核销可以扩展到会员签到。把基础打扎实了,后续改造成本很低,这也是我坚持让A同学把基础模块写规范、别图省事的原因。