小餐饮店的包厢预订,听起来只是“接个电话记个本子”的小事,可真要把订座、取消、确认、核销、改桌这一串动作统一管起来,就会发现每一步都藏着业务坑。看到这个系统名字——基于 Python+Django 的小餐饮餐桌包厢预订管理系统,同时带小程序端和安卓端——我就知道这不是一个简单 CRUD 项目。这篇内容会完整拆解:为什么需要两端、后端怎么搭、数据库怎么设计、并发订座怎么防、以及上线前后最容易被忽略的细节。想拿 Django 做项目实践,或者想给小型餐饮门店做一套预订工具的人,可以把它当成一份可落地的设计笔记。
1. 先盘业务:小餐饮店订包厢这件事,真正要管的是什么
很多人一上来就画表建接口,最后做出来的系统只是一个“订单录入工具”,老板根本不想用。原因很简单:没有先把业务里的异常情况想清楚。小餐饮店的订座场景,表面上是“顾客选一张桌,提交预订”,实际上围绕一张桌子的状态会经历好几轮变化,每一轮变化都可能出错。
1.1 电话订座与手写记录带来的典型问题
先说最原始的场景:顾客打电话订包厢,服务员拿本子记。看起来没什么问题,但经营一段时间后,下面几类情况几乎必然出现。
第一是信息缺失。顾客说“周六晚上订个八人包厢”,服务员记了日期和人数,却忘了问手机号。等到周六下午想确认一下,翻遍本子也找不到联系方式。第二是信息冲突。同一间包厢、同一个晚餐时段,两个顾客在不同时间打电话,结果都被记上去了。因为没有统一的“可订状态”,前台只能凭记忆判断。第三是变更不同步。顾客打电话改时间,接电话的服务员记了,但没有同步给其他服务员,到了晚上其他同事按照旧时间安排,客人到了又没位置。第四是无法核销。顾客到店后说“我订过了”,前台要找半天本子,核对口述信息,效率很低。
所以做这套系统时,首先要管的不是“下单界面好不好看”,而是三个核心问题:顾客怎么知道现在还有哪些桌能订?商家怎么确认、取消、核销一笔预订?一张桌子在任意时刻到底处于什么状态?把这三个问题管住了,系统才能真正替代本子。
1.2 三类角色与各自的操作边界
这套系统里一共有三类使用对象。
顾客端是小程序。顾客不需要下载安装,扫码就能进,进店前先看桌位、选时段、填联系方式提交预订。顾客端不要做太复杂,账号密码注册这种流程要尽量避免,否则转化率会很低。
商家端是安卓应用。放在收银台或者前台,店员登录后能看当天所有预订,按时间、桌位筛选,确认订单、取消订单、顾客到店后核销、临时改桌。商家端的操作频率很高,按钮要够大,信息要一眼能看全。
后端管理端用 Django Admin。管理员在这里配置餐桌、包厢、座位数、最低消费、午市晚市时段。这个角色不需要做独立前端,用 Django 自带后台足够,省掉不少工作量。
1.3 功能需求清单
| 角色 | 模块 | 关键功能 |
|---|---|---|
| 顾客端 | 桌位浏览 | 按日期、时段、人数查询可订散桌与包厢 |
| 顾客端 | 提交预订 | 填写联系人、手机号、备注,生成订单 |
| 顾客端 | 订单管理 | 查看订单状态、取消未确认或已确认订单 |
| 商家端 | 今日看板 | 按桌位/时段查看当日预订,突出联系人与人数 |
| 商家端 | 订单处理 | 确认、取消、核销、改桌、标记未到店 |
| 管理端 | 基础配置 | 桌位启用停用、包厢低消、营业时段维护 |
| 管理端 | 数据统计 | 预订量、取消率、各时段热度 |
这个清单看起来不复杂,但每一项背后都有对应的数据模型和接口设计。需求确认到这里,就可以进入技术选型了。
2. 技术选型:Django 做服务端、小程序做C端、安卓做B端,不是拍脑袋决定的
技术选型不能只看“哪个框架火”,要看项目规模和团队维护成本。这个系统定位很明确:小餐饮店、预订业务、两端接入,复杂度中等但业务规则明确。最终选择 Python+Django 作为后端,小程序承担顾客端,安卓原生承担商家端,是经过对比后的结果。
2.1 为什么服务端选 Django 而不是 Flask 或其它框架
后端要处理的核心事情包括:用户认证、商户权限、订单状态机、桌位与时段冲突检测、管理后台配置。这些事情里,认证和权限是最容易出错的,如果自己从零写,工作量不小。
Django 自带用户体系和权限系统,加上 Django REST Framework,可以快速实现基于 Token 或 JWT 的接口认证。Django 的 ORM 自带数据库迁移工具,开发时改模型、生成迁移、同步表结构,整个过程不用手写 SQL,对后期维护非常友好。Django Admin 更是省心,桌位配置、包厢低消、时段维护这些运营功能,直接注册一下模型就能在后台操作,不用额外开发管理页面。
如果换 Flask 或 FastAPI,确实更轻量,但需要自己组织认证方案、权限判断、数据库迁移流程,项目一旦进入订单和锁座阶段,这些基础设施都会变成隐性成本。对这个体量的项目来说,Django 的“全家桶”反而能缩短开发周期。
2.2 两个客户端的定位差异
为什么顾客端用小程式,商家端却用安卓原生应用?
顾客端的核心诉求是触达成本低。顾客在点评软件或者餐厅宣传物料上扫码,直接进入小程序,不需要下载安装。小程序在移动端的打开率、授权登录流畅度,都明显优于网页版,也更适合“临时打开、用完即走”的场景。
商家端的核心诉求是稳定和高频操作。店员一天要处理几十笔预订,打开应用就能看到今日列表,需要快速点击确认、核销,还要能在一台固定设备上长期保持登录状态。安卓原生应用在这类场景下更可靠,桌面组件、系统通知、电源管理都可以按需定制。如果商家端也用小程序,会遇到数据同步不及时、页面被平台限制、无法常驻前台等问题,所以从业务稳定性出发,安卓端是更合理的选择。
2.3 整体架构与一次完整预订的请求路径
系统整体可以分成三块:
- Django 服务端:提供 REST API,连接 MySQL 数据库,通过 Redis 做缓存和简单限流。
- 小程序顾客端:负责用户浏览、预订、订单查看,直接调用后端 API。
- 安卓商家端:负责门店运营,登录后调用商家专用 API。
一次完整预订的流程是这样的:
- 顾客在小程序选择日期和午市/晚市时段,后端返回可预订桌位列表。
- 顾客选择某个包厢,填写联系人和手机号,提交预订请求。
- 后端在事务中创建订单,并生成桌位时段锁,防止同一桌同一时段被重复预订。
- 商家安卓端通过轮询或刷新看到新订单,点击确认,订单状态变为已确认。
- 顾客到店,前台在安卓端点击核销,订单状态变为已完成。
- 如果顾客未到店,商家可手动标记未到店,或由定时任务自动释放过期订单。
这条链路的关键不在每一步的 CRUD,而在第 3 步的“桌位时段锁”。这部分会在数据库设计和后端接口章节重点展开。
3. 数据库设计:把桌位、时段、订单和状态机一次理顺
数据模型设计得好不好,决定了后面并发逻辑难不难写。这个系统的核心表就那么几张:餐桌表、预订订单表、桌位时段锁表。把这几个表的关系和约束想清楚,系统的主干就稳了。
3.1 餐桌与包厢的基础建模
我先给出一个最直接的餐桌模型。
class Table(models.Model): TABLE_TYPE_CHOICES = [ ('table', '散桌'), ('box', '包厢'), ] name = models.CharField('桌位名称', max_length=50, unique=True) table_type = models.CharField( '桌位类型', max_length=10, choices=TABLE_TYPE_CHOICES, default='table' ) seat_count = models.PositiveIntegerField('可坐人数') min_charge = models.DecimalField( '最低消费', max_digits=10, decimal_places=2, null=True, blank=True ) is_active = models.BooleanField('是否启用', default=True) sort_order = models.IntegerField('排序', default=0) class Meta: db_table = 'dining_table' ordering = ['sort_order', 'id'] def __str__(self): return self.name这里有几个细节值得注意。
min_charge字段是给包厢用的低消,散桌可以为空。设计时不要觉得“低消在菜品里配置就行”,订座界面需要直接展示包厢低消,否则顾客选了个包厢,到店后才发现有最低消费,体验很糟糕。
is_active字段用于软停用。某个包厢要装修,直接停用即可,历史订单数据还能保留,不需要物理删除。如果做物理删除,订单表里的外键会关联不上,历史追溯会非常痛苦。
3.2 预订订单模型与状态机
订单表是整个系统的业务中心。字段不需要过多,但关键信息一个都不能少。
class Reservation(models.Model): STATUS_PENDING = 'pending' STATUS_CONFIRMED = 'confirmed' STATUS_COMPLETED = 'completed' STATUS_CANCELLED = 'cancelled' STATUS_NO_SHOW = 'no_show' STATUS_CHOICES = [ (STATUS_PENDING, '待确认'), (STATUS_CONFIRMED, '已确认'), (STATUS_COMPLETED, '已完成'), (STATUS_CANCELLED, '已取消'), (STATUS_NO_SHOW, '未到店'), ] order_no = models.CharField('订单号', max_length=32, unique=True) table = models.ForeignKey(Table, on_delete=models.PROTECT, verbose_name='桌位') book_date = models.DateField('预订日期') time_slot = models.CharField('时段', max_length=10) contact_name = models.CharField('联系人', max_length=50) contact_phone = models.CharField('手机号', max_length=20) person_count = models.PositiveIntegerField('就餐人数') status = models.CharField( '状态', max_length=20, choices=STATUS_CHOICES, default=STATUS_PENDING ) remark = models.TextField('备注', blank=True) created_at = models.DateTimeField('创建时间', auto_now_add=True) confirmed_at = models.DateTimeField('确认时间', null=True, blank=True) completed_at = models.DateTimeField('核销时间', null=True, blank=True) cancel_reason = models.CharField('取消原因', max_length=200, blank=True)订单状态不能只有“已预订”和“已取消”两种。小餐饮店最看重“到店核销”和“未到店释放桌位”,如果状态太少,商家根本不知道哪些预订真正到店了。
状态流转可以这样理解:
- 待确认:顾客刚提交,商家还没处理。
- 已确认:商家电话或后台确认,顾客会正常到店。
- 已完成:顾客到店,前台核销,整个流程结束。
- 已取消:顾客自己取消或商家取消。
- 未到店:已确认但顾客没有出现,商家手动标记,释放桌位。
order_no要单独生成,不要用数据库自增 id 直接展示给用户,否则会暴露餐厅一天接单量,也容易被恶意遍历接口。
3.3 防止重复预订:桌位时段锁
这是整套系统最核心的表。同一张桌子、同一天、同一个时段,只能存在一个有效预订。如果直接在预订订单上做唯一约束,问题在于“已取消”和“已完成”的订单也要释放桌位,同一个桌位在一天内的多个时段可能会产生多条历史记录,无法简单用一个唯一约束解决。
更好的方式是单独建一张锁表,专门记录“哪个桌位、哪一天、哪个时段被占了”。
class TableSlotLock(models.Model): table = models.ForeignKey(Table, on_delete=models.CASCADE, verbose_name='桌位') book_date = models.DateField('预订日期') time_slot = models.CharField('时段', max_length=10) reservation = models.OneToOneField( Reservation, on_delete=models.SET_NULL, null=True, blank=True, related_name='slot_lock', verbose_name='关联订单' ) created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = 'table_slot_lock' constraints = [ models.UniqueConstraint( fields=['table', 'book_date', 'time_slot'], name='unique_table_date_slot' ) ]当顾客提交预订时,在后端事务里尝试插入一条锁记录。如果插入成功,说明这个时段没有被占用;如果插入失败,说明已被别人先订走,直接返回“该时段已被预订”。
取消订单、核销完成、标记未到店时,要同步删除锁记录。删除锁记录不代表删除订单本身,订单状态和历史仍然保留,只是桌位被释放,后续顾客可以继续预订这个时段。
有人会问,为什么不直接在订单表上加UniqueConstraint(fields=['table', 'book_date', 'time_slot'])?原因很简单:取消和完成状态的历史订单如果也参与唯一约束,同一桌位同一时段就永远无法再次预订了。锁表的本质是把“当前占用”和“历史记录”分开,职责清晰。
3.4 时段配置与备注字段的设计
time_slot字段看起来只是一个字符串,但建议在系统中定义枚举值,比如lunch和dinner,同时在后端单独维护一张营业时段配置表。
class BusinessHour(models.Model): slot = models.CharField('时段标识', max_length=10, unique=True) slot_name = models.CharField('时段名称', max_length=20) start_time = models.TimeField('开始时间') end_time = models.TimeField('结束时间')营业时段不要写死在小程序前端,否则商家调整营业时间还要发版。后端返回桌位列表时,把当天可订时段一起返回,前端渲染即可。
remark字段一定要留。很多顾客会备注“过生日”“需要宝宝椅”“尽量靠窗”,这些信息在商家端看板上直接显示比任何系统功能都实用。后来我发现,这个字段是小餐饮店里使用频率最高的字段,千万不要为了表结构简洁而省掉。
4. 后端核心接口:查座、锁座、订座、取消、核销的完整链路
接口设计按照业务动作拆分,不做一个万能接口。核心接口包括:查询可订桌位、创建预订、取消预订、商家确认、商家核销、商家统计。每个接口都要在正确的层次做权限校验。
4.1 接口清单
| 方法 | 路径 | 说明 | 权限 |
|---|---|---|---|
| GET | /api/v1/tables/available/ | 查询指定日期时段的可订桌位 | 用户可访问 |
| POST | /api/v1/reservations/ | 创建预订 | 用户登录 |
| POST | /api/v1/reservations/{id}/cancel/ | 取消预订 | 顾客本人或商家 |
| POST | /api/v1/reservations/{id}/confirm/ | 商家确认 | 商家 |
| POST | /api/v1/reservations/{id}/checkin/ | 商家核销 | 商家 |
| POST | /api/v1/reservations/{id}/no-show/ | 标记未到店 | 商家 |
| GET | /api/v1/merchant/reservations/today/ | 商家查看今日预订 | 商家 |
| GET | /api/v1/merchant/stats/summary/ | 商家查看营业统计 | 商家 |
接口统一加/api/v1/前缀,好处是后期升级接口时,老版本安卓端还能继续使用旧地址,不会因为一次改动全部不可用。
4.2 查询可订桌位的实现思路
查询接口要注意两个点:一是把已被锁定的桌位排除掉,二是根据人数过滤座位数。
from django.db.models import Q from .models import Table, TableSlotLock def get_available_tables(book_date, time_slot, person_count): locked_table_ids = TableSlotLock.objects.filter( book_date=book_date, time_slot=time_slot ).values_list('table_id', flat=True) tables = Table.objects.filter( is_active=True ).exclude( id__in=locked_table_ids ).filter( Q(seat_count__gte=person_count) ).order_by('sort_order', 'id') return tables这里用了exclude(id__in=locked_table_ids),先把已占用桌位剔除,再筛选可坐人数。有些系统喜欢把“是否被预订”也设计成字段,比如is_booked,然后用定时任务去更新,这种做法并发下极容易出错。正确思路是每次查询都实时计算锁表,数据库唯一约束保证准确性。
前端展示时,建议把可订桌位和不可订桌位一起返回,并标记不可订的原因,这样顾客看到“满”的状态,也知道不是系统问题。
4.3 创建预订与并发控制
创建预订接口是整个系统最容易出 Bug 的地方。这里必须使用数据库事务,并把生成订单和插入锁表放在同一个原子操作里。
from django.db import transaction from django.db.utils import IntegrityError from rest_framework.exceptions import ValidationError @transaction.atomic def create_reservation(table, book_date, time_slot, contact_name, contact_phone, person_count, remark): lock = TableSlotLock( table=table, book_date=book_date, time_slot=time_slot, reservation=None ) try: lock.save() except IntegrityError: raise ValidationError('该桌位在此时间段已被预订') reservation = Reservation.objects.create( table=table, book_date=book_date, time_slot=time_slot, contact_name=contact_name, contact_phone=contact_phone, person_count=person_count, status=Reservation.STATUS_PENDING, remark=remark ) lock.reservation = reservation lock.save(update_fields=['reservation']) return reservation先插入锁表,再创建预订,顺序不能反。如果先创建预订再插锁表,一旦锁表插入失败,就会留下一条没有对应锁的脏订单,后续处理很麻烦。锁表先插入成功,即使后面创建预订失败,事务回滚也会把锁表插入回滚掉,不会产生脏数据。
有些项目在查询时先filter判断是否为空,再插入。这种 check-then-act 模式在高并发下必然出问题。两个请求同时查到为空,然后同时插入,唯一约束就会拦截其中一个,返回 IntegrityError。所以并发安全必须依赖数据库唯一约束,而不是依赖代码里的判断。
前端还需要考虑重复提交问题。用户点击提交后,如果接口响应慢,用户会连续点击多次,后端就会生成多笔相同预订。建议在小程序端做按钮 loading 禁用,同时后端在订单号上做幂等控制,每笔提交带一个客户端生成的一次性 token,后端重复收到相同 token 时直接返回第一次创建的订单,避免重复数据。
4.4 取消、确认、核销的状态流转
取消接口要区分顾客取消和商家取消。
顾客取消时,只能取消“待确认”和“已确认”状态的订单。已核销的订单不能再取消,已完成订单也不能随意回到待确认状态。取消操作需要做两件事:修改订单状态为cancelled,同时删除对应的锁记录。
商家确认接口比较简单,权限要求是商家角色,订单状态从pending改为confirmed,记录confirmed_at。如果订单已经是cancelled,接口要直接返回错误,不能把已取消订单重新激活。
核销接口是商家到店后的操作。顾客报手机号或订单号,商家在安卓端搜索订单,确认后点击核销。核销后状态变为completed,锁记录删除。这里有个细节:如果核销时订单还是“待确认”状态,应该允许直接核销还是要求先确认?按实际场景,顾客已经到店了,就没必要再卡一道确认流程。可以设计成核销接口自动把pending转成confirmed再转成completed,一步到位。
标记未到店是特殊操作。顾客预订了包厢但没来,商家等到时段结束就可以点击“未到店”,订单状态变为no_show,锁记录删除。这个动作也要记录操作人,方便后续对账。
后端接口还有一个不成文的习惯:查询列表接口只返回前端需要的字段,不要把整行订单数据都丢出去,尤其是cancel_reason、内部备注这类字段。用 Django REST Framework 的序列化器控制输出字段,既能减小流量,也避免信息泄露。
5. 小程序顾客端:从选桌到下单的交互细节
小程序端的开发重点是交互简洁,用户心智不能复杂。整体页面拆成四个:首页、桌位列表页、提交订单页、订单列表及详情页。页面之间不搞花哨的跳转,核心路径控制在三步以内。
5.1 页面与流程设计
首页只需要展示餐厅名称、地址、联系电话和一个醒目的“立即预订”入口。顾客点击后进入桌位列表页。
桌位列表页是核心页面。顶部是日期选择器和时段选择器,中间是人数选择,下方是桌位卡片。桌位卡片需要展示桌位名称、类型、可坐人数、包厢低消以及当前状态。可订的卡片可以点击,不可订的卡片置灰。
提交订单页要展示刚才选的桌位、日期、时段,然后填写联系人、手机号、备注。提交按钮默认禁用,等手机号校验通过后才能点击。提交成功后跳转到订单详情页,而不是返回列表页,避免用户以为没提交成功再点一次。
订单列表页展示用户历史预订,按时间倒序。每个订单显示桌位、日期、时段、状态。取消按钮只在订单状态为pending或confirmed时展示,已完成、已取消的订单不提供取消入口。
5.2 日期、时段与人数选择器
日期选择器的默认值建议设为“明天”。很多顾客当天订当天用,但当天订单往往需要商家快速响应,预订成功率不高。默认明天反而能减少被拒绝的体验。
时段选择器不要在小程序里写死lunch、dinner,而是每次进入列表页时调用后端接口获取当天可预订时段。这样商家后台调整营业时段后,顾客端马上生效,不需要发版。时段切换后要重新请求桌位列表,不能只做前端过滤,因为不同时段的锁表数据不同。
人数选择器用步进器,最小值 1,最大值根据餐厅最大桌位人数设定,比如 20。人数变化后会触发桌位列表重新请求,筛选出可坐人数足够的桌位。这个交互看似简单,实际却很重要,否则顾客选了 10 人,列表里却显示 4 人散桌,就会产生误导。
5.3 登录态与订单状态同步
小程序登录不能做成传统的用户名密码。推荐做法是:用户进入页面时静默获取用户标识,后端生成专属用户记录并返回 token,前端把 token 存在本地缓存。提交订单时,后端从 token 中解析用户身份,把订单关联到该用户。
手机号字段单独填写,不在登录时强制授权。很多顾客对手机号授权敏感,强制授权会导致流失。在提交订单页提供手机号输入框即可,商家预订确认时主要靠手机号联系顾客。
订单详情页的状态要能实时同步。最简单的方式是下拉刷新和定时轮询,每 30 秒请求一次订单状态。顾客在小程序里看到“已确认”,到店后发现商家实际状态不同,体验会非常差。但轮询不能太频繁,否则高峰期接口压力大。30 秒对订座场景完全够用。
5.4 网络请求封装与错误处理
小程序的请求方法建议统一封装一层。
function request(path, method, data) { return new Promise((resolve, reject) => { platformRequest({ url: BASE_URL + path, method: method, data: data, header: { Authorization: 'Bearer ' + getToken() }, success: (res) => { if (res.statusCode >= 200 && res.statusCode < 300) { resolve(res.data) } else if (res.statusCode === 401) { handleTokenExpired() } else { reject(res.data) } }, fail: reject }) }) }统一封装的好处是:token 过期、网络错误、业务错误码都可以在一处处理,不用每个页面写重复逻辑。小程序端遇到接口返回“该时段已被预订”这类业务错误时,不要直接显示后台错误文案,要自己做一层错误码映射,展示给用户的文案应该更友好,比如“手慢了,这个包厢刚被别人订走,看看其他时段吧”。
页面切换时要注意状态污染。用户从列表页选了一个桌位,提交时失败了,返回列表页如果还用旧数据展示,就会出现“明明显示可订,提交却失败”的困惑。建议提交失败回列表页后,强制刷新一次桌位列表。
6. 安卓商家端:核销、改桌、统计,门店管理的日常高频动作
商家端和顾客端完全不是一回事。顾客是低频使用,商家是高频使用,而且商家端的使用者可能不太懂技术,界面必须直接、响应快、误操作率低。
6.1 为什么商家端需要单独做一个安卓应用
有人会问:“顾客端已经做小程序了,商家端直接用小程序不就行了吗?”实际不是这样。
商家在使用过程中,经常要把设备放在收银台,一天不锁屏,随时查看新订单。安卓应用可以常驻前台,保持登录状态,配合系统通知提醒新预订。小程序的页面在后台容易被回收,商家每次都要重新打开,操作效率会低很多。
另外,商家端的数据量大一些,今日预订列表、桌位状态看板、订单详情操作,这些在高频切换时,原生应用的流畅度和本地缓存能力更强。所以即使开发成本高一点,商家端单独用安卓应用也是值得的。
6.2 今日预订看板与桌位状态可视化
安卓端首页默认展示“今日预订”。按照时段分组,午市一组、晚市一组,每组内按预订时间排序。每一行需要展示:顾客姓名、人数、桌位、时段、状态,联系电话要放在明显位置,方便一键拨打。
桌位状态看板可以设计成表格形式。左侧是桌位列表,顶部是时段,交叉点显示预订状态。已预订格子显示为深色,点击格子可以看到预订详情。这个功能对商家非常有用,能一张图看懂今晚哪个包厢空着,哪个时段还有翻台机会。
看板数据来源于/api/v1/merchant/reservations/today/接口。应用启动时拉取一次,每次操作完成后刷新对应分组,不需要频繁全量刷新。为了减少后端压力,可以加一个简单的时间戳缓存,比如一分钟内不重复请求。
6.3 核销、取消与改桌操作
核销流程要极简。商家在今日列表中点击一条订单,进入详情,详情页有一个大按钮“顾客已到店”,点击后调用核销接口。核销成功后,返回列表并自动刷新。
取消操作要二次确认。商家取消订单可能是顾客电话告知不来了,也可能是商家自己操作失误。二次确认弹窗里要填写取消原因,比如“顾客行程取消”“超时未到”。这个原因会显示在订单详情里,后续对账时有据可查。
改桌是很容易被忽略的功能。顾客原本订了 A 包厢,到店后发现 A 包厢还有上一桌没走,商家需要临时把他换到 B 包厢。改桌接口要做两件事:删除 A 桌位的锁记录,给 B 桌位创建锁记录。如果 B 桌位在同一时段已有预订,接口要返回冲突提示,不能强行改桌。
6.4 新订单提醒的轻量实现
安卓商家端最怕漏单。顾客在小程序提交预订后,商家如果几分钟后才看到,用户可能已经去别的店了。
推送方案有很多,但对小项目来说,最简单可靠的是“轮询 + 本地通知”。安卓端每 60 秒调用一次增量接口,比如/api/v1/merchant/reservations/today/,拿最新订单列表,和本地缓存做对比,发现新订单就弹系统通知,提示“有新预订:张女士,8人包厢,晚市”。
轮询在小规模场景下完全够用。几百家店同时使用时,每 60 秒一次请求压力也不大。比轮询更实时的 WebSocket 或消息推送,要等业务量上来之后再考虑,前期上了反而增加维护成本。
7. 实战踩坑记录:时间冲突、并发订座、权限隔离
这个项目看起来模块不多,但真正开发时会踩到不少隐蔽的问题。我把几个典型坑单独列出来,都是实际运行中会碰到的场景,越早处理越好。
7.1 同一个桌位同一个时段被重复预订
这是最常见的并发问题。
早期版本如果没有锁表,只靠前端查询结果判断,就会出现这种情况:午市高峰期,两个顾客同时看到某个包厢可订,同时提交,两个请求先后到达后端,都在“检查是否可订”时发现数据库里没有记录,于是同时插入订单,结果两笔预订都成功。商家到了晚上发现同一包厢来了两拨人,立刻崩盘。
解决方式就是前文说的锁表加唯一约束。锁表插入失败时捕获 IntegrityError,把它转成业务错误提示。这个方案的可靠性不依赖代码判断,而是依赖数据库的唯一索引,天然能挡住并发。
这里有一个容易被忽视的细节:TableSlotLock表插入成功之后,如果后续创建订单失败,事务回滚会把锁表插入一起回滚。所以创建订单函数必须整体包在transaction.atomic里,不能分开提交。
7.2 日期与时段在不同端的理解不一致
小程序的日期选择器通常返回的都是“本地日期”,安卓端如果用new Date()再转字符串,有可能因为时区问题导致日期偏移一天。后端如果把日期字段存成DateTimeField,再用date()比较,也容易受到时区配置影响。
后来定了一个统一规范:所有日期传输用YYYY-MM-DD字符串,不传时间戳。预订只精确到“天”和“时段”,不需要精确到秒。后端在写代码时,直接用DateField存储日期,不要用DateTimeField去截取日期,否则容易出现边界问题。
时段统一用lunch、dinner这类字符串,不要用17:00-21:00这种文本去比较。比较字符串时间很容易出错,因为字符排序和真实时间顺序不一定一致。后端要想判断“当前时段是否还接受预订”,应该用BusinessHour表里的start_time、end_time配合当前时间判断,而不是解析字符串。
7.3 用户权限越权问题
有一段时间接口权限没做细分,所有登录用户都能调用商家接口。测试时发现,普通顾客的 token 也能看到所有订单列表,属于严重越权。
修复方式分两层。第一层,在视图层面做角色权限。使用 Django REST Framework 的权限类,商家专用接口要求当前用户属于商家角色,且只能在商家端使用。第二层,在数据层面做归属过滤。顾客查询订单时,queryset只返回当前用户自己的订单,不能通过修改路径中的 id 查询别人的订单。
这里要特别强调get_queryset的重要性。
def get_queryset(self): if self.request.user.is_staff: return Reservation.objects.all() return Reservation.objects.filter(user=self.request.user)如果忘记做归属过滤,即使接口路径不公开,也会被恶意遍历 id 拿到他人隐私。对小餐饮系统来说,手机号是敏感信息,越权问题绝对不能忽略。
7.4 已确认订单顾客未到店,桌位被长期占用
餐厅下午收到一个晚市包厢预订,商家确认了,结果顾客一直没来,包厢也没法再接其他客人。如果没有“未到店”状态,这个订单就会一直占着锁表,后面的顾客眼睁睁看着包厢显示已订,实际上却空着。
处理方案分两步。第一步,给商家端增加“标记未到店”按钮,营业时段结束后,商家可以手动处理。第二步,增加一个定时任务,每天深夜扫描所有confirmed状态且订座日期已过、时段已结束的订单,自动标记为no_show,并删除对应锁记录。定时任务的时间要设置在营业高峰之后,比如凌晨两点,避免误杀晚到顾客的订单。
这个坑属于典型的“不做状态机设计就要返工”。如果一开始只设计了pending、confirmed、completed、cancelled四种状态,后面发现需要“超时自动释放”,就只能补字段、改逻辑,增加不必要的复杂度。
7.5 前端重复提交与接口幂等
顾客端提交订单时,如果网络延迟,用户连续点了两次提交,后端可能生成两笔订单。两笔订单状态都正常,商家只核销其中一笔,另一笔就一直挂着,造成桌位锁被错误占用。
后端接口要具备幂等能力。最简单的方式是前端在提交时生成一个client_token,后端订单表增加一个唯一字段。第一次请求正常创建订单,第二次请求如果发现相同client_token已存在,直接返回第一次创建的订单,不再新增。
这里尤其要注意事务边界。幂等检查也要放在同一个事务里,否则两个携带相同 token 的并发请求同时到达,依然可能都判断为“不存在”,然后都创建成功。给client_token加唯一约束,配合 IntegrityError 处理,才是可靠方案。
8. 部署上线与后续扩展
系统开发完只是开始,部署和运维同样影响使用体验。小餐饮店的系统不需要高性能集群,但必须稳定、数据不丢、操作不卡。
8.1 一套小而稳的部署组合
后端部署推荐组合是:云主机 + Linux + Nginx + Gunicorn + MySQL。如果不想手动装环境,可以用 Docker Compose 把 Django、MySQL、Redis 编排在一起。
使用 Gunicorn 作为 Django 的 WSGI 服务器,配置 2 到 4 个 worker 即可。不要盲目增加 worker 数,小项目数据库连接数有限,worker 太多会把连接池耗尽,反而更慢。
Nginx 负责反向代理和静态文件服务。接口地址统一走 HTTPS,小程序和安卓端访问都要求证书,否则一些平台会拦截请求。证书可以用免费证书方案,申请和自动续期都不难。
MySQL 单独运行,设置好备份。每天凌晨用自动任务导出一份 SQL,保留最近 7 天。订单数据一旦丢失,对餐厅来说是灾难,备份是必须做的基础操作。
Redis 在这个项目里不是必需品。如果一定要用,可以缓存营业时段配置和桌位列表这类低频变化的数据,减少数据库压力。但要注意,缓存过期策略要精确,避免出现后台改了包厢低消,前端还展示旧价格的情况。
8.2 日常维护与监控重点
上线后重点关注三类问题。
第一类是接口异常。查看服务端日志中是否有IntegrityError、数据库锁等待超时、接口响应变慢。如果锁表插入频繁失败,说明并发冲突确实发生了,属于正常业务逻辑,但也要统计失败率,如果失败率太高,要考虑调整时段颗粒度或者增加桌位。
第二类是订单状态流转异常。比如订单长期停在pending状态没人确认,或者核销后锁记录没有删除。这需要定期写一个巡检脚本,扫描异常数据,把问题订单暴露出来。
第三类是客户端版本兼容。安卓端升级频率可能比小程序慢,所以接口用v1前缀保留兼容。如果某个接口要改字段,尽量新增字段而不是删除字段,老版本客户端遇到未知字段会自动忽略,不会直接崩溃。
8.3 后续可以怎么扩展
这套系统的扩展方向很多。
第一个方向是支付定金。包厢预订特别是低消较高的包厢,可以要求顾客支付小额定金减少爽约率。这需要接入支付能力并设计退款流程,复杂度会增加不少,但业务价值明显。
第二个方向是排队叫号。很多小餐饮店除了预订,还有现场排队。可以在商家端增加排队列表,顾客通过小程序取号,商家叫号后小程序提醒,形成“预订 + 排队”双通道。
第三个方向是会员与营销。预订数据累计之后,可以识别常客,记录顾客偏好,比如“李女士每次都要靠窗位”。后续可以给常客发优惠券,提升复购率。
第四个方向是多门店支持。现在所有表都围绕单店设计,如果要扩展到连锁店,需要增加store_id字段,并确保所有查询、锁表、统计都带上门店维度。这个改造越早规划越好,避免后期大规模调整。
如果只是起步,先做到“预订 + 核销 + 释放桌位”这条主链路就足够了。把这条链路跑稳,比增加一堆花哨功能重要得多。小餐饮店要的不是复杂系统,而是一套让前台少操心的工具。