☰
基于Python与Vue3的智慧物业报修系统开发实战
2026/10/6 13:36:27 网站建设 项目流程

做智慧物业的项目,我第一个想到的场景不是大屏上的"智慧驾驶舱",而是小区业主群里那条被刷掉的报修消息:"我家水管漏了,谁知道物业电话多少?"报修这件小事,才是物业和业主每天都要面对的切肤之痛。我也是因为接手了一个真实的物业公司需求,才开始动手做这套基于Python和Vue3的智慧物业报修服务系统。后端用的Python生态(FastAPI + SQLAlchemy),前端用的Vue3全家桶(Vite + Pinia + Element Plus),前后端分离,覆盖业主报修、物业派单、工人接单、维修进度、完工评价这条完整链路。这篇文章不是教科书式的项目介绍,而是把我从需求梳理、表结构设计、状态机落地到前后端联调、上线部署的完整过程摊开来讲,顺便把那些文档里不会告诉你的坑都标出来。适合手里有类似场景、想用Python配Vue3快速落地一个管理系统的开发者。

1. 物业报修这件事的水有多深:为什么需要一套系统

1.1 传统报修的三个典型困境

先说需求背景。我接触的这家物业公司管理着三个小区,报修渠道有400电话、业主微信群、前台登记三种。听起来渠道丰富,实际上每个渠道都是信息孤岛。电话报修靠客服手写工单,微信群里的报修信息会被聊天记录冲掉,前台登记的纸质单子动不动就找不到了。最崩溃的是月末对账的时候,纸质工单、微信截图、电话录音全部得手动汇总,有几张单子维修工人明明做了,但业主不认账,物业也拿不出完整的记录链。

另一个痛点是派单全靠人工。客服拿着纸质工单跑到维修班,维修班长再翻通讯录打电话,遇到修理工在别的楼栋干活,可能要到下午才能看到这张单子。业主等着急了就再打电话催,客服只能说"已经记录了,师傅尽快过去",中间的过程完全不可控。

第三个痛点是没有任何量化数据。哪个工种报修最多?哪个楼栋故障最频繁?平均响应时间是多少?维修满意度怎么样?这些全凭印象。物业经理想跟业委会汇报工作,拿不出数据,只能被业主代表"教育"。

1.2 这个系统能解决什么:面向谁、服务谁

这套系统解决的就是上面三个问题。业主端能提交报修单、拍照上传、实时查看进度、完工后评价;物业端能接单、派工、跟踪状态、看数据看板;维修工人端能收到分配的任务、更新维修状态、填写耗材和工时;管理员后台负责用户管理、工单统计、满意度分析。

整个系统有两个入口:业主通过手机浏览器或小程序入口进入,物业和维修工人通过Web管理后台操作。技术上都跑在同一个后端服务上,只是前端路由和权限控制不同。

做这类系统,最忌讳一上来就写代码。我拿到需求的第一天,先画了三张图:报修业务流程图、角色权限矩阵、数据实体关系图。画完之后发现,看似简单的报修业务,核心其实是一个状态机——报修单从创建到归档,经历哪些状态、哪些状态之间可以跳转、每个跳转由谁触发,这几个问题不搞清楚,后面写接口十有八九要返工。

2. 技术选型逻辑:Python后端配Vue3前端,好在哪

2.1 后端为什么选Python:从Flask到FastAPI的取舍

后端选Python基本没什么争议,这个项目规模不大不小,正是Python的主场。但在框架上我纠结了一下:Django、Flask、FastAPI三选一。

先说我为什么不选Django。Django的ORM、Admin、Migration确实强大,但它的自动Admin后台和permission体系在这个项目里反而显得重。报修系统的权限逻辑是"业主、客服、维修工、管理员"四种角色,Django自带的User-Group-Permission模型要绕一圈才能适配,而且Django项目结构一旦生成,很多约定俗成的写法会限制我做接口版的风格。另一个原因是,这个系统前端完全独立,后端只提供API,Django的模板系统根本用不上。

