☰
Django自习室预约管理系统:从数据库设计到并发控制实战
2026/10/22 2:41:40 网站建设 项目流程

自习室预约这种需求,几乎每个高校和付费自习室都绕不开。之前有同学在群里问能不能帮他做一个座位管理系统,我一开始没当回事,觉得不就是个“占座登记”嘛。真到自己动手用 Python + Django 把整套系统落地,才发现坑全藏在细节里:座位状态怎么实时同步?两个用户同时抢最后一个座位怎么办?学生订了座不来怎么处理?管理员每天看哪些数据才算“有效管理”?

这篇博文我把完整的自修室(自习室)管理系统的设计与实现过程拆开写一遍,从需求梳理、数据模型、预约事务逻辑到部署和避坑,都是实际跑通过的方案。如果你正好也在做类似系统,或者手里有一个半成品想重构,这篇内容应该能直接拿来用。

1. 先把问题拆清楚:自习室管理系统到底要管什么

动手写代码之前,我花了一天时间把自己代入真实使用场景里。自习室管理表面上是“一套预约系统”,实际上牵扯三方角色:学生要能快速找到空位、预约、签到;管理员要能控制开放时段、释放或锁定座位、处理违规;运营方则关心上座率、高峰时段、座位周转率这些数据。缺了任何一块,系统都像个玩具。

1.1 核心业务边界与角色划分

我最终把系统定位成“面向多校区(或者说多自习室网点)的座位资源管理系统”,这也是标题里“城市化”三个字的落点——不是单个房间做一个表,而是可以支撑城市里多个分馆、多个楼层、多个区域的统一管理。边界划分成四块:

  • 用户端:注册登录、浏览自习室与座位图、按时间段预约、签到、查看个人记录、接收违规通知。
  • 自助端(也可以理解成签到机对应的接口):扫码签到、临时离开、释放座位。
  • 管理端:维护自习室/房间/座位信息、设置开放时间、查看预约流水、执行信用分和黑名单策略。
  • 数据端:提供各区域上座率、预约取消率、爽约率等统计结果,方便后续做扩容决策。

角色只有两种:普通用户(学生)和管理员(可细分超级管理员与区域管理员)。不需要搞复杂的权限树,Django 自带的User模型加一个profile_type字段就能满足绝大多数场景。

1.2 需要构建的最小可用闭环

一个能真正上线给用户使用的系统,其实只需要一条核心链路:用户看到座位 → 提交预约 → 系统锁定座位 → 用户到馆签到 → 使用完毕释放座位。其他所有功能都是围绕这条链路做保障的支线。

我给自己设了“最小闭环”清单:

  • 座位必须绑定具体的自习室、区域、时段类型(上午/下午/晚间/全天),并有清晰的座位编号。
  • 预约必须做到“同一座位同一时段不可重复预约”,且要能处理并发请求。
  • 签到要允许管理员手动操作,同时支持用户扫码自助操作(通过 URL 带 token 实现)。
  • 爽约、超时未签到等行为要有自动惩罚机制,不能只靠管理员人工盯。

这个闭环跑通之后,黑名单、信用分、统计报表、公告推送都是往上面叠加的功能。很多人一开始就想着把界面做得花里胡哨,结果核心状态机一塌糊涂,上线三天就出数据错乱,这个问题我见得太多。

2. 数据模型设计:五张核心表与几个容易踩的字段选择

Django 的 ORM 在建模阶段很省心,但如果你没有先把表之间的关系想清楚,后期迁移数据会非常痛苦。我最终保留了五个核心模型,外加两个辅助模型,整体结构不算复杂,但每个字段都踩过或想绕过坑。

2.1 自习室、区域与座位的层级关系

自习室和座位的层级我采用了“自修室 → 区域 → 座位”三级结构。这样一个高校图书馆可以拆成“总馆自修室三楼 A 区 12 号座位”这样的完整粒度,城市多网点场景下,“自修室”这一层再挂一个所属网点(即分馆)的外键即可。

