
前几天一个学弟问我毕业设计做什么好我随口说了句“做个宿舍报修系统吧后端用python前端用vue3够完整又不复杂”。结果他追着问了一堆问题数据库怎么设计、角色权限怎么搞、图片上传怎么处理、前后端怎么联调。我干脆自己用FastAPI加Vue3从零重做了一遍也就是这个项目的由来。可能一听到“高校宿舍报修系统”你会觉得这不就是经典的增删改查吗业务上确实不复杂但真把它做成一个能跑起来、能交给学校信息中心用的系统里面涉及的角色权限、状态流转、图片上传、前后端联调、部署上线每一环对入门者都有实打实的锻炼。如果你正在找第一个全栈练手项目或者准备拿它当课设、毕设这篇文章会把选型逻辑、表设计、前后端关键代码和踩坑过程都摊开讲一遍。1. 选型为什么我固执地选择了FastAPI Vue31.1 Python后端三选一Django太重、Flask太散、FastAPI刚好做后端的时候我在Django、Flask、FastAPI之间纠结过一阵。Django确实全家桶很省事自带Admin后台和ORM但要先花不少时间理解它的App机制、中间件和模板系统。对一个“python101”级别的全栈项目来说Django的负担会盖过业务本身。Flask轻是真轻可是项目一大要自己拼SQLAlchemy、Flask-RESTful、JWT扩展选型成本摊下来并不低。FastAPI则舒服在“官方帮你定了规矩”用Pydantic做参数校验用依赖注入做权限控制用类型提示自动生成OpenAPI文档调试时直接打开/docs就能看到所有接口的参数和返回值前端联调的时候省了无数口舌。我最终定下来的技术栈是模块选型说明后端框架FastAPI异步、类型提示、自动文档ORMSQLAlchemy 2.x配合Pydantic做数据校验数据库MySQL 8开发阶段用SQLite上线切MySQL前端框架Vue3 Vite组合式API 快速冷启动UI组件库Element Plus后台管理界面最省事状态管理PiniaVuex的替代品写法更简洁后端部署Uvicorn Nginx接口服务 静态资源这个选型不是唯一答案但我认为对“既想练手又想真上线”的项目来说它是性价比最高的组合。如果你更熟悉Flask迁移到FastAPI也不难核心差异就是路径参数和依赖注入的写法。1.2 Vue3生态Vite、Element Plus、Pinia一个都不能少Vue3这部分我一开始差点踩进老路很多教程还在教webpack和Vuex。但2026年了Vite已经是事实标准开发时热更新快到几乎无感。Element Plus对后台管理系统特别友好表格、表单、弹窗、上传组件都是现成的设计上偏“管理后台风”学校这种场景完全够用。状态管理我选了Pinia理由很简单它比Vuex少了一堆样板代码配合组合式API可以直接在setup里调用store心智负担低很多。还有一个容易忽略的点Vue3的script setup语法把选项式API的data、methods、computed全合并成一个自然的函数书写方式状态一多、组件一复杂代码可读性明显比选项式好。对于要在前端做角色判断、状态标签映射、统计图表这类逻辑组合式API几乎是为这些场景设计的。1.3 项目目录前后端分目录从一开始就别混在一起很多课设项目喜欢把前端页面塞到后端模板里最后模板、接口、静态文件纠缠不清。我建议一开始就分成两个顶层目录后端管API前端管页面双方只通过JSON通信python101-repair/ ├── backend/ │ ├── app/ │ │ ├── main.py │ │ ├── models/ │ │ ├── routers/ │ │ ├── schemas/ │ │ └── core/ │ ├── uploads/ │ └── requirements.txt └── frontend/ ├── src/ │ ├── api/ │ ├── components/ │ ├── router/ │ ├── stores/ │ └── views/ ├── package.json └── vite.config.ts这样划分的好处是后端可以单独用uvicorn app.main:app --reload跑起来测试前端也可以用npm run dev独立开发哪边出问题都能快速定位。2. 需求梳理与数据库设计先把报修流程画清楚2.1 三种角色和核心业务流宿舍报修系统表面上只有“提交报修、处理报修”但学校场景下角色必须拆成三类学生提交报修单查看进度维修完成后评价。维修工查看待接单接单填写处理结果。管理员维护楼栋和报修分类派单查看统计报表。业务流程是学生填写宿舍号、问题描述、上传图片提交后报修单进入“待派单”状态。管理员看到新单后可以手动指派给某个维修工也可以让系统按“当前未完成单最少的维修工”自动分配。维修工接单后状态变为“维修中”填完处理结果上传图片后变为“待评价”。学生确认问题解决并打分评价后整个流程走到“已完成”。如果中途学生不想修了也可以取消。这个流程如果只靠“一个列表一个状态字段”去实现后期很容易改崩。所以我在第一版就把状态定义成枚举用显式状态机控制流转而不是在接口里随手写死字符串。2.2 核心表结构设计数据库我设计了七张表核心是这几张表名关键字段作用usersid, username, password_hash, role, name, phone, building_id, dorm_id三种角色共用一张用户表用role区分buildingsid, name楼栋dormitoriesid, building_id, room_no宿舍号repair_categoriesid, name, icon报修分类如水电、门窗、网络repair_ordersid, student_id, worker_id, category_id, building_id, dorm_id, description, images, status, priority, created_at, finished_at报修单主表commentsid, order_id, rating, content维修后评价operation_logsid, order_id, operator_id, action, detail, created_at操作流水方便追溯这里有个容易忽略的设计点repair_orders里我同时冗余存了building_id和dorm_id。虽然可以通过dorm_id关联到楼栋但报修单列表要按楼栋筛选、按楼栋统计次数冗余字段能少一次JOIN。学校楼栋数量少数据一致性风险低这种冗余是值得的。另一个细节是用户表里宿舍信息的处理。维修工和学生都有宿舍相关字段但含义不同学生填的是“我住在哪”维修工填的是“我负责哪些楼栋”。所以我在维修工侧用了一个building_id表示归属楼栋没有强行把一对多关系塞进一个字段。2.3 状态机报修单的几种状态与流转规则我定义了六个状态pending待派单、assigned待接单、processing维修中、pending_review待评价、completed已完成、cancelled已取消。from enum import Enum class OrderStatus(str, Enum): pending pending assigned assigned processing processing pending_review pending_review completed completed cancelled cancelled ALLOWED_TRANSITIONS { OrderStatus.pending: {OrderStatus.assigned, OrderStatus.cancelled}, OrderStatus.assigned: {OrderStatus.processing, OrderStatus.cancelled}, OrderStatus.processing: {OrderStatus.pending_review}, OrderStatus.pending_review: {OrderStatus.completed, OrderStatus.processing, OrderStatus.cancelled}, }每次状态变更都在接口层做校验如果from_status不在ALLOWED_TRANSITIONS[to_status]这个映射关系里直接返回400并说明“非法状态流转”。为什么要这么较真因为真实使用中管理员可能手滑把已完成的单子改成处理中维修工也可能在未接单状态下直接提交结果。如果没有状态机约束脏数据一旦流入统计报表所有聚合结果都会失真。3. 后端Python接口实现中的四个硬骨头3.1 登录、JWT和当前用户登录接口的逻辑很简单用户名查库验密码哈希生成JWT返回前端。真正值得注意的两个点一是密码绝对不能用明文存储二是JWT里不要塞敏感信息。我用的是python-jose库这里放一个最简示例from jose import jwt SECRET_KEY your-secret-key ALGORITHM HS256 def create_access_token(data: dict, expires_delta: timedelta | None None): to_encode data.copy() expire datetime.utcnow() (expires_delta or timedelta(hours12)) to_encode.update({exp: expire}) return jwt.encode(to_encode, SECRET_KEY, algorithmALGORITHM) async def get_current_user( token: str Depends(oauth2_scheme), db: Session Depends(get_db), ): payload jwt.decode(token, SECRET_KEY, algorithms[ALGORITHM]) user db.get(User, payload.get(sub)) if user is None: raise HTTPException(status_code401, detail用户不存在或登录已过期) return useroauth2_scheme是FastAPI自带的安全依赖它会把前端传来的Authorization: Bearer token解析出来。前端每次请求时把token带在请求头上后端就能通过get_current_user拿到当前用户对象。3.2 简单的角色权限控制FastAPI的依赖注入让RBAC实现起来非常清爽。我需要三个角色各自能访问的接口不同于是封装了一个依赖def require_roles(*roles: str): def checker(user: User Depends(get_current_user)): if user.role not in roles: raise HTTPException(status_code403, detail没有权限执行该操作) return user return checker app.post(/api/orders, dependencies[Depends(require_roles(student))]) def create_order(...): ...这里我希望强调一个经验权限校验要在依赖层完成而不是在业务函数内部用if user.role admin写一堆分支。依赖注入的好处是接口签名直接就暴露了“谁能调它”新人接手代码时不用翻完整个函数体才敢确认权限逻辑。后续添加角色时也只需要改依赖列表。3.3 派单逻辑从手动派单到“按空闲量自动分配”管理员手动派单只在单量少的时候好用。到了开学检修高峰大量报修单堆在一起管理员一个个点分配会疯掉。我加了一个自动派单接口先按报修分类过滤出对应的维修工再排序筛选出当前“未完成维修单数量最少”的人作为候选人。def find_idle_worker(db: Session, category_id: int): pending_count func.count(RepairOrder.id).label(total) return ( db.query(User, pending_count) .outerjoin(RepairOrder, (RepairOrder.worker_id User.id) RepairOrder.is_active) .filter(User.role worker, User.category_id category_id) .group_by(User.id) .order_by(func.coalesce(pending_count, 0)) .first() )排序用order_by(count)而不是随机分配理由是维修工的工作量如果不区分维修分类就会出现“管网络的累死、管水电的闲死”。按分类找候选再比空闲量分配结果基本符合预期。如果候选工人都不在线我还做了一个兜底把状态留在pending并提示管理员手动派单。3.4 图片上传与本地存储方案图片上传是每个管理系统都躲不开的环节。我用了FastAPI的UploadFile校验扩展名后存到后端uploads/目录然后返回可访问的URL路径。ALLOWED_IMAGE_TYPES {.jpg, .jpeg, .png, .webp} app.post(/api/upload) async def upload_image(file: UploadFile): ext os.path.splitext(file.filename)[1].lower() if ext not in ALLOWED_IMAGE_TYPES: raise HTTPException(status_code400, detail不支持的图片格式) filename f{uuid4().hex}{ext} path os.path.join(UPLOAD_DIR, filename) with open(path, wb) as f: f.write(await file.read()) return {url: f/uploads/{filename}}用uuid4().hex生成文件名而不是直接拿原始文件名是为了防止两个学生上传了同名的1.jpg互相覆盖。扩展名白名单比黑名单更可靠——直接只允许图片格式避免有人传个可执行文件伪装成图片。uploads目录通过FastAPI的StaticFiles挂载成静态路径前端拿到的URL就能直接访问。4. 前端Vue3里让我多花了两天的地方4.1 登录态维护、axios拦截器和路由守卫前端登录后不能只在页面里存一个token就完事刷新页面要能恢复登录态请求要能自动带token接口返回401要能自动跳回登录页。我把这三件事分别交给了Pinia、axios拦截器和路由守卫。Pinia的store里存token和userInfotoken同时持久化到localStorageexport const useAuthStore defineStore(auth, () { const token ref(localStorage.getItem(token) || ) const userInfo ref({}) function setLogin(data) { token.value data.access_token userInfo.value data.user localStorage.setItem(token, data.access_token) } function logout() { token.value userInfo.value {} localStorage.removeItem(token) router.push(/login) } return { token, userInfo, setLogin, logout } })axios拦截器里加上请求头并在响应错误时统一处理service.interceptors.request.use((config) { const auth useAuthStore() if (auth.token) { config.headers.Authorization Bearer ${auth.token} } return config }) service.interceptors.response.use( (res) res.data, (error) { if (error.response?.status 401) { useAuthStore().logout() } return Promise.reject(error) } )路由守卫要判断目标路由是否标记了requiresAuth同时对比当前用户角色和路由里的roles数组router.beforeEach((to) { const auth useAuthStore() if (to.meta.requiresAuth !auth.token) return /login if (to.meta.roles !to.meta.roles.includes(auth.userInfo.role)) return /403 })这里有个容易漏掉的细节Pinia在路由守卫里使用之前必须确保store已经实例化。如果你没有在main.ts里提前use(pinia)在beforeEach里调用useAuthStore()会直接报错。这是我联调时踩的第一个坑。4.2 报修表单与图片上传预览报修表单是整个学生端最重要的页面。Element Plus的el-form和el-upload能省不少事但组合在一起有几个注意事项。上传组件我用的是手动上传模式也就是不依赖组件内部自动调接口而是通过on-change把图片文件先收集起来等学生点击提交时才合并上传到后端的/api/upload拿到URL后放进报修单字段。这样做的好处是如果学生填了一半就退出不会产生一堆没有报修单引用的孤儿图片。图片预览用URL.createObjectURL(file)临时生成在on-remove时记得释放否则浏览器内存会被不用的Blob对象占住。表单校验里我自定义了一个“至少上传一张图片”的校验规则报修单没有图片时维修工往往很难定位问题所以我在业务上把它设成了必填项。Vue3的表单校验逻辑和Vue2区别不大但组合式API让校验集合可以单独抽出去复用const rules { dorm_id: [{ required: true, message: 请选择宿舍号, trigger: change }], category_id: [{ required: true, message: 请选择报修类型, trigger: change }], description: [ { required: true, message: 请描述故障情况, trigger: blur }, { min: 10, max: 500, message: 描述需在10到500字之间, trigger: blur } ], images: [{ required: true, message: 请至少上传一张现场图片, trigger: change }] }4.3 列表页的筛选、分页与状态标签映射学生端首页是“我的报修列表”维修工端是“待接单列表”管理员端是所有单。三个页面长得像但过滤参数和可操作按钮完全不同。我用了一个组合函数抽公共逻辑而不是把三个列表页各写一套请求逻辑。状态标签是个很影响观感的细节后端返回的是pending这种英文枚举值直接展示给用户看不友好。我建了一个映射对象配合Element Plus的el-tag动态计算类型const statusMap { pending: { label: 待派单, type: warning }, assigned: { label: 待接单, type: info }, processing: { label: 维修中, type: primary }, pending_review: { label: 待评价, type: danger }, completed: { label: 已完成, type: success }, cancelled: { label: 已取消, type: info } }分页我采用了后端分页接口统一返回{ items, total, page, size }前端用el-pagination切换时重新请求。这个方法比前端一次性拉全量数据再筛选稳得多数据量大了也不会卡。4.4 管理端统计面板ECharts与后端聚合接口管理员端我最得意的部分是统计面板按楼栋展示报修数量柱状图、按分类展示占比饼图、近30天报修趋势折线图。ECharts的Vue3封装用的是vue-echarts但数据来源才是重点。我在后端写了一个聚合接口用一条SQL完成多维度统计app.get(/api/stats/summary) def get_stats(db: Session Depends(get_db)): building_stats ( db.query(RepairOrder.building_id, func.count(RepairOrder.id)) .group_by(RepairOrder.building_id) .all() ) category_stats ( db.query(RepairOrder.category_id, func.count(RepairOrder.id)) .group_by(RepairOrder.category_id) .all() ) return { by_building: building_stats, by_category: category_stats, trend: get_daily_trend(db, days30) }这种统计接口在前端只需要一次性取数然后喂给ECharts不需要前端做复杂的groupBy或reduce。把统计逻辑放在数据库里做效率比把几百条记录拉到前端再算高得多。趋势图有个细节返回的日期数组要对齐30天某天没有报修就补0否则折线图会断开或者日期对不上我在SQL里用了LEFT JOIN生成日期序列。5. 联调、部署与真实踩坑记录5.1 跨域问题先搞清楚是开发环境还是生产环境前后端分离后跨域是第一个跳出来的拦路虎。开发阶段最简单的办法是让Vite代理请求而不是在后端开启CORS。在vite.config.ts里加server: { proxy: { /api: { target: http://127.0.0.1:8000, changeOrigin: true }, /uploads: { target: http://127.0.0.1:8000, changeOrigin: true } } }前端写请求时直接用/api/xxx开发服务器会把请求转发到后端。这样浏览器看到的只有localhost:5173一个源根本没有跨域问题。但生产环境就不一样了Nginx同时托管前端静态文件和反向代理后端API。这时候CORS也要在后端配置因为有些外部请求不一定走Nginx代理。我的做法是FastAPI加CORSMiddleware只允许实际域名通过app.add_middleware( CORSMiddleware, allow_origins[https://repair.example.edu.cn], allow_methods[*], allow_headers[*], )这里要提醒一句allow_origins别图省事写[*]。宿舍报修系统涉及学生个人信息CORS放得太宽等于给其他网站开后门任何站点都能用学生已登录的cookie或token发跨域请求。5.2 图片上传文件名冲突、大小限制、回显404图片上传我前后踩了三个坑。第一个是文件名冲突刚开发时我用时间戳命名结果同一秒内两个学生上传图片就互相覆盖了。改成UUID后问题消失。第二个是图片大小失控学生用手机拍的照片动辄5MB以上直接把接口拖慢。我在前端el-upload里加了before-upload限制为最大3MB后端也限制上传文件大小超出了就返回414或422统一提示。第三个坑最隐蔽上传成功后的图片URL是/uploads/xxx.jpg开发环境经Vite代理能访问但生产环境我把FastAPI部署在了http://127.0.0.1:8000Nginx只代理了/api路径导致所有图片请求全部404。解决办法是在Nginx里同时把/uploads反向代理到后端或者更简单把uploads目录放到Nginx直接管理的静态目录里。我最终选了后者图片访问不经过Uvicorn压力更小。5.3 并发更新两个维修工同时点了接单系统上线联调时我找两个维修工账户同时点击同一个报修单的“接单”按钮结果两个人都成功了。原因很简单接口先SELECT查状态再UPDATE改状态两个请求在查询阶段都看到状态是assigned都以为可以接单于是都更新成功。修复方案是乐观锁核心是更新时把旧状态作为条件result db.execute( update(RepairOrder) .where( RepairOrder.id order_id, RepairOrder.status OrderStatus.assigned ) .values( worker_idcurrent_user.id, statusOrderStatus.processing ) ) if result.rowcount 0: raise HTTPException(status_code409, detail该报修单已被其他维修工接单)rowcount 0说明更新影响行数为0也就是条件里的status assigned已经不成立了。这个方案不需要在表里加版本号字段对付单条订单状态变更已经够用。真正高并发的场景才需要悲观锁或版本号一个学校宿管的并发量到不了那个级别。5.4 Nginx Uvicorn 部署一份可以直接抄的配置部署时我把前端npm run build生成的dist目录放到Nginx的/var/www/repair后端用Uvicorn跑在8000端口。Nginx关键配置如下server { listen 80; server_name repair.example.edu.cn; root /var/www/repair; index index.html; location / { try_files $uri $uri/ /index.html; } location /api { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /uploads { alias /var/www/repair/uploads; } }try_files $uri $uri/ /index.html;是Vue Router的history模式必备配置没有这一行刷新/orders页面就404了。后端启动我用的是uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2本地开发用--reload生产环境必须去掉否则代码一变动就会自动重启。如果要更稳妥可以配systemd服务让后端开机自启并加上--proxy-headers参数让Nginx传递的真实IP生效。6. 如果重做一次我会改掉这些设计6.1 消息通知WebSocket推送给维修工而不是轮询现在的报修单列表需要维修工手动刷新或者前端每隔10秒轮询一次新单。轮询在单量小的时候没问题但用户体验不够好。如果重新做我会加WebSocket后端在报修单创建或派单时推给对应维修工前端连接/ws/orders收到消息后弹通知并刷新列表。FastAPI对WebSocket支持得不错成本并不高。6.2 微信小程序端复用同一套API学生用手机浏览器访问网页版总是差点意思报修时要开电脑或者忍受手机上别扭的表单。最自然的扩展是做个微信小程序反正后端API已经是纯JSON接口小程序只需要用wx.request换掉axios登录态换成wx.login换取code再换token。业务逻辑全在后端前端工作量主要是表单和列表页面我把Vue3的页面拆得比较细理论上迁移时大部分组件逻辑可以直接照搬成小程序页面逻辑。6.3 本地存储换成对象存储图片存在服务器本地短期没问题但有两个隐患磁盘会被学生拍的大图慢慢塞满服务器重装系统后图片全没了。重新做的话我会把图片传到云上的对象存储生成带签名的访问链接权限控制也更干净。FastAPI这边只需要改一个upload_image接口的实现表结构完全不用动。如果现在有人问我“python101”下一步还能加点什么我会建议先补消息通知再做小程序端最后换对象存储。这三个方向每个都踩在真实痛点上而不是为了给简历加一行“熟悉WebSocket”硬凑功能。宿舍报修系统的核心已经足够完整剩下的优化都应该是为了让它更贴近真实使用场景。