Flask我用了很多年,轻、灵活、生态成熟,但做API服务有个问题:它没有原生的请求参数校验和API文档生成。报修单有十来个字段,每个都要手写校验逻辑,校验代码比业务代码还长。Flask-RESTful能补一部分,但那个序列化模型写起来还是啰嗦。

FastAPI是最终选择。它基于Pydantic,请求参数校验直接在模型类里声明,类型不对直接返回422错误,省掉了一大堆if判断。它自动生成Swagger文档,前端联调的时候打开/docs就能看到所有接口的参数和示例,省了写接口文档的时间。它还原生支持async,后面如果要做WebSocket实时通知,FastAPI原生就是亲儿子。我在这个项目里用FastAPI + SQLAlchemy 2.0 + MySQL,没有上Django那种重型全家桶,整个后端的依赖管理很干净。

2.2 前端为什么选Vue3:Composition API与工程化优势

前端选Vue3也是综合考虑的结果。项目里需要大量表单交互和动态组件,Vue3的Composition API在逻辑复用上比Options API顺手得多。比如报修表单的"故障类型"联动"常见故障描述"这个功能,在Options API里需要把相关的data、methods、watch散落在三个区块里,而在Composition API里可以把这段逻辑完整封装成一个useFaultType.ts,哪个组件要用直接调用。

Vue3还解决了两个Vue2时代困扰我的问题。第一个是响应式性能,Vue3用Proxy替代了Object.defineProperty,表格里渲染几百条工单记录也不会卡。第二个是TypeScript友好,虽然这个项目我用JavaScript居多,但组件Props的类型校验用defineProps配合TS语法在未来要迁移时成本低。如果你跟我一样在Vue2里用惯了options写法,看到Vue3的setup函数可能觉得别扭,但适应之后,最大的感受是逻辑不再需要"绕"了。

2.3 关于Vue2与Vue3差异的快速上手参考

很多从Vue2转过来的同事问我,Vue3到底改了什么。我一般用三个最直接的差异总结:

对比项Vue2Vue3
数据响应Object.defineProperty监听属性Proxy代理整个对象
逻辑复用mixin、$emit、busComposition API、hook函数
多根节点不支持支持Fragment多根节点
状态管理VuexPinia,API更简洁
异步渲染不支持Suspense内置Suspense组件

实际项目里最大的感知差异在Composition API和Options API的选择上。我的建议是:简单组件继续用Options写法没毛病,但涉及跨组件逻辑复用、复杂表单联动、定时轮询这类场景,直接用Composition API。

3. 动手前的规划:数据模型与接口边界

3.1 核心数据表:一张报修单从出生到归档都经历什么

数据库设计是这个项目最关键的一步,没有之一。我画的实体关系里核心是repair_order这张表,围绕它展开的所有外围表都要服务于报修单的完整生命周期。

repair_order表的主要字段我列一下,你感受一下信息密度:

字段类型说明
idbigint主键
order_novarchar(32)报修单号,年月日+随机数生成
owner_idbigint业主用户ID
repair_typeint故障类型,1水电/2门禁/3电梯/4公共设施/5其他
descriptiontext问题描述
addressvarchar(128)具体房号门牌
imagesjson上传的图片URL列表
statusint状态:0待接单、1已接单、2维修中、3待验收、4已完成、5已评价、6已取消
priorityint紧急程度,1低/2中/3高
assignee_idbigint当前维修工人ID
create_timedatetime创建时间
accept_timedatetime接单时间
finish_timedatetime完工时间
close_timedatetime完成并关闭时间

围绕主表还有user表(业主和管理员/维修工通过role字段区分)、repair_attachment表(专门存图片,虽然我在主表里预留了images字段,实际项目里我改用附件表来管理,原因后面说)、evaluation表(评价评分、评价内容、评价时间)、repair_log表(状态变更履历,这个表非常重要,运营上溯源的唯一依据)。