class StudyRoom(models.Model): name = models.CharField(max_length=128, verbose_name="自修室名称") branch = models.ForeignKey(Branch, on_delete=models.CASCADE, null=True, blank=True, verbose_name="所属网点/分馆") floor = models.IntegerField(default=1, verbose_name="楼层") description = models.TextField(blank=True, verbose_name="说明") is_active = models.BooleanField(default=True, verbose_name="是否开放预约") class RoomArea(models.Model): room = models.ForeignKey(StudyRoom, on_delete=models.CASCADE, related_name="areas", verbose_name="所属自修室") name = models.CharField(max_length=64, verbose_name="区域名称") # 如:A区 / B区 / 靠窗区 seat_count = models.IntegerField(default=0, verbose_name="座位总数") is_quiet = models.BooleanField(default=False, verbose_name="是否静音区") class Seat(models.Model): area = models.ForeignKey(RoomArea, on_delete=models.CASCADE, related_name="seats", verbose_name="所属区域") seat_no = models.CharField(max_length=32, verbose_name="座位编号") status = models.CharField(max_length=16, choices=[("free", "空闲"), ("occupied", "占用")], default="free", verbose_name="实时状态") is_available = models.BooleanField(default=True, verbose_name="是否启用")

这里有个容易犯的错误:把status直接等同于“可预约状态”。实际上座位是否可预约,要结合它关联的预约记录来动态判断,而不是改一个布尔字段。status字段只是缓存用的,用于展示,真实依据永远是预约表里尚未结束的记录。

2.2 预约记录表:状态机字段与唯一约束的选择

预约记录表是整个系统的核心,直接决定并发时会不会脏数据。我设计的核心字段包括用户、座位、预约日期、开始时段、结束时段、实际签到时间、实际离开时间、状态。

class Reservation(models.Model): STATUS_CHOICES = [ ("pending", "待签到"), ("checked_in", "已签到"), ("completed", "已离座/已完成"), ("canceled", "已取消"), ("no_show", "爽约"), ("expired", "超时释放"), ] user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="reservations", verbose_name="用户") seat = models.ForeignKey(Seat, on_delete=models.CASCADE, related_name="reservations", verbose_name="座位") date = models.DateField(verbose_name="预约日期") start_time = models.TimeField(verbose_name="开始时间") end_time = models.TimeField(verbose_name="结束时间") status = models.CharField(max_length=16, choices=STATUS_CHOICES, default="pending", verbose_name="状态") check_in_time = models.DateTimeField(null=True, blank=True, verbose_name="签到时间") check_out_time = models.DateTimeField(null=True, blank=True, verbose_name="离座时间") created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")

唯一约束我采用了UniqueConstraint(fields=["seat", "date", "start_time", "end_time"]),这能挡住大部分重复预约,但它挡不住“A 时段和 B 时段重叠”的情况。要解决时段重叠,必须引入后台逻辑检查,不能只靠数据库唯一约束。后面第 3 节我会专门讲这一块。

2.3 辅助模型:公告、黑名单与信用分流水

公告系统其实就是简单的Notice模型,字段是标题、内容、发布时间、是否置顶,这个不展开。黑名单和信用分值得聊聊,因为大多数初学者会把它们做成一棵“用户身上挂一个黑名单布尔字段”的模型,这其实是不够的。

我做的是CreditRecord模型,每触发一次扣分/加分就记一条流水,附带原因和关联的预约单:

class CreditRecord(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="credit_records", verbose_name="用户") change = models.IntegerField(verbose_name="变动分值") # 正数加分,负数扣分 reason = models.CharField(max_length=128, verbose_name="变动原因") reservation = models.ForeignKey(Reservation, null=True, blank=True, on_delete=models.SET_NULL, verbose_name="关联预约") created_at = models.DateTimeField(auto_now_add=True, verbose_name="记录时间")

为什么不做成“当前信用分”一个字段?因为一旦用户要申诉、管理员要追溯,你就不知道这个分数是怎么来的了。流水式记录虽然多一张表,但所有操作都有据可查,上线后省掉大量沟通成本。用户当前的信用分用SUM(change)聚合查出来就行,量大了再做缓存也不迟。

另外黑名单没必要单独建表,信用分低于阈值就视为进入黑名单状态,用UserProfile上的一个字段做冗余标识即可。阈值常量放配置里,比如 “低于 60 分禁止预约 7 天”。

3. 预约逻辑的核心难点:时段冲突校验与并发安全

前面说到唯一约束能防止重复预约,但真正会出大问题的,是“时段部分重叠”。用户 A 预约了 14:00-16:00,用户 B 紧接着预约 15:00-17:00,两个请求同时进来,如果没有在代码里做防重校验,数据库唯一约束是拦不住的(因为两行的 (seat, date, start_time, end_time) 并不完全相等)。这一节我重点讲如何处理。

3.1 为什么不能只依赖数据库唯一约束

