SpringBoot2+Vue3+MyBatis-Plus服装生产管理系统设计与实现拆解
刚用一个周末把一套服装生产管理系统从零跑通前后端加起来小两千行代码数据库三十来张表最后部署到测试服务器上给车间用了三天反馈还不错。这篇文章就把这套 SpringBoot2 Vue3 MyBatis-Plus MySQL8.0 的服装生产管理系统的设计与实现过程完整拆解一遍从技术选型的理由、数据库怎么设计、核心业务怎么落地到前后端联调时踩过的坑全部记录下来给正在做类似管理系统的朋友一个参考。1. 项目从哪来解决了什么问题服装生产管理这个方向其实很典型它不是普通的进销存也不是纯粹的ERP而是夹在中间的一种存在。实际去服装厂或者贸易公司转一圈就会发现生产环节的信息断层特别严重——业务员接单靠Excel跟单员排产靠微信群仓库出入库靠手工记账财务核算靠月底加班对账。这套系统要解决的就是把这个链条上的订单、物料、生产工单、质检、出库这些环节全部串起来让每一步都有迹可循数据能追溯超期能预警。先说这套系统的技术背景。后端用了 SpringBoot2搭配 MyBatis-Plus 做持久层数据库是 MySQL8.0前端是 Vue3 全家桶。这个组合放到现在来看属于非常成熟的“业务系统黄金搭档”。SpringBoot2 的生态完善到基本你遇到的所有问题都能在 Stack Overflow 上找到答案MyBatis-Plus 把单表 CRUD 几乎简化到了极致Vue3 的 Composition API 写起来比 Options API 顺手得多组合式函数抽公共逻辑的时候特别方便。MySQL8.0 则是目前生产环境最稳妥的选择性能和默认的 utf8mb4 字符集都让人放心。系统核心业务按角色拆主要覆盖这几块订单管理销售订单的录入、变更、状态流转、物料管理面辅料的入库、出库、库存预警、生产管理生产工单的下发、进度汇报、工序流转、质检管理来料检验、过程检验、成品检验、成品仓库入库、出库、盘点、以及基础的数据统计看板。权限方面按角色区分业务员、跟单员、仓库管理员、质检员、车间主任、管理员每个角色看到的菜单和操作按钮都不一样。适合谁参考这套代码正在做毕业设计的学生这套系统的业务完整度和技术栈的“含金量”都很合适。刚入职需要快速上手公司现有 SpringBootVue 项目的初级开发可以通过这套代码理解完整的业务系统长什么样。想接私活搞一套生产管理类系统的开发者直接改改就能交付。模块核心功能涉及角色订单管理创建订单、订单明细、状态变更业务员、跟单员物料管理面辅料入库、出库、库存预警仓库管理员生产管理生产工单、工序记录、进度跟踪车间主任、跟单员质检管理来料检、过程检、成品检质检员成品管理成品入库、出货、盘点仓库管理员系统管理用户、角色、菜单权限管理员2. 技术选型拆解为什么是这套组合而不是别的2.1 后端选择 SpringBoot2 的考虑可能有人会问现在 SpringBoot3 已经出来一段时间了为什么还用 SpringBoot2这恰恰是我特意选它的原因。SpringBoot3 的底层是 Jakarta EE 9很多老项目的依赖需要适配尤其是一些生成环境的中间件、第三方 SDK适配成本可能远高于升级收益。而 SpringBoot2 经过这几年的沉淀社区资料极其丰富各种坑都被前人踩平了遇到问题基本搜一下就有答案。对于一个生产管理系统来说稳定和可维护性永远是第一位的技术的新旧反而是次要的。关键细节SpringBoot2 的版本也要选对。我用的 2.7.x这是 2.x 分支的最后一个大版本官方长期维护到 2023 年底之后才转向 3.x既兼容老代码的风格又修掉了大量历史 bug。如果你是做毕业设计选 2.7.18 这个最终版本最合适。2.2 MyBatis-Plus 把重复劳动降到最低MyBatis-Plus 是一个 MyBatis 的增强工具它做的核心事情是单表 CRUD 你就不用写 SQL 了。比如你要查一张订单表只需要写一个继承BaseMapperOrder的接口然后selectById、selectPage、insert、updateById这些方法直接就能用MP 在运行时帮你动态拼接 SQL。比如这段代码一个 Mapper 接口就搞定所有单表操作Mapper public interface OrderMapper extends BaseMapperOrder { // 单表操作不需要写任何 SQL直接继承 BaseMapper }但有一点我需要提醒MyBatis-Plus 适合单表操作和简单的多表关联复杂查询还是得自己写 SQL。比如订单列表要关联客户表、业务员表、订单明细表还要按物料编码筛选、按日期范围过滤这种情况下直接用Select注解或者 XML 文件写 SQL 更清晰、更高效。这套系统里我两种方式都用了单表 CRUD 用 MP 的 BaseMapper报表统计和复杂列表查询用原生 SQL。说到 MyBatis-Plus 和 Spring Data JPA 的区别不少新手容易纠结。我的看法很简单MyBatis-Plus 掌握 SQLJPA 帮你生成 SQL。Spring Data JPA 的抽象层更厚实体关系映射能力强但一旦需要复杂查询你要么写 JPQL、要么写 Specifications学习和调试成本都不低。而 MyBatis-Plus 的使用心智几乎和写 MyBatis 完全一致老 Java 开发上手零门槛团队协作时代码的可读性也更好。2.3 MySQL8.0 不比老版本默认配置更省心MySQL8.0 相比 5.7 最让我满意的地方首先是默认字符集就是utf8mb4不需要在建库的时候刻意指定。老版本默认是utf8mb3严格来说不是真正意义上的全 Unicode 支持表情符号和一些生僻字会报错。然后是窗口函数、CTE公共表表达式这些 SQL 语法层面的增强做排行榜、做同比环比统计的时候直接用 SQL 搞定不用在 Java 代码里写一堆循环计算。连接数据库的配置注意时区参数spring: datasource: url: jdbc:mysql://localhost:3306/garment?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver这里有个细节MySQL8.0 的驱动类名改成了com.mysql.cj.jdbc.Driver和 5.x 时代的com.mysql.jdbc.Driver不一样。另外serverTimezoneAsia/Shanghai一定要加否则 Java 连接时区不对插入数据库的时间会偏 8 个小时排查起来非常浪费时间。2.4 前端为什么选 Vue3 而不选 Vue2Vue3 稳定版出来这么久了如果有人还在推荐新项目用 Vue2那基本可以判断他对 Vue3 的生态了解还停留在两年前。Vue3 带来的核心提升一个是 Composition API 让逻辑复用变得简单直接另一个是响应式系统的底层重写性能更好内存占用更小。具体到这个项目Vue3 的 setup 语法糖 组合式函数自定义 hooks在实现权限控制、表单校验、状态管理这些场景时优势非常明显。比如角色权限控制我用一个usePermission组合式函数统一处理当前用户的角色和按钮级权限哪个页面能进、哪个按钮能点判断逻辑集中在一个文件里维护起来特别方便。而如果放 Vue2 的 mixin 里命名冲突、逻辑不清晰的问题会让你后期改起来想骂人。从 Vue2 迁移到 Vue3 最需要注意的变化v-model的用法变了组件上的modelValue替代了原来的valuev-bind$attrs的行为也变了filters被彻底移除了用计算属性或方法替代$children没了父子组件通信更推荐用v-model 自定义事件的方式Vue.use()变成了app.use()3. 业务模型设计服装厂的生产逻辑到底长什么样3.1 主链路拆解从订单到成品出库的完整闭环服装生产管理的核心逻辑可以理解为一条“五步链路”订单 → 采购/备料 → 生产计划 → 加工与质检 → 成品出入库。系统里的每一张表、每一个状态字段都是围绕这条链路设计的。以一件“女装衬衫”为例整个流程大概是这样的业务员录入一笔订单客户下 1000 件白色衬衫要求 30 天内交货。系统根据订单明细里的面料、辅料需求生成采购申请单。仓库收到面料后做来料检验合格入库库存增加。生产部根据订单创建生产工单分配裁剪、车缝、整烫三条主要工序。车间在每道工序完成后在系统里登记完成数量跟单员可以实时看到进度。成品完成质检后入库等待销售出库发货。这套系统的数据模型设计本质上是把这条链路里的每一个节点都拆成主表和子表主表存业务主信息订单号、客户、日期、金额、状态子表存明细SKU、数量、单价、颜色尺码。主表和子表通过外键字段关联是这套系统的数据架构骨架比如订单表和订单明细表通过order_id关联生产工单表和工序记录表通过work_order_id关联。3.2 核心数据表设计实战以下列表是这套系统里比较关键的几张表字段设计直接决定了业务逻辑是否跑得通表名核心字段设计要点userid, username, password, role_id, status密码存 BCrypt 加密哈希角色用外键关联customerid, name, contact, phone, address, level客户等级用于后续报表统计 VIP 客户占比orderid, order_no, customer_id, order_date, delivery_date, status, total_amountorder_no 用雪花算法生成全局唯一order_itemid, order_id, sku_id, quantity, unit_price, color, size订单明细一张订单对多行 SKUmaterialid, code, name, category, spec, unit, stock, safety_stock面辅料库存注意stock与safety_stock的联动预警material_stock_logid, material_id, change_type, quantity, remain, create_time每次出入库都记流水保证库存可追溯work_orderid, wo_no, order_id, product_id, plan_qty, completed_qty, status, assignee生产工单关联订单和生产计划production_processid, work_order_id, process_name, process_seq, plan_start, plan_end, actual_qty, status工序记录按 process_seq 排序quality_checkid, check_type, bill_id, check_result, defect_qty, checker, check_time质检记录check_type 区分来料/过程/成品finished_productid, product_id, warehouse_id, stock_qty, location成品库存location 字段用于仓库库位管理订单号生成我用的是 MyBatis-Plus 内置的雪花算法配置一个IdType.ASSIGN_ID的注解插入数据时 MP 自动生成全局唯一的 19 位数 ID作为订单号的主键。不用数据库自增的原因一个是高并发下自增 ID 会存在锁竞争另一个是自增 ID 做分库分表时会有主键冲突问题雪花算法天然支持分布式场景。Data public class Order { TableId(type IdType.ASSIGN_ID) private Long id; private String orderNo; private Long customerId; private Date orderDate; private Date deliveryDate; private Integer status; private BigDecimal totalAmount; }3.3 状态流转设计这些字段是整套系统的“交通信号灯”状态字段可以说是业务系统里最容易被新手忽视、但又是最核心的东西。这套服装生产系统里订单状态、工单状态、质检状态每一个状态的迁移都必须可控不能让系统出现“从已完成状态直接跳回待审核”这种逻辑漏洞。我用的方案是状态机思路在 Java 层写了一个状态流转校验工具类定义每个状态下允许流转到的目标状态集合。比如订单状态的合法流转路径是待确认 → 已确认 → 生产中 → 已完成 → 已关闭待确认 → 已取消生产中 → 已暂停 → 生产中恢复public class OrderStatusMachine { private static final MapInteger, SetInteger TRANSITIONS new HashMap(); static { TRANSITIONS.put(0, Set.of(1, 4)); // 待确认 - 已确认/已取消 TRANSITIONS.put(1, Set.of(2)); // 已确认 - 生产中 TRANSITIONS.put(2, Set.of(3, 5)); // 生产中 - 已完成/已暂停 TRANSITIONS.put(5, Set.of(2)); // 已暂停 - 生产中 TRANSITIONS.put(3, Set.of()); // 已完成 - 终点 TRANSITIONS.put(4, Set.of()); // 已取消 - 终点 } public static boolean canTransition(Integer from, Integer to) { return TRANSITIONS.getOrDefault(from, Set.of()).contains(to); } }这种设计的好处是前端不管怎么调接口后端都能兜底校验非法状态流转直接抛异常。说句实话很多管理系统做完觉得“能跑但是怪怪的”八成就是状态字段没约束好。4. 核心功能落地关键代码与实现细节4.1 后端核心控制器、服务、Mapper 三层怎么配合整体后端分层用的是最经典的 Controller → Service → Mapper 三层结构。Controller 只做参数接收和结果封装Service 写业务逻辑Mapper 做数据库操作。这套项目的代码结构按下分包├── controller/ # 接收前端请求 ├── service/ # 业务逻辑层 │ └── impl/ # 业务实现 ├── mapper/ # MyBatis-Plus 的 Mapper 接口 ├── entity/ # 数据库实体类 ├── dto/ # 数据传输对象 ├── vo/ # 视图对象 ├── config/ # 配置类MyBatis-Plus、Cors、拦截器等 ├── common/ # 通用工具类、统一返回结果、异常处理以“创建订单”这个操作为例后端处理流程是这样的Controller 接收前端传来的OrderDTO包含客户ID、交货日期、订单明细列表Service 里校验客户是否存在、交货日期是否合理、明细是否为空计算订单总金额生成订单主表记录遍历明细列表逐一插入子表用Transactional注解保证主表和子表要么全部成功要么全部回滚返回统一的结果对象Result.success(orderId)给前端Override Transactional(rollbackFor Exception.class) public Long createOrder(OrderCreateDTO dto) { // 1. 校验客户存在性 Customer customer customerMapper.selectById(dto.getCustomerId()); if (customer null) { throw new BusinessException(客户不存在); } // 2. 创建订单主表 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setCustomerId(dto.getCustomerId()); order.setOrderDate(new Date()); order.setDeliveryDate(dto.getDeliveryDate()); order.setStatus(0); // 待确认 orderMapper.insert(order); // 3. 插入明细 BigDecimal totalAmount BigDecimal.ZERO; for (OrderItemDTO itemDTO : dto.getItems()) { OrderItem item new OrderItem(); item.setOrderId(order.getId()); item.setSkuId(itemDTO.getSkuId()); item.setQuantity(itemDTO.getQuantity()); item.setUnitPrice(itemDTO.getUnitPrice()); item.setColor(itemDTO.getColor()); item.setSize(itemDTO.getSize()); orderItemMapper.insert(item); totalAmount totalAmount.add(itemDTO.getUnitPrice() .multiply(new BigDecimal(itemDTO.getQuantity()))); } // 4. 回填总金额 order.setTotalAmount(totalAmount); orderMapper.updateById(order); return order.getId(); }Transactional(rollbackFor Exception.class)这行注解值得特意说一下。默认情况下 Spring 的事务管理只在遇到运行时异常时回滚如果代码里 try-catch 住了异常又不往外抛事务不会回滚就会出现“主表没插进去子表插进去了”这种垃圾数据。所以要么抛出去要么在 catch 块里手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。4.2 分页查询MyBatis-Plus 内置分页插件的正确用法列表查询是后台管理系统最常用的功能订单列表、物料列表、用户列表无一例外都要做分页。最开始写的时候容易犯的错误是自己写LIMIT offset, pageSize然后手动拼 SQL。其实 MyBatis-Plus 提供了分页插件PaginationInnerInterceptor配置一次全项目通用。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }配置完之后分页查询只需要传入一个Page对象public PageResultOrderVO pageOrders(int page, int size, String keyword, Integer status) { PageOrder pageParam new Page(page, size); LambdaQueryWrapperOrder wrapper new LambdaQueryWrapper(); if (StringUtils.hasText(keyword)) { wrapper.like(Order::getOrderNo, keyword); } if (status ! null) { wrapper.eq(Order::getStatus, status); } wrapper.orderByDesc(Order::getCreateTime); PageOrder result orderMapper.selectPage(pageParam, wrapper); // 转 VO查客户名称、业务员姓名等冗余信息 return toPageResult(result); }LambdaQueryWrapper这种写法比写 SQL 的优势在于编译期类型安全如果实体类里没有某个字段编译器直接报错。再者 lambda 表达式让代码的可读性强了很多Order::getOrderNo一眼就能看出是在查订单编号字段。有一个性能细节需要提一下分页查询尽量不要在子表上做LIMIT。比如你要查订单列表并显示每个订单有几行明细如果先分页查订单再循环查明细会产生 N1 条 SQL。正确做法是先分页查订单主表拿到订单ID集合后一次性IN查询所有明细内存里组装关系或者干脆用一条连表 SQL 配合group_concat把明细汇总展示。4.3 前端核心Vue3 的动态路由和权限按钮前端这块我用了 Vue Router 的动态路由 后端返回当前用户角色可访问的菜单列表实现了菜单级和按钮级两层权限控制。用户登录成功之后后端根据角色查询权限菜单树返回给前端前端遍历菜单树动态注册路由。// 动态注册路由的核心思路 const modules import.meta.glob(../views/**/*.vue) export function setupDynamicRoutes(menus: MenuItem[]) { const routes menus.map(menu { const component modules[../views/${menu.componentPath}.vue] return { path: menu.path, name: menu.name, component, meta: { title: menu.title, icon: menu.icon }, children: menu.children?.map(child ({ path: child.path, name: child.name, component: modules[../views/${child.componentPath}.vue], meta: { title: child.title } })) } }) routes.forEach(route router.addRoute(route)) }这套方案的好处是权限变更只需要在数据库里改角色和菜单的关系前端不用重新发布版本。要注意的是路由守卫里要加一个“路由是否已注册”的判断否则刷新页面时动态路由会丢失直接白屏报 404。我当时的解决办法是在全局前置守卫里维护一个hasRegisterRoute标识用户刷新时重新拉取菜单并注册。按钮级权限我用的是自定义指令v-permission// 在 main.ts 里注册自定义指令 app.directive(permission, { mounted(el, binding) { const requiredPermission binding.value // 需要的权限标识如 order:create const userPermissions useUserStore().permissions if (!userPermissions.includes(requiredPermission)) { el.parentNode?.removeChild(el) } } })页面里这样用el-button v-permissionorder:create typeprimary新建订单/el-button相比在模板里v-if判断角色用自定义指令的收益是逻辑统一团队成员不需要在各自的页面里重复写权限判断的代码也不容易出现“这个页面我判断了角色 A那个页面忘了判断角色 B”的问题。4.4 库存预警如何实现定时任务 实时计算库存预警是服装厂老板最关心的功能之一。面料库存低于安全库存系统要主动提醒采购员补货。我是这么做的表结构里物料有stock当前库存和safety_stock安全库存两个字段。每次出入库操作更新stock时Python 写代码校验一下新值是否低于safety_stock如果低于直接生成一条库存预警记录存到stock_alert表里。这样做的优势是预警实时、逻辑准确不依赖定时任务扫描。同时我还写了一个每天凌晨 3 点的定时任务全表扫描所有物料的库存检查是否低于安全库存把遗漏的预警补上。这种“实时事件驱动 定时巡检”的双保险方式能防止某些极端情况下事件触发逻辑被异常绕过导致预警漏发。Component Slf4j public class StockAlertTask { Scheduled(cron 0 0 3 * * ?) public void checkStockAlert() { ListMaterial materials materialMapper.selectList(null); for (Material material : materials) { if (material.getStock() material.getSafetyStock()) { // 生成预警记录如果今天还没有预警就插入一条 log.info(物料 {} 库存不足当前 {}安全库存 {}, material.getCode(), material.getStock(), material.getSafetyStock()); stockAlertService.createAlert(material); } } } }定时任务别忘了在主启动类或配置类上加EnableScheduling注解这个老容易漏漏了之后项目启动不报错就是定时任务静默不执行排查半天。4.5 报表统计用 SQL 而不是 Java 循环系统里那个统计看板包括每月的订单金额趋势、各车间完工数量对比、物料月度消耗排行。这类统计报表我的做法是能 SQL 搞定的绝不用 Java 循环。举个例子统计每月的订单金额MySQL8.0 的日期函数可以直接按月份分组SELECT DATE_FORMAT(order_date, %Y-%m) AS month, COUNT(*) AS orderCount, SUM(total_amount) AS totalAmount FROM order WHERE order_date DATE_SUB(CURDATE(), INTERVAL 12 MONTH) GROUP BY DATE_FORMAT(order_date, %Y-%m) ORDER BY month这个 SQL 一次就把最近 12 个月的每月订单数和金额算出来了Java 层只需要把结果映射到实体里返回给前端。如果这个逻辑放 Java 里写先查一年订单按月份分组、求和、去重代码量大而且容易出错。做管理系统最重要的一条经验法则能用数据库完成的聚合操作就不要拿到应用层来做。5. 部署与运维从本机到服务器的实战过程5.1 MySQL8.0 在 Linux 服务器上的安装与初始化项目的生产环境用了 CentOS 7.9MySQL8.0 的安装用的是官方 Yum 仓库方式比编译安装省心太多。大致操作流程如下# 1. 下载并安装 MySQL 官方 Yum 仓库 wget https://dev.mysql.com/get/mysql80-community-release-el7-3.noarch.rpm rpm -ivh mysql80-community-release-el7-3.noarch.rpm # 2. 安装 MySQL 服务 yum install mysql-community-server -y # 3. 启动服务并查看临时密码 systemctl start mysqld grep temporary password /var/log/mysqld.log # 4. 登录后强制修改密码 mysql -uroot -p ALTER USER rootlocalhost IDENTIFIED BY 你的新密码;有几个坑必须提醒MySQL8.0 的密码策略默认是validate_password插件生效的要求密码至少 8 位且包含大小写字母、数字和特殊符号如果设置简单密码会直接报错。可以调低策略但生产环境建议保留默认强度。默认情况下 root 只允许 localhost 登录如果前端是独立服务器需要远程连接要单独创建远程用户并授权不要直接给 root 开远程。部署之后记得在防火墙放行 3306 端口否则外部连接会超时。对于远程连接MySQL8.0 的加密规则是caching_sha2_password老客户端比如一些旧版本的 Navicat连不上。如果遇到Authentication plugin caching_sha2_password cannot be loaded这个报错有两个解决方向一个是用高版本的数据库客户端另一个是把用户的加密方式改成 mysql_native_passwordALTER USER root% IDENTIFIED WITH mysql_native_password BY 你的密码;5.2 前端构建与 Nginx 配置Vue3 项目本地开发用的 Vite 服务器生产环境需要构建成静态文件然后用 Nginx 托管。构建命令是npm run build输出的文件在dist/目录下。然后把这些文件上传到服务器的/usr/share/nginx/html目录路径按自己习惯来Nginx 配置如下server { listen 80; server_name 你的域名或IP; root /usr/share/nginx/html; index index.html; # 前端 history 路由模式必须配这个否则刷新404 location / { try_files $uri $uri/ /index.html; } # 反向代理后端接口 location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }前端用history路由模式的话try_files这行是灵魂配置。如果没有它用户刷新/order/list这个路径时Nginx 找不到对应的物理文件直接返回 404。加了try_files $uri $uri/ /index.html之后所有前端路径都回退到 index.html由 Vue Router 接管。后台管理的 API 请求路径统一以/api开头Nginx 把/api/开头的请求反向代理到 Java 服务的 8080 端口。这样前后端部署在同一台服务器时前端代码里可以直接用相对路径/api/xxx发起请求不用写完整的 IP 和端口省去了跨域配置的麻烦。5.3 如何用 Docker 快速搭建 MySQL8.0如果你的服务器环境不想手动安装 MySQL用 Docker 是最快的方案。一条命令就搞定docker run -d \ --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD你的密码 \ -e MYSQL_DATABASEgarment \ -v /data/mysql:/var/lib/mysql \ -v /etc/localtime:/etc/localtime:ro \ --restart always \ mysql:8.0解释几个参数的含义。-v /data/mysql:/var/lib/mysql是把容器内的数据目录挂载到宿主机原因是容器被删除后数据还能保留这是生产环境最基本的数据安全意识。--restart always让 Docker 在容器意外退出或服务器重启时自动拉起 MySQL避免服务长时间宕机。挂载localtime是为了让容器内的时间和宿主机一致否则日志时间错乱调 bug 的时候容易误导判断。用 Docker 跑 MySQL 有一个注意点容器里的 MySQL 默认字符集不一定是我们想要的。进入容器查看一下docker exec -it mysql8 mysql -uroot -p SHOW VARIABLES LIKE character_set_server;如果发现不是utf8mb4可以在启动命令里加--character-set-serverutf8mb4 --collation-serverutf8mb4_unicode_ci或者建库时显式指定CREATE DATABASE garment DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;6. 开发踩坑实录那些让我调了一晚上的问题6.1 MyBatis-Plus 字段映射的“暗坑”MyBatis-Plus 默认开启了下划线转驼峰的映射也就是数据库字段order_no会自动映射到 Java 属性orderNo这是很方便的特性。但前提是数据库字段真的用了下划线命名。如果数据库里的字段叫orderno没有下划线Java 属性叫orderNoMP 就映射不上查询结果全是 null。排查这个问题第一反应肯定是看 SQL 是不是没查出来但实际上是查出来了只是映射失败。这种问题特别迷惑人因为列表刷出来不是报错而是所有字段都为空不仔细看很难联想到是字段命名规范问题。解决办法有两种保持数据库和 Java 字段命名风格的统一或者写TableField(orderno)手动指定映射关系。还有一个坑是TableId注解的type没配对。如果数据库主键是自增的Java 实体类没配IdType.AUTO插入时 MyBatis-Plus 会自己生成一个雪花 ID 去插入主键数据库会报主键冲突或重复值错误。如果数据库主键是手动赋值的却配了自增MP 会把 id 忽略掉插入时主键直接变成 0 或空。实体类的主键策略必须和数据库表的主键设计保持一致这一点写实体类的时候就要想清楚。6.2 MyBatis-Plus 的乐观锁插件版本号不是摆设系统里有一个场景车间主任张三和李四同时打开同一个生产工单张三录入完成了 100 件李四同时录入了 80 件后提交的会覆盖先提交的结果数据就丢了。要解决这种并发更新的问题我引入了 MyBatis-Plus 的乐观锁插件。实现方式简单说三步表里加一个整型字段version默认值为 0实体类对应字段加Version注解配置类里注册OptimisticLockerInnerInterceptor。之后每次执行updateById时MP 自动生成带WHERE version ?的 SQL更新成功后执行SET version version 1。Data public class WorkOrder { TableId(type IdType.ASSIGN_ID) private Long id; // 其他业务字段... Version private Integer version; }比如初始 version 是 0张三执行更新时 SQL 是UPDATE work_order SET completed_qty 100, version 1 WHERE id ? AND version 0更新成功。李四在这之后执行更新他的 SQL 还是WHERE version 0但此时数据库版本已经是 1 了影响行数为 0MP 的updateById返回值就是 0。代码里可以用这个返回值判断是否更新成功如果失败就提示“数据已被其他人修改请刷新页面重新操作”。这个机制对管理系统来说非常实用但要注意乐观锁只对 MP 自动生成的 CRUD 方法生效手写的 XML SQL 不会自动加 version 条件。如果需要就在 XML 里自己带WHERE version #{version}判断。6.3 Vue3 响应式深坑reactive 直接赋值丢响应前端开发中有一个 Vue3 新手特别容易踩的坑用reactive定义一个数组然后直接从接口返回数据整体赋值页面不更新。const list reactive([] as OrderItem[]) // 这样赋值页面不渲染 const res await getOrderList() list res.data原因很简单reactive的响应式核心是 Proxy 代理直接把这个变量重新赋值相当于换掉了原来的 Proxy 对象响应式连接就断了。正确做法是使用数组的 mutations 方法或者用ref// 方法一使用数组方法填充 const res await getOrderList() list.splice(0, list.length, ...res.data) // 方法二用 ref赋值自动解包 const list ref([] as OrderItem[]) list.value res.data我实际开发中的习惯是对象用 reactive数组和基本类型用 ref。这样从根源上避免这个问题。还有一个和响应式相关的问题是v-for中直接对遍历的对象做响应式新增属性。Vue3 用 Proxy 实现响应式新增属性本身是可以被代理的但如果一个对象是普通对象非 reactive中嵌套的直接新增属性不会触发更新。避免这种问题最简单的方式设计数据结构时把字段定义完整不要依赖前端动态加字段。如果实在需要动态扩展用reactive包裹整个对象或者用toRaw获取原始对象修改后再触发更新。6.4 Axios 请求封装与统一异常处理后台管理系统和后端接口交互我封装了一个统一的 Axios 实例做了一个核心处理响应拦截器统一解包错误状态统一提示。import axios from axios import { ElMessage } from element-plus import { useUserStore } from /stores/user const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器自动带上 token request.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }) // 响应拦截器统一处理业务码和 HTTP 错误 request.interceptors.response.use( response { const res response.data if (res.code 200) { return res.data } // 业务逻辑错误弹出提示 ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) }, error { if (error.response?.status 401) { // 登录过期跳转登录页 const userStore useUserStore() userStore.logout() router.push(/login) } else { ElMessage.error(error.response?.data?.message || 网络异常请稍后重试) } return Promise.reject(error) } )统一的拦截器带来的收益团队里每个人都遵循同一套接口规范不会出现一个人弹Toast、一个人写alert、还有一个人什么都不提示的情况。后端返回的code、message、data三段式结构就是前面提到的统一返回结果ResultT对象前后端约定好格式联调效率至少快一半。6.5 时间字段的时区问题前端显示少了 8 小时这是部署之后用户反馈“系统时间的日期不对”时踩的坑。现象前端页面显示的订单日期比数据库实际存储的时间少了 8 个小时。排查方向有两个一个是我在前面提到的 JDBC 连接参数没加serverTimezone另一个是Jackson 序列化时区配置问题。SpringBoot 的 JSON 序列化默认用的是 Jackson如果应用服务器时区和数据库时区不一致加上 Jackson 没配置正确的时间格式就可能出现时间偏移。我的解决办法是在 application.yml 里统一配置 Jackson 时间格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai同时数据库连接串加serverTimezoneAsia/Shanghai服务器系统时区也统一设置。一条经验系统里所有的时间从数据库到应用层到前端全链路统一用同一时区后端一律返回时间戳或者格式化字符串不要把java.util.Date直接扔给前端自己处理。7. 代码之外这套系统的设计心得与扩展方向7.1 项目目录结构代码规范维护半年之后你会感谢自己业务系统做得久了最痛苦的往往不是实现功能而是维护一个结构混乱的项目。这里分享几条我这套系统里坚持的规范算是给团队的约定Controller 只做参数接收和结果返回不写任何业务逻辑。这里哪怕只是多写了一个if判断都算越界。Service 层一定要有接口和实现分离。虽然小项目里接口只有一个人在用可能觉得多此一举但一旦系统膨胀要用 AOP 代理、要写单元测试 mock、要多实现切换的时候接口的价值就出来了。DTO 和 VO 必须分开。前端传来的参数不能直接用数据库实体类接收数据库实体类也不能直接返回给前端。原因是前端不一定需要所有字段比如密码、内部备注直接返回容易泄露敏感信息。统一返回结果不要用 Map。有些开发图省事接口返回直接MapString, Object前端拿到数据一头雾水。定义统一的ResultT泛型类明确code、message、data三要素接口文档、前端调用、后端内部一致性都更清晰。所有时间字段使用create_time、update_time命名并填充默认值。这里可以和 MyBatis-Plus 的MetaObjectHandler配合自动填充创建时间和更新时间。7.2 从这套代码起步后续能扩展哪些方向这套系统目前的业务边界在生产管理和基础数据管理但如果想继续扩展有以下几个方向我认为价值很高引入工作流引擎订单审批、采购审批、请假审批这些流程目前都是靠硬编码状态机控制流转的。如果流程变复杂比如多级审批、会签、驳回重走可以引入 Flowable 或 Camunda 这类工作流引擎把流程定义从代码中剥离出来。对接老板微信/企业微信通知库存预警、订单超期、工单完成这些事件做成消息推送让管理层在手机上就能收到。这一步对系统的使用体验提升是质的飞跃。做数据大屏目前管理后台有统计图表但真正的厂长办公室需要一个大屏展示今日订单、生产进度、库存周转率、质量合格率。这个方向用 Vue3 搭配 ECharts 完全能实现难点不在技术在于指标的口径定义要和业务方达成一致。引入消息队列削峰如果后续多个车间并发上报工时、出入库流水暴增可以考虑用 RabbitMQ 或 Kafka 做异步化削峰数据库压力会明显下降。但对这套系统当前的体量直接用 MySQL 事务是完全没有问题的不必为了引入而引入。7.3 安全加固与代码质量的几条建议虽然这套系统目前跑在内部网络里但安全问题依然值得重视。我建议至少做到这几件事密码不要明文存储。系统用户表的密码字段存的是 BCrypt 加密后的哈希串。Spring Security 或者 jBCrypt 库都提供了现成的加密器用户登录时用matches()方法校验明文和哈希是否对应。接口层面做参数校验。比如订单数量不能为负数、交货日期不能早于下单日期这种校验最简单的方式是使用 Spring 的Validated JSR-303 注解而不是在业务代码里手写一堆if判断。SQL 注入防线。MyBatis-Plus 的LambdaQueryWrapper天然防注入参数化查询但自己手写 SQL 时尽量不要用${}拼接一律用#{}。记住一条铁律永远不要信任前端传过来的任何参数。日志要记关键操作。谁在什么时候创建了订单、谁修改了物料库存、谁审核通过了质检单这些关键操作必须落日志出问题才能追溯。我个人在实际操作中的体会是业务系统最容易翻车的不是技术难点而是那些看起来微不足道的细节——状态流转没约束、时区没对齐、参数没校验、日志没记录。这套系统如果要把代码质量提升到生产级优先补的绝对不是什么高深框架而是这些“脏活累活”。最后再分享一个实际项目里很有用的小技巧管理系统的接口联调阶段前端总能遇到“接口报错了但后端说我没问题”的情况。我建议后端在所有接口返回时统一走自己封装的那个ResultT结构并且在 catch 块中把异常信息完整写到日志里。前端拿到统一的错误结构后可以直接把message弹在页面上而不是显示一个苍白的“网络错误”。联调时让前后端打开同一个日志平台效率会明显不一样。这套服装生产管理系统做完对我来说最有成就感的不是用了什么新框架而是车间真正用起来了跟单员不用再拿着 Excel 追着问进度。这就是做业务系统最踏实的回报。