1. 整体设计与技术选型思路
最近带了个资产管理系统项目,技术栈锁定为“SSM + Vue”,也就是后端用 Spring + SpringMVC + MyBatis,前端用 Vue 2 + Element UI。先说结论:这套组合放在今天看起来不算新潮,但在企业信息化、高校实验室管理、中小企业内部资产管理这类场景里,依然是非常能打的一套方案。原因也简单:SSM 足够轻量,上手门槛低,招人容易;Vue 2 生态成熟,Element UI 组件库几乎能覆盖后台管理系统 80% 以上的页面需求,开发效率非常高。
1.1 为什么选 SSM 而不是 Spring Boot
很多朋友一上来就问:现在新项目谁还用 SSM?直接 Spring Boot 不香吗?这里我要给个客观的说法。Spring Boot 确实在配置简化、内嵌容器、自动装配上碾压 SSM 手工配置,但这个项目选 SSM,有三个实际原因:
第一,客户现有系统就是基于 SSM 构建的,团队里几位老工程师写了三年多的 SSM 代码,仓库里沉淀了大量可直接复用的 Dao、Service 层代码,硬迁到 Spring Boot 反而增加回归风险。
第二,SSM 的 XML 配置虽然繁琐,但它的“显式”特性在某些场景下是优点。一个资产业务涉及审批流、多表关联、权限拦截,配置全部摊开在 XML 里,新来的同事翻一遍配置就能理清项目脉络。Spring Boot 的自动配置虽然省事,但出了问题排查起来反而要翻更多源码。
第三,部署环境比较特殊。客户机房里的 Tomcat 版本和 JDK 版本偏老,Spring Boot 2.x 要求 JDK 8+,虽然也能跑,但 SSM 的 WAR 包直接扔进 Tomcat 7 就能跑,兼容性毫无压力。
我的建议是:如果你是从零开始的全新项目,没有历史包袱,直接上 Spring Boot 没问题;但如果要做系统重构、或者接手的项目本身就是 SSM 底座,不要在技术栈上盲目求新,先把业务跑通比什么都重要。
Vue 端同理。Vue 2 已经停止官方维护这件事我们知道,但这个项目用的是 Vue 2.6.14 + Element UI 2.15.x,组件生态稳定,社区踩坑资料最全,无论是 vue-router 还是 vuex,任何一个报错都能搜到现成解决方案。在项目工期紧张的前提下,选成熟的、团队最熟悉的技术栈,才是理性的决策。
1.2 资产管理系统的功能边界
资产管理系统这个赛道很宽,从几万块的通用型 SaaS,到几百万的定制化 EAM,功能差异极大。我们这个项目的核心需求是:管住资产的基本信息、流转过程和维护状态。具体拆下来,功能边界包括这么几个区块:
- 资产台账管理:资产的入库登记、信息维护、批量导入导出,是整个系统的基础数据层。
- 领用与归还:员工领用资产、归还资产,系统记录完整的流转轨迹,并做好状态联动。
- 资产盘点:按部门、按资产分类发起盘点任务,支持扫码盘点和手工盘点两种方式。
- 维修与报废:资产报修、维修记录、报废审批,最终形成资产生命周期的完整闭环。
- 系统管理:用户管理、角色权限、操作日志,这是所有后台系统的标配,但也往往是数据安全的关键防线。
首页 Dashboard 展示资产总量、部门资产分布、资产状态占比、本月新增/报废数量,用 ECharts 图表呈现。这个页面看似简单,但在实际开发中其实是打通前后端数据聚合能力的第一道关口。
2. 后端 SSM 框架核心实现细节
2.1 数据库设计与 MyBatis 映射实践
资产管理系统最核心的表是asset_info资产主表,我当时的表结构设计大致如下:
CREATE TABLE `asset_info` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', `asset_code` VARCHAR(64) NOT NULL COMMENT '资产编号', `asset_name` VARCHAR(128) NOT NULL COMMENT '资产名称', `category_id` BIGINT NOT NULL COMMENT '资产分类ID', `specification` VARCHAR(255) DEFAULT NULL COMMENT '规格型号', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1在库 2领用 3维修 4报废', `department_id` BIGINT DEFAULT NULL COMMENT '使用部门ID', `user_id` BIGINT DEFAULT NULL COMMENT '当前使用人ID', `purchase_date` DATE DEFAULT NULL COMMENT '购买日期', `original_value` DECIMAL(12,2) DEFAULT NULL COMMENT '原值', `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_asset_code` (`asset_code`), KEY `idx_category` (`category_id`), KEY `idx_status` (`status`), KEY `idx_department` (`department_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='资产信息表';字段设计上我补充几个关键考虑:
asset_code设唯一索引,这是资产编号,业务上要求全局不重复,录入时后端要做重复性校验。很多新手容易忽略这个约束,结果数据跑一段时间后出现重复编码,盘点时对不上账,非常痛苦。
status用 TINYINT 而非 VARCHAR 存中文状态,查询效率更高,也方便扩展。业务状态只有四个,直接在代码里用枚举类维护即可。
user_id和department_id拆开存储,为什么不只存user_id然后通过用户表关联部门?因为在资产流转场景中,资产可能处于“在库”状态,这时没有使用人但有归属部门,字段分离能更准确表达业务语义。
MyBatis 的 mapper 文件我习惯用 XML 方式而非注解方式。资产管理系统的查询条件很多,资产名称模糊查询、分类筛选、状态筛选、部门筛选、购买日期范围,这些条件拼接用 XML 的动态 SQL 非常合适。核心查询语句大致是:
<select id="selectAssetPage" resultType="com.example.entity.AssetInfo"> SELECT a.*, c.category_name, d.dept_name, u.real_name FROM asset_info a LEFT JOIN asset_category c ON a.category_id = c.id LEFT JOIN sys_department d ON a.department_id = d.id LEFT JOIN sys_user u ON a.user_id = u.id <where> <if test="assetName != null and assetName != ''"> AND a.asset_name LIKE CONCAT('%', #{assetName}, '%') </if> <if test="categoryId != null"> AND a.category_id = #{categoryId} </if> <if test="status != null"> AND a.status = #{status} </if> <if test="departmentId != null"> AND a.department_id = #{departmentId} </if> <if test="startDate != null"> AND a.purchase_date >= #{startDate} </if> <if test="endDate != null"> AND a.purchase_date <= #{endDate} </if> </where> ORDER BY a.create_time DESC </select>一个重要的优化点是分页。这里我用了 PageHelper 插件,一行代码PageHelper.startPage(pageNum, pageSize)就能完成物理分页,底层自动拼接 LIMIT,比手写拦截器或每张表各写一套分页 SQL 靠谱得多。实际使用时有个坑:PageHelper 只对紧随其后的第一条 SQL 生效,所以不要在调用startPage()和真正执行查询之间穿插其他数据库操作,否则分页会失效。
2.2 资产领用与状态流转机制
资产领用是整个系统里最容易出 bug 的业务环节。表面上看就是一个 UPDATE 操作,把资产状态从“在库”改成“领用”,再更新使用人。但真正落地时,还需要同时处理资产领用记录表的 INSERT,而且两个操作必须在一个事务里完成。
我先说一个真实踩过的坑。第一版代码领用逻辑是这么写的:
@Override @Transactional(rollbackFor = Exception.class) public void assignAsset(AssetAssignDTO dto) { AssetInfo asset = assetMapper.selectById(dto.getAssetId()); if (asset == null) { throw new BizException("资产不存在"); } // 更新资产状态 asset.setStatus(AssetStatusEnum.IN_USE.getCode()); asset.setUserId(dto.getUserId()); asset.setDepartmentId(dto.getDepartmentId()); assetMapper.updateById(asset); // 插入领用记录 AssetAssignRecord record = new AssetAssignRecord(); record.setAssetId(asset.getId()); record.setUserId(dto.getUserId()); record.setAssignType(1); // 1领用 2归还 record.setRemark(dto.getRemark()); assignRecordMapper.insert(record); }这段代码初看没问题,但压测时暴露了一个严重问题:并发场景下,同一条资产可以被两个人同时领用。原因在selectById到updateById之间存在时间窗口,两个请求同时读到“在库”状态,然后各自执行更新,最后资产的user_id归属后提交的那个人,前一个人的领用记录却已经生成,数据就乱了。
解决方案是加锁或加乐观锁。我当时选了乐观锁方案,在asset_info表加version字段:
ALTER TABLE `asset_info` ADD COLUMN `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号';更新语句改为:
UPDATE asset_info SET status = 2, user_id = #{userId}, department_id = #{departmentId}, version = version + 1 WHERE id = #{assetId} AND version = #{version}如果UPDATE返回影响行数为 0,说明数据已被其他事务修改,直接抛出“操作过于频繁,请刷新后重试”的提示。同时,在asset_code上做唯一索引已经挡住了重复资产编号的插入,但状态流转这一层,乐观锁才是真正能兜住并发问题的防线。
细心的读者可能还会问:领用记录表要不要也做唯一约束?我的建议是加一个联合唯一索引,uk_asset_active (asset_id, assign_type),但 MySQL 不支持“只对未归还记录唯一”这种部分索引。所以我的做法是在应用层查询:发起领用前SELECT COUNT(*) FROM asset_assign_record WHERE asset_id = ? AND return_time IS NULL,防住业务层面的重复领用。真正压测通过后,这个查询加锁也能接受,毕竟资产领用的并发量远远到不了数据库瓶颈。
2.3 权限拦截与操作日志的落地写法
SSM 项目里的登录拦截,很多人第一反应就是上 Shiro 或者 Spring Security。但说实话,资产管理系统的权限模型没那么复杂,用拦截器 + 注解就能实现得很干净,而且代码量少、容易维护。
我当时的做法是这样:自定义一个@RequiresPermission注解,标注在 Controller 方法上,标明需要的权限编码;然后在 SpringMVC 的拦截器里做统一校验。
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequiresPermission { String value(); }拦截器核心逻辑:
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod = (HandlerMethod) handler; RequiresPermission annotation = handlerMethod.getMethodAnnotation(RequiresPermission.class); if (annotation == null) { return true; // 无需权限的方法直接放行 } // 从当前登录用户上下文获取权限码集合 Set<String> permissions = (Set<String>) request.getSession().getAttribute("user_permissions"); if (permissions != null && permissions.contains(annotation.value())) { return true; } response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(JSON.toJSONString(Result.error("无操作权限"))); return false; }这个方法比引入 Shiro 轻量得多,而且权限码存在 Session 里,也符合中小系统的实际情况。权限码的分配在角色管理功能里做,超级管理员编辑角色时勾选权限树,权限树用 ZTree 或 Element UI 的 Tree 组件渲染都行。
操作日志我用的是 Spring AOP 切面配合自定义注解,在需要记录日志的方法上打@OpLog("领用资产"),切面里统一记录操作人、操作方法、操作参数、IP 地址和执行耗时。这个设计的好处是业务代码不用到处写日志逻辑,新增操作时只要加注解即可。
3. 前端 Vue 核心模块与接口联调
3.1 Vue 项目的初始化与工程目录结构
前端这边我用的是 Vue CLI 4.x 创建的项目,脚手架命令vue create asset-web,选择 Manually select features,勾选 Router、Vuex、CSS Pre-processors(我选的是 Sass)。
目录结构基本遵循 Vue 官方推荐的风格:
src/ ├── api/ # 接口请求封装 │ ├── asset.js │ ├── auth.js │ └── dashboard.js ├── assets/ # 静态资源 ├── components/ # 公共组件 │ ├── Pagination.vue │ ├── ImageUpload.vue │ └── DepartmentTree.vue ├── router/ # 路由配置 │ └── index.js ├── store/ # Vuex 状态管理 │ ├── modules/ │ └── index.js ├── utils/ # 工具函数 │ ├── request.js # axios 封装 │ └── auth.js ├── views/ # 页面组件 │ ├── dashboard/ │ ├── asset/ │ │ ├── AssetList.vue │ │ ├── AssetForm.vue │ │ ├── AssetDetail.vue │ └── system/ │ ├── UserManage.vue │ └── RoleManage.vue ├── App.vue └── main.jsapi/目录独立出来的好处是页面组件里不直接写请求路径,统一由 API 模块管理,后端接口一旦调整路径,只需要改一个文件。这是很多人容易忽略的工程规范,项目大了之后收益极其明显。
3.2 axios 封装与跨域问题实战
axios 封装是前后端联调的第一道坎。先说配置,我习惯在utils/request.js里统一做这么几件事:创建 axios 实例、配置baseURL、设置请求超时、请求拦截器里带 Token、响应拦截器里统一处理错误码。
import axios from 'axios' import { Message } from 'element-ui' import router from '@/router' const service = axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 15000 }) // 请求拦截器 service.interceptors.request.use( config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }, error => { return Promise.reject(error) } ) // 响应拦截器 service.interceptors.response.use( response => { const res = response.data // 后端统一返回结构:{ code, message, data } if (res.code !== 200) { Message.error(res.message || '系统错误') if (res.code === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(new Error(res.message || '系统错误')) } return res }, error => { Message.error(error.message || '网络异常') return Promise.reject(error) } ) export default service这里特别要说明后端统一返回结构的设计。SSM 后端我定义了一个Result<T>类,包含code、message、data三个字段,所有 Controller 都返回这个包装类型。这样做的好处是前端 axios 拦截器只需要处理一种结构,判断code === 200就走成功逻辑,否则弹错误提示。不用每个页面单独try-catch,代码干净非常多。
跨域问题让很多人头疼,但这个问题本质上有三层解法:
第一,后端开启 CORS。SpringMVC 里加一个配置类:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }第二,前端开发环境配代理。Vue CLI 项目在vue.config.js里设devServer.proxy,把/api前缀的请求代理到后端地址,开发时完全规避跨域。
第三,生产环境部署时,前端打包后的静态文件由 Nginx 托管,Nginx 反向代理后端接口,从根本上解决跨域。这也是最推荐的生产方案。
我实际开发中最常用的是第二种方式,vue.config.js配置如下:
devServer: { port: 8080, proxy: { '/api': { target: 'http://localhost:8081', changeOrigin: true, pathRewrite: { '^/api': '' } } } }如果你的后端 Context Path 是/ssm-asset,那pathRewrite要对应调整。我这里后端没有统一加前缀,所以把/api去掉了。生产环境 Nginx 同样做一层路径转发,一年下来没遇到跨域问题。
3.3 资产列表页与 Element UI 表格实践
资产列表页是整个系统使用频率最高的页面。我用了 Element UI 的el-table组件,开启多选、排序、列筛选,分页组件用 el-pagination,和后台 PageHelper 的分页格式完美契合。
列表页核心逻辑:
<template> <div class="asset-list"> <!-- 搜索区 --> <el-form :inline="true" :model="queryParams"> <el-form-item label="资产名称"> <el-input v-model="queryParams.assetName" placeholder="请输入资产名称" clearable /> </el-form-item> <el-form-item label="资产状态"> <el-select v-model="queryParams.status" placeholder="请选择状态" clearable> <el-option label="在库" :value="1" /> <el-option label="领用" :value="2" /> <el-option label="维修" :value="3" /> <el-option label="报废" :value="4" /> </el-select> </el-form-item> <el-form-item> <el-button type="primary" @click="handleQuery">查询</el-button> <el-button @click="resetQuery">重置</el-button> <el-button type="success" @click="handleAdd">新增资产</el-button> </el-form-item> </el-form> <!-- 表格区 --> <el-table :data="tableData" v-loading="loading" border stripe> <el-table-column type="selection" width="55" /> <el-table-column prop="assetCode" label="资产编号" width="120" /> <el-table-column prop="assetName" label="资产名称" min-width="150" /> <el-table-column prop="categoryName" label="资产分类" width="120" /> <el-table-column prop="status" label="状态" width="90"> <template slot-scope="{ row }"> <el-tag :type="statusTagType(row.status)">{{ statusText(row.status) }}</el-tag> </template> </el-table-column> <el-table-column prop="departmentName" label="使用部门" width="120" /> <el-table-column prop="realName" label="使用人" width="100" /> <el-table-column label="操作" width="230" fixed="right"> <template slot-scope="{ row }"> <el-button type="text" @click="handleDetail(row)">详情</el-button> <el-button type="text" @click="handleEdit(row)">编辑</el-button> <el-button type="text" @click="handleAssign(row)">领用</el-button> <el-button type="text" class="danger" @click="handleDelete(row)">删除</el-button> </template> </el-table-column> </el-table> <!-- 分页 --> <el-pagination @size-change="handleSizeChange" @current-change="handleCurrentChange" :current-page="queryParams.pageNum" :page-sizes="[10, 20, 50, 100]" :page-size="queryParams.pageSize" layout="total, sizes, prev, pager, next, jumper" :total="total"> </el-pagination> </div> </template>这里有一个关键的体验细节:状态列不要直接显示数字,应该映射成中文或 Tag 标签。我用statusText()和statusTagType()两个方法做映射,el-tag配合type属性呈现不同颜色——在库用蓝色、领用用绿色、维修用橙色、报废用灰色。这种视觉化呈现对用户理解资产状态非常有帮助。
流程上是:点击查询按钮,把queryParams作为参数传给getAssetList接口,拿到数据后赋值给tableData和total,表格自动渲染。分页组件通过current-change事件触发列表重新加载,页码变化时后端利用 PageHelper 重新查询。
3.4 资产盘点模块与扫码逻辑
盘点模块是这个系统里比较出彩的功能。业务逻辑是管理员发起盘点任务,选择盘点的部门或分类,生成盘点单;盘点人用手机扫资产上的二维码或条形码,系统自动匹配资产编号,核对资产是否存在、位置是否准确。
二维码怎么生成?这里我用了qrcodejs2这个前端库,在资产详情页展示一个二维码,内容就是资产编号。盘点时用手机摄像头扫码,系统读取到资产编号,前端页面立刻跳转到资产确认页。
扫码盘点页面的核心逻辑:
// 监听扫码枪输入(扫码枪本质上是模拟键盘输入,以回车结尾) mounted() { window.addEventListener('keydown', this.handleScanInput) }, beforeDestroy() { window.removeEventListener('keydown', this.handleScanInput) }, methods: { handleScanInput(e) { if (e.key === 'Enter') { if (this.scanBuffer.length > 0) { this.verifyAsset(this.scanBuffer) this.scanBuffer = '' } } else { // 过滤掉功能键 if (e.key.length === 1) { this.scanBuffer += e.key } } }, async verifyAsset(code) { const res = await verifyAssetByCode({ assetCode: code }) if (res.code === 200) { this.$alert(`资产【${res.data.assetName}】盘点成功`, '提示', { type: 'success' }) } else { this.$alert(res.message, '提示', { type: 'error' }) } } }这个实现的妙处在于不需要额外对接摄像头或扫码 SDK,普通的扫码枪插上 USB 就能用,系统当键盘输入处理就行。如果你要用手机摄像头扫码,则需要引入vue-qrcode-reader组件,调用摄像头识别二维码,识别结果同样走verifyAsset方法。两种方式我都试过,扫码枪在仓库场景下更稳定,响应更快。
4. 打包部署与常见问题排查
4.1 后端打包与前端构建
SSM 项目的打包没有什么玄机,传统的 WAR 包方式,在pom.xml里配置<packaging>war</packaging>,Maven 执行clean package即可。要注意的是打包时需要跳过测试,避免 test 目录下的代码影响构建结果:
mvn clean package -DskipTests打出的 WAR 包扔到 Tomcat 的webapps目录下,启动 Tomcat 自动解压部署。如果你有 Jenkins 或 GitLab CI,可以把这个过程自动化,这里不展开。
Vue 前端的构建是另一个容易踩坑的点。生产构建前需要检查.env.production文件中的环境变量:
NODE_ENV=production VUE_APP_BASE_API=/prod-apiVUE_APP_BASE_API这个变量直接决定 axios 请求的 baseURL。在开发环境配的是/api,本地代理到后端;生产环境我配的是/prod-api,由 Nginx 把/prod-api前缀的请求反向代理到后端服务。如果没有配好这个变量,最常见的结果就是打包后所有接口请求 404——前端静态文件能打开,但数据全部加载不出来。
构建命令:
npm run build构建产物在dist/目录。Nginx 配置我给出一个可用版本:
server { listen 80; server_name asset.example.com; root /opt/asset-web/dist; index index.html; # 前端路由 history 模式的回退 location / { try_files $uri $uri/ /index.html; } # 接口反向代理 location /prod-api/ { proxy_pass http://127.0.0.1:8081/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 静态资源缓存 location ~* \.(js|css|png|jpg|jpeg|gif|svg|woff|woff2)$ { expires 7d; add_header Cache-Control "public, no-transform"; } }这里proxy_pass http://127.0.0.1:8081/;末尾的/极其重要,它表示去掉/prod-api/前缀后直接转发。如果忘了末尾的斜杠,后端接收到的 URL 会多出/prod-api,Controller 路由匹配直接 404。这个细节坑过无数人,务必留意。
4.2 高频问题快速排查速查表
我把这个项目开发过程中遇到的高频问题整理了成一张表,按“现象 → 原因 → 解决”的格式,方便后续项目直接对号入座。
| 问题现象 | 常见原因 | 解决方案 |
|---|---|---|
| 前端页面能打开,接口全部 404 | 生产环境 baseURL 与 Nginx 转发路径不一致 | 检查.env.production的VUE_APP_BASE_API和 Nginxlocation前缀是否匹配 |
| 本地开发跨域报错 | 未配置 devServer.proxy,或后端未开 CORS | 优先配代理;必要时后端加 CORS 配置 |
| 分页数据不正确,重复或缺失 | PageHelper 使用不当,startPage()后执行了其他 SQL | 确保PageHelper.startPage()后面紧跟目标查询 |
| 资产领用并发时数据错乱 | 缺少乐观锁或行锁 | 加version字段,UPDATE 时校验版本号 |
| Vue 打包后路由刷新 404 | 前端路由使用 history 模式,Nginx 未做回退 | 在 Nginxlocation /中添加try_files $uri $uri/ /index.html; |
| 登录后跳转路由,再次刷新却回登录页 | Token 存储位置不一致或路由守卫逻辑问题 | 统一用localStorage存 token,router.beforeEach 中同步校验 |
| 上传图片后刷新不显示 | 图片保存了相对路径但未配置静态资源映射 | 后端加资源映射,Nginx 或 Tomcat 配置虚拟目录 |
| 操作日志时间少了 8 小时 | JVM 默认时区与本地不一致 | 设置 JVM 参数-Duser.timezone=GMT+08或代码指定时区 |
这表里我想再展开说两个。
第一个是路由刷新 404 问题。Vue 2 的 history 模式在开发环境一切正常,但部署到 Nginx 后一刷新页面就 404,本质是 Nginx 收到/asset/list这个路径请求后,去文件系统找asset/list.html,找不到就返回 404。加一行try_files $uri $uri/ /index.html;的意思是:如果找不到对应路径,就返回index.html,由前端路由接管。说白了就是让 Nginx 把未知路径全部交给前端框架去处理。这个配置不加,部署后百分之百出问题。
第二个是时间少 8 小时。MyBatis 从数据库查出来的是java.util.Date,前端展示时会使用浏览器所在时区格式化。如果后端服务器时区设置不正确,MySQL 连接的serverTimezone参数没配,就会出现时间偏移。我当时的处理是在 JDBC URL 里加serverTimezone=Asia/Shanghai,同时给 JVM 启动参数加上-Duser.timezone=Asia/Shanghai,双保险。时间问题虽然定位不难,但不出问题你永远不会意识到它有多烦人。
4.3 Vue 和 SSM 联调时的一些心得
前后端联调阶段,最容易消耗时间的问题其实是接口字段不一致。后端定义的返回字段叫assetName,前端写成了name,结果页面永远显示空白,但浏览器 Network 面板里明明有数据。这种问题看后台日志很难发现,得打开 DevTools 逐步排查。所以项目开始前,最好用 Swagger 或 Postman 把接口文档先定好,字段名和类型全部对齐再开工。
我实际操作中的另一个技巧是给 axios 加debug模式。在开发环境里,请求拦截器顺便打一条 console 日志,输出完整请求 URL 和参数;响应拦截器输出返回数据。联调阶段打开控制台一目了然,能省下大量沟通时间。上线前把 debug 关闭即可。
还有就是 Vue 项目的依赖版本问题。Element UI 的 2.15.x 系列和 Vue 2.6.x 是兼容的,但如果你在package.json里用了^2.15.0这种范围版本,npm 安装时会拉最新小版本,某些小版本可能有细微行为差异。我的习惯是用package-lock.json锁定版本,团队协作时统一npm ci安装,避免大家装的依赖不一致导致行为不同。
5. 一些没有写在需求文档里的经验
项目上线后回头看,有几点是当时没预料到但最终影响很大的。
第一,资产编号规范最好在项目启动时就定好。我们一开始用的编号是ZC001这种纯流水号,后来客户提出按“资产分类+购置年月+流水号”重新生成,结果历史数据全部要刷一遍。如果前期就设计好编号规则,并且建唯一索引,后面能省事太多。
第二,列表页的大数据量问题一定要提前考虑。资产数量到十万级后,不做分页优化的话,即使 PageHelper 物理分页没问题,COUNT(*)查询也会变慢。我给资产主表建了几个常用查询的联合索引,比如(status, category_id)和(department_id, status),把列表页的响应时间从 800ms 降到了 120ms 左右。
第三,不要把业务逻辑全写在 Controller 里。我在这个项目里强制要求 Controller 只做参数接收和结果包装,具体逻辑全部下沉到 Service 层。这个规范带来的好处是后期加单元测试时非常方便。现在很多人图省事直接在 Controller 里写 SQL 操作,等到业务复杂度上来,代码就变成一坨不可维护的面条。
第四,前端路由懒加载一定要做。Vue 项目首屏加载时间直接影响用户体感。路由配置里用component: () => import('@/views/asset/AssetList.vue')的写法,Webpack 会自动代码分割,按需加载页面资源。这个项目的首屏从优化前的 4.8 秒降到 1.9 秒,效果立竿见影。
第五,日志策略要在一开始就规划好。SSM 项目我用的 logback,日志级别生产环境设为 INFO,资产领用、报废、审批这类关键写操作要单独输出业务日志。排查问题时,没有日志就只能瞎猜,有条件尽量把操作前后的数据快照打出来。
整个项目做了大概五个月,从需求梳理、数据库设计到前后端联调上线,走了一遍完整的 SSM + Vue 开发流程。虽然这套技术栈被很多人称为“老古董”,但我始终觉得,技术选型要服务于业务场景和团队实际能力,SSM + Vue 的组合在中小型管理系统里,开发效率、稳定性、人才供给都是有保障的。
最后分享一个实用的调试技巧:开发阶段遇到后端接口报错,先用 Postman 直接调接口,看后端返回的原始 JSON,确认后端没问题后再排查前端代码。很多人一上来就盯前端 Vue 代码,折腾半天发现是后端空指针,白白浪费时间。前后端分离的项目,定位问题首先分清责任边界,这是最高效的排查思路。