很多新手会想:我把四个字段加了唯一索引,不就行了吗?不行。唯一索引只对完全相同的记录生效,对区间交叉无效。也就是说,(seat=3, date="2024-06-01", start=14:00, end=16:00)和(seat=3, date="2024-06-01", start=15:00, end=17:00)会被数据库认为是两条完全合法的记录。

所以必须在应用层增加一个“时间窗重叠检查”的查询逻辑。核心查询方法是:在创建订单时,查同一座位、同一天、状态属于“正在占用座位的状态集合”(pending / checked_in),并且预约时间段与目标时间段有交集。

# 重叠检查的 Django ORM 写法 from django.db.models import Q overlap_qs = Reservation.objects.filter( seat=seat, date=target_date, status__in=["pending", "checked_in"], ).filter( Q(start_time__lt=end_time) & Q(end_time__gt=start_time) ) if overlap_qs.exists(): raise ValidationError("该座位在当前时间段已被预约")

这个判断公式很经典:两个区间[A1, A2]和[B1, B2]重叠的充要条件是A1 < B2 and A2 > B1。我一度想复杂了,还试图用时间戳换算成日期时间再比较,其实根本不需要——只要预约都在一天内,直接比较 TimeField 即可。

3.2 高并发下的抢座:事务与行锁的必要性

仅仅有上面的查询还不够。如果两个请求同时通过exists()查到“无重叠预约”,然后同时插入记录,依然可能造成冲突。要彻底解决并发场景,需要两层保障:Django 的事务原子性 + 数据库层面的锁,或者干脆利用数据库的唯一约束来兜底。

我的处理方案是在保存预约记录前,先启动一个事务,然后用select_for_update()锁定相关座位行:

from django.db import transaction @transaction.atomic def create_reservation(user, seat_id, date, start_time, end_time): seat = Seat.objects.select_for_update().get(id=seat_id) # 检查座位是否启用、是否被锁定 if not seat.is_available: raise ValidationError("该座位已停用") overlap_qs = Reservation.objects.filter( seat=seat, date=date, status__in=["pending", "checked_in"], ).filter(Q(start_time__lt=end_time) & Q(end_time__gt=start_time)) if overlap_qs.exists(): raise ValidationError("当前时间段已被预约") reservation = Reservation.objects.create( user=user, seat=seat, date=date, start_time=start_time, end_time=end_time, ) return reservation

select_for_update()会把这一行座位记录锁住,直到事务提交或回滚。第二个请求进来时会被阻塞,等第一个事务结束再继续执行,此时overlap_qs已经能查到刚提交的那条预约记录,从而正确拦截。

这个方案唯一的代价是写操作并发会排队,但一个座位本身就不应该被两个人同时预约,排队是正确的。实际测试中,在 100 个并发请求抢同一个座位的压力下,最终只有一条记录能成功创建,其余全都被拦截,没有一条脏数据。

3.3 预约状态的完整状态机设计

预约记录不是“创建了就是成功”,它应该随着时间、用户行为、管理员操作不断流转。我整理出来的状态机如下:

  • 用户提交预约成功 →pending
  • 用户在预约开始后 X 分钟内签到 →checked_in
  • 用户主动取消(且未超时) →canceled
  • 用户签到后离座(手动释放或扫码释放) →completed
  • 预约时间开始后 X 分钟未签到,系统自动释放 →expired
  • 用户全天未签到且预约时段已过 →no_show

状态只允许单向流转,不允许从canceled重新回到pending。我在Reservation模型里写了一个transition_to(new_status)方法,所有改状态的入口都必须走它:

class Reservation(models.Model): # ... 字段省略 def transition_to(self, new_status): allowed = { "pending": ["checked_in", "canceled", "no_show", "expired"], "checked_in": ["completed", "expired"], "canceled": [], "completed": [], "no_show": [], "expired": [], } if new_status not in allowed.get(self.status, []): raise ValidationError(f"非法状态转换: {self.status} -> {new_status}") self.status = new_status self.save()

这样能防止业务代码里随意改状态导致的“幽灵预约”——比如明明已经爽约了,某段代码又把它改成已完成,座位显示就全乱了。

4. 防占座与爽约惩罚:让系统真正能落地运营

自习室管理系统最难的不是“写代码”,是“规则设计”。一个没有任何约束的预约系统,上线一周就会出现大量恶意占座——用户不管会不会去,先把热门时段的座位全预约了,真正想学习的人永远抢不到。所以信用分和爽约惩罚机制不是“可选项”,而是“必选项”。

4.1 签到规则:自动释放与手动容错

