1. 户外救援业务到底长什么样:先把需求盘清楚
很多人看到“户外救援管理系统”这个标题,第一反应是“这不就是个报平安App吗”,或者“把求救电话接到后台登记一下就行”。真做过这行才知道,救援管理和普通工单系统完全是两码事。救援场景里,信息是动态变化的——人可能在移动,队伍可能在途中,物资在消耗,道路在封闭,天气在恶化。系统要跟上的不是“一条记录”,而是一条完整的、有状态、有时间轴的事件链。
我参与设计的这个系统,最初的切入点非常朴素:一支民间救援队,日常有几十名骨干队员,周末经常出野外搜救任务。过去靠微信群接龙报名、Excel表格登记装备、对讲机口头汇报进度,一旦遇到同时段两起求助,信息立刻开始打架。什么时候派的谁、带了什么装备、到达现场没有、需要什么增援,全靠人的记忆去补,这在救援场景里是致命伤。
1.1 救援链条上的三类核心角色
我梳理业务时,没有急着画用例图,而是先跟着队员出了一次模拟任务,把整条链路走了一遍。最终发现系统里真正需要服务的是三类人,他们的诉求完全不一样:
- 指挥中心/值班员:要能快速登记求助信息、判断位置、匹配合适队伍、下发任务、跟踪任务状态。他们最怕的是信息缺失——不知道队员现在到哪了、任务卡在哪一步。
- 现场救援队/队员:要能接收任务、上报现场情况、申请增援、记录救援过程。他们在户外,网络不稳定,操作必须极简,最好两个拇指能完成。
- 后勤/物资管理员:要能清楚知道每件装备在谁手里、是否归还、是否需要维护。事后还要能导出统计报表,支撑队里的装备采购决策。
这三类角色对应到权限体系里,就是三个基础角色,后续还可以扩展“系统管理员”和“游客/求助者”端。游客端通过小程序或H5发起求助,权限最小,但信息录入的标准化程度要求最高。
1.2 从接警到归档:一次完整救援的生命周期
我把一次完整的救援任务拆成了七个阶段,后面的数据库设计和流程引擎配置,全部围绕这七个阶段展开:
- 接警登记:记录求助人信息、事发地点、人数、伤势情况、环境描述。这是整个系统里最不能出错的一环,因为后续所有调度决策都基于这些初始信息。
- 任务创建:值班员确认信息有效后,创建救援任务,生成任务编号。
- 队伍指派:根据事发地点、队伍位置、队员技能标签,推荐合适的队伍并下发通知。
- 出队响应:队员确认出队,记录出发时间、携带装备清单。
- 现场救援:现场持续上报进展,可能需要增援、物资补给或医疗支持。
- 任务完结:救援结束,确认人员安全,整理救援记录。
- 复盘归档:生成任务报告,归档所有过程数据,作为后续培训和流程优化的依据。
这七个阶段环环相扣,但现实中经常出现“跳阶段”的情况。比如接警时发现信息严重不全,需要先联系当地向导确认位置才能创建任务;或者任务还没正式完结,因为天气突变需要临时中止。所以流程引擎在设计时必须支持灵活跳转和挂起,不能做成僵硬的状态机。
1.3 独立系统的必要性:电话通知排班为什么撑不住
我最常被问到的问题是:“我们用企业微信拉个群,加个表格机器人,不也能搞定吗?”如果只是一个月一两起任务,确实能搞定。但救援队一旦过了“业余爱好者”阶段,开始规范化运营,问题就来了:
- 人员排班没法自动匹配。群里的接龙只能表达“我来了”,不能表达“我在哪个位置、我擅长什么、我带了什么装备”。指挥员需要的是结构化的技能和物资标签。
- 过程记录是断裂的。群里几十条语音、图片、定位消息混在一起,事后复盘根本拼不出完整时间线。
- 物资追溯是空白。谁领走了那台红外热成像仪?什么时候还的?电充了没有?没有系统,这些就只能靠记忆。
- 数据无法驱动决策。哪个区域事故高发、哪个时段人手紧张、哪类装备损耗最快,没有长期数据积累,一切都凭感觉。
所以这个项目的核心价值,不在于“做了一个管理后台”,而在于把救援业务中那些模糊的、靠人肉维护的信息,转变成结构化的、可流转的、可追溯的数据资产。这也是我后来在代码之外,花最多时间跟业务方“对齐概念”的原因。
2. 技术选型不是堆框架:为什么是SpringBoot加Flowable
技术选型这块,我见过太多团队犯一个毛病——先定技术栈,再想业务怎么往上套。我的习惯反过来:先把业务里最难的技术痛点列出来,再看哪些框架能解决。
这个项目里有三个绕不开的痛点:
- 流程多变:救援任务的阶段流转、审批逻辑、任务分配规则,几乎每半年就会调整一次。用硬编码的if/else写状态流转,改一次要动一次代码,发布一次版本,代价太高。
- 异构端接入:队员用App,指挥中心用Web后台,求助者用小程序,未来还可能要对接政府应急平台。服务端必须提供干净的REST API。
- 快速迭代:项目从立项到上线只有四个月,开发团队不大,必须选全家桶式、社区成熟、招人容易的技术。
2.1 SpringBoot在这里解决了什么问题
很多人对SpringBoot的理解停留在“简化配置”上,其实它对这个项目最大的贡献是约定优于配置带来的快速启动能力。四个月的周期,如果从零搭建SSM框架,光各种XML配置、jar包版本兼容就能耗掉两三个星期。SpringBoot的自动配置让团队直接从业务代码写起。
另外,SpringBoot的生态太成熟了。做权限有Spring Security,做接口文档有SpringDoc,做持久层有MyBatis-Plus,做缓存有Redis,做定时任务有@Scheduled。任何一块遇到问题,Stack Overflow上都能找到答案。对于救援管理系统这种“业务逻辑复杂但技术难点不极端”的项目,成熟生态的隐形价值比炫技框架大得多。
整个项目我采用的是标准的前后端分离架构:
- 后端:Spring Boot 2.7.x + MyBatis-Plus + Flowable 6.x + Spring Security + JWT + Redis
- 前端:Vue 3 + Element Plus(管理后台)、UniApp(队员端,可打包成App和小程序)
- 数据库:MySQL 8.0,地理数据用PostGIS(后面详细说)
- 部署:Docker Compose + Nginx,服务器跑在云上
2.2 流程引擎的引入时机
流程引擎是这个项目里争议最大的选型。有人觉得,就一个救援任务流转,自己写个状态机也就一百多行代码,何必引入Flowable这种重框架。
我的判断逻辑是:看流程的变更频率和组织规模。如果系统只服务一支队伍,流程固定且简单,状态机完全够用。但我们这个系统的规划里至少有三类流程:救援任务流程、装备报修/采购审批流程、队员出勤审批流程。而且组织在成长,流程必然要调整。用Flowable,流程调整通过流程定义文件完成,不需要改Java代码、不需要重新发布版本,这个价值在后续维护期会越来越明显。
Flowable确实有学习成本,而且它自带的引擎逻辑对一些简单查询来说显得笨重。但综合考虑流程资产的可维护性和团队后续的扩展需求,这个投入是值得的。
2.3 几个反常规的选型决定
有两个选型我想单独说一下,因为它们是“逆向思维”的结果。
第一,为什么不用微服务。很多SpringBoot项目一上来就拆微服务,每个模块一个应用,好像不拆就不专业。这个项目我坚持单应用多模块的架构。原因很简单:核心团队只有三到五个人,微服务带来的运维成本和分布式事务复杂度,完全超出了这个项目的收益。单应用部署简单,出了问题排查也快。用户量短期内根本不会成为性能瓶颈,等真的到了那一天,再按业务边界拆也来得及。
第二,为什么用PostGIS而不是MySQL存储地理坐标。救援系统里,地理位置是最核心的数据之一。我们需要计算救援队到事发点的距离、判断一个点是否在某个区域内、查找附近的救援资源。MySQL的geometry类型能做基础存储,但复杂的地理计算能力较弱。PostGIS把整个PostgreSQL变成了一个完整的地理信息计算引擎,ST_DistanceSphere、ST_Within这些函数用起来非常顺手。所以我把位置数据统一放在Postgres里,业务数据用MySQL,通过应用层做跨库查询,两边的优势都拿到了。
3. 数据库建模:把救援现场搬到表结构里
数据建模是我在整个项目里花时间最多的部分,也是我觉得最需要跟业务方反复确认的部分。救援业务的数据特征非常鲜明:事件多、状态多、时间线要求完整、位置信息敏感。建模如果按照普通CRUD的思路来,后面一定会出事。
3.1 主线表:救援任务表
救援任务表是整个系统的逻辑中心,我的核心设计思路是:一张主表承接任务静态信息,多张子表承接动态过程信息,而不是把所有字段塞进一张表。
CREATE TABLE rescue_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_no VARCHAR(32) NOT NULL COMMENT '任务编号,如RS202501001', title VARCHAR(200) NOT NULL COMMENT '任务标题', category TINYINT NOT NULL COMMENT '任务类型:1-山地搜索 2-水域救援 3-城市寻人 4-自然灾害', level TINYINT NOT NULL COMMENT '紧急程度:1-一般 2-紧急 3-特急', status VARCHAR(20) NOT NULL COMMENT '当前状态:CREATED/DISPATCHED/ONGOING/COMPLETED/ARCHIVED', reporter_name VARCHAR(50) COMMENT '求助人姓名', reporter_phone VARCHAR(20) COMMENT '求助人电话', incident_time DATETIME COMMENT '事发时间', location_point GEOGRAPHY(POINT) COMMENT '事发地坐标', location_desc VARCHAR(500) COMMENT '位置描述', weather TEXT COMMENT '现场天气及环境情况', victim_count INT DEFAULT 1 COMMENT '被困/受伤人数', victim_detail TEXT COMMENT '人员情况描述', command_center_id BIGINT COMMENT '主责指挥中心ID', dispatcher_id BIGINT COMMENT '派单人ID', dispatcher_time DATETIME COMMENT '派单时间', accept_team_id BIGINT COMMENT '承办队伍ID', accept_time DATETIME COMMENT '队伍接单时间', start_time DATETIME COMMENT '实际出发时间', end_time DATETIME COMMENT '实际结束时间', result_detail TEXT COMMENT '救援结果概述', safety_confirmed TINYINT DEFAULT 0 COMMENT '是否确认人员安全', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT DEFAULT 0, KEY idx_task_status (status), KEY idx_task_incident_time (incident_time) );这张表的字段看起来很多,但有一条红线:只存“当前快照”信息,不存“历史轨迹”。比如救援队到了现场之后,位置可能每二十分钟变一次,这些不能直接更新location_point,而是要写到独立的轨迹表里。任务表里的location_point只代表事发点的坐标,一旦创建就不允许修改(除非业务方确认当时记录错误)。
3.2 支撑表:队伍、队员与物资
救援队本身的信息结构其实很像一个“有技能标签的资源池”。我在建表和设计接口时重点考虑了两类查询:根据位置找最近的队伍、根据技能找合适的人。
队伍表和队员表的关键设计:
- 队伍表(rescue_team):字段比较简单,重点是team_name、leader_id、base_location(驻点坐标)、member_count、status(待命/出勤/休整)。队伍状态是要实时更新的,出队后自动扣减可调度队员数。
- 队员表(rescue_member):除了基础信息,核心字段是skill_tags(JSON数组,如["绳索","水域","急救","无人机"])、current_location、on_duty(是否在值排)、last_task_time。队员和队伍是多对一关系,一个队员同时只属于一个队。
物资表这里有一个设计亮点,就是把“物资”和“库存流水”分开:
CREATE TABLE equipment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, category VARCHAR(50) COMMENT '分类:通讯/医疗/攀爬/水域/照明', total_count INT NOT NULL DEFAULT 0, available_count INT NOT NULL DEFAULT 0, need_maintenance TINYINT DEFAULT 0, last_maintain_time DATETIME ); CREATE TABLE equipment_stock_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, equipment_id BIGINT NOT NULL, task_id BIGINT COMMENT '关联任务', member_id BIGINT COMMENT '经手队员', flow_type TINYINT COMMENT '1-出库 2-归还 3-报废 4-盘点调整', quantity INT NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );所有库存变化只通过流水表记录,禁止直接update available_count的接口。这样做的原因很直接:救援装备是高价值、安全关键资产,一旦出现“东西找不到了”的争议,流水就是唯一的追溯依据。
3.3 关键状态字段的枚举约束:宁可建表别用魔法值
项目里最容易出现的毛病,是把状态值直接写成字符串散落在代码里:“CREATED”、“已创建”、“1”……各种表达混着来。我的团队约定:所有状态、类型字段必须用统一枚举类管理,数据库层也建立注释说明。
比如任务状态,我在Java里定义了一个枚举:
public enum TaskStatus { CREATED("CREATED", "待派单"), DISPATCHED("DISPATCHED", "已派单"), ACCEPTED("ACCEPTED", "已接单"), ONGOING("ONGOING", "行进中"), ON_SCENE("ON_SCENE", "现场救援中"), SUSPENDED("SUSPENDED", "已挂起"), COMPLETED("COMPLETED", "已完成"), ARCHIVED("ARCHIVED", "已归档"); }这里要特别强调:状态枚举的顺序不能乱调整,新增状态时必须评估对已有流程和历史数据的影响。有一次我们把“已挂起”状态加在“行进中”和“现场救援中”之间,结果Flowable里定义的状态顺序跟页面展示顺序不一致,排查了半天才发现是枚举顺序导致的格式化问题。从此以后,枚举的声明顺序和数据库字典表里的排序字段,我都要求完全对应。
4. 核心功能落地:任务派发、状态流转与物资追踪
数据模型定下来之后,核心功能的实现就有了一条清晰的路径。我挑三个我认为最有代表性的功能做拆解,它们分别对应了“业务逻辑”“流程引擎”和“数据一致性”三个层次的实现要点。
4.1 任务派发接口的实现逻辑:不能只做CRUD
任务派发是值班员每天用得最多的操作。一开始我的想法很简单:接收任务ID和队伍ID,更新任务表,完事。但跟业务方聊过之后发现,派发背后有好几条隐藏规则:
- 队伍必须处于“待命”状态,在勤的队员数要大于任务最低人数要求。
- 队伍驻点与事发地的距离要在可响应范围内(通常是山区50公里,城区20公里)。
- 任务类型和队伍技能要匹配,水域救援不能派一支只会登山探洞的队伍。
- 同一时段内一个队伍只能接一个特急任务。
这些规则如果全部散落在Service层的if/else里,后续会非常难维护。我采用了策略模式 + 校验链的方式来组织这段逻辑:
@Component public class DispatchValidatorChain { private List<Validator> validators; public DispatchValidatorChain(List<Validator> validators) { this.validators = validators; } public DispatchValidResult validate(DispatchRequest request) { for (Validator validator : validators) { DispatchValidResult result = validator.validate(request); if (!result.isValid()) { return result; } } return DispatchValidResult.ok(); } }每个校验规则单独一个类,比如TeamStatusValidator检查队伍状态,SkillMatchValidator检查技能匹配,DistanceValidator检查距离范围。新增规则时只需要加一个Validator实现类,不用改调用的代码。
派发完成之后,系统要做三件联动的事:更新任务状态为DISPATCHED、更新队伍状态为“出勤中”、向队长推送一条包含任务位置和人员要求的通知。这个联动操作我放在同一个事务里,用@Transactional保证原子性。这里有一个小坑:推送通知是外部操作,不能放在事务里。如果网络上其他服务调用导致异常,会把整个派发事务回滚掉。我的做法是用Spring的事件发布机制,事务提交之后再异步发送通知。
4.2 基于Flowable的流程定义与接入
Flowable的引入主要解决了任务状态流转和审批流程的问题。核心思路是:把任务状态当成流程节点,把状态变化当成流程推进,把业务数据和流程实例ID关联起来。
我在Flowable中定义了一个叫rescueTaskProcess的流程,bpmn文件简化后的结构:
<process id="rescueTaskProcess" name="救援任务流程"> <startEvent id="startEvent"/> <userTask id="dispatchTask" name="派单" flowable:assignee="${dispatcher}"/> <userTask id="acceptTask" name="接单" flowable:assignee="${teamLeader}"/> <userTask id="executeTask" name="执行救援" flowable:assignee="${teamLeader}"/> <endEvent id="endEvent"/> </process>这段BPMN文件可以放在classpath下,也可以通过Flowable Modeler在线编辑。维护阶段如果流程变化,只需要替换流程文件并调用repositoryService重新部署,Java代码完全不用改。这就是流程引擎的核心价值。
跟业务代码的集成,我封装了一个TaskFlowService:
@Service public class TaskFlowService { @Autowired private RuntimeService runtimeService; @Autowired private TaskService taskService; public void startFlow(Long taskId, Long dispatcherId) { Map<String, Object> vars = new HashMap<>(); vars.put("dispatcher", dispatcherId); vars.put("taskId", taskId); ProcessInstance pi = runtimeService.startProcessInstanceByKey( "rescueTaskProcess", taskId.toString(), vars); // 在业务表中记录流程实例ID rescueTaskMapper.updateProcessId(taskId, pi.getId()); } }这里有几个很实用的技巧:
- 业务表里一定要存流程实例ID。否则排查问题时要通过businessKey来回查,非常麻烦。
- 不要把整个业务实体放进流程变量。Flowable的流程变量会序列化存储,塞一个大的Java对象进去,历史表会迅速膨胀,而且跨版本部署时序列化容易出问题。只存必要的ID和状态值。
- 接单节点的处理人不能写死。流程中assignee用表达式,关键变量在运行时注入,这样同一套流程定义可以适配不同队伍的不同规模。
实际开发过程中,Flowable官方自带的listener机制和Spring Boot的集成做得还比较顺。Spring Boot 2.7 + Flowable 6.x的版本组合,我们在生产环境跑了大半年没有遇到严重的框架级Bug,稳定性是够用的。
4.3 物资出入库的库存流水设计
物资模块看起来像简单的库存管理,但在救援场景里有一个特殊要求:出库操作必须和任务绑定。也就是说,任何一次物资出库都要有任务号,事后要能回答“这批物资是什么时候、为哪个任务、由谁领走的”。
我利用MySQL的乐观锁解决并发扣库存的问题:
@Update("UPDATE equipment SET available_count = available_count - #{quantity}, " + "update_time = NOW() WHERE id = #{equipmentId} AND available_count >= #{quantity}") int deductStock(@Param("equipmentId") Long equipmentId, @Param("quantity") Integer quantity);注意这个SQL里的条件available_count >= #{quantity},它保证了一次只能成功扣减一次,不可能出现超扣。如果返回值是0,说明库存不足或物资不存在,上层需要抛异常并回滚事务。
记录流水时,我故意没有用数据库自增ID做唯一约束,而是要求同一任务同一物资只能有一条出库流水,用联合唯一索引来控制。这个设计在“队员误操作重复扫码出库”时能挡住大部分重复提交。
另外还做有一个很细节的功能:物资归还时,如果缺了东西或者损坏了,系统不会直接改库存,而是先走一条“待确认”的状态,等待管理员审核后再入账。这个流程开始时被认为“太麻烦”,但上线后第一次团建拉练就有队员反馈“多了一件没登记的绳子”,这个机制直接证明了价值。
5. 权限体系与多端协作:救援队、指挥中心和数据管理员如何各司其职
救援系统的权限设计有一个天然的矛盾:既要保证信息实时共享(让现场队员尽快拿到任务详情),又要保证操作权限边界清晰(不是谁都能改任务状态、不是谁都能看所有队员的隐私信息)。
5.1 基于Spring Security的RBAC实现:JWT无状态认证
这个项目没有采用传统的Session方案,而是用了JWT无状态认证。原因很简单:队员端是App,可能要在弱网环境下反复请求,Session的会话保持在这种场景下有诸多不便。JWT方案下,认证信息全部通过Token头传递,服务端不需要维护会话状态,天然适合移动端。
实现上主要分三层:
- Token生成与刷新:用户登录成功后,服务端生成AccessToken(有效期2小时)和RefreshToken(有效期7天)。AccessToken放到Authorization头里,所有需要认证的接口用
@PreAuthorize注解控制。 - 用户身份解析:自定义一个
JwtAuthenticationFilter,从请求头解析Token,加载用户的角色和权限信息,写入SecurityContext。 - 角色权限映射:在数据库中定义
role、permission、role_permission三张表,权限最小粒度到接口URL和方法。比如POST /api/task要求task:dispatch权限,这个权限默认只分配给“指挥员”角色。
这里有一个我之前踩过的坑:JWT里不要放太多自定义信息。一开始我想方便前端展示,把用户昵称、队伍ID、角色列表全塞进了Token里,结果Token越来越长,每次请求都要多传几个KB的头部数据,在弱网环境里直接拖慢了响应。后来只保留userId和随机jti(JWT ID),其他信息每次请求时从Redis缓存里获取,Token体积缩小了一半多。
5.2 接口级权限与数据隔离
救援系统特有的一点是“同角色不同数据范围”:两个队伍的队长都能看自己的队员列表,但不能看对方的。两个指挥中心的值班员都能看任务,但只能看到自己辖区内创建的。
这个用Spring Security自带的@PreAuthorize不太够用,因为它只能做到基于URL或方法的粗粒度控制。我采用的是三层数据隔离方案:
- 第一层:通过URL路径区分资源范围,比如
/api/team/{teamId}/members,从URL本身就能限定队伍维度。 - 第二层:Service层officer判断当前登录用户是否属于该队伍,属于才放行。
- 第三层:MyBatis-Plus的DataPermissionInterceptor做查询级别的数据自动过滤。比如查询任务列表时,如果是队长的角色,系统自动追加
AND accept_team_id = 当前用户队伍ID。
第三层这一招是网上很少有完整资料的一个点,但实际项目里非常实用。一开始我偷懒,想靠前端传team_id参数过滤,后来发现空子太多,队员改一下请求参数就能看到别的队的数据。加上DataPermissionInterceptor之后,权限过滤下沉到持久层,谁来了都过滤,彻底堵住了这个漏洞。
5.3 多端协作的接口设计原则
这个系统的前端至少有三个端,接口设计上就要考虑各自的特性:
- 指挥中心Web端:大屏优先,需要一次拿到完整的数据列表和任务详情,数据量可以大,但响应要快。
- 队员App端:弱网优先,接口必须短小精悍,一次请求只拿到当前界面需要的数据,支持失败重试。
- 管理后台:权限要求高,接口要支持批量操作和导出。
为此我定的规范是:查询接口不提供万能大而全的DTO,每个前端场景用专门的VO类。比如任务列表页只需要知道“任务编号、标题、等级、状态、事发地描述、负责人电话”,那我就会建一个TaskListVO,只包含这些字段。很多团队把实体类直接丢给前端,省事是省事,但网络传输体积、接口文档清晰度、后端灵活度全都受影响。
6. 落地排坑:高并发接警、地理坐标精度与流程引擎的坑
前面讲的都是“怎么做”,这一节我想专门分享那些真正让我加班到深夜的问题。这些坑不是看文档能避开的,必须是真刀真枪跑过线上才能积累下来的体感。
6.1 突发灾情下的接口性能瓶颈
有一场暴雨引发周边山区多处被困,指挥中心在二十分钟内接到了四十多通求助电话,同时有四个任务在流转。后台的响应时间从正常的200毫秒飙升到了2秒多,有几个队员端上报位置信息的请求直接超时了。
排查下来发现瓶颈不在数据库,而在同步调用链:任务创建成功后,系统要同步插入多张关联表、发通知、记录日志、更新缓存,而且这些都挤在同一个事务里。负载一上来,数据库连接池被打满了。
这个场景的解决办法是写操作异步化改造。我引入了一个简单的内存队列(生产环境有条件的可以用RabbitMQ),把“任务创建成功之后”的非关键操作(发短信通知、推送App消息、记录操作日志)改成异步执行。核心事务里只保留必要的数据变更。改造之后,同样条件下P95延迟从1.8秒降到了450毫秒。
这里有一个经验要分享:异步化不是所有操作的万能药。任务创建本身是绝对不能被异步的,因为后续所有流程都依赖任务主键,一旦任务创建失败而通知却发出去了,业务上会闹出大事。所以我的原则是:核心链路必须同步且强一致,非核心链路才允许异步化,并且要配套失败补偿和重试机制。
6.2 坐标数据精度丢失与坐标系混乱
位置数据的准确性是这个项目最容易出问题但也最容易被忽视的点。我遇到过两个具体问题:
第一个是数据库存储精度问题。前端传来的经纬度是小数格式(比如39.904200, 116.407400),如果用MyBatis-Plus自动映射到Java的Double字段,再写入MySQL的Double列,看上去一切正常。但有一次测试人员发现,Web端显示的坐标和App上报的坐标在小数点后第六位开始不一致,导致地图上标注位置偏差了几十米。原因就是Double类型在数据库和Java之间转换时存在精度截断。
解决方案很干脆:坐标统一用PostGIS的GEOGRAPHY类型存储,Java实体用String接收前端传参,入库前用ST_GeomFromText精确转换,查询时再通过ST_AsText输出。这样全程只做字符串传递和数据库计算,不经过精度损失的双精度转换。
第二个是坐标系不一致问题。国内地图厂商用的是GCJ-02坐标系(俗称火星坐标),GPS标准定位用的是WGS-84,两个坐标系之间有一段偏移。如果App直接把GPS原始坐标上传到后端,后端再交给高德或百度地图API去渲染,位置会偏移几百米,在山里可能就是“差一个山头”的区别。
这块的处理经验是:前端负责坐标转换,后端统一接收WGS-84标准坐标。前端拿到GPS原始坐标后,判断当前位置是否在中国大陆范围,如果是就转换成GCJ-02后再显示地图,但上报给后端时统一转回WGS-84。后端所有坐标计算(距离、范围判断)和第三方系统对接,都基于WGS-84,避免多个坐标系在服务端并存导致混乱。
6.3 Flowable历史表膨胀与清理策略
Flowable跑起来之后,最直观的“惊喜”是数据库体积膨胀速度远超预期。每启动一个流程实例,都会在ACT_RU_(运行态)和ACT_HI_(历史态)里插入几十条记录。救援任务高峰期一天二三十个流程实例,每天新增的记录数上千条。运行两个月后,ACT_HI_VARINST表接近三百万行,查询任务历史变得慢吞吞的。
Flowable官方有一份《Advanced Integration Guide》,里面有专门的“History Clean”章节,但很多人不会去细读。我的处理方案是两段式:
- 在线保留期:流程结束后,历史数据保留90天,可用于复盘和审计。
- 清理策略:超过90天的历史数据,通过定时任务分批清理。注意不能一次性delete所有数据,必须按流程实例ID分批,每批500条,循环删除,避免大事务锁表和主从延迟。
- 归档表:真正需要长期留存的业务数据(任务编号、救援结果),在流程结束时就同步写进自己的业务归档表,不依赖Flowable的历史表做长期存储。
这一套组合拳下来,Flowable相关表的总大小控制在合理范围内,查询和清理都没有再出现性能恶化。
6.4 弱网环境下的数据提交冲突
救援现场的网络条件大家都能想象,信号一格、两格是常事。队员在山上提交现场报告时,App可能因为网络波动反复重试,导致后端收到重复数据。这个问题主要体现在救援进度上报和物资扫码出库两个场景。
处理办法有两个层面:
- 客户端幂等:每次提交请求携带一个客户端生成的UUID作为请求唯一ID,后端通过Redis对相同UUID做去重,10秒内重复的请求直接返回第一次的执行结果。
- 服务端兜底:对关键业务表加联合唯一索引,比如救援进度表(task_id + progress_seq)唯一,确保同一个任务的同一序号只能有一条记录。
这两个机制配合起来,实测在弱网环境下重复提交造成的脏数据基本消除了。也推荐从这个角度看:手游厂商做断线重连的思路,跟救援上报系统是一致的,代码逻辑完全可以参考做得好的开源项目里的优化手段。
7. 验收上线前后的那些事:模拟演练、数据迁移与运维保障
系统从开发到上线,最后这几步如果走得不稳,功能代码写得再好也是白搭。我在这里想分享三个容易被忽略、但实战中特别重要的环节。
7.1 组织一次“假救援”全流程演练
我强烈建议,在上线前安排一次完整的仿真演习,让指挥员、队员、物资管理员坐在一起,按照真实的救援场景走一遍系统流程。我们当时设计了一个模拟走失案例:
- 求助热线接入,值班员在Web后台登记求助信息。
- 系统根据事发地坐标和队伍驻点,推荐了最近的一支队伍。
- 队长在队员端App收到通知,确认出队,勾选携带装备。
- 装备管理员在后台看到出队清单,确认出库。
- 现场队员每隔半小时上报一次坐标和状态。
- 任务结束后,队长填写救援记录,物资归还入库。
- 指挥中心在复盘页面查看完整时间线和物资流转记录。
听起来挺简单的流程,演练时还是暴露了至少五个问题:值班员忘记上传现场照片、队员端上报按钮在弱网下一直转圈、物资归还后状态没有自动刷新、复盘页面无法按时间段筛选任务、通知短信的落款还是测试环境的名字。
这些问题如果不上线前发现,实战中任何一个都可能造成糟糕影响。演练的过程本身就是培训的过程,队员们在演练里学会了怎么用App,指挥员学会了怎么派单,比任何文档培训都管用。
7.2 从Excel表格到系统的数据迁移
救援队过去几年的历史任务、队员档案、装备台账大多躺在Excel表格里。数据迁移看着简单,实际上坑很多。
我整理历史数据时踩过一个坑:源数据根本没有统一的主键。Excel里每行唯一的标识是“任务编号+日期”的组合,但在实际记录里有重复、有空值、有错别字。比如同一个队员的姓名,一次写成“张伟”,另一次写成“张伟(2队)”,还有一次直接写“小伟”。清洗这种数据费了整整两天时间。
我的经验是:迁移之前,先做数据探察。写几个SQL或Python脚本,统计每一列的非空率、去重率、取值分布,把异常数据先列出来,再决定是人工修正还是丢弃。对于那些实在无法确定的信息,宁可置空也不要凭空编造,然后在系统里标记“待补充”状态。
迁移完成之后,必须做一次“前后对比校验”:抽取10%的历史记录,人工核对源Excel和系统里的内容是否完全一致。我当时靠这个校验发现了迁移脚本里一个时间字段时区转换的Bug,那些凌晨的救援记录如果没发现,时间全部错乱,后续复盘统计就全不准了。
7.3 运维层面的三件套:备份、监控、应急预案
运维做得怎么样,平时看不出差别,一旦出问题就是天壤之别。这个项目我坚持做了三件事:
- 数据库每日备份:mysqldump全量备份加binlog增量,备份文件保留30天。
- 核心接口监控:用一个轻量的监控组件记录每个API的调用量、响应时间和错误率,超过阈值自动告警到值班群。救援系统最怕“一切正常但没人看”,监控告警虽然简单,但确确实实救过两次场。
- 手动容灾预案:写了一份“系统不可用时怎么办”的文档,核心内容就一条——系统可以挂,但救援不能停。预案里明确了系统故障时通过应急对讲机、微信群手动接单的替代流程。系统是工具,业务连续性才是根本。
团队的共识就是:像救援系统这样的业务型系统,上线不是终点,运维才是真正让业务稳定运行的长期保障。
最后,还是想再分享一个个人心得:做技术方案时,真正消耗精力的不是编码,而是业务建模和沟通规约。跟救援队磨合需求的那段时间,我学到最多的是如何把模糊的业务描述(“要让队长能看队员在哪里”)翻译成明确的技术语言(队员端每30秒上报一次位置,上报接口支持离线缓存重传,队长端地图渲染实时轨迹)。这一层翻译做好了,后面的技术实现基本上就是水到渠成的事。