资讯动态

SpringBoot+Vue3环保网站系统开发实战:前后端分离与动态SQL实践

发布时间:2026/10/21 7:29:46 来源:尧图企业网站定制
做环保类网站系统最怕的不是功能写不出来而是做完之后首页空荡荡后台一堆没人用的菜单。最近我把一个 SpringBoot Vue3 MyBatis 的前后端分离环保网站项目完整整理了一遍数据库用 MySQL前端构建完就是普通浏览器认识的 HTML/JS/CSS所以标题里写 HTML 语言并不冲突。这个项目既能面向普通用户展示环保资讯、科普内容和志愿活动也能给环保公益组织提供后台审核、线索跟进和活动管理能力。如果你是想练手的开发者或真要给本地环保组织做一套信息展示和线索收集系统这篇实操笔记应该能帮你把整体链路串起来。写这篇文章的时候我尽量不把它做成“第几步导入项目、第几步启动”的文档式说明而是把从建表、写接口、搭页面到上线维护这段过程里真正花过时间想清楚的判断和踩过的坑讲明白。1. 环保网站拆出来的功能模块比想象中多一层1.1 浏览与展示资讯、科普、志愿活动是前台的骨架我一开始拿到需求时脑子里冒出来的就是一个很常见的“官网”样式头部导航、几张环保公益图片、几个新闻列表。但真正开始做原型后才发现环保类网站前台的浏览逻辑要比普通企业官网多一个层次。首先是资讯模块。这个模块不只是简单发布文章还需要分类比如“环保政策动态”“环保科普”“绿色生活技巧”等。分类一旦出现后端就要设计栏目表否则新闻全堆在一张表上后期想按栏目展示、做站内搜索、给不同栏目配不同样式都会很痛苦。其次是科普知识模块。很多环保组织会定期做线上科普表现为图文、视频或问答。我在实际项目中把科普内容也归到资讯体系里用类型字段区分“文章”和“视频”这样前台可以统一走内容列表接口后台也只维护一套发布逻辑。然后是志愿活动模块。这个是整个系统里业务逻辑最重的部分。活动要有标题、时间、地点、人数上限、报名截止时间、活动状态。用户报名之后组织者还要能审核或取消报名。活动一旦有“名额限制”和“审核状态”就得单独建表不能在用户表里加一个“报名活动”字段了事。所以我在做功能拆分时把前台骨架分为三大块内容展示、活动参与、线索反馈。内容展示解决“网站有没有东西看”活动参与解决“用户愿不愿意留下来”线索反馈解决“环保组织真正需要的数据从哪来”。1.2 提交与跟进污染线索反馈比普通 CRUD 复杂如果说前台模块是“表层的”那线索反馈模块就是整个网站真正有社会价值的部分。普通 CRUD 基本就是增删改查一张表但线索反馈天然带状态流转。用户提交一条“污染线索”比如发现某个厂区排口有异常颜色、某处河道有异味、某片区域有固废堆放系统不能只是把数据存进去。它需要一个状态机新提交、待核实、处理中、已处理、已驳回。而且每个状态变化要有操作人和时间记录。我在设计这个模块时一开始没想那么细只加了一个“status”字段。后来发现运营人员需要知道“这条线索是谁审核的”“处理备注写了什么”于是又补了audit_by、audit_time、handle_note几个字段。这提醒我业务型系统的表结构一定要跟着真实使用场景走不能一开始图省事。更关键的是图片上传。用户发现污染现象时最直接的证据是现场照片。所以我保留了多图上传能力线索表和附件表分离一张线索可以关联多张图片。前端用缩略图展示详情页再加载大图。1.3 后台管理三角色权限与栏目设置要一起设计这个系统我设计了三个角色普通用户、环保组织账号、超级管理员。普通用户能浏览资讯、报名活动、提交线索、查看自己线索的处理进度。环保组织账号能发布活动、管理活动报名、审核线索、发布资讯。超级管理员负责全站配置包括栏目管理、用户管理、基础参数设置。权限落到接口上时我用了一个很常见的方案JWT 登录后在后端拦截器里解析 token从里面拿到角色再对需要管控的接口做角色判断。比如活动管理接口只允许组织账号和管理员访问线索审核接口只允许组织账号访问用户管理接口只允许管理员访问。角色 | 主要能力 | 典型前端路由 普通用户 | 浏览资讯、报名活动、提交线索、查看进度 | /home、/news、/activity、/report 环保组织 | 发布资讯、发布活动、审核线索、管理报名 | /org/content、/org/activity、/org/clue 超级管理员 | 栏目管理、用户管理、系统配置 | /admin/category、/admin/user、/admin/setting栏目设置这块也容易被忽略。前台导航、首页版块、资讯分类如果全部写死在前端代码里运营每次调整都要找开发改页面。我后来把栏目表设计成父子结构并加排序字段前端通过接口动态渲染导航菜单这样运营可以自己在后台调整栏目展示顺序。2. 技术栈为什么是 SpringBoot Vue3 MyBatis对比过才敢这么选2.1 前后端分离的收益在多人协作时才会真正显现很多人提起前后端分离第一反应是“前后端分开部署、各写各的”。但实际项目的收益远不止这些。这个环保网站系统的特点是页面多状态交互频繁权限分散。如果后端用 Thymeleaf 之类的模板引擎渲染页面那么前端任何一个按钮的位置调整、列表字段的增减都得让后端改模板。开发初期可能很快但后期每次改版都要小心翼翼地不碰坏 Java 代码。换成 Vue3 SpringBoot 之后前端页面是独立的 Vue 工程后端只提供 JSON 接口。这种分离让两个人可以在同一时间段并行干活前端先按接口文档做 mock 数据后端按数据库设计写接口最后联调时对一遍字段名和状态码就行。Vue3 还有一个明显优势组件化。环保网站常用的“资讯卡片”“活动列表”“线索状态标签”“分页控件”都可以抽成组件。我实际做的时候把首页的新闻列表和活动列表做成了两个通用组件后面后台和前台复用同一套样式省了不少时间。2.2 MyBatis 在报表和复杂查询上比 JPA 更顺手的地方选 MyBatis 之前我也考虑过 Spring Data JPA。如果你只做非常标准的单表 CRUDJPA 确实省事。但环保网站这种系统一到列表页就容易出现多条件筛选比如后台线索列表筛选条件可能是“状态 分类 关键词 时间范围”这四五个条件不是固定组合用户可能只填其中两个。用 JPA 写动态查询需要规格对象或者一大堆 if 判断用 MyBatis 的动态 SQL 则非常直观直接在 XML 里写where和if一眼就能看出 SQL 长什么样。拿列表分页来说MyBatis 的LIMIT #{offset}, #{pageSize}想怎么控制都行。而 JPA 的分页会自动生成 count 查询有些复杂 SQL 的 count 可能比实际查询还慢。对于这种业务型后台系统手写 SQL 反而能让每条关键查询都可控。我把 MyBatis 和 JPA 的对比总结成下面这张表对比项 | MyBatis | JPA / Hibernate SQL 可控性 | 高SQL 全部自己写 | 低复杂查询优化困难 动态查询 | XML 动态 SQL 直观 | Specification 学习成本高 报表/多表关联 | 写 JOIN 和子查询方便 | 容易生成冗余 SQL 字段映射 | 需要配置 resultMap 或驼峰映射 | 自动映射但也容易隐式出错 学习成本 | 中等 | 偏高所以最终选了 MyBatis。它不像 MyBatis-Plus 那样自带很多封装方法但优点是没有隐藏魔法SQL 是明明白白写出来的。后面排查慢查询、改索引直接拿 SQL 去 MySQL 里跑一遍就能定位问题。2.3 MySQL 与 MyBatis 的字段映射需要“约定优于配置”数据库表设计出来后实体类和表字段的映射如果全靠手动写很容易漏。我在项目的application.yml里开了 MyBatis 的驼峰映射配置mybatis: mapper-locations: classpath:mapper/**/*.xml configuration: map-underscore-to-camel-case: true这样数据库里的create_time字段能自动映射到 Java 实体类的createTime属性。如果哪天遇到带特殊前缀或命名不规则的字段再单独用resultMap处理。不过要注意驼峰映射只对单表查询和简单结果集有效。如果是复杂的多表 JOIN且查出来的列名有重复我会明确写resultMap而不是指望自动映射。比如线索列表要联查用户名我就会给查询结果定义ClueVO把user_name映射成userName。这样前端拿到的 JSON 字段才是稳定的。3. 从空项目到第一个可访问页面数据库、接口、页面怎么串起来3.1 建表先建栏目和用户表不要一张大表扛所有字段我的建表顺序是这样的先建category栏目表再建sys_user用户表然后建业务表。栏目表是所有内容分类的基础用户表是所有操作日志和业务关系的基础顺序不能乱。栏目表的核心字段CREATE TABLE category ( id BIGINT PRIMARY KEY AUTO_INCREMENT, parent_id BIGINT DEFAULT 0 COMMENT 父栏目ID0表示顶级, name VARCHAR(50) NOT NULL COMMENT 栏目名称, type TINYINT DEFAULT 1 COMMENT 1资讯 2活动 3线索分类, sort INT DEFAULT 0 COMMENT 排序值越大越靠前, create_time DATETIME ) COMMENT 栏目/分类表;用户表这里我特别说明一下密码字段必须加密存储项目里用 BCrypt 处理用户密码。角色字段用role区分0 管理员、1 普通用户、2 组织账号。用户状态用status字段表示启用/停用而不是直接删记录。业务表里我重点设计的是污染线索表CREATE TABLE pollution_clue ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 提交用户ID, category_id INT COMMENT 线索分类, title VARCHAR(120) NOT NULL COMMENT 线索标题, content TEXT COMMENT 线索描述, images VARCHAR(2000) COMMENT 图片URL多张用逗号分隔, address VARCHAR(255) COMMENT 事发地址, longitude DECIMAL(10,6) DEFAULT 0 COMMENT 经度, latitude DECIMAL(10,6) DEFAULT 0 COMMENT 纬度, status TINYINT DEFAULT 0 COMMENT 0待核实 1处理中 2已处理 3已驳回, handle_note VARCHAR(500) COMMENT 处理备注, audit_by BIGINT COMMENT 审核人ID, audit_time DATETIME COMMENT 审核时间, create_time DATETIME, update_time DATETIME ) COMMENT 污染线索反馈表;images字段多图用逗号拼接是因为环保线索一般来说图不会太多这样处理简单读取也方便。如果未来要支持几十张图片或需要按图搜索再拆附件表也不迟。3.2 通过 Mapper XML 把可变的筛选条件交给动态 SQL后端接口的第一版我习惯先把列表查询写好因为列表页往往是一个后台系统最常被打开的页面。以线索列表为例用 MyBatis 动态 SQL 写分页查询select idselectCluePage resultTypecom.example.ep.vo.ClueVO SELECT c.id, c.title, c.address, c.status, c.create_time, u.nickname AS userName FROM pollution_clue c LEFT JOIN sys_user u ON c.user_id u.id where if teststatus ! null AND c.status #{status} /if if testcategoryId ! null AND c.category_id #{categoryId} /if if testkeyword ! null and keyword ! AND (c.title LIKE CONCAT(%, #{keyword}, %) OR c.address LIKE CONCAT(%, #{keyword}, %)) /if if teststartTime ! null AND c.create_time gt; #{startTime} /if if testendTime ! null AND c.create_time lt; #{endTime} /if /where ORDER BY c.create_time DESC LIMIT #{offset}, #{pageSize} /select这里的where标签会自动处理多余的前导 AND不用自己写WHERE 11。if标签则是把可变的筛选条件都收进来哪个条件有值就拼哪个条件。这样前端无论传几个筛选参数SQL 都能稳定执行。分页我用的是手动LIMIT而不是 PageHelper 插件。原因有两个第一这个项目的分页场景不多手动LIMIT足够第二手动写分页可以明确控制索引避免 PageHelper 自动生成 count 语句时产生额外开销。3.3 Vue3 前端把 API、权限、页面状态分三个目录管前端工程我用 Vite 搭建目录划分很关键。我的习惯是src/api、src/store、src/views三块分开。src/api只放接口请求封装不出现页面逻辑。比如线索接口// src/api/clue.js import request from ./request export function getCluePage(params) { return request.get(/clue/page, { params }) } export function submitClue(data) { return request.post(/clue, data) } export function updateClueStatus(id, data) { return request.put(/clue/${id}/status, data) }src/store这边用 Pinia 存登录状态和用户信息。登录成功后把 token 和用户基本信息写进 localStorage同时写入 Pinia。页面刷新后Pinia 里的 state 会重置所以刷新时要先从 localStorage 恢复。src/views按页面维度建目录比如views/home、views/activity、views/report、views/admin。这样页面路由和目录一一对应后期找人改代码也方便。路由守卫这块很多新手容易在每个页面里写一堆if判断是否登录。我更推荐在全局路由守卫里统一处理// src/router/index.js router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) } else { next() } })需要登录后才能访问的页面在路由配置里加一个meta: { requiresAuth: true }就行。角色校验也可以放这里比如活动管理页面要求role 2不满足就跳回首页。3.4 登录认证链路拦截器校验 JWT 与角色后端登录接口的逻辑很简单查用户表、比对 BCrypt 密码、生成 JWT、返回 token 和用户信息。但我不会把所有用户信息都塞进 token只放用户 ID 和角色其他信息还是从数据库查。JWT 的工具方法大致长这样public static String createToken(Long userId, Integer role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(role, role) .setExpiration(new Date(System.currentTimeMillis() 24 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }登录接口返回 token 后后续请求前端把它放到请求头Authorization里。后端用一个拦截器统一处理Component public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String token request.getHeader(Authorization); Long userId JwtUtil.parseToken(token); if (userId null) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } request.setAttribute(currentUserId, userId); return true; } }实现了拦截器之后再往 SpringBoot 配置类里注册并指定拦截路径。像/login、/home、/news这些公开接口在excludePathPatterns里排除掉。有些接口还需要限制角色我通常会再写一个小注解比如RequireRole(2)在方法执行前做角色判断。这样接口层不需要到处重复写“验证角色”逻辑维护角色权限时也集中。4. 环保场景的几个实战代码片段图片上传、状态流转、分页4.1 污染线索的图片上传重点在文件路径和生产环境映射线索提交页面最容易被漏掉的不是表单而是图片。用户拍的照片文件往往很大所以后端配置里要限制上传大小spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MB上传接口用MultipartFile接收文件落到本地指定目录。文件名我习惯用 UUID 重命名避免用户上传的原始文件名带有中文或特殊字符导致前端展示时 URL 编码出问题。PostMapping(/upload) public Result upload(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { return Result.error(文件为空); } String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); String fileName UUID.randomUUID().toString().replace(-, ) ext; File dest new File(uploadDir, fileName); file.transferTo(dest); return Result.ok(/upload/ fileName); }开发环境把/upload/映射到本地磁盘目录生产环境用 Nginx 的alias指向同样的物理目录。这里最容易踩的坑是后端返回了/upload/xxx.jpg但前端直接请求时Nginx 没有配置这个 location图片 404。这个问题我放在后面部署章节单独说。4.2 线索状态流转状态字段和更新人一起记录线索状态不能随便改。我在 Service 层写了一个简单的状态校验逻辑待核实可以转为处理中或已驳回处理中只能转为已处理已处理和已驳回是终态不能再次修改。这样做的目的是防止前端或接口被乱调用时把状态改成非法组合。代码不复杂SwitchCase 判断就行public void updateStatus(Long clueId, Integer targetStatus, Long userId, String handleNote) { Clue clue clueMapper.selectById(clueId); if (clue null) { throw new BusinessException(线索不存在); } if (!validTransition(clue.getStatus(), targetStatus)) { throw new BusinessException(非法的状态流转); } clueMapper.updateStatus(clueId, targetStatus, userId, handleNote); }前端拿到状态值后用不同的标签颜色区分状态。比如灰色待核实、蓝色处理中、绿色已处理、红色已驳回。用户在我的线索列表页能看到自己提交的每条线索走到哪一步这个体验比普普通通的“提交成功”要好得多。4.3 资讯列表的分页手动 LIMIT 和前端滚动加载配合资讯列表是公开页面浏览量最大。我做了两点优化第一列表接口只查当前分页数据不查全表第二加上update_time索引保证排序稳定。接口接收pageNum和pageSize后端算出offset (pageNum - 1) * pageSize然后 SQL 里写LIMIT #{offset}, #{pageSize}。前端用缩略图 摘要信息渲染卡片每页 10 条下拉滚动到接近底部时自动加载下一页。这里有个小坑如果前端滚动加载时某个用户在列表中的内容状态发生了变化比如某条资讯被下线再次翻页可能会出现重复记录。解决办法是列表排序字段用一个稳定的 ID 或时间并且在接口返回里带上当前页数据的最小 ID翻页时传入ltId作为过滤条件。项目里我用的方案是ORDER BY id DESC翻页用id #{lastId}而不是offset数据量大了之后性能更稳定。5. 部署前必须处理的四个坑配置、驼峰映射、静态资源、端口5.1 开发/生产配置分离profile 怎么切本地开发时的数据库密码和生产环境肯定不一样上传目录也可能不同。我一开始把所有配置都写在 application.yml 里结果每次部署都要改文件非常容易出错。后来改成三份配置application.yml公共配置比如端口、上传大小限制application-dev.yml本地开发数据库、日志级别application-prod.yml生产数据库、上传路径、日志文件路径。启动生产环境只需在命令里指定 profilejava -jar ep-server.jar --spring.profiles.activeprod这样代码仓库里可以保留开发配置生产配置只维护在服务器上。涉及数据库密码这类敏感信息我还会再单独用环境变量注入而不是直接写死在 yml 里。5.2 驼峰映射与 resultMap列表字段不对先查这里第一个容易踩的坑是查询结果数对了但字段映射不对。比如前端要createTime后端返回却是null。这种问题 90% 是 MyBatis 驼峰映射没开或者多表联查时字段名没有起别名。多表联查时我会给查询结果的每个需要映射的字段都写好 resultMap尤其是带_的字段。前面线索列表那个 SQL 已经用了一个ClueVO这里面就是靠 resultMap 保证user_name能正确变成userName。另一个坑是动态 SQL 里status传 0 时if teststatus ! null是生效的但如果写成teststatus ! Java 里 Integer 转空字符串可能出问题。所以类型判断要谨慎Integer 类型只判断! nullString 类型才同时判断空字符串。5.3 Nginx 托管前端并转发 /api前后端分离的最后一段前端构建命令是npm run build产物会生成在dist目录里面就是浏览器真正需要的 HTML、CSS、JS 文件。标题里说的“HTML 语言环保网站”其实就是这个意思——Vue 源码最终会被编译成静态 HTML 资源。部署时我把dist拷贝到服务器/data/ep/distNginx 配置这样写server { listen 80; server_name your-domain.com; root /data/ep/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /upload/ { alias /data/ep/upload/; } location / { try_files $uri $uri/ /index.html; } }location /这一行是给 Vue Router 的 history 模式用的。如果不加try_files用户刷新/activity/3这样的页面时Nginx 找不到真实文件会返回 404。加上之后所有不存在的路径都会回退到index.html由前端路由接管。location /upload/和location /api/这两个配置是后端服务正常工作的关键。图片上传目录如果不映射前端图片全挂API 不转发前端所有请求都 404。项目一开始联调时我被这两个问题耽误过不少时间。6. 上线之后我才想明白的几件事日志、备份和迭代6.1 日志分级先看 error再看慢 SQL系统上线后比新功能更重要的是“出问题能快速定位”。我在本地开发时会把日志调到 debug看清每个 SQL 参数。生产环境则调到 info只记录关键流程和错误。排查问题时我的顺序非常固定先看有没有 error 日志再看慢 SQL 日志。MyBatis 的 mapper 日志可以在application-prod.yml里关掉 SQL 输出但数据库慢查询日志一定要打开。把超过 1 秒的 SQL 记录下来很多时候页面卡顿不是代码问题而是某条 SQL 缺少索引。6.2 数据库备份环保数据不能丢环保网站的信息和线索一旦挂了很难补录。我不会把备份工作都交给云服务商的自动快照自己再加一道本地定时备份。SQL 备份命令很简单mysqldump -u ep_user -p密码 ep_db | gzip /data/backup/ep_$(date %F).sql.gz用 crontab 每天凌晨执行一次保留最近 30 天这样就算数据库被误删或服务器磁盘损坏也能恢复到前一天的数据。6.3 下一步我更想做的是地图联动和数据统计这个系统的第一版做完后我明显感觉“表格列表”不是环保数据的最终形态。污染线索如果按经纬度撒到地图上一个地区有哪些问题、集中在哪、处理状态如何用户和管理员都能一眼看到。我正在规划的下一个版本就是给线索模块增加地图标记页配合区域统计接口把列表数据变成可视化信息。我个人的体会是环保类网站系统真正难的不是某个接口而是把线下收集信息、活动报名、线索处理这套流程转换成线上状态流转。刚开始我也把精力放在 UI 上后来发现用户更在意线索提交之后能不能看到处理进度。做这类系统先把表结构理清再把状态流转做稳后面的页面和接口都会顺很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