我设的默认规则是:预约开始时间后 15 分钟内必须签到,否则系统自动将该预约标记为expired并释放座位;提前签到允许(最早提前 10 分钟)。如果用户在预约开始前取消预约,不扣信用分;如果预约开始后取消或干脆没来,视为爽约。

自动释放逻辑用 Django 的定时任务实现。我用的是django_crontab(或者干脆在服务器上写一个独立的 Python 脚本,用系统 crond 定时执行),每 5 分钟扫描一次:

# management/commands/release_expired.py from django.core.management.base import BaseCommand from django.utils import timezone from datetime import timedelta from reservations.models import Reservation class Command(BaseCommand): def handle(self, *args, **options): now = timezone.now() threshold = timezone.timedelta(minutes=15) expired_qs = Reservation.objects.filter( status="pending", date=now.date(), start_time__lte=(now + timezone.timedelta(hours=8)).time(), # 假定东八区 ).filter( start_time__lt=(now + timezone.timedelta(hours=8) - threshold).time() ) for res in expired_qs: res.transition_to("expired") self.stdout.write(f"已释放 {expired_qs.count()} 条超时预约")

这里时区是个大坑。Django 默认USE_TZ = True时,datetime.now()返回的是 UTC 时间,你要确保比较的start_time也基于同一时区。我之前在服务器上调了一下午,最后发现TIME_ZONE = 'Asia/Shanghai'配好之后,timezone.localtime()和直接datetime.datetime.now()会让比较结果差 8 小时。建议全部走django.utils.timezone封装的接口,不要自己用datetime.now()。

4.2 信用分的加减规则与黑名单联动

信用分基础分 100,低于 60 直接触发 7 天禁约。加减分规则我列成了一张表,方便沟通:

行为分值变动说明
按时签到并完成使用+1每天上限 +2,防止刷分
提前主动取消预约0不鼓励也不惩罚
预约开始后取消-5增加了管理成本
爽约(未签到)-10破坏了座位周转
实际使用时长不足 30 分钟-2疑似占座后离开

信用分流水记录每次变更的原因,并在用户页面上展示最近 N 条流水,这样用户被禁约时能自己看到为什么,减少投诉。

4.3 黑名单机制:只做“预约限制”还是“门禁联动”?

很多系统设计黑名单时,喜欢直接禁用账号登录,这就做得太过了。我的建议是黑名单只限制预约行为,不限制登录。被禁用户仍然可以进入系统查看座位、查看公告,只是提交预约时会被拦截并提示“信用分低于阈值,禁止预约至 X 月 X 日”。

如果未来你要对接门禁闸机,可以在黑名单状态上再挂一个physical_access_enabled字段来联动闸机开放权限,而不是删除账号或禁用登录。这样设计既保证了惩罚力度,又不会把事情做绝,方便后续用户申诉解禁。

5. 管理后台与可视化统计:上座率才是运营者最关心的

很多做自习室系统的开发者,把精力全放在用户端,结果交付后管理员发现:我不知道今天有多少人来过,不知道哪几个座位永远没人约,不知道哪个时段排队最多。这相当于你把收银台做好了,老板却看不到日营业额。管理端必须一开始就纳入设计范围。

5.1 利用 Django Admin 做基础管理

Django Admin 天然适合管理端,你不需要从零开始写一堆 CRUD 页面就能快速支撑运营。我在 Admin 里做了几个关键调整:

  • 座位管理使用list_display展示区域、座位编号、状态,加上按自习室/区域筛选。
  • 预约记录按日期、状态、自习室做筛选,默认按创建时间倒序。
  • 用户管理直接关联CreditRecord的只读内联,方便管理员看流水。
# admin.py class ReservationAdmin(admin.ModelAdmin): list_display = ["user", "seat", "date", "start_time", "end_time", "status", "check_in_time"] list_filter = ["date", "status", "seat__area__room"] search_fields = ["user__username", "seat__seat_no"] date_hierarchy = "date"

不过 Admin 只能解决“管理”,解决不了“分析”。上座率、高峰时段这些可视化,还是要单独写统计视图。

5.2 上座率与高峰时段统计的 SQL 实现

统计的核心是“按天、按区域计算每小时的预约覆盖情况”。我写了一个函数,遍历当天所有已签到/完成的预约,把每个预约覆盖的时间段映射到按小时切分的桶里,然后聚合:

