上周正好有人问我,像“电商商品管理系统”这种听起来都快被做烂的题目,怎么才能落地得稍微有点含金量。我想了想,其实难点从来不在增删改查本身,而在于你清楚不知道每一步为什么这么做。这个项目表面上是“基于SpringBoot+Vue的电商商品管理系统”,说白了就是后台商品的建档、上下架、库存扣减、图片管理、检索分页这么一堆事,但真正做完一遍之后你会发现,它几乎能把Java后端和Vue前端最常用的知识点全部串起来,拿来做练手、做毕设、做面试项目都非常合适。这篇文章我就把整个实现过程从头捋一遍,包括技术选型的考量、数据库设计、后端核心链路、前端工程落地、联调排坑,以及最后的容器化部署,按实际动手的顺序写,方便你直接照着做或者拿去给自己的项目填坑。
1. 项目整体设计与技术选型
1.1 先说清楚这系统到底要干什么
在动手写代码之前,最重要的事情是把需求边界划清楚。电商商品管理系统不等于整个电商系统,它更偏向于后台管理的核心域。我最终确认的功能范围是:管理员登录认证、商品分类管理、商品档案的增删改查、商品上架下架、库存数量维护、商品主图与详情图上传、按名称/分类/价格区间/状态条件分页检索。这些功能听起来很基础,但每一个都牵扯到前后端协作方式、数据库设计、接口约定,甚至部署形态,所以千万别小看。
这个项目适合谁?大致是三种人:一是Java后端想接触Vue前端,想搞一个完整前后端分离项目的同学;二是准备毕业设计或者面试作品,需要一个“麻雀虽小五脏俱全”的项目的人;三是刚入职不久,被分到后台管理类需求,需要快速理解商品模块怎么设计的新人。我自己当初做这个项目的时候,最深的感受是:需求看起来简单,真正写起来全是有讲究的,比如库存并发扣减怎么保证安全、图片存本地还是走对象存储、检索用LIKE还是走全文检索,每一步都是在做取舍。
1.2 为什么锁定了SpringBoot+Vue这个组合
技术栈选择这个问题,不同背景的人会有不同答案,但在这个场景下,SpringBoot+Vue确实是最稳妥的组合。后端用SpringBoot的原因很直接:它把Spring生态里繁琐的XML配置几乎全部抹平了,起步依赖帮你把jar包版本对齐,内嵌Tomcat让你不用再折腾外部容器,现在的企业开发里它已经是绝对主流。我早期也用过SSM组合,后来切到SpringBoot再回头看,最大的感受就是“自动装配”这招实在太省心了——引入一个spring-boot-starter-web,一个spring-boot-starter-validation,大部分基础能力直接就位了,开发体验完全不同层次。
前端选Vue,核心是响应式数据绑定和组件化协作。商品列表页、表单弹窗、图片上传这些模块,天然适合拆成独立组件去维护。Vue最典型的使用方式是配合Vue Router做路由管理,配合Pinia或Vuex做状态管理,再用Axios跟后端通信。至于为什么选前后端分离而不是传统的服务端渲染模板,我的判断是:前后端可以并行开发,后端专心出接口,前端专心做交互,部署时也能各自扩展。这也意味着你需要处理好跨域、Token认证、接口文档等一系列联动问题,后面我会逐个讲。
我用一张简单的交互链路来说明这个系统的工作方式:浏览器里的Vue页面发起请求,比如查看商品列表,请求带着JWT令牌走到后端接口,SpringBoot的拦截器先校验Token,校验通过后请求落到Controller,Controller调用Service处理业务,然后经过Mapper访问MySQL数据库,最后结果以JSON格式返回给前端渲染。整个链路里,前端不直接碰数据库,后端也不管浏览器排版,各管一段,边界清清楚楚。
2. 数据库设计:商品的骨架怎么搭
2.1 核心表结构与字段设计
商品系统的数据库设计,核心是回答一个问题:商品在系统里到底是一张表还是多张表?我的结论是拆开,因为商品档案、分类、库存、图片分别有自己独立的生命周期。我给出的核心表设计大致是这样:
category表,商品分类表,字段包括id、name、parent_id、sort_order、status、create_time、update_time。分类支持二级结构,parent_id为0表示一级分类,这样做的好处是以后扩展商品属性要挂在分类上时,有地方可以关联。
product表,商品档案表,字段包括id、category_id、name、sub_title、main_image、price、original_price、stock、unit、status、sales、detail、deleted、create_time、update_time。status我用来标记上下架状态,1表示上架,0表示下架;deleted是逻辑删除标记,0正常,1已删除;price必须用DECIMAL(10,2)而不是FLOAT,这点非常重要,后面我会讲原因。
product_image表,商品图片表,字段包括id、product_id、image_url、sort_order、create_time。为什么不把多张图片塞在product表里?因为数据库设计的基本原则是“字段不可重复存储一组值”,多图场景应当单独建表或者用关联表,否则查询、排序、删除都别扭。
stock_log表,库存变更记录表,字段包括id、product_id、change_count、type、remark、create_time。这张表很值得做,哪怕你现在只需要一个库存数量,加了它以后排查数据问题会轻松十倍,也方便以后做库存流水追溯。
2.2 类型、索引和约束里那些容易出错的地方
建表SQL我直接写出来给你看,这一段包含很多我用真金白银换来的经验:
CREATE TABLE `product` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `category_id` bigint(20) unsigned NOT NULL DEFAULT 0, `name` varchar(100) NOT NULL DEFAULT '', `sub_title` varchar(200) DEFAULT '', `main_image` varchar(255) DEFAULT '', `price` decimal(10,2) NOT NULL DEFAULT 0.00, `original_price` decimal(10,2) DEFAULT 0.00, `stock` int(11) NOT NULL DEFAULT 0, `unit` varchar(10) DEFAULT '件', `status` tinyint(4) NOT NULL DEFAULT 0, `sales` int(11) NOT NULL DEFAULT 0, `detail` text, `deleted` tinyint(4) NOT NULL DEFAULT 0, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category` (`category_id`), KEY `idx_status` (`status`), KEY `idx_name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;几个关键细节:主键用bigint自增,我不推荐用UUID做主键,虽然分布式友好,但在MySQL里随机写入会造成页分裂,InnoDB索引性能会受影响,这种后台管理系统就没必要自找麻烦。价格字段用decimal(10,2),为什么?因为浮点数表示小数有精度误差,比如0.1在二进制里是无限循环小数,商品价格比较、求和时哪怕是差一分钱都可能是严重的脏数据。name字段加普通索引,因为检索商品名是最高频操作,但要注意LIKE '%关键词%'不会走索引,真正优化要靠全文索引或者搜索引擎,后台系统数据量不大时LIKE扫描也能接受,但你需要知道有这个限制。
还有一个经验:外键我建议逻辑关联,不用物理外键。电商系统里商品引用分类,分类表本身也可能调整,物理外键会带来插入校验开销和锁竞争,更新的时也很麻烦。我见过很多生产环境最后都选择拔掉外键,而是在应用层保证引用关系。deleted这个逻辑删除字段建议每个业务表都带上,别问为什么,当你误删一条数据却无法找回时,你会感谢这个设计。
3. 后端实现:SpringBoot商品管理核心链路
3.1 工程拆包与自动装配到底解决了什么
后端工程我按照典型的分层结构拆包,没有花里胡哨的微服务,一个单体工程搞定所有功能。基本包结构是这样:controller只做参数接收和结果返回,不写业务逻辑;service层写核心业务,接口与实现分离;mapper层负责数据库交互,我用的MyBatis-Plus,所以大部分单表CRUD直接用封装好的方法,不用手写XML;entity层放数据库实体;common层放统一返回结果、异常处理、JWT工具、拦截器配置。另外还有config包,放一些配置类,比如跨域配置、MinIO配置、WebMvc拦截器注册。
既然提到了SpringBoot,就必须说说自动装配。很多人只知道引入依赖就能用,但不清楚背后的魔法。其实原理并不复杂:SpringBoot的每个Starter依赖里都会包含一个META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件,文件内容是一串自动配置类的全限定类名,SpringBoot启动时会扫描这些文件,把对应的配置类加载进来,配置类里配合@ConditionalOnClass、@ConditionalOnMissingBean这些条件注解,满足条件才装配Bean。比如你引入了spring-boot-starter-data-redis,类路径下有RedisTemplate类,配置文件中又配了连接地址,Redis自动配置才会生效。理解这一点,后面遇到“我写了配置怎么不生效”时,你至少知道从哪个文件、哪个条件去找原因。
3.2 商品CRUD和分页检索的核心代码
商品新增和修改是同一个接口,用id是否为空来区分,这种做法在后管系统里很常见,能少写一个接口。我直接给出核心代码片段,重点标注容易踩坑的地方。
@PostMapping("/admin/product/save") public Result save(@RequestBody @Validated ProductSaveDTO dto) { Product product = new Product(); BeanUtils.copyProperties(dto, product); if (product.getId() == null) { product.setStatus(0); // 新增默认下架 productService.save(product); } else { productService.updateById(product); } return Result.ok(); }这里的ProductSaveDTO是我单独定义的一个接收参数的类,而不是直接把Product实体暴露给前端。为什么要多写一层DTO?因为前端传来的字段和后端实体字段不一定一模一样,比如前端可能传一个imageList数组,而实体里没有这个字段。后端接收数据时如果直接拿实体类接,会出现字段冗余或者JSON反序列化报错的问题。更关键的是,用DTO加上校验注解,可以精准控制“谁可以传什么字段”,避免恶意请求绕过校验。校验注解我习惯这样用:
public class ProductSaveDTO { private Long id; @NotNull(message = "分类不能为空") private Long categoryId; @NotBlank(message = "商品名称不能为空") @Size(max = 100, message = "商品名称不能超过100字") private String name; @NotNull(message = "价格不能为空") @DecimalMin(value = "0.01", message = "价格必须大于0") private BigDecimal price; private Integer stock; private Integer status; }分页检索我用了MyBatis-Plus的Page对象配合LambdaQueryWrapper。查询条件包括:商品名称模糊匹配、分类精确匹配、价格区间、状态条件,排序默认按创建时间倒序。这段逻辑属于很基础但必须写对的部分:
public Page<Product> searchProducts(ProductQueryDTO query) { Page<Product> page = new Page<>(query.getPageNum(), query.getPageSize()); LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Product::getDeleted, 0); if (StringUtils.hasText(query.getName())) { wrapper.like(Product::getName, query.getName()); } if (query.getCategoryId() != null) { wrapper.eq(Product::getCategoryId, query.getCategoryId()); } if (query.getMinPrice() != null) { wrapper.ge(Product::getPrice, query.getMinPrice()); } if (query.getMaxPrice() != null) { wrapper.le(Product::getPrice, query.getMaxPrice()); } if (query.getStatus() != null) { wrapper.eq(Product::getStatus, query.getStatus()); } wrapper.orderByDesc(Product::getCreateTime); return productMapper.selectPage(page, wrapper); }有几个细节必须强调:查询条件都要携带deleted=0,否则逻辑删除的数据会漏出来;LambdaQueryWrapper用方法引用代替字符串列名,编译期就能发现字段名拼写错误,这比老式QueryWrapper硬编码字段名好得多;分页时MyBatis-Plus需要配置分页插件,否则分页不会生效,这是很多新手踩过的坑。
3.3 库存扣减:事务边界和并发安全怎么处理
商品库存这个点,做后台管理系统时你可能觉得就是update product set stock = stock - 1而已,但如果把它放在真实的交易链路里,问题会放大:并发扣减可能导致库存变成负数,或者超卖。我们这次虽然只做后台管理,但库存修改这件事应该提前考虑并发安全。
我采用的方法是“数据库乐观锁”的变体,也就是带条件的更新语句,一次SQL完成检查和扣减:
public boolean deductStock(Long productId, Integer count) { int rows = productMapper.deductStock(productId, count); // 受影响行数为0说明库存不足或商品不存在 return rows > 0; }对应Mapper的更新语句是:
UPDATE product SET stock = stock - #{count}, update_time = NOW() WHERE id = #{productId} AND deleted = 0 AND stock >= #{count}为什么要用这种方式而不是“先查询再判断再更新”?因为并发场景下查询和更新之间的间隙会有其他线程同时操作,你查询到库存还有10件,等你更新时别人可能已经改成5件了,最终结果就会超卖。而把“库存充足”作为UPDATE的WHERE条件,如果当前库存不够,受影响行数为0,代码里就能感知到失败。这一步用原子更新的思路解决了并发问题,比加悲观锁更轻量。同时要给整个操作加上事务,比如扣库存和写流水必须同时成功或失败:
@Transactional(rollbackFor = Exception.class) public void deductStockWithLog(Long productId, Integer count, String remark) { boolean success = deductStock(productId, count); if (!success) { throw new BusinessException("库存不足"); } stockLogService.save(new StockLog(productId, -count, remark)); }@Transactional默认只在遇到RuntimeException时回滚,所以要显式指定rollbackFor = Exception.class,否则捕获了异常不抛出,事务不会回滚,流水照写、库存没扣,数据就全乱了。
3.4 MinIO加入SpringBoot:商品图片上传的落地姿势
图片存储选型,我直接排除了本地磁盘。原因很简单:单体本地磁盘方案在前端请求通过Nginx指向磁盘路径时还能用,但如果后端以后扩展成多实例部署,每台机器上文件各自存一份,就会出现同一张商品图在一个节点能访问、另一个节点404的窘境。对象存储是更合理的选择,考虑到部署成本和易用性,我选了MinIO,它兼容S3协议,社区版免费,docker一条命令就能跑起来。
引入MinIO到SpringBoot其实不复杂。先加依赖:
<dependency> <groupId>io.minio</groupId> <artifactId>minio</artifactId> <version>8.5.7</version> </dependency>然后在application.yml里配置:
minio: endpoint: http://127.0.0.1:9000 access-key: minioadmin secret-key: minioadmin bucket: product-images接着写一个配置类把MinioClient注入容器:
@Configuration public class MinioConfig { @Bean public MinioClient minioClient(MinioProperties properties) { return MinioClient.builder() .endpoint(properties.getEndpoint()) .credentials(properties.getAccessKey(), properties.getSecretKey()) .build(); } }上传接口的核心逻辑是生成唯一文件名、判断桶是否存在、不存在就创建、然后上传并返回可访问的URL。生成唯一文件名时不要用UUID裸拼,因为商品图片很多,全放在一个目录下文件数过多会影响性能,我习惯按yyyyMMdd分目录,文件名用UUID加原始文件扩展名。具体代码大概是:
public String upload(MultipartFile file) throws Exception { String originalFilename = file.getOriginalFilename(); String ext = StringUtils.getFilenameExtension(originalFilename); String datePath = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); String objectName = datePath + "/" + UUID.randomUUID() + "." + ext; if (!bucketExists(bucketName)) { minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build()); } minioClient.putObject(PutObjectArgs.builder() .bucket(bucketName) .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build()); return endpoint + "/" + bucketName + "/" + objectName; }这里有一个实测会踩的坑:如果MinIO控制台里设置了桶的访问策略为Private,那么上传成功后用endpoint + 路径直接访问会返回403。为了能让图片通过URL预览,你得在MinIO控制台或代码里把桶的访问策略改成Public,或者走预签名URL。我建议后台管理系统的商品图直接公开读,一方面图片本身不敏感,另一方面前端el-image组件直接展示URL更方便。如果你非要私有读,就得在保存图片URL时也保存对象名,后续通过getPresignedObjectUrl生成临时链接,但每次列表查询都要批量生成链接,复杂度高出不少,非必要不建议。
4. 前端实现:Vue侧的交易工程
4.1 工程初始化、环境配置与路由设计
前端部分我用Vue3作为主版本,配合Vite构建工具。如果你用的是Vue2项目,思路大差不差,但Vue3的组合式API在维护逻辑时确实更顺手。初始化工程这一步,很多教程还在让你用vue-cli,其实现在官方推荐的方式已经变了,直接一行命令:
npm create vue@latest这个命令会创建一个基于Vite的Vue3工程,过程中会询问你要不要装Router、Pinia、TypeScript等,我一般只勾选Router和Pinia。装完之后需要安装项目依赖:
npm install再安装UI组件库和Axios。后台管理系统我首选Element Plus,它的表格、表单、弹窗、上传组件都非常成熟:
npm install element-plus axios路由设计这块需要说清楚。商品管理模块的路由建议用懒加载方式,因为你不想首屏把全部页面一次性加载。路由配置大致是:
const routes = [ { path: '/login', component: () => import('@/views/Login.vue') }, { path: '/', component: () => import('@/layout/MainLayout.vue'), meta: { requiresAuth: true }, children: [ { path: 'product/list', name: 'ProductList', component: () => import('@/views/product/ProductList.vue'), meta: { title: '商品列表' } }, { path: 'product/form', name: 'ProductForm', component: () => import('@/views/product/ProductForm.vue'), meta: { title: '商品编辑' } } ] } ]路由有一个非常实用的小技巧:从列表页跳转到编辑页时,通过query传商品id:router.push({ path: '/product/form', query: { id: row.id } })。这样编辑页在onMounted里读取route.query.id,如果有id就是编辑模式,调用详情接口回填表单;没有id就是新增模式。这个方案比动态路由传参更直观,而且刷新页面后参数不会丢,因为query参数会体现在URL上。我用vue-devtools调试Vue应用的经验是,当你怀疑某个组件的属性更新了但页面没反应时,先看组件树里的props和data,再判断是不是响应式丢失问题。
4.2 Axios封装与统一错误处理
前端跟后端交互,我从来不直接在每个页面里裸调axios,而是统一封装一个请求模块。封装的目的有三个:统一注入Token、统一处理错误码、统一loading状态。这是前后端分离项目里很基础但也极其重要的一环,不做好后面联调会痛苦死。
我贴一个精简版的封装思路:
import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code === 200) { return res.data } if (res.code === 401) { localStorage.removeItem('token') router.push('/login') ElMessage.error('登录已过期,请重新登录') return Promise.reject(new Error('Unauthorized')) } ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message || 'Error')) }, error => { ElMessage.error(error.message || '网络异常') return Promise.reject(error) } ) export default service这里最值得说的是401处理:后端Token过期时返回401,前端统一在这里移除本地Token并跳转登录页,不用每个页面单独判断登录状态。这样设计之后,接口层面的错误处理在前端代码里只需要关注业务逻辑本身。
4.3 商品列表页和表单弹窗的实现细节
商品列表页由搜索区、表格区、分页区组成,这是后台管理页面最标准的样板。表格我直接用Element Plus的el-table,重点讲几个容易出错的绑定:
- 表格显示价格,不要直接用原始数字,要用过滤器或方法格式化为两位小数;
- 状态列建议用
el-tag,比如上架显示绿色、下架显示灰色,这个视觉效果在电商后台很重要; - 操作列放“编辑”“上架/下架”“删除”三个按钮,删除要用
ElMessageBox.confirm二次确认,防止误操作。
新增和编辑我采用弹窗内嵌表单的方式,而不是跳转独立页面。对于字段比较多的商品,弹窗会更高效。表单里有一个非常关键的绑定点:el-form的model必须和表单数据对象是同一个引用,rules校验规则要和字段名一一对应,提交之前要调用formRef.validate(),校验不通过不能提交。
图片上传组件这里再花点篇幅。Element Plus的el-upload组件推荐走后端给的上传接口,不要让组件默认做base64本地预览。我习惯这样配置:
<el-upload action="/api/admin/product/upload" :show-file-list="true" :limit="1" :on-success="handleUploadSuccess" :headers="uploadHeaders" > <el-button>上传主图</el-button> </el-upload>这里的action其实是交给Axios封装处理还是直接给原生action路径,取决于你的代理配置。如果用走Axios的方式,可能要用http-request自定义上传方法。我的经验是:为了统一携带Token,优先用自定义上传方法,否则需要手动给el-upload传headers。上传成功的回调里,后端会返回图片访问URL,把这个URL设置到表单的mainImage字段上,同时用el-image实时预览,这样用户能直观看到上传结果。
5. 前后端联调与问题排查
5.1 从第一次接口调用到全流程跑通
前后端分离之后,联调阶段往往是问题最多的。第一个最容易碰到的问题是跨域。Vite开发服务器默认在5173端口,而后端接口在8080端口,前端直接请求后端,浏览器会因为同源策略拦截响应。解决办法有两个层面:开发环境最省事的做法是在Vite的配置里加代理,让前端请求发送到和页面同源的地址,再由Vite服务器转发到后端:
// vite.config.js export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })这样前端请求/api/admin/product/list时,实际上是Vite开发服务器转发到了http://localhost:8080/api/admin/product/list,浏览器看到的请求是同源的,跨域问题自然消失。生产环境则有另一个坑:前端静态文件部署在Nginx上,Nginx接收/api请求后要反向代理到后端服务,这个配置我在后面部署章节会专门写。
第二个问题是接口路径对不上。前端调用/api/admin/product/list,后端Controller写的却是/admin/product/list,多了一层/api前缀。不要小看这种问题,联调阶段一半以上的404都是这个原因。我的习惯是后端统一加server.servlet.context-path=/api,这样Controller里写/admin/product/list,完整路径就是/api/admin/product/list。前后端都按这个约定来,就不会各写各的。
5.2 常见联调问题速查与排查工具
实际联调过程中,我最依赖的工具就是Chrome开发者工具里的Network面板和Vue Devtools。Network面板能直接看到请求URL、请求头、响应体,如果接口报错,响应体里的错误信息比什么都直观。Vue Devtools则是排查前端状态问题的神器,比如表格数据是空的,你先看Network确认接口返回了数据,再看组件里的data有没有赋值成功,如果赋了值但页面不显示,那才轮到怀疑渲染层,这样一层层排查效率很高。
我整理了一张高频问题速查表,都是我实测遇到过的坑:
| 问题现象 | 常见原因 | 解决办法 |
|---|---|---|
| 前端请求404 | 地址拼错、后端没加/api上下文、Controller路径不一致 | 对比Network里完整URL和后端RequestMapping路径 |
| 请求跨域 | 前端未配置代理、后端未配置CORS | 开发环境用Vite代理,生产用Nginx反代 |
| 登录后请求返回401 | Token没传、Token过期、后端拦截器校验失败 | 检查请求头Authorization,检查Token生成的密钥是否一致 |
| 图片上传成功但无法预览 | MinIO桶策略是Private、URL拼错 | 将桶策略设为Public,或使用预签名URL |
| 表单提交时报400 | DTO字段类型不匹配、JSON字段名不对、校验没通过 | 看响应体里的字段错误信息,用@Validated捕获 |
| 修改商品后列表没变 | 缓存问题或者前端列表未刷新 | 确认是否有Redis缓存,确认更新是否走的是同一个接口 |
| 删除商品后数据又出现 | 物理删除逻辑和逻辑删除混用 | 统一用deleted=1逻辑删除,并确保所有查询都带deleted=0条件 |
另外一个非常值得分享的经验:接口联调时不要前端和后端各看各的,先把统一返回结构定死。我用的返回结构是{ code, message, data },成功时code=200,data放业务数据;失败时code=500,message放错误信息。前后端都围绕这个结构开发,前端拦截器直接判断code,就不会出现后端抛了异常前端还一脸“我到底拿没拿到数据”的情况。
6. 容器化部署与收尾心得
6.1 用Docker部署SpringBoot+Vue的完整姿势
项目开发完最终要能跑起来给别人看,我推荐用Docker部署,这也是现在后端项目的通用部署方式。部署的核心是将整个系统拆分:前端用Nginx镜像托管静态文件并反向代理后端接口,后端用Java运行环境镜像跑SpringBoot的jar包,MySQL和MinIO各自用官方镜像,最后用docker-compose编排起来。
后端镜像我用多阶段构建,避免把整个Maven构建环境带进运行镜像。Dockerfile大致长这样:
FROM maven:3.9-eclipse-temurin-11 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn package -DskipTests FROM eclipse-temurin:11-jre WORKDIR /app COPY --from=builder /app/target/product-manager.jar ./app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar", "--spring.profiles.active=prod"]这个Dockerfile有两个容易掉进去的坑:第一,mvn dependency:go-offline这一步如果把源码复制放在前面,每次源码改动都会导致依赖下载层缓存失效,构建会很慢;第二,基础镜像的JDK版本必须和后端打包时一致,否则可能出现“UnsupportedClassVersionError”,这个错误的原因很隐蔽,但报错信息会直接告诉你class文件版本和当前JDK版本不匹配。
前端镜像更简单,直接基于Nginx镜像,构建时把打包产物复制进去,同时挂一份Nginx配置。打包命令是:
npm run build产物在dist目录,Nginx配置最关键的是处理/api反向代理和单页应用路由重写:
server { listen 80; server_name localhost; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://backend:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }proxy_pass后面的地址写成http://backend:8080是让Nginx在Docker网络内部解析后端容器名,这是docker-compose的通信方式,不能用localhost,否则前端容器里的Nginx根本找不到后端的地址。try_files那一条是前端路由刷新不404的关键,没有它,你在商品列表页按F5刷新,Nginx会去找一个不存在的真实路径,然后返回404,加上它之后一切未知路径都回退到首页,Vue Router接管后再根据URL渲染对应组件。
docker-compose.yml里至少编排四个服务:frontend、backend、mysql、minio。数据库和对象存储要挂载数据卷,否则容器一删数据就全没了,这一点千万不能省。
6.2 我踩过的坑和后续还能怎么扩展
最后分享几个这个项目里我实际踩过的坑。第一个是Vue的响应式问题:在组合式API里,如果一个对象深层的属性没有被提前声明,直接赋值时Vue的响应式系统可能检测不到变化。最典型的例子是表单对象里有个images数组,你直接通过索引修改其中一项:form.images[0] = newUrl,页面不会更新。解决办法是提前在reactive对象里把images: []初始化好,或者用this.$set、数组的push/splice方法触发更新。第二个坑是前后端日期格式不一致:后端返回2024-06-01 12:00:00,前端如果直接用字符串展示没什么问题,但如果你要比较大小或传到日期选择器里,就需要统一约定格式,最好在后端配置Jackson的日期格式化全局规则,同时前端在取出数据时也统一转换。第三个坑是数据库连接池耗尽:调试分页接口时如果每页都查询一个大字段,连接占用时间会很长,如果后端日志里频繁出现连接超时,优先检查查询是否带了大字段detail,商品列表查询尽量只查需要的列,详情才查询完整内容。
这个系统后续能扩展的方向其实很多:加一个基于Redis的缓存层,把商品列表缓存起来,减少数据库压力;加一个Elasticsearch做全文检索,替代LIKE模糊查询;加一个消息队列做库存扣减的异步化,把扣减操作放在队列里削峰填谷。从我的实际经验来看,任何一个方向单独拿出来都够你再做三个月,而且每一个都比单纯堆功能更能体现工程能力。这个项目做完之后,我对前后端分离这套模式的理解,已经不只是停留在“会调接口”这一个层面,而是对整个数据的流转、权限的校验、部署的编排都有了完整的体感。做这种系统,最重要的不是炫技,而是把每一个环节的“为什么”想清楚,等你想清楚了,代码怎么写都是顺理成章的事。