特别要说的是repair_log这张表。很多同学做系统只关心业务表当前状态,忽略了操作审计。结果就是业主打电话说"我家报修怎么被取消了?"的时候,你根本查不出来是谁、什么时候、因为什么取消的。加了repair_log,每次状态变更插入一条记录,任何工单状态变更都能追溯到具体操作人和时间。

3.2 接口清单设计:先定义契约再写业务

在写数据库迁移脚本之前,我先把接口清单列出来了。前后端分离开发,最怕的是前端等后端、后端等前端。先定好接口契约,两边并行开发才能快。

接口清单按角色划分:

  • 公共接口:短信验证码登录、微信授权登录、刷新token
  • 业主端:创建报修单、查看我的报修列表、查看报修详情、取消报修、评价报修
  • 客服/管理员端:待接单列表、派单给维修工、查看所有报修状态、关闭超时单
  • 维修工端:我的工单列表、接单/开始维修/完成任务、填写维修反馈
  • 统计接口:报修数量趋势、维修工工时排行、满意度分布

每个接口我都用FastAPI的response_model预先定义了返回结构。统一用{code, message, data}的封装,code为0表示成功,非0为业务错误码。这个模式虽然简单,但前后端沟通成本极低。

3.3 数据库设计避坑:状态字段和图片附件别按直觉来

两个经验教训值得单独说。

第一是状态的枚举值。我见过很多项目把状态做成字符串,比如'pending'、'accepted',看起来直观,但数据库存字符串有隐患:排序不对、查找效率略低、还有拼写错误风险。用int枚举值,在Python代码里定义一个常量类驻留,前端通过接口拿到映射关系。后面写到状态机的时候再展开。

第二是图片存储。我原方案只想在repair_order表放一个images JSON字段,后来发现维修过程可能多次上传照片(报修时传一次、维修完成再传一次),每次上传还涉及图片压缩、缩略图生成、权限校验,单独拆一张repair_attachment表(字段:id, order_id, uploader_id, url, thumbnail_url, create_time),用order_id关联,查询也方便,也不会让repair_order表字段无限膨胀。MySQL的JSON字段不是不能用,但多对多的关系场景下,关联表永远比JSON字段好维护。

4. Python后端实现:报修工单状态机的落地

4.1 项目初始化与环境准备

环境准备这部分,我简单说说那些容易卡壳的细节。

Python环境我用的是3.10,Windows和Linux都跑过,没有版本兼容问题。如果刚接触Python,建议直接去python.org下载安装包,安装时勾选Add Python to PATH,避免后面在命令行里找不到python。

创建虚拟环境:

python -m venv venv venv\Scripts\activate # Windows source venv/bin/activate # Linux/Mac

安装依赖:

pip install fastapi uvicorn[standard] sqlalchemy pymysql python-jose[cryptography] passlib[bcrypt] python-multipart

我用的ORM是SQLAlchemy 2.0,声明模型的方式和1.x差异挺大,建议新项目直接用2.0的Mapped和mapped_column风格,写起来更简洁,类型提示也更完善。

4.2 状态机设计:从"待接单"到"已评价"的合法流转

状态机是整个系统的核心。我把它定义为7个状态:

0 待接单 -> 1 已接单 -> 2 维修中 -> 3 待验收 -> 4 已完成 -> 5 已评价 -------------- 0 待接单 -> 6 已取消(业主主动取消或超时自动取消)

有很多同学会把状态设计得太开,比如"已接单"直接能跳到"已完成",中间漏了"维修中"和"待验收"。这样的后果是维修工可能接单后根本没干活,过了半小时直接点"完成",业主收到通知说修好了,结果回家一看水管还在漏水。状态机的价值就是强制流程完整。

在代码里我用一个字典表示合法的状态迁移:

REPAIR_STATUS_TRANSITIONS = { 0: {1, 6}, # 待接单 -> 已接单 / 已取消 1: {2, 6}, # 已接单 -> 维修中 / 已取消(管理员可强制取消) 2: {3}, # 维修中 -> 待验收 3: {4}, # 待验收 -> 已完成(业主确认或管理员自动确认) 4: {5}, # 已完成 -> 已评价 # 已评价和已取消是终态,不再有出边 }

每次状态更新接口都先校验迁移合法性,然后更新状态、插入repair_log:

def transition_repair_status(db: Session, order_id: int, target_status: int, operator_id: int): order = db.get(RepairOrder, order_id) if order is None: raise BusinessException("报修单不存在") allowed = REPAIR_STATUS_TRANSITIONS.get(order.status, set()) if target_status not in allowed: raise BusinessException(f"非法的状态迁移: {order.status} -> {target_status}") order.status = target_status log = RepairLog( order_id=order.id, operator_id=operator_id, from_status=order.status, to_status=target_status, remark="" ) db.add(log) db.commit()

这个函数是所有状态变更的入口,所有的写接口都必须走它,不允许直接update status字段。我把它放在独立的service层,调用方传参数即可,杜绝了业务代码里到处DB.update的混乱局面。

4.3 核心接口代码:创建报修单与维修派工

创建报修单接口是业主端最核心的入口。用FastAPI的Pydantic模型做参数校验:

class RepairOrderCreate(BaseModel): repair_type: int = Field(..., ge=0, le=5, description="故障类型") description: str = Field(..., min_length=2, max_length=500) address: str = Field(..., min_length=2, max_length=128) priority: int = Field(1, ge=1, le=3) images: list[str] = []

构造函数里生成单号用时间戳加随机数的方式,保证并发下不重复:

def generate_order_no() -> str: return datetime.now().strftime("%Y%m%d%H%M%S") + str(random.randint(1000, 9999))

派单接口的逻辑就需要一点业务判断了。维修工分成水电工、油漆工、安装工等不同工种,报修的repair_type要和维修工的skill_type匹配。我维护了一张worker_skill表,保养维修工和技能的关系。派单时先找到对应技能的在线维修工,再按当前待办单量最少的优先分配,算是简单的负载均衡。

def auto_assign(db: Session, order_id: int) -> int | None: order = db.get(RepairOrder, order_id) workers = ( db.query(User) .filter(User.role == "worker", User.skill_type == order.repair_type, User.status == 1) .all() ) if not workers: return None # 统计每个维修工当前未完成工单数,选最少的 best_worker = min( workers, key=lambda w: db.query(RepairOrder) .filter(RepairOrder.assignee_id == w.id, RepairOrder.status.in_([0, 1, 2])) .count() ) return best_worker.id

这里有个小细节:维修工在忙(状态为维修中),不代表不能分配新单,只是优先级低。如果只分配给空闲维修工,高峰期大量报修单会堆积在队列里没人接。分配策略要综合考虑"技能匹配、待办数量、是否是紧急单"三个因素,优先级高的紧急单可以强制插队。

4.4 文件上传:维修照片怎么处理才不炸磁盘

报修单要传图片,维修完成要传图片,文件上传这种功能看着简单,处理不好全是坑。

我用python-multipart接收文件,保存到本地uploads目录,同时生成压缩后的缩略图。图片不能原图直接存,手机拍一张照片动辄3-8MB,如果报修单传三张,1000张工单就是几十GB的磁盘占用。我用的方案:

  • 原图存一份,用PIL(pillow)等比压缩到最长边1280px,质量85,存一份缩略图
  • 原图保留7天,缩略图永久保留
  • 数据库里只存相对路径,不存绝对路径,方便后续迁移到OSS存储
from PIL import Image def save_upload_image(upload: UploadFile) -> str: # 生成唯一文件名 ext = Path(upload.filename).suffix.lower() or ".jpg" filename = f"{uuid4().hex}{ext}" upload_dir = Path("uploads") / datetime.now().strftime("%Y%m%d") upload_dir.mkdir(parents=True, exist_ok=True) file_path = upload_dir / filename with file_path.open("wb") as f: shutil.copyfileobj(upload.file, f) # 生成缩略图 img = Image.open(file_path) img.thumbnail((320, 320)) thumb_path = upload_dir / f"thumb_{filename}" img.save(thumb_path, quality=80) return f"{datetime.now().strftime('%Y%m%d')}/{filename}"

这里有个隐藏坑:Pillow打开图片的时候,如果遇到用户传了带EXIF方向的手机照片,图片可能会旋转。我花了两个小时才定位到是EXIF信息的问题,后来在保存前用ImageOps.exif_transpose处理了一下。

5. Vue3前端实现:报修页面的组件化与状态管理

5.1 工程搭建:Vite + Pinia + Element Plus

前端我用Vite搭建工程,命令很简单:

npm create vue@latest

在选项里选择Router、Pinia,JavaScript版本。UI组件库选了Element Plus,它跟Vue3的契合度最好,表格、表单、日期选择器、上传组件都齐全,少写很多造轮子的代码。项目结构里,我把pages按角色分包:owner/、worker/、admin/,每个包下放对应角色的页面组件,公共组件放components/,API请求封装在api/目录。

Element Plus引入方式,我建议用按需导入。全量引入虽然省事,但打包体积会大不少。我是通过unplugin-vue-components插件实现自动按需导入的,安装配置一次后,直接写ElButton就能在编译时自动引入组件和样式,省心。

5.2 用Composition API组织报修表单

报修表单的复杂度不在字段多,而在联动。故障类型选"水电",常见问题下拉框要换成"水龙头漏水/马桶堵塞/电路跳闸";选"电梯",问题是"电梯运行异响/困人/按键失灵"。我写了一个useFaultType hook,把这套逻辑封装起来:

import { ref, computed } from 'vue' const faultOptions = { 1: ['水龙头漏水', '下水道堵塞', '电路跳闸', '插座损坏'], 2: ['门禁卡失灵', '门禁系统报警'], 3: ['电梯运行异响', '电梯按钮失灵'], 4: ['路灯不亮', '公共设施破损'], 5: ['其他'] } export function useFaultType() { const repairType = ref(1) const faultDetail = ref('') const detailOptions = computed(() => faultOptions[repairType.value] || []) const onTypeChange = () => { faultDetail.value = '' } return { repairType, faultDetail, detailOptions, onTypeChange } }

页面组件里调用:

const { repairType, faultDetail, detailOptions, onTypeChange } = useFaultType()

这个Hook在"业主提交报修页"和"后台手动录单弹窗"两处复用,维护一份逻辑。

Composition API的核心优势在这里就体现出来了:如果是Vue2,这个联动逻辑得在mounted、watch、methods、data四处写,还要考虑多组件复用的话可能要用mixin混入,命名冲突风险很大。Composition API一个函数搞定。

5.3 工单列表与状态标签的渲染策略

工单列表是这个系统曝光量最大的界面,物业客服每天盯着它工作。我用Element Plus的el-table渲染,状态列通过一个映射函数显示不同颜色的标签:

const statusMap = { 0: { text: '待接单', type: 'warning' }, 1: { text: '已接单', type: 'primary' }, 2: { text: '维修中', type: 'info' }, 3: { text: '待验收', type: 'danger' }, 4: { text: '已完成', type: 'success' }, 5: { text: '已评价', type: 'success' }, 6: { text: '已取消', type: 'info' } }

状态标签渲染在el-table里可以直接用template插槽:

<el-table-column label="状态" width="100"> <template #default="{ row }"> <el-tag :type="statusMap[row.status].type"> {{ statusMap[row.status].text }} </el-tag> </template> </el-table-column>

前端状态枚举必须和后端保持同步。我在前端建了一个constants.js文件,后端状态转移时同步更新这个文件,避免出现后端返回3(待验收),前端不知道3是什么状态,界面直接显示空白的尴尬。这个文件我会配合后端接口动态拉取,详情见后面联调部分。

列表数据量大了之后,前端要做分页。我用el-pagination组件,后端接口用limit和offset参数分页,一次只取20条。很多初学者喜欢把全量数据拉下来前端筛选,几百条没问题,上万条就卡死了。接口分页是硬约束。

5.4 工单详情抽屉与评价流程

工单详情我用了el-drawer抽屉组件,右侧滑出,展示报修单的完整信息、图片列表、操作记录时间线、状态流转日志。

业主的评价流程是:工单状态变为"待验收"后,业主确认无误,点击"确认完成",然后弹出评价弹窗,选择满意度(1-5星),填写评价内容,提交后状态变为"已评价"。

评价提交的接口我特意加了校验:只有状态为4(已完成)的工单才能评价,如果重复评价,后端返回业务错误码。前端弹出提示,避免用户重复提交。

这个流程的体验细节是:评价弹窗底部默认勾选"匿名评价",因为有些业主担心给差评后被物业区别对待。匿名评价在数据库里单独存一个anonymous字段,列表展示时如果是匿名,评价人显示为"匿名业主"。这个需求是物业公司经理提出来的,真实业务场景里很常见,做系统时不要凭想象设计功能。

6. 打通前后端:联调中的跨域、鉴权与刷新问题

6.1 跨域配置:为什么总是Access-Control-Allow-Origin报错

前后端分离开发,前端跑在5173端口(Vite默认),后端跑在8000端口,第一次联调必遇到跨域问题。浏览器报错信息通常带Access-Control-Allow-Origin,但没有这个响应头。

FastAPI解决跨域用CORSMiddleware:

from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:5173"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], )

这里有个非常关键的配置错误,如果allow_origins写的是["*"],同时allow_credentials又设成True,浏览器会直接报错,因为带cookie的跨域请求不允许通配origin。开发阶段我直接写上前端地址,生产环境下是同一个域名的不同路径,通过Nginx转发,这个问题就不存在了。

6.2 登录态与token刷新:别把服务器当磁盘

登录鉴权我用JWT。用户登录后,后端签发一个access_token(有效期2小时)和一个refresh_token(有效期7天)。前端拿到token后存在Pinia里,并同步到localStorage,刷新页面后能恢复登录态。

axios拦截器是前端鉴权的核心。我在请求拦截器里加Authorization头,在响应拦截器里处理token过期:

import axios from 'axios' import { useAuthStore } from '@/stores/auth' const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000 }) request.interceptors.request.use((config) => { const authStore = useAuthStore() if (authStore.accessToken) { config.headers.Authorization = `Bearer ${authStore.accessToken}` } return config }) request.interceptors.response.use( (response) => response.data, async (error) => { const authStore = useAuthStore() if (error.response?.status === 401) { // 尝试用refreshToken刷新 const ok = await authStore.refreshToken() if (ok) { return request(error.config) } authStore.logout() window.location.href = '/login' } return Promise.reject(error) } )

需要注意refreshToken的接口不应该和普通接口一样挂在同一个axios实例上,否则token过期后,refresh接口本身也会被拦截器拦截,形成死循环。我把refreshToken接口单独用一个不带拦截器的axios实例调用。

后端签发JWT时,我用了python-jose库:

from jose import jwt SECRET_KEY = "your-secret-key" def create_token(user_id: int, role: str, expires_minutes: int) -> str: payload = { "sub": str(user_id), "role": role, "exp": datetime.now(timezone.utc) + timedelta(minutes=expires_minutes) } return jwt.encode(payload, SECRET_KEY, algorithm="HS256")

实际部署时SECRET_KEY绝不允许写死在后端代码里,我用环境变量注入,docker-compose里通过env文件管理。

6.3 WebSocket推送:维修进度如何实时通知业主

报修系统里业主最关心的是进度变化。最low的方案是前端定时轮询,每5秒调一次详情接口,数据能拿到,但很浪费服务器资源,而且有明显的"延迟感"。

我用FastAPI的WebSocket做了实时推送。维修工点击"开始维修",后端立刻把状态变更推送给对应业主:

from fastapi import WebSocket class ConnectionManager: def __init__(self): self.active_connections: dict[int, WebSocket] = {} async def connect(self, user_id: int, ws: WebSocket): await ws.accept() self.active_connections[user_id] = ws async def send_to_user(self, user_id: int, message: dict): ws = self.active_connections.get(user_id) if ws: await ws.send_json(message)

后端状态变更时调用:

await manager.send_to_user(order.owner_id, { "type": "repair_status_update", "order_id": order.id, "status": order.status, "message": "维修工已接单" })

前端在用户登录后建立WebSocket连接,接收消息后更新当前页面的工单状态:

const ws = new WebSocket(`${import.meta.env.VITE_WS_BASE_URL}/ws/${userId}`) ws.onmessage = (event) => { const data = JSON.parse(event.data) if (data.type === 'repair_status_update') { toast(`您的报修单${data.message}`) refreshOrderDetail(data.order_id) } }

WebSocket在生产环境需要处理断线重连。我用了心跳检测机制,前端每30秒发一个ping,后端回pong,如果连续三次没收到pong就重连。这个机制跑了两个月,稳定性很好。

7. 上线后的复盘:性能、安全与运营数据

7.1 数据库索引与慢查询

上线前最容易被忽略的是数据库索引。报修系统最频繁的查询是:业主查自己的报修列表(WHERE owner_id = ? ORDER BY create_time DESC)、维修工查待办列表(WHERE assignee_id = ? AND status IN (...))、管理员按时间范围统计。这三个查询对应的索引:

ALTER TABLE repair_order ADD INDEX idx_owner_create (owner_id, create_time); ALTER TABLE repair_order ADD INDEX idx_assignee_status (assignee_id, status); ALTER TABLE repair_order ADD INDEX idx_create_time (create_time);

没有索引之前,数据量到5万条时,查询延迟从几十毫秒飙升到1秒多。加了联合索引后稳定在100毫秒以内。

还有个慢查询的隐藏源是状态统计接口。管理员首页要显示"待接单数、维修中数、今日完成数、平均响应时间",如果每个数字单独count一次,这接口要跑5次全表扫描。我改成一个SQL聚合一次取全部数据:

result = ( db.query( func.count().filter(RepairOrder.status == 0).label("pending_count"), func.count().filter(RepairOrder.status == 2).label("repairing_count"), func.count().filter(RepairOrder.status == 4).label("completed_today_count"), ) .select_from(RepairOrder) .one() )

7.2 接口安全:越权测试、xss与文件上传限制

报修系统涉及个人信息和物业管理,安全测试我做了三轮。最值得说的是越权漏洞。

第一版详情接口我只校验了"是否登录",没有校验"这条工单是不是这个业主的"。这意味着任何一个登录用户,只要遍历order_id就能查看别人的报修记录和家庭住址。修复方案是在SQL查询层加权限条件:

def get_order_detail(db: Session, order_id: int, user: User): order = db.get(RepairOrder, order_id) if order is None: raise BusinessException("工单不存在") # 业主只能看自己的单 if user.role == "owner" and order.owner_id != user.id: raise PermissionError("无权查看该工单") # 维修工只能看分配给自己的单 if user.role == "worker" and order.assignee_id != user.id: raise PermissionError("无权查看该工单") return order

这个校验逻辑必须放在后端业务层,不能只依赖前端按钮隐藏。因为API是可以被直接调用的,绕过前端界面不是难事。

文件上传部分,我用FastAPI的UploadFile接收文件后,第一件事是校验扩展名和大小。扩展名白名单只允许jpg、jpeg、png、webp,大小限制在10MB以内。还要检查文件的MIME类型,但这里有个坑:攻击者可以伪装Content-Type,所以可靠的做法是用python-magic库读取文件真实的MIME类型再校验,防止伪装成图片的可执行文件传上来。

前端Vue3里也要注意XSS问题,使用v-text插值而不是v-html渲染用户提交的描述内容。Element Plus的组件默认已经做了转义,但如果你在template里用了v-html,就要谨慎,用户提交的故障描述字段如果包含

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

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

立即咨询