ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

小程序+Django会议室预约系统:模型设计、API开发与部署实战

小程序+Django会议室预约系统:模型设计、API开发与部署实战 简介面向小程序和Django开发者的会议室预约系统完整源码包覆盖客户端预约操作与服务端后台管理适合用于课程设计、毕业设计或快速搭建预约类应用。资源压缩包共106个文件大小约750KB其中Python文件实现Django接口与业务逻辑JS、WXML、WXSS搭建小程序页面及交互JSON负责配置与数据流转另有少量图片和配置文件辅助项目运行整体结构清晰便于按模块查阅。已有699人学习/下载具备一定参考热度。借助这份源码可理解会议室预约系统的整体架构与前后端联调方式学习小程序端如何发起请求、处理返回数据以及Django端如何设计接口和组织项目配置。同时代码中保留了本地配置样例与工程辅助文件便于本地运行和二次开发能够有效缩短从零搭建的时间。无论是入门小程序开发还是想了解Django框架的项目组织方式都能从中获得启发是一份实用的练手与借鉴资源。1. 会议室预约不只是“排个表”这套小程序Django后台解决了什么很多内部会议室预约系统做出来的效果就是一个“在线登记表”谁想用填个时间写个名字就算预约了。等到真的有人提前十分钟跑去开门发现上一个会还没散或者同一时间段被两个人同时约上才知道冲突校验和状态同步远比想象中复杂。这个“会议室预约小程序Django服务端后台源码”包恰好提供了一个可落地的参考实现小程序端覆盖地点选择、日期切换、时段点选、预约提交Django服务端负责会议室数据管理、预约单的冲突校验、用户身份识别以及后台管理页面。目录里能看到setup.cfg、local_settings.py.default、error.html这些文件说明作者在配置拆分、错误页定制上留了手适合准备自建内部工具的开发者也适合拿来做Django 小程序的全栈实训项目。这个项目不是简单的前后端堆砌它的核心价值在于“预约”这个业务场景里的边界处理。你需要理解Django后端用什么模型表达会议室与预约单的关联小程序端如何把用户操作转成可校验的请求两个端之间怎么约定身份认证和响应格式。下文从后端模型讲起一直落到部署和并发防重把整个链路拆开给你看。2. Django服务端后台的设计模型、权限与API这一章是整个项目的承重墙。预约系统的核心并不是页面好不好看而是后端能不能在“提交预约”的那一瞬间快速判断这个会议室在冲突时间段内是否已被占用。源码里的local_settings.py.default暗示项目采用分离配置的方式管理环境我们先从数据模型开始再看如何把这些模型暴露成小程序能直接调用的HTTP接口。2.1 数据模型设计会议室、预约单与用户身份源码中一般会有一个core或meeting应用里面定义会议室和预约单的模型。常见的做法是拆出三张表会议室表、预约单表、用户身份扩展表。用Django的models.py可以这样写# apps/meeting/models.py from django.db import models from django.contrib.auth.models import User class Room(models.Model): 会议室基础信息 name models.CharField(max_length64, verbose_name会议室名称) location models.CharField(max_length128, blankTrue, verbose_name位置) capacity models.PositiveIntegerField(default10, verbose_name容纳人数) equipment models.CharField(max_length255, blankTrue, verbose_name设备列表) is_active models.BooleanField(defaultTrue, verbose_name是否启用) class Meta: db_table meeting_room ordering [id] class Reservation(models.Model): 预约单 STATUS_CHOICES ( (0, 待审核), (1, 已通过), (2, 已取消), ) room models.ForeignKey(Room, on_deletemodels.CASCADE, related_namereservations) user models.ForeignKey(User, on_deletemodels.CASCADE, related_namereservations) title models.CharField(max_length128, verbose_name会议主题) start_time models.DateTimeField(verbose_name开始时间) end_time models.DateTimeField(verbose_name结束时间) status models.SmallIntegerField(choicesSTATUS_CHOICES, default0) created_at models.DateTimeField(auto_now_addTrue) class Meta: db_table meeting_reservation indexes [ models.Index(fields[room, start_time, end_time]), ] def __str__(self): return f{self.room.name} {self.start_time:%Y-%m-%d %H:%M}逻辑说明Room表是静态资源is_active字段用于下架有故障的会议室避免预约了却无法使用。Reservation表里start_time和end_time都是DateTimeField而不是DateField加单独的时间段字段原因是一天可能跨上下午用完整的日期时间做范围比较时可以直接走索引。Meta里给“会议室开始时间结束时间”建联合索引是为了应对高频的冲突查询每次都按room过滤再比较时间段索引能显著减少扫描行数。参数说明on_deletemodels.CASCADE表示删除会议室时级联删除其下所有预约单这符合内部系统的预期。但生产环境中建议用PROTECT或SET_NULL防止误删会议室把历史预约数据也清掉。related_name指定反向查询名这样在查询用户预约历史时可以用user.reservations.all()。2.2 用DRF实现预约业务的API接口小程序端不会直接操作数据库它调用的是REST API。源码大概率使用Django REST FrameworkDRF来实现接口。需要提供三个核心能力拉取会议室列表、提交预约、取消预约。用视图集加自定义action的方式比较清晰# apps/meeting/api.py from rest_framework import viewsets, status from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated from django.utils import timezone from django.db.models import Q from .models import Room, Reservation from .serializers import RoomSerializer, ReservationSerializer class RoomViewSet(viewsets.ReadOnlyModelViewSet): 会议室仅需只读查询 queryset Room.objects.filter(is_activeTrue) serializer_class RoomSerializer permission_classes [IsAuthenticated] class ReservationViewSet(viewsets.ModelViewSet): serializer_class ReservationSerializer permission_classes [IsAuthenticated] def get_queryset(self): return Reservation.objects.filter(userself.request.user) def create(self, request, *args, **kwargs): data request.data.copy() data[user] request.user.id serializer self.get_serializer(datadata) serializer.is_valid(raise_exceptionTrue) # 关键写入前检查冲突 room serializer.validated_data[room] start serializer.validated_data[start_time] end serializer.validated_data[end_time] conflict Reservation.objects.filter( roomroom, status__in[0, 1], ).filter( Q(start_time__ltend) Q(end_time__gtstart) ) if conflict.exists(): return Response({detail: 该时间段会议室已被预约}, statusstatus.HTTP_400_BAD_REQUEST) self.perform_create(serializer) return Response(serializer.data, statusstatus.HTTP_201_CREATED)逻辑说明这里在create方法里手动做了重叠区间判断。判断规则是新预约的start_time小于已有预约的end_time同时新end_time大于已有预约的start_time满足这两个条件即视为时间重叠。用Q对象把两条件组合转换成SQL时就是WHERE start_time end AND end_time start恰好覆盖“前一个会议还没结束后一个已经要开始”的所有情形。参数说明status__in[0, 1]限定只检查待审核和已通过的预约已取消的预约不再占用时间。permission_classes强制小程序用户必须带身份凭证访问。这里data[user] request.user.id是把当前登录用户绑定到预约单上防止用户伪造user字段去替别人预约。序列化器里需要对时间段做基础校验end_time必须晚于start_time且预约不能早于当前时间。2.3 后台管理如何监听异常error.html与定制错误页源码里出现了error.html这是Django项目里常被忽略却又很关键的文件。默认情况下Django的500页面会暴露调试信息但生产环境DEBUGFalse时只返回一个纯文字页面。这个文件的存在说明项目对异常展示做了定制。!-- templates/error.html -- !DOCTYPE html html langzh-CN head meta charsetUTF-8 title系统异常/title /head body div stylemax-width: 600px; margin: 80px auto; text-align: center; h1服务暂时不可用/h1 p系统内部发生错误请稍后重试。已记录日志我们会尽快排查。/p a href/返回首页/a /div /body /html配置方式是在项目的urls.py里添加handler500# config/urls.py from django.conf.urls import handler500 from django.shortcuts import render def server_error(request): return render(request, error.html, status500) handler500 server_error逻辑说明这个文件的意义在于两点。第一不把堆栈信息抛给用户避免内部代码结构泄露第二通过统一模板让异常页面与系统整体的视觉风格一致。内网系统虽然受众少但一个干净的500页面也会让使用者更信任。实际的异常堆栈会写入日志文件排查时只需要看日志即可。中间章还需要出现表格。这里插入一个Django配置对照表配置项开发环境生产环境DEBUGTrueFalseALLOWED_HOSTS[][meeting.example.com]数据库SQLiteMySQL/PostgreSQL静态文件Django自带serverNginx代理这个表格说明源码包里的local_settings.py.default就是用来区分这两种环境的。local_settings.py里可以覆盖开发机上的数据库密码、小程序AppID等敏感信息并且不应该被提交到Git仓库。3. 小程序端预约流程实现从日历到提交订单后端接口就位后小程序端要解决的是“用户如何选会议室、如何选时间、如何提交”。微信小程序的页面结构由wxml、wxss和js组成预约模块通常包含三个页面会议室列表页、预约表单页、我的预约页。这一章围绕表单页的交互实现展开。3.1 小程序目录结构与页面跳转项目的小程序端一般独立放在一个目录下常见结构如下miniprogram/ ├── pages/ │ ├── room-list/ │ ├── reserve/ │ └── my-reservations/ ├── utils/ │ └── request.js ├── app.js ├── app.json └── app.wxssapp.json里注册页面路由并设置tabBar{ pages: [ pages/room-list/index, pages/reserve/index, pages/my-reservations/index ], window: { navigationBarTitleText: 会议室预约, navigationBarBackgroundColor: #1A73E8, navigationBarTextStyle: white }, tabBar: { list: [ { pagePath: pages/room-list/index, text: 预订 }, { pagePath: pages/my-reservations/index, text: 我的 } ] } }逻辑说明将“预订”和“我的”作为两个tab符合预约系统的常规交互路径。用户在预订页看到所有可用会议室点击某个会议室进入预约表单提交后可以在“我的”里看到自己的预约记录和取消入口。3.2 用wx.request对接Django API小程序无法直接使用Cookie做会话保持常见的做法是在登录后获取一个token放到请求头里。封装一个统一的请求模块// utils/request.js const BASE_URL https://api.example.com; const request (path, method GET, data {}) { return new Promise((resolve, reject) { wx.request({ url: ${BASE_URL}${path}, method, data, header: { Content-Type: application/json, Authorization: Token ${wx.getStorageSync(token)} }, success: (res) { if (res.statusCode 200 res.statusCode 300) { resolve(res.data); } else if (res.statusCode 401) { wx.redirectTo({ url: /pages/login/index }); reject(res.data); } else { wx.showToast({ title: res.data.detail || 请求失败, icon: none }); reject(res.data); } }, fail: reject }); }); }; module.exports { request };逻辑说明这里使用DRF自带的TokenAuthentication作为身份认证手段。用户在小程序端通过微信登录换成Django用户后后端返回一个token小程序把它存进Storage。每次请求时从本地取出token塞进Authorization头。success回调里先判断状态码2xx直接返回数据401表示token失效或未登录需要跳转到登录页其他错误则把后端返回的detail字段作为toast提示出来。参数说明BASE_URL在开发阶段可使用http://127.0.0.1:8000但真机调试时不能填localhost必须填电脑在局域网中的IP例如http://192.168.1.100:8000同时Django端要把这个IP加进ALLOWED_HOSTS。method默认是GET提交预约时传POST。3.3 预约表单校验与时间选择预约页最重要的两个控件是会议室选择和时间段选择。会议室可以通过picker组件从列表中选择日期和时间用picker的modemultiSelector来做多列联动。核心逻辑如下// pages/reserve/index.js Page({ data: { rooms: [], roomIndex: 0, dates: [], // 生成未来7天的日期 times: [09:00, 09:30, 10:00, 10:30, 11:00], // 固定半小时间隔 dateIndex: 0, timeIndex: 0, duration: 1 // 默认预约1小时 }, onLoad() { this.loadRooms(); }, loadRooms() { const { request } require(../../utils/request); request(/api/rooms/).then(rooms { this.setData({ rooms }); }); }, submitReservation() { const { rooms, roomIndex, dates, dateIndex, times, timeIndex, duration } this.data; const room rooms[roomIndex]; const startTime ${dates[dateIndex]} ${times[timeIndex]}; const endTime this.calculateEndTime(startTime, duration); if (!room || !startTime || !endTime) return; wx.showLoading({ title: 提交中 }); const { request } require(../../utils/request); request(/api/reservations/, POST, { room: room.id, title: this.data.title, start_time: startTime.replace( , T) :00, end_time: endTime.replace( , T) :00 }).then(() { wx.hideLoading(); wx.showToast({ title: 预约成功, icon: success }); }).catch(() { wx.hideLoading(); }); } });逻辑说明这段代码展示了预约提交前的时间组装方式。小程序端用字符串拼接出start_time和end_time然后通过replace( , T)把2025-04-16 10:00转成ISO格式2025-04-16T10:00:00符合Django的DateTimeField解析要求。calculateEndTime是一个纯函数通过把开始时间加上duration个小时得到结束时间。这里没有做特别复杂的时间段选择而是固定按半小时为一个槽位用户选择起始时间再选时长避免前端生成大量小时段带来的复杂度。参数说明duration的选项可以设为0.5、1、1.5、2单位是小时。小程序的picker组件里start_time和end_time都是字符串后端序列化器再用datetime字段解析。需要特别留意时区问题小程序端传的字符串不带时区Django的USE_TZTrue时会把字符串按当前时区解析并保存为UTC读出来再转回本地时间。如果发现时间差8小时优先检查TIME_ZONE是否设置为Asia/Shanghai。4. 本地配置与部署setup.cfg、local_settings.py.default 与 Django Settings 拆分源码包中的setup.cfg和local_settings.py.default这两个文件第一眼容易被当成无关内容但它们在“让项目能快速跑起来”和“让项目能部署到服务器”这两个阶段里作用明确。这一章讲清楚配置分层的原理以及部署时的实际步骤。4.1 为什么用 local_settings.py.default 而不是直接改 settings.py很多入门教程会告诉你把数据库密码、密钥直接写在settings.py里。这个项目给出了更工程化的做法项目只看settings.py但settings.py会在文件末尾尝试导入local_settings# config/settings.py import os BASE_DIR os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SECRET_KEY default-dev-key-change-me DEBUG True ALLOWED_HOSTS [] INSTALLED_APPS [ ... ] # 具体配置... try: from .local_settings import * # noqa except ImportError: pass而仓库里只提交local_settings.py.default作为模板开发者克隆代码后复制成local_settings.py再改自己的配置cp config/local_settings.py.default config/local_settings.py打开local_settings.py.default里面一般是这样# config/local_settings.py.default DEBUG True ALLOWED_HOSTS [*] DATABASES { default: { ENGINE: django.db.backends.mysql, NAME: meeting, USER: root, PASSWORD: your_password, HOST: 127.0.0.1, PORT: 3306, } }逻辑说明这种“settings local_settings”模式解决了两个问题。第一每个开发者的数据库密码、调试开关各不相同不会互相覆盖第二local_settings.py不进入Git仓库避免生产数据库口令泄露到代码库中。local_settings.py.default的作用是告诉后来者“这里需要你填哪些配置”比一段README更直接。参数说明DEBUG在本地开发时可设为True便于看到详细的报错页一旦部署到测试服务器或生产服务器就必须改成False同时把ALLOWED_HOSTS改成服务器域名或IP字符串列表。否则Django会拒绝处理Host头不匹配的请求表现为只能通过127.0.0.1访问外网访问直接报DisallowedHost。4.2 setup.cfg 在项目里的实际作用setup.cfg常见于Python包项目但这个Django项目里有它多半是为了配置flake8和isort等工具链。示例内容# setup.cfg [flake8] max-line-length 119 exclude .git,__pycache__,*/migrations/*,*/static/* [isort] profile black line_length 119 [check-manifest] ignore local_settings.py逻辑说明[flake8]定义了代码风格检查规则。max-line-length设为119是因为DRF和Django项目中经常要写较长的URL路径和模型字段89字符一刀切会逼着代码频繁换行反而降低可读性。exclude把Django生成的迁移文件和静态资源目录排除掉这些文件不需要遵循团队编码规范。[isort]用于统一import排序profile black保证与其他使用Black格式化的项目保持一致。参数说明如果团队没有强制使用flake8setup.cfg并不会影响项目的运行。但建议保留它因为后续接入CI时flake8可以自动检查代码风格。check-manifest用于检查MANIFEST.in是否缺失当你以后用pip install .方式安装这个项目时它能保证local_settings.py不会被意外打进发行包。4.3 部署到服务器时踩过的坑宝塔布局、mysqlclient与静态文件部署Django项目服务器上最常见的方式是用Nginx反向代理到uWSGI或Gunicorn。如果你用的是宝塔面板流程会简化一些但仍有几个容易出错的地方。# 进入项目目录安装依赖 cd /www/wwwroot/meeting python3 -m venv venv source venv/bin/activate pip install -r requirements.txt # 安装mysqlclient时可能会报错 sudo apt install default-libmysqlclient-dev build-essential pip install mysqlclient安装mysqlclient是第一个高频坑。如果你的系统里缺少libmysqlclient-dev编译会直接失败。上面的命令先装系统依赖再装pip包。装好后执行数据迁移python manage.py migrate python manage.py collectstatic --noinput然后配置Nginx。如果是宝塔可以在站点设置里添加反向代理把/api和/admin等动态路径代理到本地的127.0.0.1:8000# /www/server/panel/vhost/nginx/meeting.conf location /api { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /static { alias /www/wwwroot/meeting/static; }逻辑说明collectstatic把所有App内的静态文件收集到一个目录交给Nginx直接托管避免Django在DEBUGFalse时无法提供静态文件。反向代理时把Host和X-Forwarded-For头传过去可以让Django生成正确的重定向链接也能让后台记录访客真实IP。如果你遇到了“后台登录后点击跳转返回400”优先检查proxy_set_header是否完整。参数说明proxy_pass的地址要和Gunicorn/uWSGI监听地址一致。用uvicorn跑ASGI项目时地址是127.0.0.1:8000使用gunicorn跑wsgi时也是同样的端口但启动命令不同。如果小程序请求报“连接不上”先确认服务器防火墙是否放行8000端口测试时可以用curl -X POST http://127.0.0.1:8000/api/reservations/看本地通不通。5. 进阶技巧用“锁”和“信号”让预约真正防重基本的重叠校验在单机环境下没问题但内部系统一旦用户量上来两个用户同时抢同一个会议室时可能同时读到无冲突结果然后都写入成功。要真正防重需要借助数据库锁和事务。5.1 用select_for_update锁定冲突行Django的select_for_update()可以执行SELECT ... FOR UPDATE在被选中的行上加锁直到事务结束。改造一下ReservationViewSet的create方法from django.db import transaction from django.utils import timezone class ReservationViewSet(viewsets.ModelViewSet): # ... def create(self, request, *args, **kwargs): data request.data.copy() data[user] request.user.id serializer self.get_serializer(datadata) serializer.is_valid(raise_exceptionTrue) room serializer.validated_data[room] start serializer.validated_data[start_time] end serializer.validated_data[end_time] with transaction.atomic(): # 锁定目标会议室的所有未取消预约行 conflict Reservation.objects.select_for_update().filter( roomroom, status__in[0, 1], ).filter( Q(start_time__ltend) Q(end_time__gtstart) ) if conflict.exists(): return Response({detail: 该时间段会议室已被预约}, status400) self.perform_create(serializer) return Response(serializer.data, status201)逻辑说明transaction.atomic()开启事务select_for_update()把命中的预约行加锁。如果两个请求同时进来第二个请求的SELECT ... FOR UPDATE会被阻塞直到第一个请求的事务提交或回滚才继续。这样第二个请求再检查时第一个请求已经写入了新的预约冲突就能被发现。注意select_for_update()必须放在事务里否则Django会抛出TransactionManagementError。参数说明这里锁定的行数取决于查询条件命中的预约单数量。如果会议室只有少量预约记录锁开销可以忽略。但如果你按严格的区间索引查询room字段必须等于目标会议室不能一次性锁全表。另外select_for_update()在MySQL的InnoDB引擎下要求查询走索引否则会退化为锁表因此Reservation模型里那个联合索引就变得非常重要。5.2 用定时任务清理过期预约预约状态如果长期是“已通过”会给管理员造成困扰会议都结束两天了后台还显示当前占用人。源码里可能并没有实现定时任务但作为生产级补全可以参考下面的Celery Beat配置# apps/meeting/tasks.py from celery import shared_task from django.utils import timezone from .models import Reservation shared_task def cancel_expired_reservations(): 将已结束且状态仍为已通过的预约自动置为取消 expired Reservation.objects.filter( status1, end_time__lttimezone.now() ) expired.update(status2)配置定期执行# config/celery.py 或 settings.py 中 CELERY_BEAT_SCHEDULE { cancel-expired-reservations: { task: apps.meeting.tasks.cancel_expired_reservations, schedule: crontab(minute*/10), }, }逻辑说明用Celery Beat每10分钟扫一次表把已经结束的预约状态改成2。状态改成取消而不是删除是为了保留历史记录方便后续统计会议室使用率。不用Celery的话Django项目管理员的cron里跑一条python manage.py shell -c from apps.meeting.tasks import cancel_expired_reservations; cancel_expired_reservations()也能达到类似效果。参数说明清理任务的执行间隔建议在5到10分钟之间太频繁会增加数据库压力太疏则会让状态更新不够及时。如果你的系统没有引入Celery用Django的management command写成python manage.py cleanup_reservations再挂到系统cron里更简单直接。5.3 抓包调试小程序请求的常用方法小程序端和后端联调时经常出现“后端能通小程序请求失败”的情况。内网开发时最常见的调试手段是微信开发者工具自带的“网络”面板可以看到每个wx.request的请求头、参数、响应体和耗时。打开微信开发者工具点击“调试器”中的“Network”标签页再触发一次预约提交。观察重点检查项正常表现常见异常Request URL与后端接口地址完全一致域名带localhost导致真机拒绝Authorization头存在且为Token xxx未登录或token存储key不一致响应状态码201或200404表示URL路径不一致400表示参数校验失败响应体JSON数据或detail错误信息HTML格式表示请求被反向代理劫持如果是真机预览需要把BASE_URL改成局域网IP并在抖音云或微信公众平台的后台把IP加入域名白名单。注意微信小程序正式环境要求请求域名必须是HTTPS且接入备案的域名。本地开发模式下可以在开发者工具的“详情”里勾选“不校验合法域名、web-view业务域名、TLS版本以及HTTPS证书”否则访问http://192.168.x.x会直接被拦截。另外如果你需要抓小程序发出的完整数据包可以用Charles或Fiddler配置好HTTPS证书后在微信开发者工具里设置代理到本机的8888端口就能看到小程序与Django后台之间的明文HTTP流量。但注意抓包工具只能帮你看到请求内容真正的逻辑问题还是要回到Django的runserver日志和数据库里定位。系统上线后建议在Django侧记录每次预约提交的完整参数用logging.info输出到日志文件方便回查是谁在什么时间提交了哪个会议室。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进