前阵子帮某版权登记服务中心做了个软件著作权预约登记平台,前后踩了不少坑。从最开始用Excel排号,到后来用Python把整套预约流程跑通,中间换了三次方案,也重构了一轮数据模型。今天把整个项目的设计思路、技术选型逻辑、关键模块实现和踩坑记录完整梳理一遍,给准备做类似预约系统(政务预约、实验室设备预约、论文答辩排期这类资源分配型平台)的同学做个参考。
这套平台的目标很明确:申请人在线提交软件著作权登记材料、选择办理时段、跟踪审查进度,审核人员在后台处理预约请求、管理每日办理名额。系统上线后,人工电话沟通成本直线下降,每天窗口接待量从原先靠嗓门抢号,变成可预约、可查询、可统计的数字化流程。
先说清楚这套平台的适用场景。它面向的不是大型互联网高并发系统,而是一个典型的"预约 + 审核"工作流:用户量不大(日均几百到上千),但业务状态复杂、角色权限分明、对数据一致性要求很高。这个定位决定了后面所有架构和代码层面的取舍。
1. 从纸质排号到在线预约:这个平台解决的业务痛点
1.1 软件著作权登记的办理流程与人工管理的困局
软件著作权登记,简单说就是申请人对自己的软件作品做版权确权,需要提交源代码、文档、申请表等一系列材料,登记部门审核通过后颁发登记证书。整个流程涉及材料初审、补正、复核、制证多个环节,周期比较长,而且每个环节都有明确的时间节点和责任人。
在实际接待过程中,最头疼的不是审核本身,而是预约环节。以前全靠人工登记:申请人打电话或者现场排队,工作人员在一张Excel表里手动排期,每天放出若干个时间段,谁先打电话谁占坑。这种模式的问题非常典型:
- 电话占坑约束不住爽约行为,很多人约了不来,当天窗口大量空转,想补位的人又不知道有空缺;
- 手动填表容易出现重复预约、时间冲突,两个人填了同一时段,后面核对时才发现,非常被动;
- 申请人完全看不到自己的进度,只能反复打电话问"我的材料到哪一步了",经办人接电话接到手软;
- 每天放多少号、不同业务类型的配额各占多少,全靠经办人拍脑袋,月底统计工作量又是一轮Excel大战。
做这个系统的第一件事不是写代码,而是把这些业务规则一条条列清楚。我们当时把预约、材料提交、材料审核、补正、归档这几个环节拆成了独立的业务状态,再定义清楚每个状态的触发条件和流转方向。这部分工作占了将近一周,但后面写代码时发现,业务规则理清楚之后,代码反而写得特别顺——大部分返工都是因为规则本身没对齐,而不是代码写错了。
1.2 平台的用户角色与业务闭环
这个平台一共涉及三类角色:申请人(个人或企业)、窗口审核员、管理员。三类角色看到的界面和操作权限完全不同,这也是整个权限设计的起点。
申请人的操作路径是:注册账号 → 填写基础信息 → 选择业务类型(比如首次登记、变更登记、转让登记)→ 上传材料清单 → 选择办理时段 → 提交预约 → 查看审核进度 → 收到补正通知后重新上传材料 → 等待最终结果。
审核员的操作路径是:查看当日预约列表 → 核对材料 → 通过/退回/要求补正 → 录入审核意见;每天开局时还要根据实际情况动态调整各时段容量,比如临时有同事请假,就把下午的容量调小。
管理员负责地基:维护业务类型、设置每日预约配额、配置可预约时段、查看全量统计数据、管理用户账号。
这三件事构成了系统的核心闭环:预约、审核、进度追踪。所有页面、接口、数据表都是围绕这三条主线展开的。页面做得再花哨,主线不清晰,系统就是废的。
2. Flask与Django的组合:不是二选一,而是各司其职
2.1 为什么同时用两个框架
很多做Python Web开发的人习惯用Django全家桶,或者轻量到只用Flask。但在这个项目里,我选择了两者并存的架构。这不是为了炫技,而是两个框架在这个场景下各有不可替代的优势。
Django承担了核心业务系统:数据模型、权限体系、后台管理、审核工作流。选它的理由很直接——Django自带Admin后台和完整的ORM,权限管理(django.contrib.auth)开箱即用,对于"审核员和管理员要用后台管理数据"这类需求,改造Django Admin比从零写一套后台省太多事。而且Django的ORM在处理关联查询、事务、复杂过滤条件时非常成熟。我们这套系统恰好有大量关联查询:预约记录关联用户、关联时段、关联材料清单,审核记录又关联预约记录,用ORM写比裸SQL清晰太多。
Flask则承担了对外接口层:申请人端的前台接口、预约提交的入口、文件上传链路。这里用Flask的原因恰好跟Django相反——预约提交这种高频写操作,需要的是轻量、快速、方便单独部署和扩容。Django自带的东西多,启动一个进程要加载的组件也多,把这些只读查询和预约写入放到Flask这层,能把核心链路的延迟压下来。另外Flask的灵活度更高,自定义中间件、自定义错误处理、按需注册蓝图,都比Django那一套"约定大于配置"的框架更自由。
2.2 两个服务的边界划分与通信方式
双框架架构最容易翻车的地方是职责不清。一开始我差点把两边搞成重复系统——Flask这边也建了用户表,Django那边也管用户,两边数据一对不上就崩了。后来重新画的边界是这样的:
- Django负责一切"管理侧"的数据:用户账号、角色、预约记录、审核记录、业务类型、时段配置,全部以Django的模型为准;
- Flask负责"用户侧"的读接口和预约提交接口,但Flask自身不持有主数据,所有数据操作都通过调用Django对外暴露的内部API完成。
这样说可能有点绕,打个比方:Django是总部的档案室,所有原始档案和台账都在这里,审核员和管理员直接在这个档案室里工作;Flask是前台接待窗口,申请人通过窗口递交材料,窗口人员自己不存档案,而是把材料实时转交到档案室登记。
两个服务之间的通信,我们用HTTP + JSON走内网地址,没有引入消息队列。原因很简单:预约系统的调用链路是同步的,提交预约必须立刻返回成功或失败,异步化反而会把问题变复杂。只有当需要发送站内通知、短信提醒时,才把通知任务丢给Celery队列异步处理。
2.3 双框架环境的工程管理细节
两个框架在同一个项目里,工程管理上有些细节必须处理好,否则开发到一半会非常难受。
第一,依赖环境隔离。两个框架的依赖放在同一个虚拟环境里问题不大,但用Docker部署时建议拆成两个镜像。我们这个项目就是拆成两个服务容器,各自带独立的requirements.txt,避免互相干扰,也方便单独扩容。Flask服务在高峰期可以多开几个worker,Django服务则保持稳定实例数。
第二,公共代码抽离。比如工具函数、日期时间处理、短信接口封装,这些两边都要用的代码,我们抽成了一个公共Python包,用pip install -e本地安装的方式同时被两个服务引用。一开始图省事直接复制了一份,结果改一个日期格式化函数两边不同步,线上日期格式都乱过一阵,后来老老实实抽了公共包。
第三,数据库连接配置统一。两边都要读同一个数据库配置来源,我们把它放在环境变量里统一管理,避免"测试环境连了生产库"这种低级事故。CI流水线里也加了校验,环境配置不匹配直接构建失败。
3. 数据模型设计:预约业务的关键表结构
3.1 核心业务表拆解
数据模型是整个平台的骨架,前后重构过两轮。第一轮设计得太粗糙,把预约时段和预约记录全塞在一张表里,导致状态查询乱七八糟;第二轮才拆成下面这套结构,上线后一直很稳定。
核心表一共四张。
第一张是用户表(user)。字段包括用户名、密码哈希、用户类型(申请人/审核员/管理员)、手机号、邮箱、创建时间。这里有个细节:用户类型没有用Django默认的is_staff字段直接区分,而是单独加了一个profile表来存业务角色。因为一个用户可能既是申请人,又被授权为某个业务线的审核员,单一布尔字段表达不了这种多角色关系。
第二张是时段表(appointment_slot)。字段包括日期、开始时间、结束时间、业务类型、总容量、已预约数、可用数、状态。这张表的核心是"容量配额"逻辑:每个时段绑定一个业务类型,管理员可以单独调整某一天某个时段的容量。比如周一到周四各放20个号,周五放10个号,遇到节假日还可以直接把时段停用,前端就不会再展示。
第三张是预约记录表(appointment)。字段包括预约单号、用户ID、时段ID(外键)、申请的业务类型、材料清单(JSON字段存文件名和路径)、审核状态、审核意见、创建时间、更新时间。预约单号和时段是强关联的,一个时段可以被多个申请人预约,但容量有限,达到上限就不能再约。
第四张是审核记录表(review_log)。字段包括预约记录ID、操作人、操作类型(通过/退回/补正)、操作意见、操作时间。这张表的作用是留痕——申请人能看到"谁在什么时候对我的材料做了什么处理",后续出现纠纷也有据可查。这也是软件著作权登记这类政务性质业务里很看重的一点。
除了这四张核心表,还有几张辅助表:业务类型字典表、系统通知表、文件上传记录表、操作日志表。辅助表的作用是避免硬编码和保证可追溯。举个例子,业务类型如果直接写死在代码里,后面想加一个"撤销登记"的业务类型就得改代码发版,用字典表只需在后台加一条记录。
3.2 预约状态机的流转设计
预约记录的状态是平台逻辑最复杂的部分,我把它设计成一个严格的状态机,不允许跳状态。状态定义如下:
- 待审核(PENDING):申请人提交预约后进入,此时时段容量已被占坑;
- 材料审核中(REVIEWING):审核员开始处理这条记录;
- 待补正(REQUIRED_SUPPLEMENT):材料不齐全,退回给申请人补充,此时预约单号保留,但时段容量释放;
- 已通过(APPROVED):材料审核通过,进入正式受理环节;
- 已取消(CANCELLED):申请人主动取消,或者超时未补正自动取消。
状态机的流转规则只有几条:PENDING可以转REVIEWING或CANCELLED;REVIEWING可以转REQUIRED_SUPPLEMENT、APPROVED或CANCELLED;REQUIRED_SUPPLEMENT可以转REVIEWING(补正后重新提交)或CANCELLED。
这里有一个容易踩坑的细节:待补正状态要不要释放时段容量?一开始我们设计的是不释放,因为申请人补正后大概率还要继续办理。但实际运行中发现,很多申请人收到补正通知后就不来了,原时段被白白占着,影响其他人预约。后来改成"补正超时两天自动释放容量",把释放动作做成定时任务,效果好了很多。
状态机的实现,我没有用第三方库,直接在模型层做了一个自定义的StateField,加一张合法的状态转移表,非法转移直接抛异常。这样最直观,出问题也好排查,比引一个重型工作流引擎划算得多。
4. 预约时段分配与防并发冲突:最容易翻车的地方
4.1 时段分配策略:从固定时段到动态配额
预约系统的核心是"怎么分时段才能让容量利用率最高"。最开始的产品方案是固定的半天时段:上午9:00-12:00,下午14:00-17:00,每个时段同样容量。但实际业务中,每天办理量呈现明显的波峰波谷——周一周二人多,周四周五人少,上午十点左右往往是高峰。
后来我们把粒度细化到小时时段,并引入了一套规则调整机制:管理员可以按星期几配置每日的基准容量,再按业务类型配置不同占比。比如周一到周三每天基准容量50,其中首次登记占70%、变更登记占20%、其他占10%;到了周四容量降到30,比例自动重新计算。
"比例自动重新计算"这块用了一个很简单的算法:先把各业务类型的预约需求按前七天的历史数据统计出来,算出占比,再根据当天总容量乘以占比得到各类型配额。不搞复杂的预测模型,用滑动窗口平均就能满足业务需求,毕竟平台流量基数不大,复杂模型反而容易过拟合。
4.2 并发预约的踩坑:从超卖到事务加锁
这个项目踩得最深的坑就在预约提交这一步。第一次上线后测试,用脚本并发创建预约,发现超卖严重——100个同时请求,明明时段容量只有20,却有30多个成功了。
排查过程很有意思。第一反应是Django事务隔离级别的问题,翻日志没发现异常。后来定位到是"先查后写"的竞态问题:两个请求同时读到剩余容量=1,各自判断"还够",然后各自插入一条预约记录,最后容量变成负数。
这个问题的标准解法是数据库行锁。我用select_for_update()把时段记录锁住,在事务内完成"检查容量→插入预约→扣减容量"三步操作。改完之后并发测试从30个成功降到了正好20个,问题解决。
from django.db import transaction @transaction.atomic def create_appointment(user_id, slot_id, biz_type, materials): from .models import AppointmentSlot, Appointment slot = AppointmentSlot.objects.select_for_update().get(id=slot_id) if slot.available_count <= 0: raise BusinessError("该时段已约满") appointment = Appointment.objects.create( user_id=user_id, slot=slot, biz_type=biz_type, materials=materials, status="PENDING" ) slot.booked_count += 1 slot.save(update_fields=["booked_count", "available_count"]) return appointment这里有两个细节必须提醒:
第一,select_for_update()是数据库行锁,必须搭配事务使用,否则锁不会生效。Java那边有synchronized习惯的人容易误以为Python里也有类似的锁可以直接用,但应用层锁在多worker部署下完全无效。
第二,锁的粒度要放在时段记录而不是用户记录上。因为竞争的资源是时段容量,不是用户。把锁放在用户ID上,会导致同一个用户并发提交时互相阻塞,但不同用户之间照样超卖。
后续又发现一个坑:当多个时段同时被高频预约时,行锁排队可能导致数据库连接池耗尽。我们当时的处理是调大数据库连接池上限,同时在业务上做限流——对同一个用户ID的预约提交接口做一秒一次的频率限制,效果很明显。
4.3 取消预约的容量回补与定时任务
有预约就要有取消。取消操作本身逻辑不复杂:把状态置为CANCELLED,然后把对应时段的可预约数加回来。但有一个边界情况很容易被忽略:如果取消的时候时段已经开始(比如当天的时段),回补的容量因为没人来得及约,等于没用。这时候就要靠定时任务把过期未确认的预约批量释放,同时把当天剩余的容量统一作废。
定时任务我们用Celery Beat管理,每天凌晨扫描所有时段和预约记录:把昨天的未审核预约标记为失效、把补正超时的记录释放容量、把当天已结束时段的预约汇总成日报发到管理员邮箱。这些任务看着琐碎,但少了任何一个,第二天的业务就会出问题。比如补正超时释放的任务挂了,一周后容量表就会积累大量僵尸占坑,管理员也无从查起。
5. 角色权限设计与后台管理:Django Admin的改造
5.1 三种角色的权限边界
权限设计上,我们没有引入复杂的RBAC第三方库,直接基于Django自带的permission框架扩展。三类角色的权限边界如下:
- 申请人:只能查看和操作自己的预约记录;可以创建预约、取消预约、补充材料;不能查看其他用户任何数据。
- 审核员:可以看到分配给自己或所属业务线的预约列表;可以执行通过、退回、补正操作;可以查看审核记录;不能修改系统配置。
- 管理员:拥有全部权限,包括用户管理、时段配置、业务类型维护、统计报表导出、日志查询。
Django的permission系统天然支持"模型级别"的权限控制,但"只能看自己业务线的预约"这种行级控制它管不了,需要在queryset层做过滤。我们的做法是给审核员增加一个"业务线"字段,查询预约列表时手动加上业务线过滤条件,而不是依赖Django的默认权限判断。这个过滤条件在列表页、详情页、修改页三处都要保持一致,是很容易漏改的地方。
5.2 Django Admin后台的改造思路
Django自带Admin后台很好用,但直接拿来做业务后台会显得太"裸"。我们对Admin做了三层改造。
第一层,自定义list_display和list_filter,让审核员在列表页就能看到关键信息:预约单号、用户名、业务类型、时段、状态、提交时间。给状态字段加了一个自定义的color标签,通过/退回/补正在视觉上一眼区分。
第二层,重写get_queryset方法,让不同管理员看到的数据范围不一样。超级管理员看全部,普通管理员只看自己负责的业务线。这里有个细节容易被忽略:get_queryset在list页面、detail页面、change页面三处都要生效,只改一处会导致用户在详情页看到本来没权限看的数据。
第三层,把"审核操作"从Django自带的change页面里抽出来,做成自定义action。审核员点一下按钮、填一下意见,就完成一次状态流转,完全不需要理解Django admin的表单结构。这一点对业务人员特别友好,培训成本低很多。
from django.contrib import admin from django.contrib import messages from .models import Appointment, ReviewLog @admin.register(Appointment) class AppointmentAdmin(admin.ModelAdmin): list_display = ("order_no", "user", "slot", "biz_type", "status", "created_at") list_filter = ("status", "slot__date", "biz_type") actions = ["mark_approved", "mark_rejected"] @admin.action(description="批量通过选中的预约") def mark_approved(self, request, queryset): for obj in queryset: if obj.status == "REVIEWING": obj.status = "APPROVED" obj.save(update_fields=["status"]) ReviewLog.objects.create( appointment=obj, operator=request.user, operation="APPROVED", comment="后台批量操作" ) self.message_user(request, f"已通过 {queryset.count()} 条预约")5.3 文件上传与材料清单的处理
软件著作权登记需要上传源码、文档、申请表,单个文件可能几十MB。一开始我们想直接存数据库,很快发现不行——数据库体积膨胀、备份变慢、写入性能下降。后来改成对象存储服务存文件,数据库只存文件的路径和元数据。
上传接口放在Flask层,做了分片上传和秒传检测。分片上传解决大文件断点续传问题,秒传检测通过MD5比对避免重复上传相同材料。对申请人来说,最直观的体验是"上传大文件不再担心页面卡死",对运维来说,带宽和存储成本也得到控制。
这个模块在上线后遇到的问题主要是文件类型校验。有些申请人把源码打包成rar压缩包传上来,后台审核员打不开。后来在前端和Flask两层都做了扩展名白名单校验,后端才是最后一道防线,前端校验只是优化用户体验用的,任何时候都不能只靠前端校验。
6. 部署上线后的真实运行记录与优化方向
6.1 部署架构与一次慢查询排查
部署形态是这样的:Nginx做入口,同时反向代理Django和Flask两个服务;Django用Gunicorn跑,Flask也用Gunicorn但worker数量配置不同;数据库用PostgreSQL,缓存和分布式锁用Redis;Celery Worker单独一组容器跑定时任务和通知发送。
上线初期遇到最大的性能问题是慢查询。有一次上午高峰期,Django服务整体卡顿,页面一直转圈。排查后发现是一条关联了五个表的汇总查询没走索引,导致全表扫描。定位过程用了Django ORM的connection.queries记录慢SQL,加上数据库层面的慢查询日志,把耗时超过500ms的语句全部捞出来。解决办法是给外键字段和常用过滤字段补齐联合索引,查询时间从2秒降到100毫秒以内。
顺带说一句,这类预约系统的QPS其实不高,真正的瓶颈往往在数据库查询效率和锁竞争上。与其纠结用什么异步框架提高并发,不如先把索引、事务、连接池这三件事做好。
6.2 消息触达、数据可视化和电子预审
项目稳定运行之后,我一直收集实际使用中的反馈,有几个方向值得继续做。
第一是消息触达的多样化。目前只有站内通知,很多申请人根本不上系统看消息,导致补正材料长期没人处理,后面只能靠打电话催。下一步计划接入短信、邮件和模板消息推送,不再依赖用户主动登录系统。
第二是预约数据的可视化。管理员目前只能看表格和Excel导出,后续打算在Django Admin里嵌入一个轻量图表面板,展示每日预约量、各业务类型占比、审核平均耗时、爽约率趋势。让管理者做容量决策时不再是拍脑袋。
第三是接入电子材料预审。在申请人上传材料时就做一轮格式和完整性校验,比如检查源码文件类型对不对、文档页数够不够、申请表必填项是否完整,把明显不合格的材料挡在提交之前,能有效减少审核员的工作量。这个功能已经在做了,预审规则的维护界面用Django Admin管,发现规则太严格时还能随时调。
最后,说点个人体会
这个项目从需求梳理到上线,前后约三个月。我最深的体会是:预约类系统的难点从来不在"预约"这个动作本身,而在于容量管理、状态流转、数据一致性这三件事。
容量管理要设计好撮合逻辑,状态流转要把业务规则理清成状态机,数据一致性则要靠事务、锁、定时任务三管齐下。框架选型上,Flask和Django的搭配如果划清边界,能让各自的优势都发挥出来——Django管好复杂业务和后台,Flask管好高频写接口和上传链路,比硬把一个框架掰成两个用省心得多。
如果你也在做类似的项目,我的建议是:先花足够时间跟业务方把所有状态和流转规则核对清楚,再去写代码;数据库设计的优先级高于一切;并发控制从第一版就要想好,不要抱着"先上线再说"的心态,否则后面改起来成本极高。希望这篇分享能帮你少踩几个坑。