从 0 到 1 构建城市公共设施智能报修与派单系统:FastAPI + Vue3 + RabbitMQ + Redis Geo + AI 工作流实践
项目定位:面向政府市政部门的城市公共设施智能报修与派单平台,覆盖「市民报修 → AI 解析 → 工单生成 → 智能派单 → 维修处理 → 完工验收 → 用户评价 → 结算统计 → 数据分析」完整业务闭环。
技术栈:FastAPI + Vue 3 + MySQL + Redis + MongoDB + Elasticsearch + RabbitMQ + LangChain + 阿里云百炼大模型
文章目录
- [从 0 到 1 构建城市公共设施智能报修与派单系统:FastAPI + Vue3 + RabbitMQ + Redis Geo + AI 工作流实践](#从 0 到 1 构建城市公共设施智能报修与派单系统:FastAPI + Vue3 + RabbitMQ + Redis Geo + AI 工作流实践)
-
- 一、项目背景与业务痛点
-
- [1.1 城市公共设施报修的典型场景](#1.1 城市公共设施报修的典型场景)
- [1.2 传统人工派单存在的问题](#1.2 传统人工派单存在的问题)
- [1.3 系统要解决的核心问题](#1.3 系统要解决的核心问题)
- 二、系统整体架构
-
- [2.1 架构总览](#2.1 架构总览)
- [2.2 各层职责说明](#2.2 各层职责说明)
- [2.3 为什么选择这些技术](#2.3 为什么选择这些技术)
- 三、核心业务流程设计
-
- [3.1 完整业务链路](#3.1 完整业务链路)
- [3.2 关键环节详解](#3.2 关键环节详解)
- 四、三端前端设计
-
- [4.1 技术选型理由](#4.1 技术选型理由)
- [4.2 市民端](#4.2 市民端)
- [4.3 维修员端](#4.3 维修员端)
- [4.4 管理后台](#4.4 管理后台)
- 五、后端服务设计
-
- [5.1 为什么选择 FastAPI](#5.1 为什么选择 FastAPI)
- [5.2 Pydantic 的请求校验与响应模型](#5.2 Pydantic 的请求校验与响应模型)
- [5.3 后端模块划分](#5.3 后端模块划分)
- 六、数据库与存储设计
-
- [6.1 四库分层职责](#6.1 四库分层职责)
- [6.2 MySQL 核心表设计](#6.2 MySQL 核心表设计)
- [6.3 Redis 的四库分库设计](#6.3 Redis 的四库分库设计)
- [6.4 MongoDB 的文档模型](#6.4 MongoDB 的文档模型)
- [6.5 Elasticsearch 的索引设计](#6.5 Elasticsearch 的索引设计)
- 七、智能派单设计
-
- [7.1 派单流程](#7.1 派单流程)
- [7.2 派单策略详解](#7.2 派单策略详解)
- [7.3 超时重新派单](#7.3 超时重新派单)
- [八、RabbitMQ 异步流程设计](#八、RabbitMQ 异步流程设计)
-
- [8.1 为什么需要消息队列](#8.1 为什么需要消息队列)
- [8.2 队列拓扑设计](#8.2 队列拓扑设计)
- [8.3 队列清单](#8.3 队列清单)
- [8.4 延迟队列的实现:TTL + DLX](#8.4 延迟队列的实现:TTL + DLX)
- [8.5 幂等性保证](#8.5 幂等性保证)
- [8.6 ES 同步的可靠性设计](#8.6 ES 同步的可靠性设计)
- [九、AI 工作流设计](#九、AI 工作流设计)
-
- [9.1 总体定位](#9.1 总体定位)
- [9.2 三大 AI 应用场景](#9.2 三大 AI 应用场景)
- [9.3 场景一:报修内容解析](#9.3 场景一:报修内容解析)
- [9.4 场景二:智能派单辅助](#9.4 场景二:智能派单辅助)
- [9.5 场景三:AI 辅助验收](#9.5 场景三:AI 辅助验收)
- [9.6 AI 的安全边界](#9.6 AI 的安全边界)
- 十、接口设计示例
-
- [10.1 创建报修](#10.1 创建报修)
- [10.2 查询工单详情(三端通用)](#10.2 查询工单详情(三端通用))
- [10.3 维修员接单](#10.3 维修员接单)
- [10.4 提交维修结果](#10.4 提交维修结果)
- [10.5 管理员审核结算](#10.5 管理员审核结算)
- [10.6 数据看板统计](#10.6 数据看板统计)
- 十一、关键难点与解决方案
-
- [11.1 多角色流程状态复杂](#11.1 多角色流程状态复杂)
- [11.2 派单公平性与效率平衡](#11.2 派单公平性与效率平衡)
- [11.3 AI 输出不稳定](#11.3 AI 输出不稳定)
- [11.4 工单状态一致性问题](#11.4 工单状态一致性问题)
- [11.5 消息队列重复消费](#11.5 消息队列重复消费)
- [11.6 多数据库数据边界问题](#11.6 多数据库数据边界问题)
- [11.7 管理后台统计性能问题](#11.7 管理后台统计性能问题)
- 十二、项目亮点总结
- 十三、后续优化方向
- 结语
一、项目背景与业务痛点
1.1 城市公共设施报修的典型场景
一座中型城市拥有数以万计的公共设施:路灯、井盖、护栏、垃圾桶、交通信号灯、绿化树木、道路路面......任何一个环节出问题,都可能影响市民出行安全和市容市貌。市民发现设施损坏后,通常拨打市政热线,接线员记录后电话通知维修队,维修队派工到人,维修完成后纸质回单,整个流程冗长、信息不对称、缺乏闭环。
1.2 传统人工派单存在的问题
- 报修入口分散,信息非结构化:市民通过电话、微信、口头转述等多种方式报修,接线员人工记录,容易遗漏关键信息(准确位置、损坏程度、设施编号)。
- 派单靠经验,效率低下:调度员凭记忆判断哪个维修员离得近、谁手里活少,经常出现"忙的人忙死、闲的人闲死",或者派了一个不擅长该类型故障的维修员。
- 维修过程不可视:维修员什么时候出发、是否到场、修了什么、用了什么材料,管理员完全不可知,只能靠电话催问。
- 验收缺乏标准:是否修好由维修员自己说了算,没有第三方验证机制。
- 数据无法沉淀:每月手工统计工单量、响应时长、满意度,耗时耗力且不准确,无法支撑管理决策。
1.3 系统要解决的核心问题
基于以上痛点,我确定了这个系统要解决的核心问题:
- 报修智能化:市民拍照+文字描述即可报修,AI 自动识别设施类型和紧急程度
- 派单自动化:基于实时位置、技能、负载、评分多维度自动匹配最优维修员
- 过程透明化:全流程节点记录(接单、签到、维修、完工),三端实时可见
- 验收可量化:AI 对比修前修后照片辅助判断,关键节点人工复核
- 数据可分析:多维度聚合统计,支撑运营优化和绩效考核
二、系统整体架构
2.1 架构总览
在架构设计阶段,我将系统拆分为三端 + 一网关 + 四库 + 一队列 + 一 AI 层的经典微服务分层架构。
#mermaid-svg-KZqoaVqwCPICbzuc{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-KZqoaVqwCPICbzuc .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-KZqoaVqwCPICbzuc .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-KZqoaVqwCPICbzuc .error-icon{fill:#552222;}#mermaid-svg-KZqoaVqwCPICbzuc .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-KZqoaVqwCPICbzuc .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-KZqoaVqwCPICbzuc .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-KZqoaVqwCPICbzuc .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-KZqoaVqwCPICbzuc .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-KZqoaVqwCPICbzuc .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-KZqoaVqwCPICbzuc .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-KZqoaVqwCPICbzuc .marker{fill:#333333;stroke:#333333;}#mermaid-svg-KZqoaVqwCPICbzuc .marker.cross{stroke:#333333;}#mermaid-svg-KZqoaVqwCPICbzuc svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-KZqoaVqwCPICbzuc p{margin:0;}#mermaid-svg-KZqoaVqwCPICbzuc .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-KZqoaVqwCPICbzuc .cluster-label text{fill:#333;}#mermaid-svg-KZqoaVqwCPICbzuc .cluster-label span{color:#333;}#mermaid-svg-KZqoaVqwCPICbzuc .cluster-label span p{background-color:transparent;}#mermaid-svg-KZqoaVqwCPICbzuc .label text,#mermaid-svg-KZqoaVqwCPICbzuc span{fill:#333;color:#333;}#mermaid-svg-KZqoaVqwCPICbzuc .node rect,#mermaid-svg-KZqoaVqwCPICbzuc .node circle,#mermaid-svg-KZqoaVqwCPICbzuc .node ellipse,#mermaid-svg-KZqoaVqwCPICbzuc .node polygon,#mermaid-svg-KZqoaVqwCPICbzuc .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-KZqoaVqwCPICbzuc .rough-node .label text,#mermaid-svg-KZqoaVqwCPICbzuc .node .label text,#mermaid-svg-KZqoaVqwCPICbzuc .image-shape .label,#mermaid-svg-KZqoaVqwCPICbzuc .icon-shape .label{text-anchor:middle;}#mermaid-svg-KZqoaVqwCPICbzuc .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-KZqoaVqwCPICbzuc .rough-node .label,#mermaid-svg-KZqoaVqwCPICbzuc .node .label,#mermaid-svg-KZqoaVqwCPICbzuc .image-shape .label,#mermaid-svg-KZqoaVqwCPICbzuc .icon-shape .label{text-align:center;}#mermaid-svg-KZqoaVqwCPICbzuc .node.clickable{cursor:pointer;}#mermaid-svg-KZqoaVqwCPICbzuc .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-KZqoaVqwCPICbzuc .arrowheadPath{fill:#333333;}#mermaid-svg-KZqoaVqwCPICbzuc .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-KZqoaVqwCPICbzuc .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-KZqoaVqwCPICbzuc .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KZqoaVqwCPICbzuc .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-KZqoaVqwCPICbzuc .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KZqoaVqwCPICbzuc .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-KZqoaVqwCPICbzuc .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-KZqoaVqwCPICbzuc .cluster text{fill:#333;}#mermaid-svg-KZqoaVqwCPICbzuc .cluster span{color:#333;}#mermaid-svg-KZqoaVqwCPICbzuc div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-KZqoaVqwCPICbzuc .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-KZqoaVqwCPICbzuc rect.text{fill:none;stroke-width:0;}#mermaid-svg-KZqoaVqwCPICbzuc .icon-shape,#mermaid-svg-KZqoaVqwCPICbzuc .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KZqoaVqwCPICbzuc .icon-shape p,#mermaid-svg-KZqoaVqwCPICbzuc .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-KZqoaVqwCPICbzuc .icon-shape .label rect,#mermaid-svg-KZqoaVqwCPICbzuc .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KZqoaVqwCPICbzuc .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-KZqoaVqwCPICbzuc .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-KZqoaVqwCPICbzuc :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 外部服务
多存储层
异步消息层
AI 工作流层
业务服务层
API 网关层
前端层 (3 Apps)
市民移动端 H5
Vue3 + Vite + Pinia
维修员移动端 H5
Vue3 + Vite + Pinia
市政管理后台 PC
Vue3 + Element Plus + ECharts
FastAPI
Uvicorn ASGI
JWT 认证 + CORS
报修服务
派单服务
维修服务
结算服务
统计服务
LangChain + 百炼 qwen-vl-max
NLP 解析 / 派单评分 / AI 验收
RabbitMQ
dispatch / timeout / review / es_sync
MySQL
核心业务数据
ACID 事务
Redis
缓存 / Geo / 锁 / 计数器
MongoDB
附件 / AI日志 / 维修记录 / 审计
Elasticsearch
全文检索 / 聚合统计
阿里云 OSS
图片存储
高德地图 API
逆地理编码 / 驾车距离
2.2 各层职责说明
| 层次 | 技术选型 | 职责 |
|---|---|---|
| 前端层 | Vue 3 + Vite + Pinia + Element Plus + ECharts | 三端 UI,分别面向市民、维修员、管理员 |
| 网关层 | FastAPI + Uvicorn | 统一 API 网关,JWT 认证,请求路由,参数校验 |
| 业务层 | FastAPI 异步路由 | 报修创建、智能派单、维修闭环、结算审核、统计分析 |
| AI 层 | LangChain + 阿里云百炼 qwen-vl-max | NLP 报修解析、AI 验收对比、派单评分辅助 |
| 消息队列 | RabbitMQ (aio-pika) | 派单异步、超时延迟检查、差评复核、ES 同步 |
| 存储层 | MySQL + Redis + MongoDB + ES | 四库分层,各司其职 |
2.3 为什么选择这些技术
- FastAPI:原生异步支持(async/await),自动生成 OpenAPI 文档,Pydantic 类型校验,性能接近 Node.js/Go。对于需要同时处理 HTTP 请求、数据库查询、Redis 操作、MQ 发布的项目,异步非阻塞是刚需。
- Vue 3 + Vite:Composition API 让复杂交互逻辑更清晰,Vite 开发体验极快。三端应用共享相同的技术栈和 API 封装模式,降低维护成本。
- Redis:不仅仅是缓存。Redis Geo 支持空间半径查询(GEORADIUS),天然适合"找附近的维修员"场景;分布式锁(SETNX)保证派单互斥;多 DB 分库(DB0 缓存、DB1 Geo、DB2 锁、DB3 计数器)隔离不同用途。
- MongoDB:AI 解析结果、维修记录(耗材不固定)、审计日志(字段可变)都是非结构化或半结构化数据,Schema-less 的 MongoDB 比 MySQL 更合适。
- Elasticsearch:管理后台需要按区域、设施类型、状态、时间多维度聚合统计,ES 的聚合查询(aggs)比 MySQL GROUP BY 灵活得多,加上 IK 中文分词器可以做工单内容全文搜索。
- RabbitMQ:派单、超时、差评复核都是典型的"触发后不必立即完成"的场景,用消息队列解耦可以避免 API 超时,同时利用 TTL + DLX 实现优雅的延迟任务。
三、核心业务流程设计
整个系统的核心是一条完整的业务闭环链路。下面我从市民提交报修开始,逐步拆解每一步的工程实现。
3.1 完整业务链路
管理员 维修员 RabbitMQ AI 工作流 FastAPI 市民 管理员 维修员 RabbitMQ AI 工作流 FastAPI 市民 #mermaid-svg-Pt6pqpDogmh39qAf{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Pt6pqpDogmh39qAf .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Pt6pqpDogmh39qAf .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Pt6pqpDogmh39qAf .error-icon{fill:#552222;}#mermaid-svg-Pt6pqpDogmh39qAf .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Pt6pqpDogmh39qAf .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Pt6pqpDogmh39qAf .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Pt6pqpDogmh39qAf .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Pt6pqpDogmh39qAf .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Pt6pqpDogmh39qAf .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Pt6pqpDogmh39qAf .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Pt6pqpDogmh39qAf .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Pt6pqpDogmh39qAf .marker.cross{stroke:#333333;}#mermaid-svg-Pt6pqpDogmh39qAf svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Pt6pqpDogmh39qAf p{margin:0;}#mermaid-svg-Pt6pqpDogmh39qAf .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Pt6pqpDogmh39qAf text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-Pt6pqpDogmh39qAf .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Pt6pqpDogmh39qAf .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-Pt6pqpDogmh39qAf .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-Pt6pqpDogmh39qAf .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-Pt6pqpDogmh39qAf #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-Pt6pqpDogmh39qAf .sequenceNumber{fill:white;}#mermaid-svg-Pt6pqpDogmh39qAf #sequencenumber{fill:#333;}#mermaid-svg-Pt6pqpDogmh39qAf #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-Pt6pqpDogmh39qAf .messageText{fill:#333;stroke:none;}#mermaid-svg-Pt6pqpDogmh39qAf .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Pt6pqpDogmh39qAf .labelText,#mermaid-svg-Pt6pqpDogmh39qAf .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-Pt6pqpDogmh39qAf .loopText,#mermaid-svg-Pt6pqpDogmh39qAf .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-Pt6pqpDogmh39qAf .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Pt6pqpDogmh39qAf .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-Pt6pqpDogmh39qAf .noteText,#mermaid-svg-Pt6pqpDogmh39qAf .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-Pt6pqpDogmh39qAf .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Pt6pqpDogmh39qAf .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Pt6pqpDogmh39qAf .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Pt6pqpDogmh39qAf .actorPopupMenu{position:absolute;}#mermaid-svg-Pt6pqpDogmh39qAf .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-Pt6pqpDogmh39qAf .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Pt6pqpDogmh39qAf .actor-man circle,#mermaid-svg-Pt6pqpDogmh39qAf line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-Pt6pqpDogmh39qAf :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 1. 提交报修(文字+图片+GPS) 防重复检查(Redis 5分钟窗口) 工单入库(MySQL,状态=pending) 2. NLP 解析报修内容 返回分类/紧急度/维修建议 更新工单(状态=accepting,AI 结果写入) 3. 发布派单任务 消费派单,执行智能派单 Redis Geo 筛选 + 多维评分 锁定维修员(状态=dispatching) 4. 发布超时检查(10分钟延迟) 5. 接单(状态=repairing) 6. 到场签到(GPS 验证) 7. 完工提交(修前/修后照片) 8. AI 验收对比 返回验收结果 状态=verifying(通过)或退回 repairing 9. 市民确认完结 状态=closed,自动生成结算单 10. 评价(1-5星) 差评触发复核(MQ → review_queue) 11. 复核差评 / 审核结算 12. 数据同步 ES(聚合统计)
3.2 关键环节详解
环节 1:市民提交报修
市民在 H5 页面填写故障描述、拍摄现场照片、授权 GPS 定位后提交。后端做三层处理:
- 防重复:对「用户ID + 设施类型 + 坐标哈希」做 SHA256,Redis 缓存 5 分钟,防止用户短时间内重复提交相同报修。
- GPS 反查地址:调用高德逆地理编码 API,将经纬度转为"XX 区 XX 路 XX 号"的可读地址。
- 图片上传 :先上传阿里云 OSS,拿到 URL 后再写 MongoDB
ticket_attachments集合,带上水印哈希(SHA256)防止篡改。
环节 2:AI 解析报修内容
这是系统的第一个 AI 应用点。将市民的文字描述和图片 URL 传入 LangChain + 百炼 qwen-vl-max 视觉模型,输出结构化分类结果。具体设计见第九节。
环节 3-4:智能派单 + 超时检查
这是系统的核心亮点。工单创建后发布到 RabbitMQ dispatch 队列,消费者异步执行派单逻辑。派单完成后发布一条 10 分钟 TTL 的延迟消息到 dispatch_timeout.delay 队列------如果 10 分钟后无人接单,消息过期自动路由到 dispatch_timeout 队列,触发强制重新派单。具体设计见第七节和第八节。
环节 6:到场签到
维修员到达现场后点击"签到",前端获取 GPS 坐标传给后端。后端用 Haversine 公式计算签到位置与设施位置的距离,阈值 50 米以内视为有效签到,记录到 MongoDB repair_records 的 gps_checkin 字段。超过阈值则提示"请到达设施附近后再签到"。
环节 7-8:完工提交 + AI 验收
维修员上传修前照片和修后照片,填写耗材和工时。后端调用 AI 验收服务,视觉模型对比修前修后照片判断是否合格。AI 判定通过 → 工单进入 verifying 状态等待市民确认;AI 判定不通过 → 工单退回 repairing 状态。AI 不是最终决策者,只是一个高效的前置过滤器。
环节 10-11:评价与差评复核
市民评价 2 星及以下时,系统自动发布消息到 RabbitMQ review_queue,触发管理员复核流程。管理员可以查看维修记录、AI 验收结果、照片对比后做出裁定(支持申诉)。
环节 12:结算与统计
工单关闭后自动生成结算单。结算逻辑从 MongoDB repair_records 读取耗材明细,从 MySQL audit_rules 读取对应设施品类的计价规则(基价、加班费率、紧急倍率、夜班补贴),计算劳务费 + 材料费 = 总结算金额。所有工单数据同步到 Elasticsearch,支撑管理后台的数据看板。
四、三端前端设计
4.1 技术选型理由
三端统一使用 Vue 3 + Vite + Pinia + Vue Router,理由如下:
- Vue 3 Composition API :三端都有大量表单、列表、状态切换场景,Composition API 的
setup语法让逻辑复用(如定位、图片上传、状态轮询)变得非常自然。 - Vite:HMR 极快,三端独立构建互不影响。
- Pinia:相比 Vuex,API 更简洁,TypeScript 支持更好。在本项目中,市民端管理用户信息 + 工单列表,维修员端管理待接单列表 + 实时位置,后台管理登录态 + 数据看板缓存。
- Element Plus(仅后台):管理后台是典型的 PC 中后台场景,表格、表单、弹窗、分页、日期选择器大量使用,Element Plus 是最成熟的选择。
- ECharts(仅后台):驾驶舱的实时指标卡片、工单趋势折线图、片区分布柱状图、设施品类饼图,全部基于 ECharts 实现。
4.2 市民端
路由结构:
txt
/login → 登录(用户名密码 / 短信验证码)
/register → 注册
/home → 首页报修表单(文字 + 拍照 + GPS 定位)
/ticket/:id → 工单详情(进度时间轴 + 维修员信息)
/evaluation/:id → 评价页(星级 + 标签 + 文字)
/my-tickets → 我的工单列表(分页 + 状态筛选)
关键交互:
- 报修表单集成了高德 SDK 定位 ,降级方案用浏览器原生
navigator.geolocation - 工单详情页展示状态时间轴(提交 → AI 解析 → 派单 → 接单 → 签到 → 维修 → 验收 → 完结),每个节点有对应的时间戳
- 评价页支持快捷标签("响应快""态度好""技术专业")+ 自定义文字
4.3 维修员端
路由结构:
txt
/login → 登录
/home → 工作台(今日统计 + 快捷入口)
/queue → 接单大厅(按距离排序的待接工单列表)
/ticket/:id → 工单详情 + 操作按钮
/complete/:id → 完工提交(修前/修后照片 + 耗材 + 工时)
/my-tickets → 我的工单历史
/notifications → 派单通知列表
关键交互:
- 接单大厅:后端返回按距离排序的工单列表(基于维修员实时位置 + Redis Geo),维修员可以看到每个工单的距离、设施类型、紧急程度
- 实时位置上报 :登录后启动定时器,每 30 秒将 GPS 坐标上报到
PUT /worker/location,后端写入 Redis Geo - 定位工具 :封装了
utils/geolocation.js,高德 SDK 优先 + 原生 GPS 降级,签到场景精度阈值 50 米
4.4 管理后台
页面结构:
txt
/login → 管理员登录
/dashboard → 数据驾驶舱(实时指标 + ECharts 图表)
/tickets → 工单管理(ES 全文搜索 + 状态筛选 + 强制指派)
/workers → 人员管理(维修员 CRUD + 技能标签配置)
/facilities → 设施管理(设施档案 CRUD)
/settlements → 结算审核(待审/已审/驳回)
/audit → 审计日志(操作人/操作类型/变更前后快照)
/config → 配置管理(结算规则 CRUD)
关键交互:
- 驾驶舱:4 个实时指标卡片(今日工单、在岗人数、待派单、待验收)+ 工单趋势折线图(近 6 个月)+ TOP10 故障设施柱状图 + 片区分布饼图
- 工单搜索:基于 Elasticsearch + IK 中文分词,支持按描述关键词、区域、设施类型、状态、时间范围组合搜索
- 强制指派 :管理员可手动将工单指派给指定维修员(走
force_dispatch流程,释放旧锁重新派单)
五、后端服务设计
5.1 为什么选择 FastAPI
在技术选型阶段,我对比了 Django、Flask、FastAPI 三个 Python 后端框架:
| 维度 | Django | Flask | FastAPI |
|---|---|---|---|
| 异步支持 | 3.x 才开始 | 需扩展 | 原生 async/await |
| 类型校验 | ORM 层面 | 需手动 | Pydantic 自动 |
| API 文档 | 需 drf-yasg | 需 flasgger | 自动生成 OpenAPI |
| 性能 | 中等 | 中等 | 接近 Node.js/Go |
| 学习曲线 | 陡峭 | 平缓 | 平缓 |
这个项目需要同时操作 MySQL、Redis、MongoDB、ES、RabbitMQ 五种外部依赖,异步 I/O 是刚需------如果使用同步框架,一个慢查询就会阻塞整个 Worker。FastAPI 的 async/await 让所有 I/O 操作天然并发。
5.2 Pydantic 的请求校验与响应模型
FastAPI 基于 Pydantic 做请求参数校验和响应序列化,这在本项目中非常实用。举一个创建报修的 Schema 示例:
python
from pydantic import BaseModel, Field
from typing import Optional
class TicketCreateRequest(BaseModel):
description: str = Field(..., min_length=10, max_length=500, description="故障描述")
facility_type: str = Field(..., description="设施品类")
location_lng: float = Field(..., ge=-180, le=180, description="经度")
location_lat: float = Field(..., ge=-90, le=90, description="纬度")
image_urls: Optional[list[str]] = Field(default=[], description="图片URL列表")
class TicketResponse(BaseModel):
ticket_id: str
status: str
ai_category: Optional[str]
emergency_level: int
created_at: str
class Config:
from_attributes = True # 支持从 ORM 对象直接序列化
Pydantic 的 Field 校验器可以在请求进入业务逻辑之前拦截非法参数(如经度 > 180),减少防御性代码。from_attributes = True 让 ORM 对象可以直接传给 Response Schema,省去手动 to_dict() 的样板代码。
5.3 后端模块划分
我将后端按业务领域拆分为以下模块:
txt
backend/app/
├── api/v1/ # 路由层
│ ├── auth.py # 统一认证(用户名/短信登录)
│ ├── citizen/
│ │ ├── tickets.py # 市民工单 CRUD
│ │ └── evaluations.py # 市民评价
│ ├── worker/
│ │ ├── auth.py # 维修员认证
│ │ ├── tickets.py # 接单大厅 / 接单 / 签到 / 完工
│ │ ├── notifications.py # 派单通知
│ │ └── performance.py # 绩效查询
│ ├── admin/
│ │ ├── dashboard.py # 驾驶舱数据
│ │ ├── tickets.py # 工单管理 / 搜索 / 强制指派
│ │ ├── workers.py # 人员管理
│ │ ├── facilities.py # 设施管理
│ │ ├── settlements.py # 结算审核
│ │ ├── audit_logs.py # 审计日志
│ │ └── config.py # 配置管理
│ └── utils.py # 逆地理编码 / 图片上传
├── models/
│ ├── mysql/ # MySQL ORM 模型(7 张表)
│ └── mongo/ # MongoDB 文档模型(5 个集合)
├── services/
│ ├── citizen/ # 报修服务
│ ├── worker/ # 派单服务 / 维修服务
│ ├── ai/ # NLP / 验收 / 派单评分
│ ├── mq/ # RabbitMQ 发布/消费
│ ├── map/ # 高德地图 API
│ └── storage/ # OSS 图片存储
├── core/ # JWT / 密码哈希 / 依赖注入
├── middleware/ # 认证中间件 / CORS
└── config/ # Settings 配置类 / Redis 客户端 / MongoDB 连接
模块划分遵循单一职责原则 :路由层只做参数提取和响应返回,业务逻辑全部下沉到 services/,数据访问通过 models/ 的 ORM/Document 模型完成。这样即使未来将某个服务拆成独立微服务,迁移成本也很低。
六、数据库与存储设计
6.1 四库分层职责
多数据库不是"为了用而用",而是每种数据的特点天然适合不同的存储引擎。下表总结了这个项目中四种存储的明确边界:
| 存储 | 数据特点 | 存储内容 | 选型理由 |
|---|---|---|---|
| MySQL | 强结构化、需要 ACID 事务 | 用户、工单、设施、维修员、结算、评价、计价规则 | 关系型数据 + 状态流转必须保证一致性 |
| Redis | 热数据、低延迟、支持 Geo | 工单缓存、维修员实时位置、分布式锁、在线集合、计数器 | Geo 空间查询 + 高性能缓存 + 原子操作 |
| MongoDB | 非结构化、Schema 可变、JSON 原生 | AI 解析日志、维修记录(耗材)、图片附件元数据、审计日志、通知 | Schema-less,适合 AI 输出和半结构化记录 |
| Elasticsearch | 全文检索、聚合统计 | 工单索引、设施索引、维修员绩效索引 | IK 分词 + 聚合查询,支撑搜索和分析 |
6.2 MySQL 核心表设计
users 用户表:
sql
CREATE TABLE users (
user_id VARCHAR(32) PRIMARY KEY, -- USR 前缀
username VARCHAR(64) UNIQUE NOT NULL,
password_hash VARCHAR(256) NOT NULL, -- bcrypt
phone VARCHAR(16),
role ENUM('citizen','worker','admin'),
district VARCHAR(64), -- 所属行政区
is_active BOOLEAN DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
tickets 工单主表:
sql
CREATE TABLE tickets (
ticket_id VARCHAR(32) PRIMARY KEY, -- TK 前缀
user_id VARCHAR(32) NOT NULL,
facility_code VARCHAR(32),
facility_type VARCHAR(32), -- 路灯故障/道路破损/井盖异常...
district VARCHAR(64), -- 冗余字段,避免 JOIN
status ENUM('pending','accepting','dispatching',
'repairing','verifying','closed','cancelled'),
description TEXT,
address VARCHAR(255), -- GPS 反查地址
location_lng DOUBLE,
location_lat DOUBLE,
emergency_level TINYINT DEFAULT 0, -- 0普通 1紧急
assigned_worker_id VARCHAR(32),
ai_category VARCHAR(64), -- AI 解析分类
ai_confidence FLOAT,
created_at DATETIME,
accepted_at DATETIME,
started_at DATETIME,
completed_at DATETIME,
closed_at DATETIME,
INDEX idx_user (user_id),
INDEX idx_worker (assigned_worker_id),
INDEX idx_status_created (status, created_at), -- 联合索引,高频分页查询
INDEX idx_district (district)
);
设计要点:
district冗余存储避免每次查询都 JOIN 设施表status + created_at联合索引支撑"按状态分页查询工单列表"这一最高频场景- 时间轴字段(accepted_at / started_at / completed_at / closed_at)不仅用于展示,还用于计算响应时长、维修时长等 SLA 指标
workers 维修员表:
sql
CREATE TABLE workers (
worker_id VARCHAR(32) PRIMARY KEY, -- W 前缀,关联 users.user_id
name VARCHAR(64),
skills JSON, -- ["路灯","井盖","综合维修"]
max_daily_orders INT DEFAULT 10,
district VARCHAR(64),
night_duty BOOLEAN DEFAULT FALSE, -- 是否值夜班
star_rating FLOAT DEFAULT 5.0,
total_orders INT DEFAULT 0,
avg_response_minutes FLOAT DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE
);
skills 使用 JSON 类型存储,灵活支持一个维修员拥有多个技能标签,派单时做技能匹配。
6.3 Redis 的四库分库设计
Redis 在这个项目中承担了远不止缓存的角色。我将其分为 4 个 DB 隔离不同用途:
| DB | 用途 | 关键 Key | 数据结构 |
|---|---|---|---|
| DB0 (cache) | 热点缓存 | ticket:{id}:info, worker:{id}:profile, tickets:accepting |
Hash, ZSET, String |
| DB1 (geo) | 空间坐标 | workers:geo |
Geo (Sorted Set) |
| DB2 (lock) | 分布式锁 | lock:ticket:{id} |
String (SETNX) |
| DB3 (counter) | 计数器 | worker:{id}:daily_order, id_seq:{prefix} |
String (INCR) |
Redis Geo 的核心操作:
python
# 维修员登录/位置上报时写入坐标
GEOADD workers:geo 116.397428 39.90923 worker_001
# 派单时:搜索设施周围 5km 内的维修员,返回距离和坐标,按距离升序
GEORADIUS workers:geo 116.404 39.915 5 km WITHDIST WITHCOORD ASC COUNT 20
# 派单成功后移除(避免同一维修员被重复指派)
ZREM workers:geo worker_001
6.4 MongoDB 的文档模型
MongoDB 存储所有"结构可变"的数据。以 repair_records 为例:
json
{
"_id": ObjectId("..."),
"ticket_id": "TK20260706001",
"worker_id": "W001",
"materials": [
{"name": "LED灯板24W", "qty": 2, "unit": "个", "unit_cost": 85.00},
{"name": "LED驱动电源", "qty": 1, "unit": "个", "unit_cost": 120.00}
],
"labor_hours": 1.5,
"work_notes": "更换灯板和驱动电源,线路检查正常",
"before_photos": ["https://oss.../before1.jpg", "https://oss.../before2.jpg"],
"after_photos": ["https://oss.../after1.jpg"],
"gps_checkin": {"lng": 116.404, "lat": 39.915, "timestamp": "2026-07-06T10:30:00+08:00"},
"ai_verify_result": {
"verified": true,
"confidence": 0.94,
"diff_summary": "修后灯板已更换,灯光明亮,故障已排除"
}
}
不同设施类型的耗材完全不同(修路灯用灯板+驱动,修道路用沥青+乳化剂),MongoDB 的 Schema-less 特性天然适配这种场景。
6.5 Elasticsearch 的索引设计
工单索引 city_repair_tickets 的 Mapping:
json
{
"mappings": {
"properties": {
"ticket_id": {"type": "keyword"},
"description": {"type": "text", "analyzer": "ik_max_word"},
"facility_type": {"type": "keyword"},
"district": {"type": "keyword"},
"status": {"type": "keyword"},
"emergency_level": {"type": "integer"},
"created_at": {"type": "date"},
"location": {"type": "geo_point"}
}
}
}
description 使用 IK 中文分词器,市民搜索"路灯不亮"能匹配到"路灯故障"的工单。facility_type、district、status 使用 keyword 类型做精确聚合。
七、智能派单设计
智能派单是这个系统最核心也最复杂的模块。我从"最近派单"逐步演进到"多因子评分派单"。
7.1 派单流程
#mermaid-svg-M9MlPQtODQjBYXAT{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-M9MlPQtODQjBYXAT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-M9MlPQtODQjBYXAT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-M9MlPQtODQjBYXAT .error-icon{fill:#552222;}#mermaid-svg-M9MlPQtODQjBYXAT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-M9MlPQtODQjBYXAT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-M9MlPQtODQjBYXAT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-M9MlPQtODQjBYXAT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-M9MlPQtODQjBYXAT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-M9MlPQtODQjBYXAT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-M9MlPQtODQjBYXAT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-M9MlPQtODQjBYXAT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-M9MlPQtODQjBYXAT .marker.cross{stroke:#333333;}#mermaid-svg-M9MlPQtODQjBYXAT svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-M9MlPQtODQjBYXAT p{margin:0;}#mermaid-svg-M9MlPQtODQjBYXAT .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-M9MlPQtODQjBYXAT .cluster-label text{fill:#333;}#mermaid-svg-M9MlPQtODQjBYXAT .cluster-label span{color:#333;}#mermaid-svg-M9MlPQtODQjBYXAT .cluster-label span p{background-color:transparent;}#mermaid-svg-M9MlPQtODQjBYXAT .label text,#mermaid-svg-M9MlPQtODQjBYXAT span{fill:#333;color:#333;}#mermaid-svg-M9MlPQtODQjBYXAT .node rect,#mermaid-svg-M9MlPQtODQjBYXAT .node circle,#mermaid-svg-M9MlPQtODQjBYXAT .node ellipse,#mermaid-svg-M9MlPQtODQjBYXAT .node polygon,#mermaid-svg-M9MlPQtODQjBYXAT .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-M9MlPQtODQjBYXAT .rough-node .label text,#mermaid-svg-M9MlPQtODQjBYXAT .node .label text,#mermaid-svg-M9MlPQtODQjBYXAT .image-shape .label,#mermaid-svg-M9MlPQtODQjBYXAT .icon-shape .label{text-anchor:middle;}#mermaid-svg-M9MlPQtODQjBYXAT .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-M9MlPQtODQjBYXAT .rough-node .label,#mermaid-svg-M9MlPQtODQjBYXAT .node .label,#mermaid-svg-M9MlPQtODQjBYXAT .image-shape .label,#mermaid-svg-M9MlPQtODQjBYXAT .icon-shape .label{text-align:center;}#mermaid-svg-M9MlPQtODQjBYXAT .node.clickable{cursor:pointer;}#mermaid-svg-M9MlPQtODQjBYXAT .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-M9MlPQtODQjBYXAT .arrowheadPath{fill:#333333;}#mermaid-svg-M9MlPQtODQjBYXAT .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-M9MlPQtODQjBYXAT .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-M9MlPQtODQjBYXAT .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-M9MlPQtODQjBYXAT .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-M9MlPQtODQjBYXAT .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-M9MlPQtODQjBYXAT .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-M9MlPQtODQjBYXAT .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-M9MlPQtODQjBYXAT .cluster text{fill:#333;}#mermaid-svg-M9MlPQtODQjBYXAT .cluster span{color:#333;}#mermaid-svg-M9MlPQtODQjBYXAT div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-M9MlPQtODQjBYXAT .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-M9MlPQtODQjBYXAT rect.text{fill:none;stroke-width:0;}#mermaid-svg-M9MlPQtODQjBYXAT .icon-shape,#mermaid-svg-M9MlPQtODQjBYXAT .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-M9MlPQtODQjBYXAT .icon-shape p,#mermaid-svg-M9MlPQtODQjBYXAT .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-M9MlPQtODQjBYXAT .icon-shape .label rect,#mermaid-svg-M9MlPQtODQjBYXAT .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-M9MlPQtODQjBYXAT .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-M9MlPQtODQjBYXAT .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-M9MlPQtODQjBYXAT :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否
是
否
是
工单创建
Redis Geo 半径筛选
半径内有维修员?
扩大半径重试 / 通知管理员
硬约束过滤
通过硬约束?
降级策略:放宽约束
高德驾车距离修正
多维评分排序
Redis SETNX 分布式锁
获取锁成功?
已被其他流程锁定
MySQL 指派 + Redis 状态同步
从 Geo 移除该维修员
发布 10 分钟超时检查
创建派单通知
ES 同步
7.2 派单策略详解
第一层:空间筛选(Redis Geo)
python
radius_km = 10 if emergency else 5 # 紧急工单扩大搜索半径
nearby_workers = await redis_geo.georadius(
"workers:geo", facility_lng, facility_lat,
radius_km, unit="km", withdist=True, withcoord=True,
sort="ASC", count=20
)
第二层:硬约束过滤
python
for worker in nearby_workers:
# 约束1:日单量未达上限
if today_orders >= worker.max_daily_orders: continue
# 约束2:夜班时段(22:00-06:00)必须开启夜班
if is_night and not worker.night_duty: continue
# 约束3:技能标签匹配设施类型
if not skill_matched(worker.skills, facility_type): continue
filtered.append(worker)
如果硬约束过滤后候选人为空,启用降级策略:日单上限放宽至 1.5 倍,技能匹配放宽。
第三层:高德驾车距离修正
Redis Geo 返回的是直线距离,实际维修员需要走道路。我用高德驾车路径规划 API 修正距离,并缓存 5 分钟:
python
route = await driving_distance(worker_lng, worker_lat, facility_lng, facility_lat)
worker["distance_km"] = route["distance_km"] # 替换直线距离
worker["driving_duration_min"] = route["duration_min"]
await redis_cache.setex(f"amap:driving:{worker_id}:{ticket_id}", 300, json.dumps(route))
第四层:多维评分
python
# 简化版:只按距离排序,每公里扣 20 分
score = max(0, 100 - distance_km * 20)
# 完整版(启用 LLM_ENABLE_DISPATCH_SCORING 时):
# score = distance_weight(40%) + load_weight(30%) + rating_weight(20%) + response_weight(10%)
当前简化版已能满足大部分场景------最近的维修员通常是最高效的选择。完整版通过 LangChain 调用 LLM 做综合判断,LLM 失败时自动回退到简化版本地算法。
第五层:分布式锁 + 事务写入
python
# Redis 分布式锁(SETNX,300 秒自动过期防死锁)
acquired = await redis_lock.set(f"lock:ticket:{ticket_id}", worker_id, nx=True, ex=300)
if not acquired: return # 已被其他并发流程锁定
# MySQL 事务写入
ticket.assigned_worker_id = worker_id
ticket.status = "dispatching"
await db.commit()
# Redis 状态同步
await redis_cache.hset(f"ticket:{ticket_id}:info", mapping={...})
await redis_cache.zrem("tickets:accepting", ticket_id) # 从接单大厅移除
await redis_geo.zrem("workers:geo", worker_id) # 从 Geo 移除
7.3 超时重新派单
派单完成后发布一条 10 分钟 TTL 的延迟消息。10 分钟后如果工单状态仍是 dispatching(无人接单),触发 force_dispatch 流程:
python
async def force_dispatch(ticket_id, db, admin_id):
# 释放旧锁
await redis_lock.delete(f"lock:ticket:{ticket_id}")
# 重新执行完整派单流程
result = await execute_dispatch(ticket_id, db)
# 写入审计日志
await mongo_db.audit_logs.insert_one({...})
return result
八、RabbitMQ 异步流程设计
8.1 为什么需要消息队列
以下场景不适合在 HTTP 请求-响应周期内同步完成:
- 派单:涉及 Redis Geo 查询 + 高德 API + MySQL 事务,耗时可能超过 2 秒
- 超时检查:需要 10 分钟后触发,HTTP 无法实现延迟执行
- 差评复核:低优先级任务,不需要立即处理
- ES 同步:失败需要重试,不能丢失
RabbitMQ 让这些流程从"同步阻塞"变为"异步解耦",核心收益是 API 响应速度大幅提升 + 可靠性保障。
8.2 队列拓扑设计
#mermaid-svg-J4DWm9Yhjc6ZBOaW{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-J4DWm9Yhjc6ZBOaW .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .error-icon{fill:#552222;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .marker{fill:#333333;stroke:#333333;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .marker.cross{stroke:#333333;}#mermaid-svg-J4DWm9Yhjc6ZBOaW svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-J4DWm9Yhjc6ZBOaW p{margin:0;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .cluster-label text{fill:#333;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .cluster-label span{color:#333;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .cluster-label span p{background-color:transparent;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .label text,#mermaid-svg-J4DWm9Yhjc6ZBOaW span{fill:#333;color:#333;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .node rect,#mermaid-svg-J4DWm9Yhjc6ZBOaW .node circle,#mermaid-svg-J4DWm9Yhjc6ZBOaW .node ellipse,#mermaid-svg-J4DWm9Yhjc6ZBOaW .node polygon,#mermaid-svg-J4DWm9Yhjc6ZBOaW .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .rough-node .label text,#mermaid-svg-J4DWm9Yhjc6ZBOaW .node .label text,#mermaid-svg-J4DWm9Yhjc6ZBOaW .image-shape .label,#mermaid-svg-J4DWm9Yhjc6ZBOaW .icon-shape .label{text-anchor:middle;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .rough-node .label,#mermaid-svg-J4DWm9Yhjc6ZBOaW .node .label,#mermaid-svg-J4DWm9Yhjc6ZBOaW .image-shape .label,#mermaid-svg-J4DWm9Yhjc6ZBOaW .icon-shape .label{text-align:center;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .node.clickable{cursor:pointer;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .arrowheadPath{fill:#333333;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-J4DWm9Yhjc6ZBOaW .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-J4DWm9Yhjc6ZBOaW .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-J4DWm9Yhjc6ZBOaW .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .cluster text{fill:#333;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .cluster span{color:#333;}#mermaid-svg-J4DWm9Yhjc6ZBOaW div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-J4DWm9Yhjc6ZBOaW rect.text{fill:none;stroke-width:0;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .icon-shape,#mermaid-svg-J4DWm9Yhjc6ZBOaW .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .icon-shape p,#mermaid-svg-J4DWm9Yhjc6ZBOaW .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .icon-shape .label rect,#mermaid-svg-J4DWm9Yhjc6ZBOaW .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-J4DWm9Yhjc6ZBOaW .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-J4DWm9Yhjc6ZBOaW .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-J4DWm9Yhjc6ZBOaW :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 消费者
工作队列
交换器
生产者 (FastAPI)
routing_key=dispatch
routing_key=dispatch_timeout.delay
TTL=10min
TTL 过期, DLX
routing_key=dispatch_timeout
routing_key=review_queue
routing_key=es_sync
重试延迟
TTL 过期, DLX
重试耗尽
派单服务
评价服务
工单服务
city_repair.direct
Direct Exchange
city_repair.dlx
Dead Letter Exchange
dispatch
dispatch_timeout.delay
dispatch_timeout
review_queue
es_sync
es_sync.delay
es_sync.dlq
派单消费者
超时检查消费者
差评复核消费者
ES 同步消费者
8.3 队列清单
| 队列 | 用途 | 特殊机制 |
|---|---|---|
dispatch |
智能派单 | 普通队列,立即消费 |
dispatch_timeout.delay |
超时延迟 | per-message TTL + DLX → dispatch_timeout |
dispatch_timeout |
超时检查 | 由延迟队列 DLX 路由过来 |
review_queue |
差评复核 | 普通队列 |
es_sync |
ES 同步 | DLX → city_repair.dlx,消费者内重试 |
es_sync.delay |
ES 同步延迟重试 | per-message TTL + DLX → es_sync |
es_sync.dlq |
ES 同步死信 | 重试耗尽后人工处理 |
8.4 延迟队列的实现:TTL + DLX
RabbitMQ 没有内置的延迟队列,但可以通过 per-message TTL + Dead Letter Exchange 优雅实现:
python
# 声明延迟队列:消息过期后自动转发到目标队列
timeout_delay_queue = await channel.declare_queue(
"dispatch_timeout.delay",
durable=True,
arguments={
"x-dead-letter-exchange": "city_repair.direct", # 过期后投递到的交换器
"x-dead-letter-routing-key": "dispatch_timeout", # 过期后投递到的队列
},
)
# 发布带 TTL 的消息
message = aio_pika.Message(
body=json.dumps({"ticket_id": ticket_id, "action": "timeout_check"}).encode(),
expiration=str(10 * 60 * 1000), # 10 分钟 TTL(毫秒)
delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
)
await exchange.publish(message, routing_key="dispatch_timeout.delay")
8.5 幂等性保证
消息可能被重复投递(网络抖动、消费者崩溃重启),必须保证幂等。我的做法是:
- 派单消息 :消费前检查工单状态,
dispatching状态说明已派单,跳过 - ES 同步消息 :ES 的
index操作本身幂等(相同_id的文档会覆盖) - 超时检查消息 :执行前检查工单是否仍处于
dispatching状态
8.6 ES 同步的可靠性设计
ES 同步是最容易出问题的环节(ES 可能暂时不可用),我设计了指数退避重试 + 死信队列:
python
async def _handle_es_sync(message: aio_pika.IncomingMessage):
body = json.loads(message.body.decode())
ticket_id = body["ticket_id"]
retry_count = int(message.headers.get("x-retry-count", 0))
try:
await sync_ticket_to_es(ticket_id)
await message.ack()
except Exception:
if retry_count < settings.ES_SYNC_MAX_RETRIES:
# 指数退避:2s → 4s → 8s → 16s → 32s
delay = min(settings.ES_SYNC_BASE_DELAY_SEC * (2 ** retry_count),
settings.ES_SYNC_MAX_DELAY_SEC)
# 发布到延迟队列
retry_msg = aio_pika.Message(
body=message.body,
headers={"x-retry-count": retry_count + 1},
expiration=str(delay * 1000),
)
await exchange.publish(retry_msg, routing_key="es_sync.delay")
await message.ack()
else:
# 重试耗尽 → 死信队列
await message.reject(requeue=False) # DLX 自动处理
九、AI 工作流设计
9.1 总体定位
AI 在这个系统中承担的是辅助决策 角色,而不是替代人工。每个 AI 输出都会记录到 MongoDB ai_analysis_logs,包含输入、输出、置信度、模型版本,便于事后审计和回溯。
9.2 三大 AI 应用场景
#mermaid-svg-uFGnh3IuUGM1NyvY{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-uFGnh3IuUGM1NyvY .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-uFGnh3IuUGM1NyvY .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-uFGnh3IuUGM1NyvY .error-icon{fill:#552222;}#mermaid-svg-uFGnh3IuUGM1NyvY .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-uFGnh3IuUGM1NyvY .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-uFGnh3IuUGM1NyvY .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-uFGnh3IuUGM1NyvY .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-uFGnh3IuUGM1NyvY .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-uFGnh3IuUGM1NyvY .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-uFGnh3IuUGM1NyvY .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-uFGnh3IuUGM1NyvY .marker{fill:#333333;stroke:#333333;}#mermaid-svg-uFGnh3IuUGM1NyvY .marker.cross{stroke:#333333;}#mermaid-svg-uFGnh3IuUGM1NyvY svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-uFGnh3IuUGM1NyvY p{margin:0;}#mermaid-svg-uFGnh3IuUGM1NyvY .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-uFGnh3IuUGM1NyvY .cluster-label text{fill:#333;}#mermaid-svg-uFGnh3IuUGM1NyvY .cluster-label span{color:#333;}#mermaid-svg-uFGnh3IuUGM1NyvY .cluster-label span p{background-color:transparent;}#mermaid-svg-uFGnh3IuUGM1NyvY .label text,#mermaid-svg-uFGnh3IuUGM1NyvY span{fill:#333;color:#333;}#mermaid-svg-uFGnh3IuUGM1NyvY .node rect,#mermaid-svg-uFGnh3IuUGM1NyvY .node circle,#mermaid-svg-uFGnh3IuUGM1NyvY .node ellipse,#mermaid-svg-uFGnh3IuUGM1NyvY .node polygon,#mermaid-svg-uFGnh3IuUGM1NyvY .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-uFGnh3IuUGM1NyvY .rough-node .label text,#mermaid-svg-uFGnh3IuUGM1NyvY .node .label text,#mermaid-svg-uFGnh3IuUGM1NyvY .image-shape .label,#mermaid-svg-uFGnh3IuUGM1NyvY .icon-shape .label{text-anchor:middle;}#mermaid-svg-uFGnh3IuUGM1NyvY .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-uFGnh3IuUGM1NyvY .rough-node .label,#mermaid-svg-uFGnh3IuUGM1NyvY .node .label,#mermaid-svg-uFGnh3IuUGM1NyvY .image-shape .label,#mermaid-svg-uFGnh3IuUGM1NyvY .icon-shape .label{text-align:center;}#mermaid-svg-uFGnh3IuUGM1NyvY .node.clickable{cursor:pointer;}#mermaid-svg-uFGnh3IuUGM1NyvY .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-uFGnh3IuUGM1NyvY .arrowheadPath{fill:#333333;}#mermaid-svg-uFGnh3IuUGM1NyvY .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-uFGnh3IuUGM1NyvY .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-uFGnh3IuUGM1NyvY .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uFGnh3IuUGM1NyvY .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-uFGnh3IuUGM1NyvY .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uFGnh3IuUGM1NyvY .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-uFGnh3IuUGM1NyvY .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-uFGnh3IuUGM1NyvY .cluster text{fill:#333;}#mermaid-svg-uFGnh3IuUGM1NyvY .cluster span{color:#333;}#mermaid-svg-uFGnh3IuUGM1NyvY div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-uFGnh3IuUGM1NyvY .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-uFGnh3IuUGM1NyvY rect.text{fill:none;stroke-width:0;}#mermaid-svg-uFGnh3IuUGM1NyvY .icon-shape,#mermaid-svg-uFGnh3IuUGM1NyvY .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uFGnh3IuUGM1NyvY .icon-shape p,#mermaid-svg-uFGnh3IuUGM1NyvY .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-uFGnh3IuUGM1NyvY .icon-shape .label rect,#mermaid-svg-uFGnh3IuUGM1NyvY .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uFGnh3IuUGM1NyvY .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-uFGnh3IuUGM1NyvY .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-uFGnh3IuUGM1NyvY :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 场景3: AI 辅助验收
修前照片 + 修后照片 + 维修描述
qwen-vl-max 视觉对比
是否合格 + 置信度 + 差异摘要
场景2: 智能派单辅助
工单信息 + 候选维修员列表
本地评分算法 / LLM
候选维修员评分 + 派单建议
场景1: 报修内容解析
市民文字描述 + 图片
LangChain + qwen-vl-max
设施类型 + 故障分类 + 紧急度 + 维修建议
9.3 场景一:报修内容解析
输入:
python
{
"text": "解放路和人民路交叉口的路灯不亮了,已经三天了,晚上很黑很危险",
"image_urls": ["https://oss.../lamp_broken.jpg"],
"lng": 116.404,
"lat": 39.915
}
输出(Pydantic Schema):
python
class NLPOutput(BaseModel):
category: str # "路灯故障"
sub_category: str # "灯具不亮"
emergency_level: int # 0 普通
confidence: float # 0.92
repair_knowledge: list[str] # ["检查供电线路...", "LED驱动电源..."]
tools_needed: list[str] # ["绝缘手套", "万用表", "螺丝刀套装"]
safety_tips: list[str] # ["作业前必须断电并验电..."]
parts_needed: list[str] # ["LED灯板24W", "LED驱动电源"]
降级策略:当 LLM API Key 未配置或调用失败时,自动回退到内置关键词匹配知识库。知识库覆盖了路灯故障、道路破损、井盖异常、护栏损坏、环卫设施、交通信号设施、公共绿化 7 大类,每类都包含标准的维修知识、工具、安全提示------确保即使 AI 不可用,维修员仍能看到有用的指导信息。
9.4 场景二:智能派单辅助
当前默认使用简化版本地算法(只按距离排序),但架构上已预留了 LLM 评分的能力。开启 LLM_ENABLE_DISPATCH_SCORING=true 后,会将候选维修员列表 + 权重配置传入 LLM 做综合判断。
python
# LLM 评分模式(开启后)
model = provider.get_model()
structured_model = model.with_structured_output(DispatchScoreOutput)
result = await structured_model.ainvoke(messages)
# 失败自动回退
except Exception:
result = _simple_score_candidates(candidates)
9.5 场景三:AI 辅助验收
这是三个场景中技术含量最高的------它需要视觉模型对比修前和修后照片。
关键设计:
- 使用
qwen-vl-max-latest(支持图片理解的多模态模型) - 构造多模态消息:修前照片 + 修后照片 + 维修描述
- 输出结构化结果:
{verified: bool, confidence: float, diff_summary: str} - AI 判定通过 → 进入
verifying状态等市民确认 - AI 判定不通过 → 退回
repairing状态 - AI 不可用时默认通过(
verified=True, confidence=0.92),不阻塞流程
9.6 AI 的安全边界
- AI 输出不直接改变工单状态,必须经过规则校验(如验收必须修前修后照片都有)
- 所有 AI 结果写入 MongoDB 审计日志,完整保留输入输出
- 关键决策节点(差评复核、结算审核)仍需要人工确认
- LLM 调用失败有多层降级策略,保证业务不中断
十、接口设计示例
10.1 创建报修
txt
POST /api/v1/citizen/tickets
Authorization: Bearer <jwt_token>
请求体:
json
{
"description": "解放路和人民路交叉口路灯不亮,已经三天了",
"facility_type": "路灯故障",
"location_lng": 116.404,
"location_lat": 39.915,
"image_urls": ["https://oss-cn-beijing.aliyuncs.com/.../photo1.jpg"]
}
响应:
json
{
"code": 200,
"data": {
"ticket_id": "TK20260706001",
"status": "accepting",
"ai_category": "路灯故障",
"ai_confidence": 0.92,
"emergency_level": 0,
"address": "北京市朝阳区解放路与人民路交叉口",
"created_at": "2026-07-06T10:25:00+08:00"
}
}
说明:创建成功后异步触发 NLP 解析 → 派单流程,市民立即拿到工单 ID 和预估分类。
10.2 查询工单详情(三端通用)
txt
GET /api/v1/citizen/tickets/TK20260706001
GET /api/v1/worker/tickets/TK20260706001
GET /api/v1/admin/tickets/TK20260706001
响应(聚合了 MySQL + MongoDB + Redis 数据):
json
{
"code": 200,
"data": {
"ticket_id": "TK20260706001",
"status": "repairing",
"facility_type": "路灯故障",
"description": "...",
"address": "北京市朝阳区...",
"emergency_level": 0,
"timeline": [
{"event": "市民提交报修", "time": "2026-07-06T10:25:00"},
{"event": "AI解析完成", "time": "2026-07-06T10:25:03"},
{"event": "已派单给维修员张三", "time": "2026-07-06T10:25:05"},
{"event": "维修员已接单", "time": "2026-07-06T10:28:00"},
{"event": "维修员已到场签到", "time": "2026-07-06T10:45:00"}
],
"worker": {
"name": "张三",
"phone": "138****5678",
"star_rating": 4.8,
"skills": ["路灯", "交通信号设施"]
},
"ai_analysis": {
"category": "路灯故障",
"repair_knowledge": ["..."]
}
}
}
10.3 维修员接单
txt
PUT /api/v1/worker/tickets/TK20260706001/accept
Authorization: Bearer <worker_jwt>
响应:
json
{
"code": 200,
"data": {
"ticket_id": "TK20260706001",
"status": "repairing",
"accepted_at": "2026-07-06T10:28:00+08:00"
}
}
说明:接单时需要 Redis 分布式锁 + MySQL 乐观锁双保险,防止多人同时抢单。
10.4 提交维修结果
txt
PUT /api/v1/worker/tickets/TK20260706001/complete
Authorization: Bearer <worker_jwt>
请求体:
json
{
"before_photos": ["https://oss.../before1.jpg"],
"after_photos": ["https://oss.../after1.jpg"],
"materials": [
{"name": "LED灯板24W", "qty": 2, "unit": "个", "unit_cost": 85.00}
],
"labor_hours": 1.5,
"work_notes": "更换灯板和驱动电源,线路检查正常"
}
响应:
json
{
"code": 200,
"data": {
"ticket_id": "TK20260706001",
"status": "verifying",
"ai_verify": {
"verified": true,
"confidence": 0.94,
"diff_summary": "修后灯板已更换,灯光明亮,故障已排除"
}
}
}
10.5 管理员审核结算
txt
PUT /api/v1/admin/settlements/ST20260706001/audit
Authorization: Bearer <admin_jwt>
请求体:
json
{
"action": "approved",
"comment": "材料用量合理,审核通过"
}
10.6 数据看板统计
txt
GET /api/v1/admin/dashboard/realtime
GET /api/v1/admin/dashboard/analytics?months=6
响应(analytics):
json
{
"code": 200,
"data": {
"total_tickets": 12340,
"trend": [
{"month": "2026-01", "count": 1890},
{"month": "2026-02", "count": 1750}
],
"top_facilities": [
{"type": "路灯故障", "count": 4520},
{"type": "道路破损", "count": 3210}
],
"district_distribution": [
{"district": "朝阳区", "count": 2890},
{"district": "海淀区", "count": 2340}
],
"avg_response_minutes": 8.5,
"satisfaction_rate": 0.94
}
}
十一、关键难点与解决方案
11.1 多角色流程状态复杂
难点:系统涉及市民、维修员、管理员三个角色,工单状态有 7 个(pending → accepting → dispatching → repairing → verifying → closed / cancelled),每个状态允许的操作不同。
解决方案:
- 在 MySQL
tickets表使用ENUM约束状态值,数据库层面防止非法状态 - 每个 API 路由层校验当前状态是否允许该操作(如只有
dispatching状态的工单才能接单) - 状态变更通过
services/层的专用函数完成,确保状态 + 时间戳 + 审计日志原子写入
11.2 派单公平性与效率平衡
难点:纯距离优先可能导致某个维修员一直接到远处的单子(因为他在 Geo 集合里),而纯负载均衡可能派一个很远的维修员。
解决方案:
- 使用高德驾车距离替换 Geo 直线距离,让距离计算更真实
- 日单量上限作为硬约束(不是软权重),避免"累死一个人"
- 派单后从 Redis Geo 移除该维修员,直到他完成当前工单后重新上报位置
11.3 AI 输出不稳定
难点:LLM 可能返回非预期的格式、幻觉内容,或直接超时。
解决方案:
- LangChain 的
with_structured_output()约束输出格式为 Pydantic Schema,自动重试格式不匹配的响应 - 内置关键词匹配降级知识库,覆盖 7 大类设施,确保 LLM 不可用时也能给出合理分类
- 紧急程度归一化函数:兼容数字 0/1、中文"紧急/高"、英文"urgent/high"等多种输入
- AI 验收默认通过(
verified=True),不阻塞流程
11.4 工单状态一致性问题
难点:工单状态同时存在于 MySQL(主存储)、Redis(热缓存)、Elasticsearch(搜索索引),三处可能不一致。
解决方案:
- MySQL 是唯一真相源(Single Source of Truth)
- Redis 缓存设置 14 天 TTL,作为加速层,缓存未命中时回源 MySQL
- ES 通过 RabbitMQ 异步同步,有重试 + 死信机制保证最终一致性
- 管理后台搜索时优先查 ES,ES 不可用时降级查 MySQL
11.5 消息队列重复消费
难点:RabbitMQ 的 at-least-once 语义意味着消息可能被重复投递。
解决方案:
- 消费者处理前检查业务状态(派单消费者检查工单是否已处于
dispatching) - ES 同步天然幂等(相同
_id覆盖写入) - 超时检查使用 Redis 分布式锁避免重复执行
11.6 多数据库数据边界问题
难点:MySQL、Redis、MongoDB、Elasticsearch 四库共存,数据边界不清晰会导致维护噩梦。
解决方案:建立清晰的规则------
- 强结构化 + 需要事务 → MySQL
- 热数据 + 需要快速访问 → Redis(来自 MySQL 的缓存副本)
- Schema 可变 + JSON 原生 → MongoDB
- 全文搜索 + 聚合统计 → Elasticsearch(来自 MySQL 的搜索副本)
跨库查询通过应用层组装,不搞跨库 JOIN。
11.7 管理后台统计性能问题
难点:按区域、设施类型、时间范围多维度聚合统计,MySQL GROUP BY 大表性能差。
解决方案:
- 实时指标(今日工单数、在岗人数)用 Redis 计数器 + MySQL
CURDATE()查询 - 聚合统计(趋势、分布、TOP N)走 Elasticsearch 的聚合查询
- ES 同步通过 RabbitMQ 异步完成,不阻塞主流程
十二、项目亮点总结
回顾整个项目,我认为以下几个技术点是最有工程价值的:
-
三端协同架构 :市民端、维修员端、管理后台共享同一套 API,通过 JWT 的
role字段做接口级权限控制,代码复用率高。 -
AI + 规则引擎结合:AI 负责"理解"(NLP 解析、视觉验收),规则引擎负责"执行"(状态流转、硬约束过滤)。AI 输出不直接改变系统状态,关键节点保留人工复核。
-
Redis Geo 空间派单:利用 Redis 内置的 Geo 数据结构实现"找附近的维修员",比用 MySQL 计算 Haversine 距离快两个数量级,且天然支持排序和分页。
-
RabbitMQ 异步解耦:派单、超时检查、ES 同步、差评复核全部通过消息队列异步化,API 响应时间从秒级降到毫秒级。延迟队列(TTL + DLX)优雅实现了超时自动派单。
-
四库分层存储:MySQL(ACID 事务)、Redis(Geo + 缓存 + 锁)、MongoDB(非结构化文档)、Elasticsearch(搜索 + 聚合),每种存储做它最擅长的事。
-
FastAPI 异步接口:原生 async/await 让所有 I/O 操作(数据库查询、Redis 操作、MQ 发布)都非阻塞,单进程即可承载高并发。
-
数据闭环:从报修到评价,全流程数据可追踪、可审计、可分析,管理后台的驾驶舱让运营决策有数据支撑。
十三、后续优化方向
这个系统还有不少可以深挖的方向:
-
WebSocket 实时推送:目前工单状态变更需要前端轮询,改为 WebSocket 推送后,维修员可以实时收到新工单通知(类似外卖骑手接单体验)。
-
更完整的规则引擎:当前硬约束是硬编码的,可以引入 Drools 或自研轻量规则引擎,让管理员可以可视化配置派单策略。
-
GIS 地图服务:管理后台接入高德/百度地图 SDK,实现工单热力图、维修员轨迹回放、片区工单密度可视化。
-
AI 图片识别设施损坏程度:目前 AI 验收只是对比修前修后,未来可以让 AI 直接评估损坏程度(如路面坑洞的面积和深度),输出量化的损坏等级。
-
维修员路径规划:维修员一天可能要去多个点位,引入路径规划算法(TSP 问题),帮维修员规划最优路线。
-
工单 SLA 体系:为不同设施类型和紧急程度定义 SLA(如紧急工单 30 分钟响应、普通工单 2 小时响应),超时自动告警升级。
-
更完善的权限系统:引入 RBAC 或 ABAC,支持多级管理员、片区经理、维修队长等更细粒度的角色。
-
可观测性:接入 OpenTelemetry 做分布式链路追踪,Prometheus + Grafana 做指标监控,ELK 做日志聚合。
-
容器化部署与 CI/CD:编写 Docker Compose / Kubernetes 部署配置,搭配 GitHub Actions / GitLab CI 做自动化测试和部署。
结语
这个项目从零开始搭建,到最终形成一个覆盖「报修 → AI 解析 → 派单 → 维修 → 验收 → 评价 → 结算 → 分析」完整闭环的系统,技术栈横跨前端三端、后端微服务、四类数据库、消息队列、AI 大模型、空间计算等多个领域。
做这个项目最大的体会是:架构设计的核心不是"用了多少技术",而是"每个技术放在它最合适的位置上"。MySQL 做事务、Redis 做 Geo 和缓存、MongoDB 存文档、ES 做搜索和聚合、RabbitMQ 做异步解耦------每一层都有明确的职责边界,组合在一起才是一个健壮的系统。
希望这篇文章能对正在做类似智慧城市、O2O 派单、工单管理系统的同学有所启发。