from django.db.models import Count def calc_occupancy(room_id, date): from django.db import connection sql = """ SELECT DATE_FORMAT(check_in_time, '%H:00') as hour_slot, COUNT(DISTINCT reservation.id) as cnt FROM reservations_reservation reservation JOIN reservations_seat seat ON reservation.seat_id = seat.id JOIN reservations_roomarea area ON seat.area_id = area.id WHERE area.room_id = %s AND reservation.date = %s AND reservation.status IN ('checked_in', 'completed') GROUP BY hour_slot ORDER BY hour_slot """ with connection.cursor() as cursor: cursor.execute(sql, [room_id, date]) return cursor.fetchall()

这种 SQL 写法简单直接,数据量不大(每天几千条预约)时性能完全没问题。如果你要适配 PostgreSQL 或 SQLite,把DATE_FORMAT换成对应的TO_CHAR或strftime即可。

5.3 管理端的几个实用报表

最终我给管理端做了四个报表页:

  • 日上座率总览:每个自修室当天预约人次、签到人次、取消人次、爽约人次,以及综合上座率(签到人次 / 座位总数)。
  • 时段热力表:横轴是时间点(8:00-22:00),纵轴是区域,颜色深浅表示预约集中度,可以用简单的表格加 CSS 背景色实现。
  • 用户信用排行:信用分低于 70 的用户列表,方便管理员人工关注。
  • 座位利用排行:按区域统计每个座位一个月内的预约总时长,找出“永远没人约的死角座位”。

这些报表不需要 ECharts 都能展示,一张纯 HTML 表格加背景色就能达到可读效果。当然如果预算允许,引入 ECharts 画堆叠柱状图会更直观,但别等到项目后期再补,因为数据出口的设计要提前预留。

6. 从开发到上线的落地细节:配置、部署与安全加固

系统功能开发完只是第一步。我看到太多项目卡在最后一步——本地跑得好好的,一上服务器就各种报错。这里把我实际部署时总结的经验集中说一下,按这个顺序来基本不会翻车。

6.1 核心配置项与敏感信息管理

项目里不要明文写数据库密码和 DjangoSECRET_KEY。我用的是环境变量加.env文件的方式,settings.py里读取:

import os from pathlib import Path from dotenv import load_dotenv BASE_DIR = Path(__file__).resolve().parent.parent load_dotenv(BASE_DIR / ".env") SECRET_KEY = os.getenv("DJANGO_SECRET_KEY") DEBUG = os.getenv("DJANGO_DEBUG", "False") == "True" ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS", "*").split(",") DATABASES = { "default": { "ENGINE": "django.db.backends.mysql", "NAME": os.getenv("DB_NAME"), "USER": os.getenv("DB_USER"), "PASSWORD": os.getenv("DB_PASSWORD"), "HOST": os.getenv("DB_HOST", "127.0.0.1"), "PORT": os.getenv("DB_PORT", "3306"), } }

再提醒一次:.env文件一定要写进.gitignore,不要提交到代码仓库。我见过不止一次有人把项目推到公开仓库,顺带把数据库密码也带了出去,这个错一次都不能犯。

6.2 Nginx + uWSGI 的基础部署

部署我采用的方案是 Nginx + uWSGI + MySQL,这是 Django 最经典的组合,稳定且资料多。核心步骤是:

  1. 服务器上安装 Python 虚拟环境,用pip install -r requirements.txt安装依赖。
  2. 迁移数据库:python manage.py migrate。
  3. 收集静态文件:python manage.py collectstatic --noinput。
  4. 配置 uWSGI 的.ini文件:
[uwsgi] http-timeout = 86400 http = 0.0.0.0:8001 module = studyroom.wsgi master = true processes = 4 threads = 2 chdir = /var/www/studyroom virtualenv = /var/www/studyroom/venv static-map = /static=/var/www/studyroom/static
  1. Nginx 里配置反向代理:
server { listen 80; server_name your-domain.com; client_max_body_size 20M; location / { include uwsgi_params; uwsgi_pass 127.0.0.1:8001; } location /static/ { alias /var/www/studyroom/static/; } }

静态文件用 Nginx 直接服务,动态请求转给 uWSGI,这是最清晰的路径划分。如果未来想要更高性能,可以换成 Daphne 跑 ASGI 来支持 WebSocket(比如实时显示座位状态的“有人/无人”变化推送),但 MySQL 连接池、Redis 缓存都要配套跟上,目标是上线第一版的话,暂时没必要。

6.3 Django 安全加固清单

开发时可以开DEBUG = True,上线必须关掉。关掉DEBUG之后静态文件不会自动服务,这也是我前面强调collectstatic的原因。安全方面的最低配置如下:

