5.25
从增删改查到完整业务流程——配置管理子系统升级记录
一、缘起
之前完成的配置管理子系统已经实现了 19 个模块的基础 CRUD:车站管理、网格管理、位置管理、设备分类、巡检线路/项目、保养线路/项目、检测线路/项目、故障字典、备件库位/分类/型号、设备厂商、键值字典、流程定义/路由。每个模块都能增删改查,数据也能正常展示。
但是老师在看了我的项目之后指出了一个问题:"你这只是做了表面的增删改查,没有体现出配置管理子系统内部的业务流程。"
确实,我仔细一看:
- 删除车站时不会检查是否有下属网格和线路,直接删了导致数据断裂
- 创建巡检项目时不验证线路和分类是否存在,可以传入任意 ID
- 设备分类是自引用树形结构,但可以把一个父节点直接删掉,子树全部变成孤儿
- 备件型号的安全库存下限可以填负数,上限可以比下限还小
- 19 个模块各管各的,没有任何跨模块的数据一致性保障
换句话说,系统有骨架、没血肉。而这 19 个模块之间是有明确的业务关系的——它们构成了整个"配置管理"的主数据层。
二、理解业务:19 个模块的 5 大领域
在动手写代码之前,我先花了时间梳理这 19 个模块之间的数据依赖关系:
Domain 1 空间区域: Station → Grid → Location
Domain 2 设备分类: EquipmentCategory(树) → EquipmentPurpose
Domain 3 运维线路: Station → InspectionRoute → InspectionItem → Category
Station → MaintenanceRoute → MaintenanceItem → Category
Station → TestingRoute → TestingItem → Category
Domain 4 备件配置: SparePartsCategory(树) → SparePartsModel → Manufacturer
Station → SparePartsLocation
Domain 5 通用支撑: FaultDictionary → Category | EquipmentManufacturer
KeyValueDictionary | ProcessDefinition → ProcessRouting
画成关系图之后,数据创建的自然顺序就很清楚了:先建车站 → 再建分类 → 才能建线路和项目 → 最后配故障字典和流程。
在实现上,我把这个分析结果输出成了一张 SVG 架构图,用 5 个颜色区域区分不同的业务域,箭头标注了外键引用关系。对于理解后续的校验逻辑很有帮助。
三、后端:构建业务校验层
3.1 三层架构
后端改动是这次升级的核心。我在原有的 models → crud → api 三层之外,新建了一个 app/core/ 校验层:
app/core/
├── exceptions.py # 统一的业务异常类 BusinessRuleError
├── validators.py # 通用校验函数
└── dependencies.py # 级联依赖解析器(9 实体映射表)
app/services/
└── business_rules.py # 业务规则层(调用 core 层,组合校验逻辑)
设计思路很简单:所有校验逻辑集中在 core 层,19 个模块的路由统一调用,不散落在各处。
3.2 五种业务规则
规则一:删除级联保护。 删除任何实体前,先查依赖解析器看它被哪些数据引用。如果有依赖,返回 409 + 详细的依赖列表,阻止删除。
比如尝试删除"石家庄站"时:
{
"error": "HAS_CHILDREN",
"message": "无法删除「石家庄站」:仍有 11 个关联数据",
"detail": {
"dependents": [
{"type": "grid", "count": 3},
{"type": "inspection_route", "count": 2},
{"type": "spare_parts_location", "count": 2}
]
}
}
前端收到这个响应后,弹出一个红色的依赖预览面板,列出所有受影响的数据类型和数量,让用户清楚地知道为什么不能删、需要先处理哪些数据。
规则二:外键存在性校验。 创建或更新时,检查传入的外键 ID 对应的实体是否存在且状态为"启用"。比如创建巡检项目时,如果传入的 route_id 不存在或已停用,直接返回 422 并指出具体哪个字段有问题。
规则三:自引用树约束。 设备分类和备件分类都是 parent_id 自引用树。这个规则做了两件事:禁止将自身设为父节点,以及向上遍历祖先链检测循环引用。如果尝试构造 A → B → C → A 这样的死循环,系统会阻止并明确指出循环路径。
规则四:状态流转。 所有模块都有 status 字段(启用/停用)。当某个实体被停用后,其他模块不能再引用它。这保证了系统中的引用链都指向有效数据。
规则五:库存参数校验。 备件型号的 safety_stock_min 不能为负,且不能大于 safety_stock_max。这条规则虽然简单,但避免了配置错误导致后续的库存预警逻辑出问题。
3.3 依赖查询 API
新增了一个独立的 API 端点:GET /api/v1/dependents/{id}?entity_type=station
支持查询 9 种实体类型的依赖关系。前端在删除操作前会先调这个接口,如果返回的 total > 0,就弹出影响预览面板而不是直接删。
这个 API 的实现依赖一个静态映射表 DEPENDENCY_MAP,用 (dep_type, model, field) 三元组定义了所有实体的被引用关系。查的时候遍历映射表,对每个依赖者执行 SELECT COUNT(*) WHERE fk = entity_id。
四、前端:三层交互增强
4.1 删除影响预览
这是最直观的变化。之前点击删除按钮只有一个 confirm("确定删除?"),现在变成了一个信息丰富的模态面板:
⚠ 确认删除「石家庄站」
删除后将影响以下关联数据:
· grid 3 条
· location 5 条
· inspection_route 2 条
这些数据不会被删除,但将失去车站关联。
[取消] [仍然删除]
4.2 表单校验反馈
之前提交表单如果出错,toast 只显示"提交失败"。现在后端返回的结构化错误信息被前端解析,能够精确指出哪个字段有问题:
提交失败: 设备分类: 不能将自己设为父节点
提交失败: station_id: 车站已停用,无法引用
4.3 飞书机器人异步化
这是修了一个隐藏的 bug。飞书 webhook 要求在 3 秒内返回 200 响应,否则会重试(导致用户看到重复回复)。但之前的代码在 webhook 处理函数里同步等待 run_agent()——AI 推理可能要 2-5 秒——经常超时。
修复方案:用 FastAPI 的 BackgroundTasks 把 agent 处理逻辑丢到后台执行,webhook 立即返回 200。改动其实很小,就几行代码:
@router.post("/webhook")
async def feishu_webhook(request: Request, background_tasks: BackgroundTasks):
# ... 解析和去重 ...
background_tasks.add_task(_process_and_reply, parsed)
return JSONResponse({}, status_code=200)
五、地图可视化
5.1 技术选型
地图这块踩了不少坑。最开始用 OpenStreetMap 瓦片,结果在国内 tile.openstreetmap.org 直接被墙,返回 ERR_CONNECTION_RESET。换了 CartoDB 也是同样的问题。最终方案:
| 层 | 选型 | 原因 |
|---|---|---|
| 地图库 | Leaflet 1.9.4 | 免费开源,API 简洁 |
| CSS | 全部内联 | 避免 CDN 加载失败导致地图白屏 |
| JS CDN | jsdelivr.net | 国内访问最快的开源 CDN |
| 默认底图 | 高德地图瓦片 | 国内秒开,无需 API Key |
| 备用底图 | ESRI 街道图 | 全球通用,国内通常可达 |
| 坐标系统 | WGS84 ↔ GCJ-02 双向变换 | 数据库存 WGS-84,高德地图显示 GCJ-02 |
5.2 坐标纠偏
这是一个花了不少时间才搞明白的问题。数据库里存的站点坐标是 WGS-84 坐标系(GPS 原始坐标),但高德地图用的是 GCJ-02(火星坐标系),中国境内两者有 300-500 米的偏移。如果不做转换,地图上的标记会偏到隔壁街区去。
解决方案是实现双向坐标变换:加载数据时 WGS→GCJ 再放标记,地图点选新增时 GCJ→WGS 再存入数据库。变换算法是国测局公开的公式,包含在渲染逻辑中。
5.3 功能清单
地图页面现在支持的操作:
- 数据库中的站点自动标注在地图上(绿色=启用,灰色=停用)
- 点击标记弹出详情面板:健康状态指示灯 + 巡检/保养/检测线路统计 + 故障知识库条目数 + 联系人信息
- 侧栏站点列表,悬停显示编辑/删除按钮
- 点击侧栏站点 → 地图飞到该位置 + 16 级放大 + 标记金色高亮脉冲
- 搜索框输入地名 → Nominatim 全球地名查询 → 选结果在地图上标记 → 一键创建车站
- "新增车站"按钮 → 地图点击选经纬度 → 弹出表单填写信息 → 保存
- 右上角图层切换:高德地图 / ESRI 街道图
5.4 数据联动:站点健康状态
每次点击站点标记时,前端会调 GET /api/v1/station/{id}/detail,后端查询该站点所有关联数据并汇总:
{
"health": {"status": "healthy", "label": "运行良好", "color": "#3d6b4f"},
"counts": {
"inspection_routes": 2, "inspection_items": 6,
"maintenance_routes": 2, "maintenance_items": 4,
"testing_routes": 2, "testing_items": 3,
"grids": 3, "spare_parts_locations": 2,
"fault_knowledge_entries": 0
}
}
健康状态判定逻辑:
- 绿色"运行良好" — 状态=启用、已配置运维线路、故障知识库无关联条目
- 黄色"未配置" — 已启用但无任何运维线路
- 红色"需关注" — 故障知识库有相关条目
- 灰色"已停用" — status≠启用
5.5 遇到的坑
-
page_size=9999返回 422:API 限制了page_size最大 100,最初传了 9999 导致请求失败。改成 100 解决。 -
flyTo is not defined:JavaScript 的 IIFE 闭包问题。函数在(function(){ ... })()内部定义,HTML 的onclick访问不到。需要把函数挂到window对象上。而且必须在函数定义之后挂,否则赋值时函数还不存在。 -
叶子 CSS 加载失败导致地图白屏:把 Leaflet 的 CSS 全部内联到 HTML 中,完全不依赖 CSS CDN。
-
浏览器缓存旧版文件:即使服务器文件已更新,浏览器 304 仍返回旧版。需要强制刷新
Ctrl+Shift+R。
六、反思
这次升级让我体会到"增删改查"和"完整业务系统"之间的差距。CRUD 只是工具,真正的业务逻辑体现在数据之间的约束和关联上。
架构上收益最大的是把校验逻辑集中到 app/core/ 层。19 个模块共享同一套规则,改一处全部生效。如果当时在每个路由文件里分别写校验,光是代码量就要多出好几倍,而且大概率会有遗漏。
地图集成的经验是:在国内环境下做 WebGIS,瓦片源选择和坐标系统是最先要解决的问题。OpenStreetMap 瓦片被墙、CDN 不稳定、WGS-84 和 GCJ-02 的偏移——这些问题与其说是技术难题,不如说是环境适配。选高德作为默认瓦片、内联 CSS、用 jsdelivr 加载 Leaflet JS,这三个决策让地图从"永远加载不出来"变成了"秒开"。
前端虽然还是原生 HTML/CSS/JS 没有用框架,但通过合理的组件化思维(删除面板、详情弹窗、表单模态框各自独立),代码的可维护性并不差。对于课程项目这个规模来说,不引入额外构建工具链反而是正确的选择。
七、后续可以做的
- 批量导入导出 — 目前 API 已有导入预览端点,前端批量导入面板还待完善。
- 地图网格可视化 — 现在网格数据已有,但地图上只显示了站点。如果能把网格区域用多边形画出来会更直观。
- 故障工单联动 — 健康状态中的"故障知识条目数"目前只是引用计数,真正有意义的是关联到实际的故障报告和处理工单。
- 巡检任务生成 — 配置管理子系统提供了巡检线路和项目的模板,下一步可以基于这些模板自动生成巡检计划任务。
这个项目从只读分析文档到画出架构图、编写设计文档、制定实施计划、逐任务推进、修 bug、最后到一篇复盘博客——整个过程做下来最大的体会是:"先理清业务关系再写代码"听起来像是废话,但真的做起来才知道有多重要。 19 个模块的关系图画了之后,校验规则该写什么、数据创建该遵循什么顺序,都变得自然而然的清晰了。

浙公网安备 33010602011771号