1. 先把业务想清楚:消防政务服务平台系统app到底给谁用
“消防政务服务平台系统app”这个名字一看就很正式,很多人第一次拿到这个题目会直接打开 IDE 开始建表、写 Controller,最后做出来一个四不像的东西。我带过几届毕业设计,也帮朋友看过类似的项目源码,踩过最多的坑不是技术难题,而是业务主线没理顺。所以在写第一行代码之前,先花半小时把角色和场景想清楚,后面能省掉大量返工。
从业务本质来看,这类 app 解决的核心痛点是“信息不对称”和“流程不透明”:普通用户想上报一处楼道里堆放的杂物、灭火器过期这类隐患,过去要么打电话说不清楚位置,要么上报之后完全不知道有没有人处理;经营场所负责人想查询自己办理的消防业务进度,只能反复跑窗口或者打电话问;基层管理人员手里一堆纸质台账和表格,汇总一次数据要熬半宿。这套系统把这三件事搬到线上,本质上是一条上报链路、一条办理链路、一份数据看板。
适合谁参考这批源码和开题?我把它分成三类人:一类是正在做课程设计或毕业设计的学生,需要一套结构完整、能跑起来、能讲清楚的系统;二类是想快速上手一套政务类业务系统通用骨架的初级开发者,这套东西的角色权限、工单流转、附件上传、统计报表几乎可以原样迁移到很多同类场景;三类是做小型信息化项目的独立开发者,拿来改改就能交付。下面我按“业务拆解—架构选型—数据模型—代码落地—部署联调—开题写作—排错”的顺序完整讲一遍,中间会把我在实际项目里踩过的坑和参数选择过程都写出来。
1.1 三个角色,三条互不重叠的主线
任何一个政务类 app,第一件事都是画角色。这个系统我一般拆成三端:普通用户端、基层处理人员端、后台管理端。注意这里说的是“端”不是“app”,因为很多时候普通用户和管理人员用的其实是同一个安装包,只是登录后菜单不同,靠角色字段控制,这样开发和分发都省事。
普通用户端的主线是“报”和“查”。报,就是隐患上报,填位置、拍照、描述、提交,然后能看到自己提交的那条记录当前是什么状态:待受理、处理中、已办结、已退回。查,就是消防知识科普和业务办理指南的浏览,再加上业务申报的进度查询。这条主线的技术难度不高,但对交互细节要求很细:定位必须准、图片必须能压缩后上传、状态必须有明确文案而不是一串数字。
基层处理人员端的主线是“接”和“办”。接到辖区内的工单,能看详情、能导航到现场、能上传处理后的照片、能填写处理结果并提交。这里有一个非常关键的设计点:工单必须有归属。很多同学做出来的系统,工单是全局列表,任何一个人登录都能看到全部,这在真实业务里是不可接受的。正确做法是给处理人员绑定辖区字段,查询时按辖区过滤,超时未处理的工单再往上流转。
后台管理端的主线是“配”和“看”。配是配置用户、角色、辖区、知识文章、通知公告;看是看统计,比如本月上报量、办结率、按类型的分布、按辖区的热力分布。这块往往是整个项目里最容易被做浅的部分,只放了两个数字就交差。实际上统计是这类系统最有说服力的亮点,后面我会专门讲怎么用一条 SQL 把多维统计做出来。
1.2 功能清单怎么精简:MVP 的取舍逻辑
拿到题目最容易犯的错是功能列了三十条,最后一条都没做透。我的建议是按“有没有闭环”来筛:上报要有回执,办理要有结果,数据要有汇总,这三条闭环是必须完成的;剩下的属于加分项。
必须做的我用一个表列清楚,方便你直接对照自己的项目进度:
| 优先级 | 模块 | 判断标准 | 大致工作量 |
|---|---|---|---|
| P0 | 注册登录与角色区分 | 三种角色能登录且看到不同菜单 | 1 天 |
| P0 | 隐患上报与图片上传 | 提交后后台能查到,图片能打开 | 2 天 |
| P0 | 工单列表与状态流转 | 状态能从待受理走到已办结 | 2 天 |
| P0 | 后台用户与辖区管理 | 能新增处理人员并绑定辖区 | 1 天 |
| P1 | 知识宣传与公告 | 列表加详情,富文本或纯文本均可 | 1 天 |
| P1 | 统计看板 | 至少三个维度,图表能出数据 | 2 天 |
| P2 | 消息通知 | 站内消息即可,不必强行接推送 | 1 天 |
| P2 | 业务申报进度查询 | 单号加手机号查询 | 1 天 |
这张表的意义在于:P0 做完项目就能答辩,P1 做完项目才算好看,P2 属于有余力再补。我见过太多同学在消息推送这类功能上卡了四五天,结果核心的工单流转还是半成品,得不偿失。这里给一个明确的判断依据:如果某个功能砍掉之后,你的演示流程还能从登录一路走到出统计图,那它就可以砍。
1.3 一个容易被忽略的设计:状态文案与业务动作要分离
新手写状态流转,最常见的写法是在数据库里存 0、1、2、3,然后前端用if判断显示什么文字,按钮能不能点。这种写法在功能少的时候没问题,一旦状态增加到五六个,前端会变成一坨很难维护的分支代码,而且前后端对状态含义的理解经常对不上。
我的做法是把“状态”和“动作”都做成枚举,后端统一维护一张状态流转表,接口返回给前端的时候顺带返回当前状态下允许执行的动作列表。前端只负责把动作渲染成按钮,点哪个就调哪个接口,不关心状态数字。这样做的好处是:以后加一个“退回补充材料”的状态,只需要改后端配置和枚举,前端一行不用动。这个思路在本项目里特别值得用,因为隐患工单的流转天然就是多状态的。
2. 技术选型:这套组合为什么最适合此类项目
2.1 后端选 Spring Boot 单体,别碰微服务
每次看到有同学在这个体量的项目里上 Spring Cloud、拆七八个服务、还要配注册中心,我都替他捏把汗。这套系统真实的并发量是多少?一个区县级别的应用,日活可能就几百,峰值 QPS 不会超过两位数。这种量级下,单体应用是最优解,没有之一。
我推荐的具体组合是:Spring Boot 2.7 或 3.x + MyBatis-Plus + MySQL 8 + Redis(可选)+ JWT。选 MyBatis-Plus 的理由很实在,代码生成器能一键生成 Entity、Mapper、Service、Controller,字段驼峰转换、分页插件、逻辑删除都是现成的,对于这种以增删改查为主的业务系统,能省掉至少三分之一的工作量。JPA 也能用,但涉及到多表关联统计的时候,手写 SQL 更直观。
提示:如果用 Spring Boot 3.x,注意 JDK 最低要求是 17,很多学校机房或者同学自己的电脑还装着 JDK 8。动手之前先确认环境,不然一堆
Unsupported class file major version的报错能耗掉你半天。
版本上我给一个实测比较稳的搭配:JDK 17 + Spring Boot 3.1.x + MyBatis-Plus 3.5.5 + MySQL 8.0.33。如果电脑配置老、只能用 JDK 8,那就退到 Spring Boot 2.7.18 加 MyBatis-Plus 3.5.3,功能上完全够用,别纠结新特性。
为什么不上微服务,我把理由说透一点:微服务带来的收益是团队并行开发和独立伸缩,代价是分布式事务、链路追踪、配置中心、服务治理一整套基础设施。单人项目里,这些收益为零,代价却是实打实的。而且开题报告里如果写微服务,答辩老师大概率会追问“你为什么要拆”“拆的依据是什么”,答不上来反而扣分。老老实实做单体分层,把 Controller、Service、Mapper 的职责边界划清楚,比强行上架构更能体现工程能力。
2.2 移动端三选一:原生、uni-app、小程序
这是这套项目里最关键的决策,因为它直接影响你后续所有页面代码的写法,中途换方案等于重做。
三个方案我都实际做过,给你一个对照:
| 方案 | 优势 | 劣势 | 适合场景 |
|---|---|---|---|
| Android 原生 | 定位、拍照、文件权限控制最灵活;答辩时“原生开发”听起来扎实 | 只能安卓;页面开发慢;需要懂 Java 或 Kotlin | 明确要求安卓、需要演示调用摄像头和定位 |
| uni-app | 一套代码出安卓和小程序;组件生态成熟;上手快 | 打包偶尔玄学;深度原生能力要写插件 | 想同时有 app 和小程序、时间紧 |
| 微信小程序 | 免安装、演示方便、扫码即用 | 需要备案和类目资质,个人开发者很多类目做不了 | 只是课程演示,且已有可用的小程序账号 |
如果这个项目是要交付一套完整源码加一个能装到手机上的 app,我建议uni-app + vue2/vue3。理由是页面开发速度快,uni.chooseImage、uni.getLocation、uni.uploadFile这些 API 封装得足够好,做隐患上报这种“拍照+定位+上传”的组合几乎不用写原生代码。而且同一套代码还能顺手编译出 H5 版本,答辩现场用浏览器演示,比插着数据线装 apk 稳妥得多。
注意:如果用 uni-app,
manifest.json里的定位和相册权限一定要配好,安卓 10 以上还需要在AndroidManifest.xml里声明ACCESS_FINE_LOCATION和CAMERA,否则真机运行会静默失败,日志里什么都看不到。这个坑我见过太多次了。
2.3 数据库、缓存与文件存储的取舍
数据库就 MySQL,没什么好讨论的。字符集统一utf8mb4,排序规则utf8mb4_general_ci,这样用户填写的描述里就算有特殊符号也不会报错。这一点在政务类系统里尤其要注意,因为用户可能会粘贴带表情符号的文字。
Redis 要不要上?我的建议是:如果只是做登录 token 和验证码,可以上,但不上也不影响。用 JWT 做无状态鉴权的话,token 存前端就够了,服务端不用存。验证码如果不想引入 Redis,用一个带过期时间的本地缓存(比如 Caffeine)也能顶。真正需要 Redis 的场景是排行榜、高频计数、分布式锁,这个项目里基本用不到。开题报告里写了 Redis 却没用上,答辩被问起来会很尴尬,宁可写“预留缓存层”。
文件存储这块,隐患上报的图片是主要占用。方案有两种:存服务器本地磁盘 + 通过静态资源映射访问,或者存对象存储。前者零成本、部署简单,缺点是换服务器要搬文件、多实例部署会出问题;后者更规范,但要配置密钥和桶权限。对于这个项目我推荐前者,路径按年/月/日/uuid.jpg组织,数据库只存相对路径,这样以后迁移到对象存储只需要改一个上传工具的实现类,业务代码不用动。
这里有个参数你必须提前算:单张图片压缩到多大合适。手机原图动辄 4 到 8MB,一次上报传三张,弱网环境下要传到天荒地老。我的经验值是压缩到长边 1280px、质量 80%,单张控制在 300KB 左右,肉眼看详情完全够用。这个参数在后面的上传章节我会给具体实现。
3. 数据库设计:几张核心表撑起整个系统
3.1 用户、角色、辖区三张基础表
用户表不要图省事把角色写成一个字符串字段,虽然这个项目角色少,但用角色 id 关联更规范,也为以后加“督导员”“审核员”留了口子。我一般这样设计:
CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE COMMENT '登录账号', password VARCHAR(100) NOT NULL COMMENT 'BCrypt 加密后', real_name VARCHAR(32) COMMENT '真实姓名', phone VARCHAR(20) COMMENT '联系电话', role_id BIGINT NOT NULL COMMENT '角色ID', area_id BIGINT COMMENT '所属辖区,普通用户可为空', avatar VARCHAR(255) COMMENT '头像相对路径', status TINYINT DEFAULT 1 COMMENT '1正常 0禁用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, deleted TINYINT DEFAULT 0 COMMENT '逻辑删除' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;密码字段长度要给到 100,因为 BCrypt 加密后固定是 60 位,用 MD5 存密码的做法在这个年代已经说不过去了,答辩时被问“密码怎么存的”答不上来会很扣分。BCrypt 的好处是自带盐值、可以调计算强度,new BCryptPasswordEncoder().encode("123456")一行搞定,校验用matches。
辖区表看起来可有可无,但它决定了工单能不能正确派发。设计上支持两级就够:街道(父级)和社区(子级),用parent_id自关联。
CREATE TABLE sys_area ( id BIGINT PRIMARY KEY AUTO_INCREMENT, parent_id BIGINT DEFAULT 0, name VARCHAR(64) NOT NULL, level TINYINT COMMENT '1街道 2社区', sort INT DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;角色表里我建议再加一个menu_perms字段,存菜单权限的逗号分隔编码,登录后一起返回给前端做菜单渲染。这样不用单独做一套菜单权限表,项目体量下足够用。
3.2 隐患工单表与状态机设计
工单表是整个系统的核心,字段设计直接决定了后面查询好不好写。我把关键字段列出来:
CREATE TABLE fire_hazard ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '工单号,如 YH20240512001', reporter_id BIGINT NOT NULL COMMENT '上报人', hazard_type TINYINT NOT NULL COMMENT '1消防通道 2器材缺失 3电气线路 4其他', address VARCHAR(255) NOT NULL, longitude DECIMAL(10,6), latitude DECIMAL(10,6), description VARCHAR(500), images VARCHAR(1000) COMMENT '图片路径,逗号分隔', area_id BIGINT COMMENT '所属辖区', handler_id BIGINT COMMENT '处理人', status TINYINT DEFAULT 0 COMMENT '0待受理 1已受理 2处理中 3已办结 4已退回', handle_result VARCHAR(500), handle_images VARCHAR(1000), accept_time DATETIME, finish_time DATETIME, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, deleted TINYINT DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;几个设计细节值得说清楚。第一,order_no不要用自增 id 直接拼,用户看到id=17会觉得很怪,用“前缀 + 日期 + 三位流水”观感好很多,生成方式可以用日期加当天计数,或者简单点用时间戳后六位加随机两位,只要保证唯一即可。第二,images存逗号分隔的路径数组是个取舍,规范化做法应该建一张附件表,但项目体量下逗号分隔完全够用,查询和展示都简单,我在实际项目里也更倾向这种处理方式。第三,longitude和latitude用DECIMAL(10,6),精度大约到 0.1 米,够用了;用 float 存会在某些坐标上出现精度丢失,导致地图打点偏移。
状态机的流转规则一定要写死在服务端,我一般用一个枚举加校验方法:
public enum HazardStatus { PENDING(0, "待受理"), ACCEPTED(1, "已受理"), PROCESSING(2, "处理中"), FINISHED(3, "已办结"), REJECTED(4, "已退回"); private final int code; private final String desc; // 构造、getter 省略 // 定义允许的流转:key 是当前状态,value 是允许流转到的状态 private static final Map<Integer, Set<Integer>> FLOW = Map.of( 0, Set.of(1, 4), 1, Set.of(2, 4), 2, Set.of(3, 4), 3, Set.of(), 4, Set.of() ); public static boolean canTransfer(int from, int to) { return FLOW.getOrDefault(from, Set.of()).contains(to); } }这段代码看着不起眼,但它是防止脏数据的第一道防线。很多项目里状态能被任意修改,测试的时候随便点点没感觉,一旦数据乱了根本查不出是哪一步出的问题。加上流转校验之后,非法请求直接返回业务异常,问题定位会快很多。
3.3 业务申报与知识文章表
业务申报这块,真实业务会很复杂,但项目里做成“提交申请—审核—出结果”三步就够了,不需要把整个审批流程引擎搬进来。
CREATE TABLE fire_apply ( id BIGINT PRIMARY KEY AUTO_INCREMENT, apply_no VARCHAR(32) NOT NULL UNIQUE, applicant_id BIGINT NOT NULL, apply_type TINYINT COMMENT '1公众聚集场所投入使用营业前检查 2其他备案', venue_name VARCHAR(100), venue_address VARCHAR(255), contact_phone VARCHAR(20), materials VARCHAR(1000) COMMENT '材料附件路径', status TINYINT DEFAULT 0 COMMENT '0待审核 1审核中 2已通过 3已驳回', reject_reason VARCHAR(255), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;知识文章表就更简单了,标题、分类、封面、富文本内容、浏览量、发布时间。这里提醒一个点:富文本内容如果直接从管理端编辑后原样渲染,一定要做 XSS 过滤,哪怕是毕业设计,被答辩老师点出来“你这里会被注入脚本”也是很尴尬的。用jsoup的Jsoup.clean(html, Safelist.basicWithImages())处理一下,几行代码的事。
4. 核心功能的落地细节与代码骨架
4.1 登录鉴权:JWT 加拦截器这套老组合依然好用
鉴权这块我不建议上 Spring Security 全套,配置复杂、学习曲线陡,对这个项目来说是杀鸡用牛刀。一个自己写的 JWT 工具类加一个 HandlerInterceptor,两小时能跑通,而且逻辑清晰,答辩的时候你能把每一步讲明白。
JWT 工具类的核心就三件事:生成、解析、校验。生成的时候把用户 id、角色 id 放 claims 里,设置过期时间 7 天(移动端 app 用户最烦天天登录)。
public class JwtUtil { private static final String SECRET = "your-secret-key-at-least-32-chars"; private static final long EXPIRE = 7 * 24 * 3600 * 1000L; public static String create(Long userId, Integer roleId) { return Jwts.builder() .claim("uid", userId) .claim("role", roleId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parse(String token) { return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody(); } }拦截器里取出 header 的Authorization,去掉Bearer前缀,解析失败就直接返回 401。这里有个细节:拦截器要放行登录接口和静态资源,/user/login、/doc.html、/files/**这些路径必须排除,否则连登录都进不去,很多人第一次跑起来就是卡在这。
提示:密钥不要写死在代码里提交到仓库,用
application.yml配合环境变量,或者至少写个注释说明“生产环境请替换”。虽然是课程项目,但这个习惯从第一天养成比以后改要容易得多。
4.2 隐患上报的图片链路:压缩、上传、回显三步走
这是整个 app 里最容易出问题的地方,我拆成三步讲。
第一步,选图与压缩。uni-app 里用uni.chooseImage拿到临时路径,接着调uni.compressImage压缩。参数上我建议quality: 80,如果对尺寸有要求,压缩后再用 canvas 按长边缩放,但大多数情况下quality就够了。
uni.chooseImage({ count: 3, sizeType: ['compressed'], success: async (res) => { const paths = []; for (const p of res.tempFilePaths) { const compressed = await new Promise((resolve) => { uni.compressImage({ src: p, quality: 80, success: (r) => resolve(r.tempFilePath), fail: () => resolve(p) // 压缩失败就用原图兜底 }); }); paths.push(compressed); } this.imageList = paths; } });注意sizeType: ['compressed']这个参数,它让系统相册直接返回压缩版,能省一道处理。另外压缩失败要有兜底逻辑,某些机型在特定系统版本下compressImage会失败,不做兜底会直接卡住流程。
第二步,上传。用uni.uploadFile,一定要带 token,接口地址要写全(不能像普通请求那样配 baseURL)。
uni.uploadFile({ url: BASE_URL + '/api/file/upload', filePath: path, name: 'file', header: { 'Authorization': 'Bearer ' + uni.getStorageSync('token') }, success: (res) => { const data = JSON.parse(res.data); if (data.code === 200) { /* 保存 data.data 里的相对路径 */ } } });这里有个高频坑:uni.uploadFile返回的res.data是字符串,不是对象,必须手动JSON.parse,不然你会拿到undefined然后怀疑人生。
第三步,回显。后端存的是相对路径,比如/files/2024/05/12/abc.jpg,前端拼上域名访问。我建议后端加一个WebMvcConfigurer把/files/**映射到磁盘目录,这样图片就能通过 http 直接访问,app 里的<image>组件也能正常加载。
4.3 工单流转与消息触达:站内消息比推送更靠谱
工单从“待受理”走到“已办结”,每一步都需要留痕,我一般再建一张流转记录表,记录谁在什么时间把状态从什么改成了什么,附了什么备注。这张表在详情页展示成时间轴,视觉效果很好,答辩演示的时候特别加分。
CREATE TABLE fire_hazard_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, hazard_id BIGINT NOT NULL, operator_id BIGINT NOT NULL, from_status TINYINT, to_status TINYINT, remark VARCHAR(255), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;消息触达这块我要泼一盆冷水:不要为了炫技去接第三方推送。厂商推送需要注册开发者账号、配置应用签名、处理各种厂商通道,光调试就能耗掉一周,而且课程项目里根本用不上。老老实实做站内消息表,用户打开 app 在消息中心看未读数,功能闭环完整,实现成本一天。
CREATE TABLE sys_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, title VARCHAR(64), content VARCHAR(500), biz_type TINYINT COMMENT '1工单 2公告 3系统', biz_id BIGINT COMMENT '关联业务ID,点击可跳转', is_read TINYINT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;有了biz_type和biz_id,消息点进去就能跳到对应的工单详情,体验立马上一个台阶。这个小设计成本很低,但很少有同学想到。
4.4 定位与就近分配:坐标系转换是隐藏的坑
隐患上报必须带位置,uni.getLocation默认返回的是wgs84坐标,而国内地图展示通常用gcj02。这两个坐标系之间差几百米,如果你直接拿 wgs84 的点在地图上打,会发现标记位置总是偏。
uni.getLocation({ type: 'gcj02', isHighAccuracy: true, success: (res) => { this.longitude = res.longitude; this.latitude = res.latitude; // 再调逆地理编码接口把坐标换成文字地址 }, fail: () => { uni.showToast({ title: '定位失败,请手动填写地址', icon: 'none' }); } });定位失败的处理比定位成功更重要。室内、地下室、电梯里定位失败是常态,必须允许用户手动填写地址,同时保留最后一次成功的坐标,不能让流程卡死。这一点在真实场景里非常关键,因为消防隐患很多恰恰发生在信号不好的地方。
就近分配的逻辑不用做得很复杂,用经纬度算一个球面距离,取辖区内距离最近的三个处理人员展示即可。公式用 Haversine,SQL 里直接算:
SELECT id, real_name, 6371 * ACOS( COS(RADIANS(#{lat})) * COS(RADIANS(latitude)) * COS(RADIANS(longitude) - RADIANS(#{lng})) + SIN(RADIANS(#{lat})) * SIN(RADIANS(latitude)) ) AS distance FROM sys_user WHERE role_id = 2 AND status = 1 ORDER BY distance ASC LIMIT 3;注意这只是个推荐,最终派单还是由管理员决定,不要做成全自动,因为真实业务里还要考虑人员忙闲和值班安排。
4.5 统计看板:一条 SQL 出多维数据
统计部分我见过最多的写法是循环查库,比如先查出所有辖区,再对每个辖区执行一次 count。数据量小的时候看不出问题,等到有两三万条记录的时候接口就明显变慢,答辩演示时点一下要转三秒圈,观感很差。
正确做法是用GROUP BY一次查完,前端拿数组直接渲染图表:
SELECT a.name AS areaName, COUNT(*) AS total, SUM(CASE WHEN h.status = 3 THEN 1 ELSE 0 END) AS finished, ROUND(SUM(CASE WHEN h.status = 3 THEN 1 ELSE 0 END) / COUNT(*) * 100, 1) AS rate FROM fire_hazard h LEFT JOIN sys_area a ON h.area_id = a.id WHERE h.create_time BETWEEN #{start} AND #{end} AND h.deleted = 0 GROUP BY h.area_id, a.name ORDER BY total DESC;办结率的计算放在 SQL 里做,比取回 Java 再算要干净。另外按类型分布的饼图、按月趋势的折线图,都可以用类似的一条聚合查询搞定。图表库用 ECharts 就行,管理端是网页的话直接引 CDN,配置项写死几个颜色,出来的效果就足够专业了。
注意:统计查询的日期范围条件一定要有上限,不能让前端传递一个跨十年的区间,否则全表扫描加上聚合,数据库会很难受。默认给最近 30 天,最大允许 1 年,这是我在实际项目里定下的规矩。
5. 从零跑通:环境搭建、部署与真机联调实录
5.1 本地环境一次性跑通的顺序
不要东一榔头西一棒子,按这个顺序来,基本不会卡住。先装 JDK 和 Maven,配置好JAVA_HOME和国内镜像源,然后装 MySQL 8 并建库,字符集选utf8mb4。接着导入 SQL 脚本,注意脚本里如果有DROP DATABASE语句,执行前确认一下别把别的库删了。
后端项目拉下来之后,先改application.yml里的数据库连接、账号密码、文件上传路径。文件上传路径建议用绝对路径,比如D:/upload/fire/或者/data/upload/fire/,用相对路径在不同启动方式下会指向不同位置,这是个隐蔽的坑。
启动类跑起来之后,先访问一下接口文档(如果集成了 Knife4j 就是/doc.html),能打开说明基本正常。然后调登录接口拿 token,再调一个需要鉴权的接口验证拦截器配置对不对。这套流程走完,后端就活了。
移动端这边,HBuilderX 新建项目导入源码,改common/config.js里的BASE_URL。如果是在安卓模拟器里跑,localhost要换成10.0.2.2(模拟器访问宿主机的地址);如果是真机,必须用电脑的局域网 IP,比如192.168.1.100,而且要关掉电脑防火墙或者放行对应端口,否则会一直报网络错误。这个坑几乎每个新手都会踩一次。
5.2 打包部署到服务器
后端打包用mvn clean package -DskipTests,生成 jar 之后丢到服务器上,用nohup java -jar xxx.jar --spring.profiles.active=prod > app.log 2>&1 &启动。日志一定要重定向到文件,不然关掉终端进程就没了,排查问题的时候两眼一抹黑。
数据库别忘了做定时备份,用mysqldump加一个 crontab 每天凌晨跑一次。虽然是课程项目,但“数据备份”这个概念在答辩时提到会显得你考虑过生产环境,是个不错的加分点。
文件目录的权限要单独设置,运行程序的用户需要对上传目录有读写权限,否则上传接口会报Permission denied,而且这个错误在日志里可能只显示一行 IO 异常,不容易第一时间反应过来。
如果用 Nginx 做反向代理,记得把client_max_body_size调大,默认 1MB 传个图片就 413 了。设置成 10MB 比较稳妥,同时在后端也配置spring.servlet.multipart.max-file-size和max-request-size,两边都要改,只改一边照样报错。
5.3 真机联调的细节清单
真机联调是整个流程里最磨人的环节,我把常见问题整理成清单,你对着一项项排查会快很多。
打包成 apk 的时候,签名文件一定要保管好,keytool -genkey生成的 keystore 丢了就没法覆盖安装,只能卸载重装。测试阶段用同一个签名,别每次打包都重新生成。
安卓 10 以上访问 http 接口会被拦截,需要在manifest.json里配置networkSecurityConfig,允许明文流量。生产环境当然应该上 https,但本地联调阶段这个配置能救你一命。
应用在后台待久了再切回来,token 可能过期,所有请求都会 401。我的处理是在请求封装里统一拦截 401,弹一个“登录已过期”的提示,然后跳登录页,而不是每个页面单独处理。这个统一封装越早做越好,晚了要改几十个地方。
图片显示不出来还有一种可能:后端拼接路径时少了域名前缀,或者前端<image>的src没加引号导致被当成变量。这类低级问题排查起来最花时间,建议写一个简单的调试页面,把返回的路径原样打印出来看一眼。
6. 开题报告怎么写才不像复制品
6.1 研究背景与技术路线的正确写法
开题报告最大的问题是“背景部分全是套话”。我见过太多开题写“近年来信息技术飞速发展,各行各业都在推进信息化建设”,这句话放在任何题目下都成立,等于什么都没说。正确的写法是落到具体场景里的具体问题,用数据和现象说话。
比如说,可以从“基层隐患上报依赖电话和纸质记录,导致信息失真、处置滞后”这个具体问题切入,描述清楚上报一次隐患要经过几个环节、每个环节可能丢失什么信息、最终统计时数据有多不准。这样的背景有现场感,读的人能立刻明白你为什么做这个系统。
技术路线部分要写清楚“用什么技术解决什么问题”,而不是罗列技术名词。比如写“采用 Spring Boot 构建 RESTful 接口层,将工单状态流转规则收敛到服务端统一校验,避免客户端直接修改状态造成的数据不一致”,这句话就比“采用 Spring Boot 框架进行开发”有价值得多。
文献综述部分,建议至少分三组:一组讲政务信息化的通用模式,一组讲移动端上报类系统的技术方案,一组讲工单流转与状态机设计。每组三到五篇,读完写清楚每篇解决了什么、还留下什么问题、你的系统准备怎么补。这样的综述才能引出你的研究点,而不是简单堆砌。
6.2 功能模块图与数据库设计图的呈现方式
功能模块图不要画成一个大方块套十几个小方块,那种图没法看。正确做法是按角色分层:顶层是系统名称,第二层是按端拆的三块,第三层才是具体功能。这样表达的是“谁用什么功能”,信息量比平铺大得多。
数据库设计图我用 E-R 图,实体控制在六到八个以内,关系线标清楚是一对多还是多对多。有几个细节能让图看起来专业:主键统一加下划线,外键用虚线,核心表放在中间。图不要太大,一页画不下就拆成两张,一张业务主表,一张系统配置表。
这里提醒一句:图上画了什么,代码里就得有什么。答辩老师真的会拿你的 E-R 图去对数据库表,对不上很难解释。
6.3 创新点怎么写才站得住脚
“创新点”这三个字很容易把人带偏,很多同学写“本系统首次将某技术应用于某领域”,稍微懂行的老师一问就露馅。对于这类项目,我的建议是把“创新点”改成“特色与改进”,写一些你能证明的东西。
能站得住脚的方向大概有这几个:一是状态流转的可配置化,把流转规则抽出来,未来新增状态不用改代码;二是上报链路的弱网适配,包括图片压缩策略、定位失败兜底、请求失败重试;三是统计的实时聚合,用一条 SQL 完成多维度统计,避免多次查询。
这三个方向都是你自己动手做过、能讲出取舍理由的,比空喊“智能化”“大数据”靠谱一百倍。而且这些点在答辩时被追问,你是有话可说的,因为坑都是你自己踩过来的。
7. 常见问题速查与避坑经验
7.1 报错速查表
下面这张表是我在多个类似项目里攒下来的,遇到问题先对一遍,能省不少时间。
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 启动报数据源初始化失败 | 账号密码错误或库没建 | 用命令行先mysql -u xxx -p连一次验证 |
| 接口 401 但 token 明明带了 | 拦截器路径没排除或前缀多了空格 | 打印原始 header,检查Bearer后的空格 |
| 上传 413 报错 | Nginx 或 Spring 的上限没改 | 两边都要调大,缺一不可 |
| 图片存了但访问 404 | 静态资源映射路径写错 | 检查addResourceHandler与addResourceLocations是否对应 |
| 坐标在地图上偏移几百米 | 用了 wgs84 没转 gcj02 | 定位和打点统一用 gcj02 |
| 分页查出来总数不对 | 逻辑删除字段没在查询里过滤 | 用 MyBatis-Plus 的@TableLogic自动处理 |
| 时间显示差 8 小时 | 数据库时区不是东八区 | 连接串加serverTimezone=Asia/Shanghai |
| 真机请求超时 | 用的 localhost 或防火墙拦截 | 换成局域网 IP 并放行端口 |
| 富文本渲染出脚本标签 | 没做 XSS 过滤 | 用 jsoup 的clean方法过滤后入库 |
这张表里我特别想说“时间差 8 小时”这一条。它出现的频率极高,而且表现形式很迷惑:数据库里存的时间是对的,查出来就变了,新手往往会怀疑是不是插入的时候写错了。其实根源在 JDBC 连接的时区参数上,加上那一句就好了。
7.2 答辩现场最容易被追问的几个问题
我带过的同学里,被问得最多的几个问题,提前准备一下会从容很多。
“你这个工单状态为什么这么设计?”回答的要点是讲清楚业务动作和状态的一一对应关系,说明为什么要区分“已受理”和“处理中”,因为受理代表认领,处理中代表已经到现场,两个动作在业务上是可以分开追责的。
“如果两个人同时接手同一个工单怎么办?”这个问题考的是并发意识。标准答法是处理接口上加一个乐观锁,用状态作为版本号,更新时带上WHERE status = 0,影响行数为 0 就提示“该工单已被处理”。这个答案能体现你对并发场景的思考,非常加分。
“图片存在服务器上,服务器磁盘满了怎么办?”答法是说明当前的压缩策略、单文件大小控制、按日期分目录便于清理,以及后续可以平滑迁移到对象存储。把“我考虑过这个问题,也知道下一步怎么做”表达清楚就够了。
“你这个系统和现成的某某平台有什么区别?”这个问题不要硬碰硬去比较功能,从定位上说:面向的目标用户规模不同、部署方式不同、是否可以二次定制不同。承认差距、讲清定位,比强行吹嘘更让人信服。
7.3 几条真正能省时间的实操心得
第一条,先跑通一条完整链路再补功能。从登录到上报到受理到办结,这条主线打通之后,你心里就有底了,剩下的功能都是往骨架上挂肉。反过来,先做一堆互不相干的功能,最后发现接不起来,返工成本极高。
第二条,统一响应格式和异常处理要在写第一个接口之前定好。定一个Result<T>包装类,一个全局异常处理器,后面所有接口都按这个来。我见过项目写到一半才想起来统一格式,结果要改几十个接口的返回结构,前端也得跟着改,那叫一个痛苦。
第三条,接口文档边写边更,别等到最后补。用 Knife4j 或者 apifox 都行,好处是你自己调试的时候能对照参数,前端对接时也不用反复问你。这个习惯在团队协作里是基本功,一个人做项目时更容易偷懒,但偷懒的代价最后都会还回来。
第四条,版本管理从第一天开始。Git 提交粒度小一点,每完成一个小功能提交一次,提交信息写清楚改了什么。等到某天改崩了想回退,你会发现这个习惯救了你一命。.gitignore里记得排除target/、node_modules/、unpackage/和上传目录。
第五条,留一个测试数据脚本。演示之前手动造几十条工单数据,覆盖各个状态、各个类型、各个辖区,统计图表才有东西可看。空数据的系统演示效果差得远,而造数据这件事,写个 SQL 批量插入十分钟搞定。
8. 关于这套源码后续怎么扩展
我在实际改这类项目的过程中发现,它的骨架其实可以迁移到很多相邻场景:把“隐患类型”换成“事件类型”,把“辖区”换成“网格”,它就变成了一套通用的网格化事件上报系统;把“业务申报”的字段改一改,又能变成各类事项的线上办理入口。真正有价值的部分不是那几个页面,而是角色权限、工单状态机、附件上传、统计聚合这四块基础设施。
如果要继续往下做,我建议优先补两个方向。一是数据导出,管理人员最需要的是把统计结果导成 Excel 交上去,用 EasyExcel 几十行代码就能实现,实用价值很高。二是操作留痕的完整性,把登录日志、关键操作日志都记下来,这在实际交付中几乎是硬性要求,而且实现成本很低,一个 AOP 切面就能搞定。
最后说个我自己的体会:这类项目做得好不好,跟用了多少新颖的技术关系不大,关键在于你有没有真的站在使用者角度把流程走一遍。我自己测试的时候会故意用“笨办法”,比如上传一张特别大的图、故意不填必填项、在没网的情况下点提交,往往就是这些看似低级的场景,暴露出最多的问题。把这些边界情况处理掉,系统的完成度会有质的提升,答辩时讲起来也底气十足。