SECURE_CONTENT_TYPE_NOSNIFF = True SECURE_BROWSER_XSS_FILTER = True SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True X_FRAME_OPTIONS = "DENY"

这些配置配合 Django 自带的 CSRF 中间件,能挡住绝大多数基础攻击。用户提交的文本内容里可能有恶意脚本,模板渲染时 Django 默认会做 HTML 转义,不要随便关掉autoescape。如果需要支持富文本,优先使用白名单过滤方案。

6.4 数据库备份与日志监控

上线后第一件事,就是把自动备份配置好。我写了一个简单的 shell 脚本,每天凌晨mysqldump导出一份 SQL 文件,保留最近 30 天,并做简单的磁盘告警:

#!/bin/bash BACKUP_DIR=/var/backups/studyroom DATE=$(date +%Y%m%d_%H%M%S) mysqldump -u $DB_USER -p$DB_PASSWORD studyroom > $BACKUP_DIR/studyroom_$DATE.sql find $BACKUP_DIR -name "*.sql" -mtime +30 -delete

日志方面,Django 的LOGGING配置我建议主要通过文件输出 ERROR 和 WARNING 日志,排查问题时不用翻runserver的控制台输出。预约异常、状态转换失败这类关键业务错误要单独记录到一个日志文件,方便和普通访问日志分开处理。

7. 几处最值得记住的踩坑与改进方向

开发这个系统的过程中我踩了不少坑,有几处是写的时候完全没想到的,在这里集中列一下,希望能帮你少走弯路。

7.1 时区问题:TimeField 与 DateTimeField 混用时的陷阱

我在第 4 节提到过时区问题,这里再展开铺一遍。Django 的TimeField在 MySQL 存储的是单纯的时间(比如 14:00),而你的服务器当前时间可能是 UTC(比如 06:00)。如果你用datetime.now().time()去做比较,等于拿 UTC 时间去匹配东八区的时间段,结果永远是错的。

我的统一做法是:用一个函数把本地时间算出来:

from django.utils import timezone def get_local_now(): return timezone.localtime(timezone.now()) def get_local_time(): return get_local_now().time()

所有脚本和业务逻辑里,凡是需要“当前时间”的都调用这两个函数,禁止直接datetime.datetime.now()。这个约定写进项目 README,团队协作时才能少踩坑。

7.2 超时释放任务与签到事务的竞态

自动释放脚本和用户签到并发执行时,可能会出现一种微妙竞争:脚本把预约状态改成expired的同时,用户正在请求签到。两个请求同时操作同一条记录,状态会乱。

解决方式有两个。一是用乐观锁:在Reservation表加一个version整数字段,更新时对比version,不匹配就重试。二是用事务加select_for_update()把预约记录锁住再改状态。我采用的是第二种,因为签到和释放都是低频操作,锁的做法更直观:

from django.db import transaction @transaction.atomic def check_in(reservation_id): res = Reservation.objects.select_for_update().get(id=reservation_id) if res.status != "pending": raise ValidationError("当前状态不可签到") res.check_in_time = timezone.now() res.transition_to("checked_in")

这样做能保证:如果脚本先锁定了记录,用户的签到请求会等脚本提交后再执行,此时状态已经是expired,签到会被正确拒绝;反过来同理。既不会出现重复签到,也不会出现已签到记录被脚本误释放。

7.3 下一步可做的扩展方向

第一版跑稳之后,如果还有余力,可以按优先级做几个扩展:

  • 用 WebSocket 实时推送座位状态变化,前端不再依赖轮询刷新。
  • 对接企业微信或钉钉通知,预约成功、超时提醒直接触达用户。
  • 引入 Redis 缓存热门座位列表,降低预约页面高并发下的数据库压力。
  • 做一个简单的小程序或 H5 界面,把选座流程从 PC 端搬进手机,这对自习室场景是刚需。

我个人实际测试下来的体感是:这类项目只要把核心状态机、事务并发和签到规则想清楚,其余都是工作量问题。很多系统后期跑飞,根源不是程序员码力不够,而是业务规则在开发中途变来变去。所以我的经验是:开发前花时间跟你的“甲方”把规则确认到具体到“几点几分、扣几分、如何展示”的程度,后面编码会顺畅很多。

如果你也在做类似的系统,建议从最小闭环开始,先把“预约-签到-释放-统计”这条链路跑通,再一层层往上加功能。功能做得多不是本事,做得稳才是。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询