做了好几套公司内部管理系统资产类的项目算是最容易被低估、却最容易在后期出幺蛾子的类型。表面看就是增删改查但真要跑到借还、盘点、报废、权限这些场景数据一致性、页面交互、报表统计全都会堆到你脸上。这次做的一套公司资产网站系统技术栈定的是SpringBoot2 Vue3 MyBatis-Plus MySQL8.0前后端完全分离。整套源码带文档一起交付中间踩了不少坑也总结了不少可以直接落地的写法。这篇文章我就把从选型、建库、后端接口、前端页面到部署上线的过程完整梳理一遍把我实际用过的代码片段、配置、排查思路都放出来给后面要做类似系统的朋友一个参考。1. 选型考量为什么不是微服务也不是SpringBoot31.1 需求体量决定架构复杂度先看这个系统到底要解决什么问题公司有几千件固定资产分布在多个部门每件资产有购入信息、存放位置、当前使用人、状态在用、闲置、维修、报废。员工可以发起借用、归还管理员做审批和盘点。用户量撑死几百人数据量撑死几十万行。这个体量上微服务纯粹是自找麻烦。服务拆开之后分布式事务、服务注册发现、配置中心、链路追踪全都要配维护成本比业务成本还高。我用SpringBoot2单体应用一个JAR包跑起来部署在2核4G的服务器上毫无压力出问题排查也简单看一个日志文件就够了。1.2 SpringBoot2和SpringBoot3怎么选SpringBoot3要求JDK17起步SpringBoot2跑在JDK8上就行。这差别在团队里很现实很多老项目、内部工具链、生产环境的JDK都还是8强行上SpringBoot3意味着所有人的开发环境都要升级CI/CD的镜像也要改这不是一个资产管理系统该承担的成本。JDK8虽然年纪不小但配合SpringBoot2.7.x完全够用。Lambda表达式、Stream流处理资产列表的过滤和排序写起来干净利落。我举个例子统计本月新增资产long count assetList.stream() .filter(a - a.getCreateTime().isAfter(LocalDateTime.now().withDayOfMonth(1))) .count();这种代码在JDK8上跑得飞起。除非你这个系统要从零开始并且团队全是新环境否则SpringBoot2仍然是内部管理系统最稳的选择。1.3 前端为什么锁死Vue3Vue3最核心的价值是组合式APIComposition API。做资产表单的时候新旧资产录入、借用登记、归还登记都需要一组相似的表单逻辑我把这些逻辑抽成一个useAssetForm()组合函数在不同页面里复用代码量直接砍一半。加上Vite做开发服务器冷启动基本秒开热更新也比webpack时代快太多。TypeScript支持也成熟接口返回的数据结构可以直接定义成interface前后端联调阶段能挡掉大量的字段拼写错误。有一点要提醒Vue3生态虽然已经很成熟但Element PlusVue3版Element UI的个别组件在早期版本有样式和类型问题。建议直接用最新版不要用2022年前后的老版本我受过这个教训后面在部署环节会细说。1.4 MyBatis-Plus和JPA我为什么站前者JPA/Hibernate在简单场景下确实省事关联关系一复杂懒加载、N1查询问题就把人搞得很痛苦。MyBatis-Plus的思路更接地气——单表CRUD全自动复杂查询自己写SQL控制。MyBatis-Plus几个实用的能力继承BaseMapperTselectById、insert、updateById、deleteById就全有了不用手写XML。LambdaQueryWrapper条件构造器动态拼接查询条件特别顺手不用关心字符串拼接的空指针问题。内置分页插件物理分页不走内存假分页。内置乐观锁插件、逻辑删除插件这些在资产借还和已删除数据保留场景下非常有用。但我也明确一个边界多表关联查询、统计报表这种场景不要硬用Wrapper硬拼老老实实写XML。一套系统里单表CRUD用MyBatis-Plus复杂查询写SQL两边各干各擅长的活。2. 数据库设计资产表结构规划与MySQL8.0落地实操2.1 核心资产表的结构规划资产系统的核心表我实际落地是五张资产分类表、资产信息表、部门表、借用记录表、操作日志表。字段设计直接决定着后续开发的顺手程度这里给出我实际使用的核心结构。资产信息表是最关键的字段设计原则是够用但不过度冗余CREATE TABLE asset_info ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键, asset_code varchar(32) NOT NULL COMMENT 资产编号, asset_name varchar(128) NOT NULL COMMENT 资产名称, category_id bigint NOT NULL COMMENT 资产分类ID, specification varchar(255) DEFAULT NULL COMMENT 规格型号, supplier varchar(128) DEFAULT NULL COMMENT 供应商, purchase_date date DEFAULT NULL COMMENT 购入日期, original_price decimal(10,2) DEFAULT NULL COMMENT 原值, department_id bigint DEFAULT NULL COMMENT 所在部门ID, use_person varchar(32) DEFAULT NULL COMMENT 当前使用人, status tinyint NOT NULL DEFAULT 0 COMMENT 状态:0闲置,1在用,2维修,3报废,4待归还, remark varchar(512) DEFAULT NULL COMMENT 备注, version int NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, deleted tinyint NOT NULL DEFAULT 0 COMMENT 逻辑删除:0未删,1已删, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_asset_code (asset_code), KEY idx_category_id (category_id), KEY idx_department_id (department_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT资产信息表;这里有几个关键设计意图asset_code设置了唯一索引。资产编号在现实业务中就是唯一标识必须保证不重复后端插入前还要做一次查重校验。version字段是给乐观锁用的。资产借还和核销是典型并发场景不用悲观锁也能防止数据覆盖。deleted字段做逻辑删除。资产记录有别于普通业务数据删了之后审计要对得上所以用逻辑删除而不是物理删除。status字段我留了待归还状态。这个状态解决了借用审核通过但资产还没从借用人手里收回来的中间态很多系统就是因为漏了这个状态导致数据对不上。借用记录表存的是每次借还的流水字段相对简单asset_id、borrow_person、borrow_time、expected_return_time、actual_return_time、status借用中/已归还、operator_id。2.2 MySQL8.0和旧版的核心差异MySQL8.0最直观的变化是从utf8mb4变成默认字符集中文存储不会出现之前那种utf8mb4是选修课的尴尬。另一个要注意的是默认认证插件改成了caching_sha2_password导致老版本的Navicat、JDBC驱动连接失败。解决方案有两个方向JDBC驱动升级到mysql-connector-java8.0.x版本和MySQL8.0配套。如果某些老工具连不上可以在MySQL侧把认证方式改回mysql_native_password但这不推荐安全性和兼容性都欠佳。Windows下安装MySQL8.0的坑主要在初始化。解压后在bin目录执行mysqld --initialize-insecure注意用--initialize-insecure而不是--initialize前者root用户初始密码为空后者会生成一个随机密码写在数据目录的err日志里。新手建议用前者装完自己再ALTER USER rootlocalhost IDENTIFIED BY 新密码;设置密码免得找不到初始密码卡在那。2.3 Docker方式部署MySQL8.0的实际操作服务器上我更推荐用Docker跑MySQL8.0。我实际用的命令很简单但有几个参数必须带上否则数据丢失了才知道后悔docker run -d \ --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORDyour_password \ -e MYSQL_DATABASEasset_manage \ -v /opt/mysql-data:/var/lib/mysql \ -v /opt/mysql-conf:/etc/mysql/conf.d \ --restartalways \ mysql:8.0两个-v挂载是关键/var/lib/mysql是数据目录不挂载的话容器一删数据全没了/etc/mysql/conf.d挂载可以放自定义配置文件比如改max_connections。时区问题也顺手解决MySQL8容器默认UTC时区连接串上加serverTimezoneAsia/Shanghai即可。3. SpringBoot2后端实战MyBatis-Plus的正确打开方式3.1 项目和依赖的基础配置SpringBoot我用的2.7.18这是2.x系列的最终版本相对最稳定。核心依赖如下dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency注意MyBatis-Plus要选3.5.x版本别用3.4.x老版本新版对SpringBoot2的兼容性和分页插件细节都有修正。application.yml里我实际用的关键配置是spring: datasource: url: jdbc:mysql://localhost:3306/asset_manage?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpllogic-delete-field配置好之后MyBatis-Plus会在所有select操作自动追加AND deleted0。这个功能极其方便但要注意逻辑删除字段配置后如果你某条SQL确实想把已删除的数据查出来就得自己写原生SQL绕过。3.2 一个标准的实体类和Mapper层实体类我习惯用Lombok配合MyBatis-Plus注解Data EqualsAndHashCode(callSuper false) public class AssetInfo { TableId(type IdType.AUTO) private Long id; private String assetCode; private String assetName; private Long categoryId; private BigDecimal originalPrice; private Integer status; Version private Integer version; TableLogic private Integer deleted; private LocalDateTime createTime; private LocalDateTime updateTime; }Version让乐观锁插件生效TableLogic配合全局配置的逻辑删除。Mapper接口只需继承BaseMapperMapper public interface AssetInfoMapper extends BaseMapperAssetInfo { }就这么一个空接口基础的增删改查全都有了。写Service时继承IService实现类继承ServiceImpl连ServiceImpl里那五个基础方法的实现也能省略这是MyBatis-Plus省开发量最直观的部分。3.3 LambdaQueryWrapper动态查询实战资产列表页往往有多个筛选条件按名称模糊查、按分类过滤、按状态过滤、按价格区间过滤。用LambdaQueryWrapper处理这种动静组合的条件非常干净public IPageAssetInfo pageAssets(AssetQueryVO queryVO) { PageAssetInfo page new Page(queryVO.getPageNum(), queryVO.getPageSize()); LambdaQueryWrapperAssetInfo wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(queryVO.getAssetName()), AssetInfo::getAssetName, queryVO.getAssetName()) .eq(queryVO.getCategoryId() ! null, AssetInfo::getCategoryId, queryVO.getCategoryId()) .eq(queryVO.getStatus() ! null, AssetInfo::getStatus, queryVO.getStatus()) .ge(queryVO.getMinPrice() ! null, AssetInfo::getOriginalPrice, queryVO.getMinPrice()) .le(queryVO.getMaxPrice() ! null, AssetInfo::getOriginalPrice, queryVO.getMaxPrice()) .orderByDesc(AssetInfo::getCreateTime); return assetInfoMapper.selectPage(page, wrapper); }每个条件前面的布尔表达式如queryVO.getCategoryId() ! null决定了这个条件是否拼进SQL这样前端不传的参数自动忽略传了的自动加上不用手写大量if嵌套。3.4 分页插件的注册方式分页插件不注册的话selectPage就是假分页会把全表数据都查出来再内存裁剪。注册方式在MyBatis-Plus 3.5.x版本里是Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }分页插件在3.5.x中直接传DbType.MYSQL作为参数不要再用3.4.x的new PaginationInnerInterceptor()无参构造否则在某些环境下方言判断会出问题。这个我踩过分页SQL分页语法不对查出来的总数是错的。3.5 自定义SQL处理统计报表资产仪表盘要展示部门资产数量、总价值、状态占比。这种聚合查询不适合用LambdaQueryWrapper硬拼我直接在Mapper里定义方法public interface AssetInfoMapper extends BaseMapperAssetInfo { ListCategoryCountVO countByCategory(); ListDepartmentAssetVO countByDepartment(); }在resources/mapper/AssetInfoMapper.xml中写SQLselect idcountByCategory resultTypecom.asset.vo.CategoryCountVO SELECT c.category_name AS categoryName, COUNT(a.id) AS assetCount FROM asset_category c LEFT JOIN asset_info a ON c.id a.category_id AND a.deleted 0 GROUP BY c.id, c.category_name ORDER BY assetCount DESC /select注意LEFT JOIN时把a.deleted 0放进ON条件而不是WHERE否则左连接会被WHERE条件悄悄转换成内连接分类表中没有资产的那些分类就统计不出来了。这个细节我吃了两次亏才记住。4. Vue3前端实现从零到可用的资产管理系统页面4.1 初始化项目和集成Element Plus创建Vue3项目我直接用Vitenpm create vitelatest asset-web -- --template vue-ts cd asset-web npm install npm install element-plus axios vue-router piniaElement Plus在Vue3里比Element UI在Vue2里更顺滑组件全量引入对内部管理系统来说省心不用按需引入调半天样式import { createApp } from vue import ElementPlus from element-plus import element-plus/dist/index.css import App from ./App.vue const app createApp(App) app.use(ElementPlus)代码量上稍微大一点但开发体验好很多内部系统别在打包体积上过度纠结。如果真要按需引入记得用unplugin-vue-components和unplugin-auto-import这两件套否则总会漏组件样式。4.2 Axios封装和拦截器逻辑前端核心是请求层我封装了一个request.tsimport axios from axios import { ElMessage } from element-plus import { useRouter } from vue-router const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res.data }, error { if (error.response?.status 401) { localStorage.removeItem(token) useRouter().push(/login) } ElMessage.error(error.response?.data?.message || 网络异常) return Promise.reject(error) } ) export default request统一封装的作用后端返回结构约定为{ code, message, data }前端在响应拦截器里把data拆出来业务代码拿到的是干干净净的数据。401统一跳登录页避免每个页面都写一段token过期处理。4.3 资产列表页面的组合式API写法资产列表页我实际是这么组织的。重点在于状态的展示和操作按钮的联动script setup langts import { ref, onMounted } from vue import request from /utils/request import { ElMessage, ElMessageBox } from element-plus interface AssetItem { id: number assetCode: string assetName: string categoryName: string status: number usePerson: string originalPrice: number } const loading ref(false) const assetList refAssetItem[]([]) const total ref(0) const pageNum ref(1) const pageSize ref(10) const statusMap { 0: { text: 闲置, type: info }, 1: { text: 在用, type: success }, 2: { text: 维修, type: warning }, 3: { text: 报废, type: danger }, 4: { text: 待归还, type: warning } } const fetchList async () { loading.value true try { const data await request.get(/asset/page, { params: { pageNum: pageNum.value, pageSize: pageSize.value } }) assetList.value data.records total.value data.total } finally { loading.value false } } const handleBorrow async (row: AssetItem) { await ElMessageBox.confirm(确定借用${row.assetName}吗, 借用确认) await request.post(/asset/borrow, { assetId: row.id }) ElMessage.success(借用成功) fetchList() } onMounted(fetchList) /scriptstatusMap把状态数字映射成文字和标签颜色Element Plus的el-tag用起来直接绑statusMap[row.status].type。借用确认调用的是后端接口前端不做业务判断只做提示和刷新。这有个好处后端权限校验失败时前端通过拦截器统一弹错误不会出现前端绕过逻辑强行调用接口的问题。4.4 资产表单的校验处理新增和编辑资产共用一个表单组件Vue3组合式API让这种复用变得很自然。日期校验有个容易漏的细节购入日期不能晚于当前日期Element Plus表单校验规则这么写const rules { purchaseDate: [ { required: true, message: 请选择购入日期, trigger: change }, { validator: (_rule: any, value: string, callback: (error?: Error) void) { if (value new Date(value).getTime() Date.now()) { callback(new Error(购入日期不能晚于今天)) } else { callback() } }, trigger: change } ], originalPrice: [ { required: true, message: 请输入原值, trigger: blur }, { pattern: /^\d(\.\d{1,2})?$/, message: 请输入正确的金额格式, trigger: blur } ] }资产编号的正则校验也值得写/^ZC-[0-9]{6}$/这种格式约束能在前端就挡住大部分录入错误减少后端不必要的接口调用。5. 资产借还与数据一致性最容易翻车的隐藏难点5.1 并发借还会发生什么假设同一件闲置资产两个员工同时点了借用。后端接口一进来都查到status0闲置都往借用记录里插入都更新状态。如果没有任何控制最终库里会出现两条借用记录资产状态被后一条覆盖而实际这件资产只有一件。这就是典型的并发数据不一致。解决方案用乐观锁最合适不需要在数据库层面锁行也不影响用户体验。5.2 乐观锁事务组合方案MyBatis-Plus的乐观锁实现思路很清晰更新前比较version更新时SET version version 1 WHERE id ? AND version ?影响行数为0说明期间有人改过本次更新失败。资产借用接口的实际逻辑Transactional(rollbackFor Exception.class) public void borrowAsset(Long assetId, String borrower) { // 1. 查询当前资产 AssetInfo asset assetInfoMapper.selectById(assetId); // 2. 前置状态校验 if (asset.getStatus() ! 0) { throw new BusinessException(该资产当前不可借用); } // 3. 更新状态乐观锁版本号同时递增 AssetInfo updateAsset new AssetInfo(); updateAsset.setId(asset.getId()); updateAsset.setStatus(1); updateAsset.setUsePerson(borrower); int rows assetInfoMapper.updateById(updateAsset); // 4. 影响行数为0说明并发冲突了 if (rows 0) { throw new BusinessException(操作冲突资产状态可能已变化请刷新后重试); } // 5. 插入借用记录 BorrowRecord record new BorrowRecord(); record.setAssetId(assetId); record.setBorrowPerson(borrower); record.setStatus(1); borrowRecordMapper.insert(record); }Transactional保证更新资产状态和插入借用记录在同一个事务里要么都成功要么都回滚。乐观锁字段在实体上标注了Version之后MyBatis-Plus会在updateById时自动带上版本条件。这里有个容易忽略的点事务和乐观锁一起用时乐观锁冲突抛异常后事务要能正常回滚才算是安全的。上面代码里rows 0时抛的是RuntimeException子类BusinessException继承自RuntimeException配合rollbackFor Exception.class就能保证回滚。5.3 行级权限的考虑公司资产系统的用户分三类普通员工只能看自己借用和本部门资产资产管理员能维护全部资产数据部门主管能看到本部门资产并审批借用。前端通过路由守卫挡路由跳转但真正的权限控制必须在后端。我在资产查询接口里增加了数据范围的处理// 在Service层根据当前用户角色拼接数据权限 if (isAdmin()) { // 管理员不做数据过滤查全部 } else if (isDeptManager()) { wrapper.eq(AssetInfo::getDepartmentId, currentUser.getDepartmentId()); } else { wrapper.and(w - w .eq(AssetInfo::getUsePerson, currentUser.getUsername()) .or() .eq(AssetInfo::getDepartmentId, currentUser.getDepartmentId())); }后端接口做数据权限校验前端只做展示控制这是内部管理系统权限设计的底线不能省。6. 打包部署、Nginx配置与含文档交付6.1 后端JAR打包后端项目的打包比较常规mvn clean package -DskipTests打出来的JAR在target目录。生产环境启动时我推荐指定--spring.profiles.activeprod把数据库地址、账号密码等通过环境变量注入不要把生产配置写在源码里。6.2 前端构建和Nginx部署前端构建npm run build生成dist目录。部署时用Nginx托管静态文件并反向代理后端接口。这里有一个大坑Vue3的Vue Router如果用了history模式页面刷新会出现404。解决方式是Nginx配置try_filesserver { listen 80; server_name asset.example.com; root /opt/asset-web/dist; index index.html; location / { try_files $uri $uri/ /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; } }try_files $uri $uri/ /index.html的意思是找不到对应文件时回退到index.html由前端路由接管。没有这一行你在资产详情页按个F5就会看到Nginx的404页面。proxy_pass后面带不带斜杠差别很大。上面配置里http://127.0.0.1:8080/末尾带斜杠表示把/api/xxx转发为后端/xxx接口路径里的/api前缀被去掉了。如果后端Controller注解里也加了/api前缀这里就不要带斜杠否则会出现路径重复导致404这个要跟后端定义统一。6.3 含文档交付的内容清单项目带文档交付我实际给出的文档包括五部分缺一不可环境要求文档JDK8/Vue3环境/MySQL8.0版本要求以及安装步骤配截图。数据库初始化脚本建库建表SQL、初始数据SQL管理员账号、基础资产分类README里写清楚执行顺序。部署手册从拉取代码到前后端打包再到Nginx发布的完整过程逐条列出命令。接口文档用的是Apifox导出的HTML格式或者Markdown格式。每个接口标注请求方式、请求参数、响应示例、错误码。常见问题FAQ比如MySQL8认证插件连接失败、端口被占用、前端构建内存溢出等问题的解决方案。文档里我习惯写一句遇到问题先查FAQ查不到再提Issue能省掉大量重复答疑时间。这属于实操下来非常管用的交付技巧。6.4 Element Plus版本问题复盘前面提过的Element Plus老版本坑具体是这样的我用的是npm install直接安装的最新版按理说不会踩老版坑。但在某个旧项目中由于锁定了一年前lockfile里的element-plus2.3.3版本导致el-table的固定列在Chrome下出现错位。解决方式是升级到2.4.0以上或者干脆删除依赖重新npm install。如果你在做Vue3资产类页面遇到表格样式怪的问题先检查Element Plus版本。另一个生产环境的细节Nginx worker进程数配成auto会按CPU核数自动分配。登录页和后端接口的请求量不大但对响应速度的体验提升明显因为静态资源分发可以多进程并行。最后再说两句整套系统从零到上线我实际用了不到三周。大头时间不是花在CRUD上而是花在资产借还的并发控制、数据权限的边界处理和部署环境的排查上。MyBatis-Plus把单表操作压缩到极简Vue3的组合式API让页面逻辑更清晰但真正决定系统能不能在公司里用起来、用得好是那些看起来不起眼的细节资产编号唯一吗并发借还会不会冲突逻辑删除和审计对得上吗Nginx刷新会不会404如果这篇实战记录对你有帮助建议你动手做一个最小版本一张资产表、一个后端口、一个前端列表页先跑通再往里面加部门、加审批、加报表。自己跑一遍比我写一万字都管用。做完这套基础后面要扩展盘点、折旧这些功能你会发现思路已经清楚了。