ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

基于Django与Vue 3的AI实验室预约系统设计与实现

基于Django与Vue 3的AI实验室预约系统设计与实现 1. 从抢工位这件小事说起AI 实验室预约系统到底要解决什么但凡在高校实验室或者企业 AI 研发团队待过的人大概率都经历过这样的场景早上九点冲到实验室发现唯一一台带 A100 的机器已经被占满微信群里问谁在用三号机半小时没人回导师临时要跑一组对比实验结果发现预约表还停留在 Excel 阶段最后一条记录是上周三。这些看起来是管理问题本质上其实是信息同步和资源调度的问题。我所在的实验室去年就踩过这个坑。当时我们有三台训练服务器、两台推理机、若干台带消费级显卡的工作站外加一批共享的摄像头、机械臂和边缘设备。人不多二十来号人但设备冲突几乎天天发生。最开始用共享文档登记后来发现有人改完不保存、有人直接覆盖别人的时间段再后来换成在线表格又出现了时区混乱、并发编辑冲突的问题。折腾了两个月我决定干脆自己写一套预约系统。技术选型上我没有太多犹豫后端用Django DRF前端用Vue 3数据库用MySQL。原因很直接——Django 自带 ORM、Admin 后台和权限体系DRF 能把接口层做得干净利落Vue 3 的 Composition API 写交互逻辑比 Options API 舒服太多而 MySQL 在团队里大家都熟运维成本低。这套组合不是最时髦的但对于一个实验室内部系统来说稳定、好维护、上手快才是第一位的。这篇文章我会把整个系统的设计思路、关键实现、踩过的坑和实测经验完整讲一遍。适合正在做类似内部工具的后端或全栈开发者也适合想拿一个真实项目练手 Django Vue 3 的同学。我不会只贴代码更重要的是讲清楚每个决策背后的理由——为什么这么设计、不这么设计会出什么问题、实际跑起来之后又遇到了哪些意料之外的情况。2. 需求拆解预约系统远不止选个时间段那么简单2.1 表面需求与真实需求的差距如果只看表面预约系统的需求好像很简单用户选设备、选时间、提交、完事。但真正落地的时候你会发现光是时间这一项就能衍生出一堆问题。比如预约是按小时还是按半小时跨天怎么算提前多久可以预约能不能取消取消要不要扣信用分设备维护期间怎么处理已有预约这些问题如果不在设计阶段想清楚后面改起来就是灾难。我在动手之前花了两天时间把实验室里所有人拉过来开了个短会让大家把最烦的事情列出来。结果整理出来大概有这么几类冲突问题同一时间段被两个人预约或者预约了但人没来设备空转。权限问题新生不应该直接预约高价值设备需要导师审批或者通过考核。可见性问题不知道某台设备当前状态是空闲、使用中还是维护中。记录问题出了问题要追溯谁在什么时候用了哪台机器跑了什么任务。通知问题预约成功、即将开始、被取消这些节点需要有人知道。这五类问题基本覆盖了系统的核心功能边界。我最终把需求归纳成四个模块设备管理、预约调度、权限审批、使用记录。每个模块下面再细分具体功能点形成一份可以对照开发的需求清单。2.2 角色划分与权限模型实验室场景里的角色其实比想象中复杂。我最后定义了四种角色角色权限范围典型操作普通成员查看设备、预约低权限设备、查看自己的记录预约工作站、取消自己的预约高级成员预约全部设备、发起审批预约训练服务器、审批新人的高权限申请管理员设备增删改、查看全部记录、处理异常添加新设备、强制取消冲突预约访客只读查看设备状态和空闲时段这套权限模型用 Django 的 Groups 和 Permissions 就能实现不需要自己造轮子。但有一个细节要注意Django 默认的权限粒度是模型级别的add/change/delete/view而我们需要的是能不能预约某类设备这种业务级权限。我的做法是在模型层加一个required_level字段然后在视图层做校验而不是硬塞进 Django 的权限系统里。这样逻辑更清晰也更好维护。2.3 时间槽设计为什么我放弃了自由时间段最开始我想做的是自由时间段——用户自己填开始和结束时间。听起来很灵活但实际测试下来问题很多有人填 9:00 到 9:01有人填 9:00 到 23:59时间粒度完全不可控统计和冲突检测都变得很麻烦。后来我改成了固定时间槽方案每天从 8:00 到 22:00按 30 分钟切分一共 28 个槽位。用户预约的时候选连续的槽位系统自动合并成时间段。这个方案的好处是冲突检测变成简单的区间重叠判断数据库索引也好建。代价是灵活性降低但对于实验室场景来说30 分钟粒度完全够用。提示如果你的场景需要更细的粒度可以把槽位改成 15 分钟但要注意槽位数量会翻倍前端渲染和数据库查询都要相应优化。3. 后端架构Django DRF 的分层设计与关键实现3.1 项目结构与应用拆分Django 项目最容易犯的错误就是所有代码堆在一个 app 里。我一开始也差点这么干后来及时拆开了。最终的 app 划分是这样的lab_booking/ ├── config/ # 项目配置 ├── accounts/ # 用户与权限 ├── devices/ # 设备管理 ├── bookings/ # 预约核心逻辑 ├── approvals/ # 审批流程 └── records/ # 使用记录与统计拆分的依据是职责边界accounts 管人devices 管物bookings 管调度approvals 管流程records 管历史。每个 app 内部的 models、serializers、views、urls 各自独立跨 app 的引用通过外键或者 service 层函数完成。这里有个经验不要为了拆分而拆分。如果你的系统只有三五个模型硬拆成五个 app 反而增加复杂度。我之所以拆是因为预约逻辑和审批逻辑确实有独立的业务规则混在一起会让 views.py 膨胀到上千行。3.2 核心模型设计Booking 模型的字段取舍预约系统的核心模型是 Booking我前后改了四版才定下来。最终字段如下class Booking(models.Model): user models.ForeignKey(User, on_deletemodels.CASCADE) device models.ForeignKey(Device, on_deletemodels.CASCADE) start_time models.DateTimeField() end_time models.DateTimeField() status models.CharField(max_length20, choicesSTATUS_CHOICES) purpose models.TextField(blankTrue) created_at models.DateTimeField(auto_now_addTrue) updated_at models.DateTimeField(auto_nowTrue)几个关键决策点第一start_time 和 end_time 用 DateTimeField 而不是分开的日期和时间字段。这样区间查询和冲突检测都简单MySQL 的索引也能直接用上。第二status 用字符串枚举而不是布尔字段。预约状态至少有 pending、approved、rejected、cancelled、completed 五种用布尔字段根本表达不了。字符串枚举配合 choices 约束既清晰又不容易出错。第三purpose 字段设为可空。一开始我设成必填结果用户抱怨每次都要写用途太麻烦。后来改成选填但管理员可以在后台看到填写率用于后续分析。第四没有加 is_deleted 软删除字段。这是刻意的——预约记录属于审计数据不应该被物理删除。取消操作只是把 status 改成 cancelled记录永远保留。3.3 冲突检测一个容易被低估的难点冲突检测看起来简单实际上是最容易出 bug 的地方。我最初的实现是这样的def has_conflict(device, start, end): return Booking.objects.filter( devicedevice, status__in[pending, approved], start_time__ltend, end_time__gtstart ).exists()这个逻辑本身没问题区间重叠判断的标准写法就是start_time end AND end_time start。但问题出在并发上两个用户同时提交预约两个请求都通过了冲突检测然后都写入数据库结果就冲突了。解决方案有两种一种是在数据库层加唯一约束但区间重叠没法用简单的唯一约束表达另一种是用事务加行锁。我最终采用的是悲观锁 事务的方案from django.db import transaction transaction.atomic def create_booking(user, device, start, end): device Device.objects.select_for_update().get(iddevice.id) if has_conflict(device, start, end): raise ConflictError(该时间段已被预约) return Booking.objects.create(...)select_for_update()会在事务期间锁住设备行第二个请求必须等第一个事务提交后才能继续这时候冲突检测就能正确拦截了。实测下来即使模拟 50 个并发请求抢同一个时间段也只有一个人能成功。注意select_for_update()必须在事务内使用否则会报错。另外 MySQL 的存储引擎必须是 InnoDBMyISAM 不支持行锁。3.4 DRF 序列化器的分层策略DRF 的序列化器我分了三层列表序列化器、详情序列化器、写入序列化器。列表用轻量字段详情带关联信息写入只接受必要字段。这样做的好处是接口返回的数据量可控前端渲染也更快。class BookingListSerializer(serializers.ModelSerializer): device_name serializers.CharField(sourcedevice.name, read_onlyTrue) user_name serializers.CharField(sourceuser.username, read_onlyTrue) class Meta: model Booking fields [id, device_name, user_name, start_time, end_time, status] class BookingCreateSerializer(serializers.ModelSerializer): class Meta: model Booking fields [device, start_time, end_time, purpose] def validate(self, attrs): if attrs[start_time] attrs[end_time]: raise serializers.ValidationError(结束时间必须晚于开始时间) return attrs这里有个细节写入序列化器的 validate 只做基础校验真正的冲突检测放在视图层的事务里。因为序列化器的 validate 不在事务内如果在这里查数据库做冲突检测还是会遇到并发问题。4. 前端实现Vue 3 组合式 API 下的预约交互设计4.1 为什么选 Vue 3 而不是 Vue 22024 年还在纠结 Vue 2 和 Vue 3 的人我的建议是直接用 Vue 3。Composition API 在处理复杂交互逻辑时的优势太明显了——预约页面涉及时间槽选择、设备筛选、表单校验、状态同步用 Options API 写会散落在 data、methods、computed 各处而用 Composition API 可以把相关逻辑组织在一个函数里。我用的技术栈是 Vue 3 Vite Pinia Element Plus。Vite 的启动速度比 Webpack 快一个数量级Pinia 比 Vuex 简洁很多Element Plus 的日期选择器和表格组件开箱即用省了大量样式工作。4.2 时间槽选择器的实现思路时间槽选择器是整个前端最复杂的组件。我的实现思路是后端提供一个接口返回某设备某天的所有槽位状态前端渲染成一个网格用户点击选择连续槽位。const slots ref([]) const selectedSlots ref([]) async function loadSlots(deviceId, date) { const res await api.get(/api/devices/ deviceId /slots/, { params: { date } }) slots.value res.data.slots } function toggleSlot(slot) { if (slot.status ! available) return const idx selectedSlots.value.indexOf(slot.index) if (idx 0) { selectedSlots.value.splice(idx, 1) } else { selectedSlots.value.push(slot.index) } }后端返回的 slots 数组里每个元素包含 index、start、end、status 四个字段。status 有 available、booked、maintenance 三种。前端根据 status 渲染不同颜色只有 available 的槽位可以点击。这里有个体验优化点用户选择多个槽位时如果中间有不连续的应该自动提示或者自动补齐。我最初没做这个处理结果有用户选了 9:00-10:00 和 11:00-12:00中间空了一小时提交后系统报错说时间段不连续。后来我在 toggleSlot 里加了连续性检查不连续的选择会自动清空重新选。4.3 状态管理与接口封装Pinia 的 store 我按业务模块划分userStore 管登录状态和权限deviceStore 管设备列表和筛选条件bookingStore 管预约数据和提交逻辑。每个 store 只负责自己的领域跨 store 的通信通过组件层协调。接口封装用 axios 加拦截器统一处理 token 注入、错误提示和 loading 状态const api axios.create({ baseURL: import.meta.env.VITE_API_BASE }) api.interceptors.request.use(config { const token localStorage.getItem(token) if (token) config.headers.Authorization Bearer token return config }) api.interceptors.response.use( res res.data, err { ElMessage.error(err.response?.data?.detail || 请求失败) return Promise.reject(err) } )这个封装看起来简单但省掉了每个组件里重复的错误处理代码。实测下来前端代码量减少了大概 30%。5. 数据库与部署MySQL 配置和实际运行中的坑5.1 MySQL 版本选择与字符集配置MySQL 我用的是 8.0 版本。为什么不选 5.7因为 8.0 的窗口函数、CTE 和 JSON 支持更好而且默认字符集是 utf8mb4不用像 5.7 那样手动改配置。安装过程在 Windows 上稍微麻烦一点需要先初始化数据目录再注册服务mysqld --initialize-insecure --datadirD:\tool\mysql-8.0.46-winx64\data mysqld --install MySQL80 --defaults-fileD:\tool\mysql-8.0.46-winx64\my.ini net start MySQL80my.ini里必须配好字符集否则中文会乱码[mysqld] character-set-serverutf8mb4 collation-serverutf8mb4_unicode_ci [client] default-character-setutf8mb4Django 的 settings 里也要对应配置DATABASES { default: { ENGINE: django.db.backends.mysql, NAME: lab_booking, USER: lab_user, PASSWORD: your_password, HOST: 127.0.0.1, PORT: 3306, OPTIONS: { charset: utf8mb4, init_command: SET sql_modeSTRICT_TRANS_TABLES, }, } }注意init_command里的 STRICT_TRANS_TABLES 很重要它会让 MySQL 在数据截断或类型不匹配时直接报错而不是静默处理。这在开发阶段能帮你提前发现很多数据问题。5.2 索引设计与查询优化预约系统最频繁的查询是某设备某天的预约列表对应的 SQL 大概是SELECT * FROM bookings_booking WHERE device_id 1 AND start_time 2025-01-15 00:00:00 AND start_time 2025-01-16 00:00:00 AND status IN (pending, approved);为了这个查询我建了一个联合索引CREATE INDEX idx_device_time_status ON bookings_booking (device_id, start_time, status);索引顺序很关键device_id 选择性最高放最前start_time 用于范围查询放中间status 放最后。实测下来这个索引让查询从全表扫描的 200ms 降到了 5ms 以内。另一个容易忽略的点是分页。预约记录会随时间不断累积如果不分页列表接口迟早会拖垮数据库。我用的是 DRF 的 LimitOffsetPagination默认每页 20 条前端滚动加载。5.3 部署方案为什么我选了 Docker Compose部署这块我纠结过很久。裸机部署简单直接但环境依赖容易出问题Kubernetes 太重实验室这点规模用不上。最后选了 Docker Compose三个容器MySQL、Django用 gunicorn、Nginx托管 Vue 打包后的静态文件并反代 API。services: db: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: rootpass MYSQL_DATABASE: lab_booking volumes: - mysql_data:/var/lib/mysql web: build: ./backend command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 depends_on: - db nginx: image: nginx:alpine ports: - 80:80 volumes: - ./frontend/dist:/usr/share/nginx/html - ./nginx.conf:/etc/nginx/conf.d/default.conf这套方案的好处是一键启动、环境隔离、数据持久化。踩过的坑是 MySQL 容器首次启动需要初始化时间web 容器如果启动太快会连不上数据库。解决办法是在 web 的启动脚本里加一个等待循环或者用depends_on配合 healthcheck。6. 实测中的意外情况与排查记录6.1 时区问题一个让预约时间全部错乱的坑系统上线第一天就出事了用户预约的是下午 2 点但列表里显示的是早上 6 点。排查了半天发现是时区配置不一致导致的。Django 的USE_TZTrue会把时间存成 UTC但 MySQL 容器默认时区是 UTC而前端显示用的是本地时间中间差了 8 小时。解决方案是三层统一Django 的TIME_ZONE设为Asia/ShanghaiMySQL 容器加TZ: Asia/Shanghai环境变量前端用 dayjs 做格式化时明确指定时区。改完之后时间就对了。提示涉及时间的系统一定要在开发阶段就把时区问题解决掉。等到数据写进去再改迁移成本会非常高。6.2 并发预约的边界情况前面提到的select_for_update()方案在大多数情况下工作正常但有一个边界情况如果两个请求预约的是不同设备它们不应该互相阻塞。我最初的实现是锁整个 Booking 表结果性能很差。后来改成锁 Device 行不同设备的预约就能并行了。还有一个情况是同一用户重复提交。用户手快点了两次提交按钮结果创建了两条预约。前端加防抖只能缓解根本解决要在后端做幂等。我的做法是在 Booking 模型上加一个request_id字段前端每次提交生成一个 UUID后端检测到重复 request_id 就直接返回已有记录。6.3 前端时间槽渲染的性能问题设备多的时候时间槽网格会变得很大。我们有 10 台设备每台 28 个槽位如果一次性渲染所有设备的槽位就是 280 个 DOM 节点再加上每个节点的状态样式页面会明显卡顿。优化方案是虚拟滚动 按需加载。默认只渲染当前选中设备的槽位切换设备时再加载。同时用v-memo缓存已渲染的槽位避免不必要的重渲染。改完之后页面切换设备的响应时间从 800ms 降到了 100ms 以内。7. 这套系统跑了一年之后的一些体会系统上线到现在差不多一年累计处理了三千多条预约记录冲突率从最初的每周好几次降到了几乎为零。但比数字更有意思的是一些使用习惯上的变化。比如有了使用记录之后大家开始自觉填写预约用途因为月底统计的时候能看到谁跑了多少实验、用了多少机时。这种透明度反过来促进了资源分配的公平性。再比如审批流程上线后新生预约高价值设备需要导师确认设备误操作的情况明显减少。技术上如果让我重新做一遍有几个地方会调整一是前端会更早引入 TypeScript现在纯 JS 写复杂组件确实容易出类型错误二是会把通知功能做成独立的服务而不是塞在 Django 的 signal 里因为 signal 的调试体验实在一般三是数据库会考虑加一个只读从库把统计查询和业务查询分开。如果你也在做类似的内部工具我的建议是先把核心流程跑通再考虑扩展功能。预约系统的核心就是不冲突和可追溯把这两件事做扎实其他都是锦上添花。至于技术选型Django Vue 3 MySQL 这套组合对于中小规模系统来说开发效率和维护成本都很平衡值得一试。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表