
接到某高校图书馆自习室座位预约系统这个折腾活时我原本以为是套普通的CRUD——建座位表、写预约页、后端无非是查空闲、锁座位、改状态。结果需求评审会上馆里的老师说了一句关键话座位可以随便约但预约了不来的人越来越多闸机统计显示高峰期到场率不到六成。这句话直接把签到签退从“附加功能”变成了整个系统的地基。标题里写着Python、Django、Flask很多人以为又是选框架的老问题。实际上这套系统让我把两个框架“捏”在一起用顺手把预约状态、履约规则、并发容错、二维码身份校验这些坑全踩了一遍。这篇文章不想铺开讲整个项目那太长也容易丢重点。我打算集中拆解标题里最容易被低估的部分Django负责主业务、Flask负责签到签退API的分工方案是怎么落地的签到签退背后的状态机、超时释放、防重复提交、时区处理到底该怎么写。无论你是做课设、给图书馆搭系统还是想搞明白两个框架协同使用的真实姿势这篇都能给你一点参考。1. 为什么自习室预约系统必须做签到签退占座乱象背后的产品逻辑1.1 座位不够用的真相是“预约了不来”很多自习室管理者有个共同错觉座位不够就多放桌子。但把闸机数据和预约记录一比对真相往往是另一回事——高峰期“预约后未到场”的比例可以达到三成以上真正想用座位的人约不到约了座位的人又不来于是有座的人心虚、没座的人抱怨管理员还得挨个手工核销。签到机制本质上解决的是“虚占”问题。用户预约座位那一刻只是把资源锁住了只有到场签到这个座位才算真正被使用。如果没有签退机制管理员根本不知道谁还在座位上谁已经走了更没法把空出来的座位及时放给排队的人。我在这套系统里把签到签退定位成“履约闭环”预约是承诺签到是履约签退是释放资源。三者缺一个系统就退化成简单的登记表。1.2 签到和签退各自的职责边界签到解决“来不来”签退解决“占多久”。具体拆开看签到用户在预约时段内到达自习室验证身份后确认座位被实际使用。它有两个衍生作用一是判断用户是否迟到二是作为履约率的原始数据。签退用户离开时主动结束占用系统记录实际使用时长。如果用户到点不签退就属于超时占用会直接影响后面时段其他用户的预约体验。这套系统里我一开始只在签到上花了大力气签退做得特别敷衍——界面上一个“退出”按钮后端更新个字段就完了。后来实测发现签退才是运营侧最关心的数据来源。馆方要看每个座位的单日周转率、每个用户的平均使用时长、哪些时段超时占用最严重这些全都依赖准确的签退记录。没有签退所有统计报表都是空中楼阁。1.3 预约规则是系统的灵魂代码只是执行规则技术上再漂亮的系统如果预约规则定得不合理上线第一天就会被用户骂回来。我和馆方反复推了几轮最终定下的是一套“偏宽松、带缓冲”的规则规则项默认值设计意图可签到时间窗预约开始前15分钟起允许早到的人先落座避免扎堆排队迟到判定预约开始后30分钟超过30分钟未签到自动释放座位置为“可预约”超时占用判定预约结束时间之后结束时间后仍未签退自动签退并标记超时违约累计一周内3次违约停用7天软约束为主不搞一刀切禁约提前签退任何时候都可以签退后座位立即释放给排队用户腾位置这套规则的逻辑是学生备考过程中难免有临时情况迟到三五分钟就判定违约太不近人情但三十分钟还不来基本就是真不来了。超时占用那边同理允许晚走一会儿超过预约结束时间后由系统自动处理不需要管理员去现场赶人。产品逻辑定了之后紧跟着就是技术选型和分工问题。2. 框架没有必要二选一Django与Flask的分工方案2.1 两个框架同时出现不是笔误先回应一个我见过无数次的争论Django和Flask到底选哪个在这套自习室系统里我的答案是两个都用。标题里Python、Django、Flask三个词一起出现不是拼错了而是一种很务实的组合玩法。很多人把框架之争看成非此即彼的信仰问题实际工程里资源都是按“哪个框架擅长什么”来分的。Django最擅长的是“重业务”用户注册登录、座位管理、预约流程、后台数据维护这些东西需要成熟的ORM、现成的Admin后台、完善的认证体系。Flask最擅长的是“轻API”路由简单、无约束、上手快特别适合做签到签退这种高频、低复杂度的纯接口服务。我在这套系统里的分工是这样的模块负责框架关键职责用户与预约主系统Django用户注册、座位查询、预约创建、取消预约、后台管理后台管理界面Django Admin座位增删改、违约名单管理、基础数据维护签到/签退APIFlask二维码身份校验、签到、签退、超时逻辑定时任务Flask侧APScheduler超时未签到释放、超时未签退自动处理2.2 Nginx一入口、两个后端服务协同实际部署时我用了Nginx做统一入口按路径把请求分发到两个后端服务。核心业务和后台走8000端口的Django签到签退API走5001端口的Flask。这样前端用户感知不到背后有两个服务维护时也可以单独重启其中一个而不影响另一个。配置大概是这样的server { listen 80; server_name study.example.com; location /api/ { proxy_pass http://127.0.0.1:5001; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里最关键的一点是两个框架必须共享同一个数据库。我的方案是Django负责建表和管理表结构通过migrations统一控制数据库SchemaFlask侧的SQLAlchemy只读同一批表不自己建表也不改表结构。这样既避免了两套ORM互相覆盖又保证了数据结构只有一处来源。2.3 两个框架协同时最容易翻车的几个细节这套架构踩过一些协作上的坑挑三个典型的说。第一个坑是登录态。Django默认的session cookie和Flask的session是完全隔离的用户在Django登录后在Flask里是“陌生人”。我最后的处理是统一用JWT作为身份凭证Django负责发放JWTFlask在签到接口里校验JWT并取出用户ID。两个服务虽然会话隔离但通过同一个token建立了信任关系。第二个坑是数据库时区。Django配置USE_TZTrue后写入数据库的DATETIME统一是UTCFlask侧如果图省事用datetime.now()写入的就是服务器本地时间。两边的数据一混签到时间判断直接乱套。统一规则是所有后端代码一律用UTC时间存库到前端展示时再转本地时区。第三个坑是CSRF。Django默认带了CSRF中间件表单提交会被校验Flask默认不校验。签到签退API从设计上就不允许浏览器直接跨站请求我在Flask侧加了请求来源校验只接受带自定义Header的Ajax请求同时也给签到接口做了秒级限流防止有人脚本刷接口。分工和架构定清楚之后就要落到最核心的部分数据库怎么设计才能支撑签到签退这套逻辑。3. 数据模型设计座位、预约、签到记录三张表的关系3.1 三张表的分工与核心字段这套系统的数据模型最终拆成了三张核心表座位表、预约表、签到记录表。座位表管“资源”预约表管“占用关系”签到记录表管“行为流水”。三者通过外键关联但我在实际表设计里基本不建物理外键只保留逻辑关联和索引原因是为了避免高频写入时的外键检查开销。先看座位表CREATE TABLE seat ( id INT PRIMARY KEY AUTO_INCREMENT, room_name VARCHAR(32) NOT NULL, seat_no VARCHAR(16) NOT NULL, is_disabled TINYINT NOT NULL DEFAULT 0, UNIQUE KEY uk_room_seat (room_name, seat_no) );座位表本身不存“当前是否被占用”这种状态字段。原因是状态会随时变化如果放在座位表里每次签到签退都要更新两条记录出问题的时候还容易数据不一致。我让“是否占用”完全由预约表的记录推导出来有正在生效的预约就说明座位被占用。这样座位表只需要管物理属性和是否停用。预约表是整张设计的核心CREATE TABLE reservation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, seat_id INT NOT NULL, start_at DATETIME NOT NULL, end_at DATETIME NOT NULL, status VARCHAR(16) NOT NULL DEFAULT booked, checkin_time DATETIME NULL, checkout_time DATETIME NULL, duration_minutes INT NULL, is_overstay TINYINT NOT NULL DEFAULT 0, timeslot_token VARCHAR(32) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_seat_slot (seat_id, start_at), KEY idx_user_day (user_id, start_at), KEY idx_status (status) );这里有个值得说的设计决策为什么用start_at和end_at两个DATETIME字段而不是拆成reserve_date加start_time加end_time三件套第一版我确实用了DATETIME分离存储后来发现所有签到签退比较时间都要先做DATETIME拼接而且跨天的预约处理起来非常别扭。把起止时间存成完整的DATETIME后所有判断都变成了两个时间点直接比较代码清爽很多。再配合统一UTC存储不同时段跨天也好、系统跨时区也好都只有一个标准。预约状态我用字符串枚举而不是数字。原因很简单调试的时候一眼能看懂statuschecked_in是什么意思不用翻字典。虽然字符串索引会浪费一点空间但在这种数据量级下完全不是问题。3.2 签到记录表一次行为一条流水签到记录表管的是审计和统计。为什么需要单独一张表而不是只在预约表上改几个时间字段因为预约表永远只记录“当前的最新状态”如果用户反复点签到按钮、签退又重签预约表上顶多覆盖时间字段但运营人员没法知道具体发生过什么操作、操作源头是什么。CREATE TABLE signin_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, reservation_id BIGINT NOT NULL, action_type VARCHAR(16) NOT NULL, action_time DATETIME NOT NULL, source VARCHAR(16) NOT NULL DEFAULT manual, KEY idx_user_time (user_id, action_time), UNIQUE KEY uk_resv_action (reservation_id, action_type) );action_type记录checkin、checkoutsource记录操作来源qrcode表示扫码、manual表示手动签退、auto表示定时任务自动处理。PostgreSQL里我会顺手加个CHECK约束MySQL下就在应用层保证。这张表里uk_resv_action唯一索引非常关键它保证“同一个预约同一种动作最多只能成功一次”是防重复签到的数据库层最后防线。3.3 状态机是整个系统的脊椎预约状态从创建到终态一共五个状态booked已预约等待签到checked_in已签到座位正在使用checked_out已签退正常完成闭环expired超时未签到预约失效cancelled用户主动取消状态迁移规则是booked可以走向checked_in签到、expired超时释放、cancelled用户取消checked_in只能走向checked_out签退或超时自动签退。任何一次状态更新都同时写一条signin_log流水。代码里判断状态时只认这五个字符串多一个拼写错误都进不了状态机。这条状态机的核心价值是把业务规则全部收敛到时序判断里。前端不用知道细节后端只需要在接口层执行“能签到吗”“能签退吗”两个判断所有复杂逻辑都在状态迁移里完成。表结构定了签到接口就是接下来真正吃功夫的地方。4. 签到接口不是改一个状态位那么简单4.1 签到前必须想清楚的五个判断我见过很多版本的签到接口最潦草的写法是前端传个座位号后端UPDATE seat SET statusoccupied就算完事。这种实现上线必被打穿。一个合格的签到接口至少要过五道判断第一预约是否存在。传进来的预约ID查不到记录直接拒绝。第二当前用户是否是预约人本尊。第三预约当前状态是否还是booked——已经签到过或者已经取消的预约不能二次签到。第四当前时间是否落在签到时间窗内太早不能进迟到超限直接判过期。第五同一预约是否已经存在签到记录防止重复提交。这五个判断看着是if-else但落库时必须保证原子性否则就会出现两个请求同时通过检查、同时写入的并发问题。4.2 用Flask实现一个靠谱的签到接口因为签到接口走Flask侧我用的是SQLAlchemy 2.0的会话管理。核心代码如下from flask import Blueprint, request, jsonify from sqlalchemy.orm import Session from datetime import datetime, timedelta, timezone from .models import Reservation, SigninLog from .auth import identify_user bp Blueprint(checkin, __name__) bp.post(/api/checkin) def checkin(): user identify_user(request) if user is None: return jsonify(code401, msg未登录) reservation_id (request.get_json() or {}).get(reservation_id) now datetime.now(timezone.utc) with Session() as session: resv ( session.query(Reservation) .filter(Reservation.id reservation_id) .with_for_update() .first() ) if not resv: return jsonify(code404, msg预约不存在) if resv.user_id ! user.id: return jsonify(code403, msg不能为他人预约签到) if resv.status ! booked: return jsonify(code409, msg当前状态不可签到) # 签到时间窗允许提前15分钟迟到30分钟释放 if now resv.start_at - timedelta(minutes15): return jsonify(code406, msg未到签到时间请稍后再试) if now resv.start_at timedelta(minutes30): resv.status expired session.commit() return jsonify(code408, msg已超过签到时限本次预约自动失效) resv.status checked_in resv.checkin_time now session.add(SigninLog( user_iduser.id, reservation_idreservation_id, action_typecheckin, action_timenow, sourceqrcode, )) session.commit() return jsonify(code0, msg签到成功)这段代码里最关键的是.with_for_update()也就是SELECT ... FOR UPDATE行锁。它把同一行预约记录锁住让第二个并发请求在读取时就等第一个请求提交完毕。没有这把锁两个快速连点产生的两个请求可能同时读到statusbooked双双通过校验最后写入两条签到记录。4.3 并发防重的三层防线只有行锁还不够我在这套系统里额外加了三层防线按照顺序递进第一层业务状态判断。也就是上面的status ! booked直接拒绝在绝大多数场景下就能拦住重复请求。但它不是绝对可靠的两个并发事务拿到同一份快照时判断会同时通过。第二层数据库唯一索引。signin_log表上的uk_resv_action (reservation_id, action_type)能在物理层面保证同一个预约只能插入一条checkin日志。哪层防线失手了第二层会抛唯一键冲突异常业务层捕获后返回“您已签到过”的友好提示。第三层幂等token。签到接口要求前端每次扫码都带上一个timeslot_token这个token在预约创建时生成、一个预订时段内只允许使用一次。哪怕攻击者抓包重放请求携带已经用过的token也会被拒绝。这三层防线不是同一件事它们各管一段业务状态判断管逻辑是否正确唯一索引管数据是否重复幂等token管请求是否重放。5. 签退流程与超时释放一次签退牵扯出的整套定时任务机制5.1 签退接口既要记录行为也要算出时长签到解决了“来不来”签退负责“占多久”。签退接口同样需要行锁因为用户可能在签退瞬间触发多个重复请求。核心代码如下bp.post(/api/checkout) def checkout(): user identify_user(request) if user is None: return jsonify(code401, msg未登录) reservation_id (request.get_json() or {}).get(reservation_id) now datetime.now(timezone.utc) with Session() as session: resv ( session.query(Reservation) .filter(Reservation.id reservation_id) .with_for_update() .first() ) if not resv or resv.user_id ! user.id: return jsonify(code403, msg非法操作) if resv.status ! checked_in: return jsonify(code409, msg当前状态不可签退) resv.status checked_out resv.checkout_time now resv.duration_minutes int((now - resv.checkin_time).total_seconds() // 60) resv.is_overstay now resv.end_at session.add(SigninLog( user_iduser.id, reservation_idreservation_id, action_typecheckout, action_timenow, sourcemanual, )) session.commit() return jsonify(code0, msg签退成功)签退时真正要算明白的是两个数用户实际用了多久duration_minutes、是不是超时占用is_overstay。实际使用时长能用于报表统计超时标记则用于后续信用分和违约逻辑。用户提前签退时我会抛弃原有预约剩余时段直接释放座位而不是傻等到预约结束——这样才能让排队的人尽快拿到座位。5.2 超时未签退和超时未签到的自动处理自习室不可能每次都有管理员盯着谁没签到、谁没签退这些脏活必须交给定时任务。我用的方案是APScheduler挂在Flask进程里跑每分钟扫一遍预约表。逻辑很简单找出所有超时未签到的booked置为expired找出所有超时未签退的checked_in置为checked_out并标记超时。from apscheduler.schedulers.background import BackgroundScheduler def sweep_expired_reservations(): now datetime.now(timezone.utc).replace(tzinfoNone) with Session() as session: # 超时未签到预约开始时间已经过了30分钟 expired_list ( session.query(Reservation) .filter( Reservation.status booked, Reservation.start_at now - timedelta(minutes30), ) .all() ) for resv in expired_list: resv.status expired session.add(SigninLog( user_idresv.user_id, reservation_idresv.id, action_typetimeout, action_timenow, sourceauto, )) # 超时未签退预约结束时间已经过了 overstay_list ( session.query(Reservation) .filter( Reservation.status checked_in, Reservation.end_at now, ) .all() ) for resv in overstay_list: resv.status checked_out resv.checkout_time now resv.is_overstay True resv.duration_minutes int((now - resv.checkin_time).total_seconds() // 60) session.add(SigninLog( user_idresv.user_id, reservation_idresv.id, action_typeauto_checkout, action_timenow, sourceauto, )) session.commit() scheduler BackgroundScheduler(timezoneUTC) scheduler.add_job(sweep_expired_reservations, interval, minutes1) scheduler.start()这个定时任务每分钟跑一次扫描全表。数据量小的时候完全没有压力如果预约表涨到几十万行就需要加上status和start_at的联合索引或者在statusbooked和statuschecked_in分别建条件索引避免全表扫描。5.3 定时任务的边界条件最容易出事的隐藏坑定时任务看着简单边界条件特别多。我先踩到一个凌晨闭馆之后系统还在跑超时释放逻辑所有跨夜预约都被当成“超时未签退”自动释放了。但备考自习室晚上闭馆后本来就不允许继续使用凌晨释放逻辑没有问题如果未来开放24小时自习区这个任务就必须区分“哪些座位允许跨夜连续使用”。第二个隐藏问题是分钟级扫描的误差。APScheduler每60秒扫一次实际每分钟的生成时刻不确定某些边界场景下会延迟几十秒才生效。对用户来说预约结束时间过了2分钟还没被自动释放体验还算能接受如果换成会议室预约这类高敏感场景定时任务要改成按秒扫或直接把释放逻辑放到查询路径里。第三个坑是记录自动操作文件名。定时任务里我把action_type设计为timeout和auto_checkout统计报表时可以区分是用户手动签退还是系统代签退。不要把自动操作和手动操作混在一起否则后面的实到率、超时率统计全都会失真。6. 实测中的三个坑时区、并发、二维码身份识别这一章的三个坑是整套系统上线前后最真实的教训每个都不是看文档能避开的全是在运行日志里查出来的。6.1 时区提前20分钟签到被系统判定“已超时”系统联调时负责测试的同学反馈了一个诡异现象她提前20分钟到座位扫码系统居然提示“已超过签到时限本次预约自动失效”。查数据库发现预约表里的start_at比前端页面显示的时间晚了整整8个小时。根因是Django和Flask两个框架的时区处理不一致。Django侧设置了TIME_ZONEAsia/Shanghai且USE_TZTrueORM写入时会自动把本地时间转成UTC再入库。Flask侧SQLAlchemy用的是datetime.now()带上本地时区后又经过MySQL驱动做了一次转换结果就是同一张表里Django写入的start_at是标准的UTCFlask写入的比较基准是本地时间两边一相减8小时的偏移就出来了。修复方案是三条强制约定第一数据库连接串统一加serverTimezoneUTC参数第二所有后端代码一律使用datetime.now(timezone.utc)严禁裸用datetime.now()第三所有存库时间统一为UTC所有展示层时间由前端拿到UTC之后自行转为本地时区。这里我彻底重建了时间处理逻辑不再允许某个接口悄悄用本地时间。6.2 并发连点签到按钮数据库冒出两条签到记录上线后第一次真人测试一个学生连点了两下签到按钮运维日志里出现了重复的签到记录。排查后发现最开始版本的接口只有应用层状态判断读预约、检查状态、改状态。两个并发请求同时进来时两个事务都读到了statusbooked于是双双通过校验。根治方案就是我前面代码里写的with_for_update()行锁。但行锁不是万能的如果将来把服务从单库扩到读写分离FOR UPDATE在从库上根本锁不住主库的行。所以我同时保留了signin_log表上的唯一索引作为数据库层的最后防线。两层一配合绝大数情况下应用层就拦住了极少数穿透到数据库层的也会被唯一索引兜住。实测中这个方案在100并发压力测试下没有产生一条重复签到记录。6.3 二维码身份识别截图被转发别人也能签到第一版二维码只编码了reservation_id测试群里有人把签到二维码截图发出来其他人扫码也能发起签到请求。问题根源不在二维码加密强度而在签到接口没校验“当前登录用户是否等于预约用户”。攻击者根本不需要破解什么只是换了个身份发了个合法请求而已。修复动作两个配合来第一二维码内容改成带签名的JWT或token串至少包含user_id、reservation_id和随机nonce服务端解码后必须比对当前登录用户ID第二这个token一次性使用签到时标记为已消费。同时前端二维码默认每30秒自动刷新旧码作废。这样截屏分享的码在极短时间内失效能最大程度控制风险。这三个坑分别对应着三张表里的三条核心原则时间字段必须统一时区、并发请求必须有数据库层的兜底、身份校验必须回库里比对不能只相信前端传来的字符串。7. 从签到签退延伸出去管理后台与统计报表的落地7.1 Django Admin负责日常管理少写大量页面签到签退的数据跑起来之后管理侧的需求马上冒出来了要查某人有没有违约、要一键停用某个座位、要看今天的预约和实到人数。直接用Django Admin登记几个模型就能满足大部分需求完全不用为管理员单独开发一套前端页面。from django.contrib import admin from .models import Seat, Reservation, SigninLog admin.register(Seat) class SeatAdmin(admin.ModelAdmin): list_display (id, room_name, seat_no, is_disabled) list_filter (room_name,) admin.register(Reservation) class ReservationAdmin(admin.ModelAdmin): list_display (id, user_id, seat_id, start_at, status) list_filter (status, start_at) search_fields (user__username,)预约表和签到记录表数据量一大直接放到Admin里展示就会卡。我的建议是Admin只负责座位和用户的管理预约和签到日志全都放到只读报表页里用原生SQL聚合展现不要指望Django ORM把所有统计查询都包办。7.2 用原生SQL做统计数据别硬靠ORM统计报表这块我试过用Django ORM的annotate硬写复杂一点的日期聚合写起来非常绕。后来干脆引入原生SQL让DBA同学帮我把查询优化到位效果立竿见影。几个最常用的统计如下-- 当日履约率与平均使用时长 SELECT SUM(CASE WHEN status IN (checked_in, checked_out) THEN 1 ELSE 0 END) / COUNT(*) AS checkin_rate, AVG(duration_minutes) AS avg_minutes FROM reservation WHERE start_at CURDATE() AND start_at CURDATE() INTERVAL 1 DAY; -- 一周内各时段热门程度 SELECT HOUR(start_at) AS slot, COUNT(*) AS cnt FROM reservation WHERE start_at DATE_SUB(CURDATE(), INTERVAL 7 DAY) GROUP BY HOUR(start_at) ORDER BY cnt DESC;履行率报表对整个系统价值很大。开完一周以后馆方拿着报表跟我说修复前高峰期到场率只有55%签到机制上线后一周稳定在88%以上。数据上来之后治“预约不来”这个老毛病终于有了抓手。7.3 运营规则和消息通知的后续迭代统计报表做出来之后运营规则也开始有据可依。我发现超时未签退主要集中在午餐和晚餐时段于是加了两个自动提醒预约快开始前推送“您预约的自习座位将开放签到”预约结束后如果没签退再推一条“本次预约已超时”。消息通道直接用现成的公众号模板消息后端只需要维护一个发送队列。信用分规则也根据统计结果做了调整。运营同学原来想每次都扣分我劝住了备考学生偶尔有急事很正常一周内3次违约才停用7天既保留威慑力又不至于误伤正常用户。规则上线后全校投诉量反而没怎么涨取消预约也比以前规范了。回头看这套系统签到签退两个动作看起来都极小真正花时间的是把动作背后的规则理顺、把并发和时区的边界条件想透。如果让我重新做一次我会先画好状态机、定好时区约定、写清防并发方案再动手写业务代码。任何预约类项目哪怕是会议室、琴房、实验室预约这套思路都能照样套用——越简单的入口越考验背后的细节控制。