去年接了个家政保洁预约系统的开发需求,项目标题挂着“python+flask+vue框架”三个关键词,朋友说得很轻松:用户下单、保洁员干活、管理员看数据。结果深入一梳理才发现,“角色多”这三个字才是整个项目最大的复杂度来源——普通的后台管理系统通常就两类角色,这个系统里客户、保洁员、运营、财务、客服各管一段,每个人看到的界面都不一样,能操作的数据边界也完全不同。整个系统用 Flask 提供 REST API,Vue 做前端页面,前后端完全分离,折腾了大半个月跑通全流程,今天把从需求拆解到权限落地的完整过程整理出来,含数据库设计、订单状态机、时间冲突处理和 RBAC 权限细节,适合准备用 Python 技术栈做预约类系统、又不想被“角色多”拖垮的同学参考。
1. 家政预约里到底有谁:角色模型先于代码
1.1 三张主要面孔:客户、保洁员、运营人员
很多人拿到需求的第一反应是“不就是用户下单、管理员派单吗”,等真去梳理流程才发现,家政服务在线下早就分化出了好几个角色。我在这个项目里最终确认下来的是三大类:客户、保洁员、运营人员,其中运营人员又拆出管理员、客服、财务三个细分岗位。
客户这个角色最直观,注册登录、浏览服务项目、下单预约、在线支付、评价投诉。但这里有一个容易忽略的点:下单的人和使用服务的人可能不是同一个人。比如子女帮父母预约保洁,下单账号是子女,实际上门服务的地址是父母家,联系电话也可能填父母的。所以客户角色下面要区分“下单账号”和“服务联系人”两个概念,否则后面客服打电话确认订单时会找不到人。
保洁员角色看起来就是接单干活,实际操作里权限更细:要看到自己被派了什么单、档期能不能自己调整、服务完成后要提交图片和耗材记录、月底要查自己的服务流水。有些平台允许保洁员自己抢单,有些是管理员统一派单。我这个项目采用的是派单制,保洁员只读自己的排班和订单,不能看到全站的单子,这既是权限控制问题,也是数据隐私问题。
运营人员的功能就更多了。管理员管全站数据、服务项目上下架、保洁员审核入驻;客服只能看订单和客户信息,能改订单状态但不能改价格和财务数据;财务只看支付流水和结算账单。这三类人你不可能给同一个权限,否则就是给自己埋雷。把角色拆开之后,才发现后端接口几乎每一个都需要做角色校验,这也是这个项目的工作量比预想大得多的直接原因。
1.2 每个角色各自能碰哪些数据
角色模型拆清楚之后,我列了一张权限矩阵,把每个角色能访问的数据范围固定下来。这张表很重要,后端写装饰器、前端写路由守卫全靠它。项目里的核心数据对象包括用户信息、服务项目、订单、支付记录、排班档期、评价记录,六个对象交叉三大类角色,组合起来是这样的:
| 数据对象 | 客户 | 保洁员 | 管理员 | 客服 | 财务 |
|---|---|---|---|---|---|
| 用户资料 | 本人 | 本人 | 全部 | 可读 | 不可见 |
| 服务项目 | 可浏览 | 只读 | 增删改 | 只读 | 不可见 |
| 订单 | 本人订单 | 被派给自己的 | 全部 | 全部 | 只读 |
| 支付记录 | 本人的支付信息 | 不可见 | 全部 | 不可见 | 全部 |
| 评价 | 本人的 | 收到的主评 | 全部 | 只读 | 不可见 |
| 排班档期 | 不可见 | 自己的 | 全部 | 只读 | 不可见 |
这张矩阵看起来简单,但它决定了后端的每一个视图函数怎么写。比如同一个 GET /orders 接口,客户请求只返回自己创建的订单,保洁员请求只返回服务对象是自己的订单,管理员请求返回全部订单。一个人没法用一套通用代码写死,必须在查询层根据当前登录用户的角色动态拼接查询条件。这就引出了权限控制的核心:身份认证只解决“你是谁”,角色授权才解决“你能干什么”。
1.3 角色边界不清会带来的典型问题
我在需求评审阶段吃过亏,当时第一版没把角色拆细,客户、保洁员、管理员用的都是同一个用户表,用一个 role 字段区分。看起来省事,实际上后面每个接口都要写 if user.role == 'customer' 之类的分支,代码写得又臭又长,而且很容易漏判。有一次保洁员就能调管理员的统计接口,只是因为没有做校验,整个站点的客户电话全被导出,幸好是测试环境,不然就是事故。所以凡是涉及多角色的系统,角色模型一定是最先干的事,而且宁可多拆、不要少拆,后期合并角色比拆角色容易得多。
还有一个很典型的需求盲区:客户给保洁员评价之后,保洁员能不能申诉?客服能不能删除恶意差评?如果评价也纳入角色权限矩阵,就得考虑谁有删除权、谁能申诉、申诉走什么流程。这类边界问题早期不理清楚,后期改数据库加状态字段的成本是很高的。我建议项目启动时,找业务方把每个角色干一遍完整的“核心操作路径”,比如客户从注册到下单到评价,保洁员从接单到完工到结算,管理员从审核到对账到下月结款,把每个路径上的数据权限写进文档。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Flask + Vue 的选型理由:为什么不用全家桶
2.1 Python 生态里选 Flask 而非 Django 的真实原因
很多人问我,Python 后端框架一抓一大把,为什么偏偏用 Flask?我用 Django 做过后台管理系统,确实开发效率高,自带的 Admin、ORM、认证体系都很完善,但它太重了。家政预约系统不同于内容管理类系统,它没有一个现成的数据模型模板可以套,订单的流转逻辑也很个性化,比起 Django 的强约束,我更希望有一个“骨架轻、自由度高”的框架,我能自己控制路由规划、ORM 映射和请求流程。Flask 就是典型的微框架,路由自己写,扩展按需装,项目结构清爽,适合 t 这种业务规则复杂、但数据量不至于大到需要分布式架构的中小型预约系统。
Flask 的生态里还有几个很合适的组件。Flask-SQLAlchemy 做 ORM,Flask-JWT-Extended 做登录状态和身份校验,Flask-Migrate 做数据库迁移,Flask-CORS 解决前后端分离的跨域问题。这些组件都是经过大量项目验证的,拼装在一起就是一套非常干净的后端底座。对比 Java 系,用 Spring Boot 做这类系统当然也行,但 Python 后端团队招人相对容易,开发迭代也快,像我这个项目从 0 到上线跑通核心流程只花了两周多一点,换 Spring Boot 这种重框架,光环境配置和项目初始化就要多耗不少时间。
2.2 Vue 侧的技术选型与版本决策
前端选择 Vue 同样有明确原因。家政预约系统面向两类使用终端,客户和保洁员用的是手机浏览器,管理员用的是电脑浏览器,这意味着前端要做响应式页面,同时要考虑移动端适配上对 Vue 很友好的开发模式。Vue 3 配合 Vite 的构建速度非常快,开发体验也远比 Vue 2 的 webpack 配置舒适。组件库我选了 Element Plus,表格、表单、日期选择器、弹窗提示这些后台常用组件都能开箱即用,省掉了大量的样式投入。移动端那边,直接用 Element Plus 在手机屏幕的显示效果会稍显拥挤,我给关键页面额外写了移动端适配样式,让客户手机上下单不至于要放大页面才能点按钮。
Vue 的路由和状态管理在这个项目里用处很大。登录后用户角色决定了能访问哪些页面,我用 Vue Router 的动态路由来实现:管理员登录后能看到订单管理和保洁员审核,客户登录后看到的是“我的预约”页面;保洁员看到的则是“我的日程”和“服务记录”。状态管理选了 Pinia,用来保存用户信息和侧边栏展开状态。对比一下不用状态管理的方案,每次刷新页面都要重新请求用户信息接口,体验差别是肉眼可见的。
2.3 前后端接口风格的统一约定
前后端分离最忌讳的是接口风格不统一,前端拿到一个接口用 query 参数,另一个接口要 JSON body,写 axios 封装的时候就得大量定制。这个项目我把接口规范固定下来:列表类接口用 GET + query 参数,带分页参数 page 和 page_size;提交类接口统一 POST + JSON body;查询详情统一 GET + /id;状态修改统一 PATCH + JSON。返回结构统一为 code、message、data 三层。比如一个正常的订单列表返回长这样:
json复制{
"code": 0,
"message": "success",
"data": {
"items": [],
"total": 0,
"page": 1,
"page_size": 10
}
}
code 为 0 表示业务成功,非 0 是业务失败,前端拿到 code 不为 0 就弹出 message 提示。HTTP 状态码只用来表达网络层结果,401 未登录、403 无权限、404 资源不存在、500 服务异常,业务层的报错全部放进 code 字段。这样一个机制让前端拦截器写得非常轻松:拦截器里处理 HTTP 状态码,业务层只判断 code。我还规定时间统一用时间戳,前后端都不传字符串时间,避免时区问题和格式不一致造成的解析错误。这套约定在项目中期开始发挥价值,后期加接口时,前端几乎不用问后端“参数是什么类型”,看接口文档格式就能直接对接。
3. 数据库设计:把角色多和时间维度落进表里
3.1 用户与角色基础表
角色模型和权限矩阵定好之后,数据库设计的底子就清楚了。用户表我并没有简单地用 id、username、password、role 四个字段,而是扩展了几项对家政预约场景至关重要的信息。我建的 users 表核心字段如下:
sql复制CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role ENUM('customer', 'worker', 'admin', 'cs', 'finance') NOT NULL,
phone VARCHAR(20),
real_name VARCHAR(50),
status TINYINT DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
role 字段用了 ENUM,角色枚举在数据库层面就固定下来,程序里用错字符串时数据库能直接报错。这里有一个细节:同一张用户表容纳了客户和保洁员,两者的个人信息是不同的。客户需要家庭住址、常用服务偏好,保洁员需要服务技能、工作年限、身份证验证状态、服务区域。所以我又加了 worker_info 表放保洁员特有信息,customer_profile 表放客户扩展信息,形成 1 对 1 的关系扩展,而不是把所有字段堆进 users 表。这样用户表只承载登录最核心的信息,查询更快,也不会因为加入大字段导致索引膨胀。
角色和权限是三个概念:用户表里的 role 字段标识用户的角色,role 和权限点之间通过一张 role_permission 关联表绑定。实现 RBAC 的时候我没做太复杂的用户-角色-权限多对多,因为角色固定就五类,直接在代码里用字典把角色和可访问路由绑定更简单。但数据库层面仍然留了 role_permission 表的扩展位,万一以后新增角色,不需要改代码里的路由硬编码。
3.2 服务项目与保洁员画像
家政保洁系统的另一个核心是服务项目的管理。项目里常见的服务有日常保洁、深度保洁、玻璃清洁、开荒保洁、家电清洗等,每类服务的计价方式不一样:有些按小时,有些按面积,有些按固定次数。我设计了 service_items 表:
sql复制CREATE TABLE service_items (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
category VARCHAR(50),
price_type ENUM('hour', 'area', 'fixed') DEFAULT 'hour',
price DECIMAL(10,2) NOT NULL,
duration_minutes INT DEFAULT 120,
description TEXT,
status TINYINT DEFAULT 1
);
price_type 字段直接影响前端页面渲染逻辑,按小时计费的显示“每小时 xx 元”,按面积计费的显示“每平米 xx 元”,按固定次数计费的直接显示总价。这和订单表的计价字段联动,下单时把单价快照进订单,防止服务项目以后改价影响历史订单的结算。
保洁员能干哪些服务项目、不能干哪些,这是一个多对多关系。保洁员并不是什么保洁都会做,深度保洁需要多功能清洁设备培训,开荒保洁需要高空作业经验,家电清洗各细分品类又不一样。我用 worker_skills 关联表来表示 worker 和 service_items 的多对多,同时加上一个 skill_level 字段标记熟练程度。派单时系统必须优先匹配有对应技能标签且在服务区域内的保洁员,如果派了一个不具备开荒保洁技能的保洁员去干开荒,大概率会在现场出问题。
3.3 订单、排班与状态流转:表的骨架
订单表是整个系统数据结构的枢纽,我花了最多心思设计。一个订单必须完整记录:谁下的单、谁服务、服务什么项目、在什么时间地点、价格多少、状态是什么。核心字段如下:
sql复制CREATE TABLE orders (
id INT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) UNIQUE NOT NULL,
customer_id INT NOT NULL,
worker_id INT,
service_item_id INT NOT NULL,
address VARCHAR(255) NOT NULL,
contact_name VARCHAR(50),
contact_phone VARCHAR(20),
appointment_start DATETIME NOT NULL,
appointment_end DATETIME NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status ENUM('pending_pay', 'paid', 'awaiting_assign', 'assigned', 'in_service', 'completed', 'cancelled') DEFAULT 'pending_pay',
payment_status ENUM('unpaid', 'paid', 'refunded') DEFAULT 'unpaid',
memo TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
我把 appointment_start 和 appointment_end 拆成两个字段,而不是只存一个开始时间再推断时长。这样查询保洁员档期冲突时,直接对比两个时间范围就能判断,不需要额外的计算。订单金额 amount 是下单时根据服务单价和时长计算出来的快照,它不随服务项目表的价格变化而变动,后续任何改价操作都只生成新的订单数据,不用考虑追溯。
排班表记录保洁员的档期占用情况,这是处理时间维度冲突的关键。核心字段包括 worker_id、date、start_time、end_time、order_id,其中 order_id 可空,空表示未占用。当订单支付成功进入等待派单状态时,系统先在排班表里占住这个时间段,再等待人工派单或自动匹配,占住后其他订单不能选择同一保洁员的同一时段,这样从源头上防范了时间冲突。值得注意的是排班表的唯一约束,我把 worker_id + appointment_start + appointment_end 设成联合唯一索引,数据库层面再兜一层底,防止并发穿透。
3.4 订单审核流:评价表和退款单
订单完成后还有两层业务数据要处理:评价和退款。评价表很简单,order_id 唯一,rating 1 到 5 星,comment 文本,还可以传服务完成时的现场图片。退款单表则复杂一点,一个订单可能发起多次退款申请,但不能通过多次——同一订单要么不退款要么退一次全款或部分款,我设置了 refund_orders 表,order_id 字段加唯一索引,如果员工误操作并发插入两条退款记录,数据库会直接拒绝其中一条。
评价和退款这两张表在需求里看似边缘,实际上占用了大量开发调试时间。评价要有防刷机制,同一订单只能评一次,订单状态不完成不能评价;退款要记录申请原因、审核人、退款金额、退款状态,财务人员的权限在这里表现得很具体——只有 finance 角色能变更退款状态。这类边缘模块的功能一多,更验证了前面角色拆细的重要。
4. 预约主流程的实现:派单、档期与冲突处理
4.1 下单链路:从价格确认到预占档期
下单单接口的处理逻辑是核心,我拆成了四步:校验服务项目可用性,校验时间段合法性,校验服务区域的保洁员排班,创建订单并锁定排班。这里最关键的是第二步和第三步。客户选好服务项目和时段之后,系统要立即查一次排班表,看看这个时段在该服务区域下的保洁员有没有空闲档期,如果有就预占,没有就提示客户选择其他时段。预占是一个原子操作,务必保证两步之间不能被并发请求插队,否则两个客户同时下单同一时段,都会查到“有空档”,然后各自把排班表插了一条记录,节后保洁员就冲突了。
我用的是“先插入排班记录后创建订单”的顺序加数据库事务。排班表里有一个唯一索引 (worker_id, appointment_start, appointment_end, date),当两个并发请求同时插入同一条排班记录时,第二个请求会因为唯一约束冲突而失败,我就把这个异常捕获后返回给前端“该时段已被预约”,前端提示客户更换时段。用数据库的唯一约束做并发兜底,比自己加锁要可靠得多,也是这类预约系统最扎实的防并发方式。实际压测下来,两个并发请求同时提交,只有一个成功,另一个能稳定拿到冲突提示。
下单完成并不意味着订单已经派给保洁员,状态是 pending_pay,需要客户在 15 分钟内完成支付,超时未支付则释放排班档期。释放动作我用了一个定时任务扫描 pending_pay 状态超过 15 分钟的订单,把状态改成 cancelled,同时释放排班表里对应的记录。定时任务用 Flask 的调度器扩展实现,每隔一分钟扫一次,对脏数据及时清理。这里要特别说一下,订单状态 cancelled 和排班释放必须放在同一个事务里执行,否则可能出现订单取消但排班还没释放的情况,后面客户再下单就会查到错误的时间占位。
4.2 派单机制:为什么我选了手动加自动兜底
下单成功之后,订单进入 await_assign 状态,这时候要解决“谁来派单”。抢单模式在小规模平台有一个麻烦:保洁员在手机上不停刷新抢单,高峰期没抢到的保洁员会不满,而且客户往往希望尽快确定人员,抢单延迟一长客户体验就差了。我这个项目的客户体量中等,最终采用的是管理员手动派单加系统自动兜底的双轨方案:管理员在后台看到待派单列表,可以根据保洁员所在区域、技能标签、历史评分人工选择目标派单;同时系统每小时跑一次自动匹配,把超过一小时未人工派单的订单,按技能匹配度与保洁员次日空档进行自动分配,并给保洁员发送服务通知。
这个设计的理由很现实:小团队初期根本没有足够的客服人手做到全天候秒级响应派单,自动匹配能兜底;而有些客户对特定保洁员有偏好,比如孕婴家庭指定经验丰富的阿姨,就需要人工派单来照顾这类需求。自动匹配逻辑的核心是一个筛选函数,优先选服务区域内技能匹配的、其次是评分和历史订单数综合排序的、再次是最早空闲的。排序规则我写成了可配置项,后期如果改为评分优先或响应速度优先,直接改配置不用改代码。自动匹配执行后要记录派单日志,派单员、派单时间、匹配依据都保存,方便财务和客服追溯一个订单为什么派给了这个人。
4.3 同一保洁员被双订:时间冲突的兜底设计
时间冲突是预约类系统绕不开的坎,频繁出现在三种场景里:两个客户几乎同时预约同一保洁员的同一时段、客户修改订单时间时撞上一个已被占用的时段、自动派单和人工派单同时执行导致同一订单被派给两个人。先说客户端修改时间的场景,这个最容易出低级 bug。我之前直接在更新订单的时候只把订单表的新时间更新了,忘了更新排班表的对应记录,结果同一个保洁员的时间表上同时存在两单。后来改成更新订单时间和更新排班记录放在同一个事务里,用排班表的 order_id 反查旧的排班记录,更新起始结束时间,才彻底根治。
人工派单和自动派单并发是另一个隐蔽问题。管理员在页面上刚点派单按钮的同时,自动匹配定时任务也在跑,操作同一个订单,极易出现一个订单被两次分配的脏数据。我的解决方式是状态机的并发保护:派单操作更新订单时,加一个条件判断“当前订单状态必须为 await_assign”,使用 UPDATE orders SET worker_id=xxx, status='assigned' WHERE id=xxx AND status='await_assign',受影响行数为 0 说明已被别的人抢着派过了,直接拒绝本次派单。这种方式依靠数据库的条件更新,不用额外引入分布式锁,小规模系统完全够用。更重要的是,我把所有的状态变更都规范成“条件更新 + 行数判断”的模式,后续加任何状态流转都沿用这个套路,保持着整个状态机的一致性和可预测性。
4.4 服务完成的验收流程与异常上报
保洁员在服务完成后需要提交一个动图片和耗材说明,这就是订单状态从 in_service 变成 completed 的触发点。但服务过程中可能遇到意外:客户临时加项目、清洁难度远超预估、房子没人需要改期。我在订单里预留了一个异常单分支,保洁员在服务状态可以提交“异常上报”,说明原因并附现场图片,订单临时挂起等待客服处理。客服看到上报后可以直接改期或终止服务,终止服务的订单进入取消流程,客服备注原因,财务按实际情况决定是否退款。这个异常分支在真实场景里极其重要,如果做一个封闭式的订单状态机,一旦发生线下意外,保洁员和客服只能靠电话沟通,系统里沉淀不下来单据。增加一个异常上报节点以后,大部分问题都能在线处理,数据留痕也完整。
5. 多角色权限:后端校验与前端拦截各做一半
5.1 后端 JWT 角色校验与接口级权限
身份认证用的是 JWT(JSON Web Token),登录成功后后端签发一个带角色信息的 token,前端每次请求都带着这个 token,后端解析出当前用户和角色,再决定是否放行。Flask-JWT-Extended 提供了 @jwt_required() 装饰器来校验 token 有效性,但角色校验需要自己实现。我封装了一个 check_roles 装饰器:
python复制from functools import wraps
from flask_jwt_extended import get_jwt_identity, verify_jwt_in_request
def check_roles(*roles):
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
verify_jwt_in_request()
identity = get_jwt_identity()
if identity.get('role') not in roles:
return {'code': 403, 'message': '无权限访问'}, 403
return fn(*args, **kwargs)
return wrapper
return decorator
有了这个装饰器之后,接口级别的角色控制就非常干净了。比如管理员才能改服务项目的状态,我在视图函数上直接加 @check_roles('admin');客服和财务都能看到订单,但财务不需要看订单地址和联系人电话,我又在视图函数里再做字段级过滤,返回财务想要的数据时去掉敏感字段。这种“装饰器控制接口是否可调、视图函数内控制返回字段”的双层设计,既保证了入口安全,又控制了数据暴露范围。
JWT 的过期机制也要仔细处理。家政保洁的保洁员通常在户外作业,网络不稳定,如果 token 三十分钟过期一次,保洁员正在服务客户时 token 失效,重新登录又是一顿折腾。我把 token 有效期设成了 7 天,同时每次有效请求都返回刷新后的 token 信息,用前端拦截器的响应钩子自动更新本地 token 存储,实现无感续期。不过这里有个代价,7 天有效期的 token 一旦泄漏风险也更大,所以刷新 token 的接口要额外校验设备信息或 IP,至少在关键操作(改密码、解绑手机)时要求重新登录。
5.2 Vue 路由守卫和动态菜单的实现
前端权限不仅仅是隐藏按钮那么简单,更关键的是路由级别的控制。Vue Router 里我写了一个全局前置守卫,每次路由切换前判断 token 是否存在、用户角色是否允许访问目标页面。路由配置里每一条路由都带 meta 字段,标记 allowedRoles 数组,比如:
javascript复制{
path: '/admin/orders',
component: () => import('@/views/admin/OrderManage.vue'),
meta: { title: '订单管理', allowedRoles: ['admin', 'cs'] }
}
路由守卫的核心逻辑是取到当前用户的 role,检查是否在 to.meta.allowedRoles 中,不在就跳转到 403 页面或重定向到登录页。但光有这个还不够,用户如果记住了管理员的 URL,直接在浏览器输入地址栏访问,没有路由守卫就直接进页面了,虽然有后端接口拦截兜底,但前端页面还是会露出一个空壳,给用户体验很不友好。所以路由守卫和动态菜单必须同时做:动态菜单控制用户看得到哪些导航,路由守卫控制用户能进哪个页面,双重防护才能把权限控制扎实。
动态菜单的实现是前端根据角色从后端拉取菜单列表,后端返回菜单数组,包括菜单名称、路由路径、图标类型,前端通过 router.addRoute 动态注册路由。一开始我先写死全部路由,再根据角色过滤显示,但仔细想想这种方案有隐患——过滤只是不显示菜单,路由仍然是注册好的,用户绕过导航直接访问 URL 依然能进页面。后来我改成了真正的动态路由注册,用户登录以后先拉菜单列表和角色,再按角色注册相应路由,角色不同注册的路由表就不同,不存在的路由会落到 404 页面,这样从路由层面彻底截断了越权访问的可能。
5.3 数据级权限:保洁员只能看到被派给自己的订单
接口权限和路由权限只能控制“能进哪个接口”,控制不了“接口里返回谁的数据”。这个必须单独处理。我做了一个小小的查询工厂函数,根据当前用户角色拼接数据过滤条件,核心逻辑如下:
python复制def get_orders_for_user(user):
if user.role in ('admin', 'cs', 'finance'):
return Order.query
if user.role == 'worker':
return Order.query.filter(Order.worker_id == user.id)
if user.role == 'customer':
return Order.query.filter(Order.customer_id == user.id)
return None
这个函数用在所有订单查询相关的视图入口,客户永远只能拿到自己创建的订单,保洁员永远只能拿到服务对象是本人的订单,就算客户用工具直接构造接口请求,把另一个客户的订单 id 传过来,后端在详情查询时同样会先走这个过滤条件,查不到就是 404,不会暴露任何别人的数据。这类数据级权限是家政平台合规的底线,客户位置、联系方式、上门时间都属于隐私,如果没有数据级隔离,随便一个注册用户就能遍历订单 id 拉全部订单数据,后果非常严重。这一点我在项目上线前专门做过一轮测试,用一个普通客户账号尝试访问不在自己权限范围内的订单 id,全部被正确拒绝后才算放心。
6. 上线后踩过的坑,每个都值得记账
6.1 支付回调的幂等处理
项目接入微信支付以后,我最早踩的坑是支付回调。微信支付成功后会回调通知后端接口,网络重试机制导致同一个支付结果可能回调两次甚至更多。我第一次没做幂等处理,回调一进来就直接改订单状态为 paid,结果同一个订单被更新了两次,日志里看到两条“支付成功”记录,数据库订单状态倒是没问题,但支付流水表里多了两条相同流水。后来我在支付流水表加了 out_trade_no 唯一索引,回调处理先查是否已存在,存在就直接返回成功,不再执行订单更新逻辑。这个改动虽然小,但避免了财务对账时的流水异常,属于基础功里面有价值的细节。
6.2 状态变更的并发保护
订单状态变更是整个系统里并发风险最集中的地方。客户在“待支付”状态下可以取消订单,管理员在“待派单”状态下可以派单,保洁员在“已派单”状态下可以开始服务,这些动作并行发生时,一定要防止老状态覆盖新状态。具体来说,取消订单时如果管理员同时已经派单了,取消操作不能把派单结果顶掉。我统一把状态变更写成一个带条件更新的模式:
python复制updated = Order.query.filter(
Order.id == order_id,
Order.status == current_status
).update({
'status': new_status,
'updated_at': datetime.utcnow()
})
if updated == 0:
return {'code': 409, 'message': '订单状态已变化,请刷新后重试'}
受影响行数为 0 时说明订单状态已经不是预期值,直接返回冲突提示,前端拿到提示后重新拉最新订单详情展示给用户。这个模式在所有状态接口里统一使用,后续新增任何新状态流转,都照这个模板写,防止并发下状态回退。上线运行到现在,还没有出现过状态异常覆盖的案例。
6.3 浏览器回退导致的重复提交
前端踩到的一个很实际的坑是,用户填写完订单详情后点了提交,紧接着点浏览器回退,然后再次提交,就产生了两个一模一样的订单。后端做幂等控制只靠数据库唯一索引比较难,因为两个订单的时间戳必然不同,无法通过唯一约束去重。我的方案是下单接口要求前端生成一个 idempotency_key(UUID),同一个操作流程内多次提交携带同一个 key,后端记录已处理过的 key,重复 key 直接返回第一次下单的结果。这个方案在金融支付类系统里是标配,家政预约虽然支付金额不大,但双订单问题会让客户困惑,也会增加客服解释成本。实现起来成本不高,就用一个缓存表存储 key 和订单号映射就行。
6.4 移动端适配和时间展示的细节
上线前最后一轮体验才发现移动端页面有一堆细节:客户用手机号下单时,日期选择器的交互在手机上很不顺手;保洁员的“开始服务”按钮在部分小屏手机上被底部导航栏挡住。我把客户端的日期选择器换成了更适合触屏操作的移动端组件,把保洁员服务页面的主操作按钮改成了吸底悬浮样式,关键操作不需要滚动页面就能点到。另外时间展示也有讲究,后端返回的是 UTC 时间戳,前端如果不做时区转换,保洁员看到的服务时间会比实际早八小时。这个问题在联调时就踩到了,前端在展示层统一用 dayjs 做时区转换,所有时间格式化都走同一个工具函数,后面再也没碰到过时间错乱的情况。
回过头看这个项目,最耗时间的不是写代码,而是把角色间的数据边界和状态流转理顺。用 Python + Flask 做后端、Vue 做前端,本身技术挑战不算大,但角色一多,每一层都要想清楚“谁能访问什么、什么状态下允许执行什么操作”,哪怕是一个小功能,画清楚边界再动手,比写完了返工要快得多。这套系统的订单状态机和权限控制方式,放到其他预约类业务里(洗车、美甲、宠物寄养)依然能复用,核心设计也大差不差。如果你也在做类似的多角色预约系统,建议一定先把角色权限矩阵和订单状态机画在纸上,再开始搭数据库,省下的绝对不止一个星期的工期。
