大概去年年中我接了一个项目帮本地一位做海鲜批发的客户把线下门店搬到线上。客户的要求很朴素——不用花哨但得能真实用起来早上进货入库顾客在线下单后台能打印订单、改库存、管理海鲜的规格和价格。做下来就是这套 SpringBoot Vue3 MyBatis 的前后端分离海鲜市场系统数据库用的 MySQL前端独立部署后端提供 REST 接口两边通过 JSON 交互。如果你正准备做一个电商类的毕业设计、面试项目或者接私活时担心海鲜这种“非标品”怎么处理规格和库存这篇文章应该对你有用。1. 海鲜市场系统的需求拆解非标品电商的特殊玩法1.1 海鲜业务的特殊痛点卖海鲜和卖标准工业品完全是两码事。图书有 ISBN手机有统一型号SKU 清晰库存数量稳定。但海鲜是典型的非标品活虾按斤卖黄花鱼按条卖螃蟹还能按公母分开标价今天进到的货大小不一明天批发市场的行情一变后台就得马上调价。我做的这个系统里最核心的一个设计决策就是不把每个商品写死成一个规格而是用“商品 规格”双表结构来支持灵活的售卖单位。我归纳了这批真实需求里的几个硬性规则一件商品可以有多档规格比如“中号 38-42 头/斤”和“大号 30-35 头/斤”价格完全不同。商品按克计算重量顾客下单时输入的是克数而不是件数。商品列表需要展示“今日到货”标记方便客户在下架未进货的商品时不至于误导顾客。后台必须支持快速改价因为海鲜价格随行就市一天能改好几次。这些需求决定了系统不可能照搬普通电商模板。如果按“一个商品一个价格一个库存量”来做客户用两天就会发现根本没法用。1.2 模块划分与功能清单整个系统从用户视角和管理视角拆成了两大端用户端H5 页面和管理端PC 后台。用户端面向 C 端顾客管理端面向店主和运营人员。模块功能点说明用户端商品列表、商品详情、搜索、购物车、下单结算、订单列表、地址管理面向顾客以微信内嵌 H5 为主管理端商品管理、规格价格管理、订单管理、库存管理、首页 Banner 管理面向店主负责日常运营订单中心订单生成、状态流转、订单查询、待发货列表贯穿两端的核心业务系统支撑用户登录注册、JWT 鉴权、文件上传、数据统计基础能力前后端分离通用部分从技术实现上来说这个项目的关键是前后端彻底分离后端出 REST API前端走 Ajax 数据交互后端不渲染任何页面。这样做的直接好处是将来客户想加一个小程序或 App后端 API 可以直接复用不用重新开发业务逻辑。2. 数据库表设计与库存扣减的细节2.1 核心表结构与字段设计数据库设计是我在整个项目里花时间最多的一步。海鲜市场的表结构其实脱胎于通用电商模型但有几个字段必须有针对性调整。核心表如下user用户表存微信 openid、昵称、头像、手机号product商品主表存商品名称、描述、主图、状态上架/下架、品类IDproduct_sku商品规格表存规格名、单价decimal、库存量、单位、排序category商品分类表比如“活鲜”“冰鲜”“贝类”“干货”cart购物车表存用户ID、SKU ID、克重orders订单主表存订单号、用户ID、总金额、状态、收货信息order_item订单明细表存商品快照、SKU 名、单价、克重、小计金额address收货地址表banner首页轮播图表admin_user后台管理员表这里有两个关键设计。第一个是金额一律用DECIMAL(10,2)绝对不用FLOAT或DOUBLE。原因很简单二进制浮点数在表示 0.29 这种十进制小数时存在精度误差几个小数相乘后累计对账会差出分钱这在财务上是不能接受的。BigDecimal 和 DECIMAL 能保证金额精确这点没有妥协余地。第二个是订单明细必须做商品快照。顾客下单那一刻的商品名称、价格、规格、图片都要冗余写入order_item不能只存一个商品ID。因为海鲜价格波动大如果顾客 3 天后在订单详情里看到的价格和当时买的不一样售后纠纷很难扯清楚。快照设计在可能改价的业务里是必须的。2.2 库存扣减与并发防超卖海鲜库存有两个特点一是很多商品到货量小活鲜可能就二三十斤必须精确扣减二是老顾客会卡着点来抢购并发访问集中在某个时间段容易出现超卖。我先给出表结构的核心片段再讲扣减逻辑CREATE TABLE product_sku ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL, sku_name VARCHAR(50) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, unit VARCHAR(20) DEFAULT 500, version INT NOT NULL DEFAULT 0 );扣减库存时不能用“先查再改”的老套路// 错误示范并发下会出现超卖 SKU sku skuMapper.selectById(skuId); if (sku.getStock() num) { throw new BizException(库存不足); } sku.setStock(sku.getStock() - num); skuMapper.updateById(sku);在并发请求下两个线程可能同时读到库存为 1然后都判断库存足够都把库存改成 0但实际卖出了两份。正确做法是用乐观锁在更新时做条件判断UPDATE product_sku SET stock stock - #{num}, version version 1 WHERE id #{id} AND stock #{num}这条 SQL 执行后如果返回值是 1说明这次扣减成功如果返回值是 0说明库存不足或数据已变直接抛异常提示用户。整个扣减操作要放在事务里和订单创建保持同一事务边界。2.3 订单状态机的定义订单状态我用了整型枚举存储而不是字符串。比如0待支付、1已支付待发货、2已发货待收货、3已完成、4已取消、5退款中。用数字的好处是节省存储空间、查询效率略高、前后端传输也短坏处是别人接手时看不懂所以我特意在项目里放了一张状态对照表注释写在枚举类上。订单状态流转是这个系统中被客户“挑刺”最多的部分顾客关心的主要是“我买的东西到哪了”店主关心的是“哪些订单需要今天去发货”。两个视角不能混淆所以订单列表接口必须支持按状态筛选而且默认排序要让“待发货”的订单排在最前面。3. 后端实现SpringBoot MyBatis 的工程项目组织3.1 分层结构与包设计后端工程我采用了比较经典的四层结构com.seafood ├── controller # 接口层 ├── service # 业务逻辑层 ├── mapper # MyBatis 数据访问层 ├── entity # 数据库实体 ├── dto # 请求参数封装 ├── vo # 响应给前端的对象 └── common # 异常、结果包装、工具类分层不是教条是有实际好处的。最典型的是VO和entity分离数据库里的product表可能有十几个字段但商品列表页只需要展示名称、图片、最低价、状态 4 个字段。如果直接把 entity 序列化返回给前端敏感字段容易泄露传输体积也大。单独定义ProductListVO只放需要暴露的字段代码更干净也更安全。Controller 层只做参数接收和结果包装不写业务逻辑。Service 层处理事务、校验、业务规则。很多新手容易把业务全写在 Controller 里看起来代码很快但后续加一个买多件商品的优化逻辑时就得改接口维护成本很高。3.2 MyBatis 的用法选择注解还是 XML我实际项目中采取的策略是简单的 CRUD 用注解动态查询和复杂结果映射用 XML。这个原则值得展开说说。MyBatis 支持两种写 SQL 的方式。注解方式适合单表、条件固定、SQL 短的场景代码全部写在 Mapper 接口里跳转方便public interface ProductSkuMapper { Select(SELECT * FROM product_sku WHERE product_id #{productId} ORDER BY price ASC) ListProductSku selectByProductId(Long productId); Update(UPDATE product_sku SET stock stock - #{num}, version version 1 WHERE id #{id} AND stock #{num}) int deductStock(Param(id) Long id, Param(num) Integer num); }XML 方式适合动态条件、多表联查、复杂分页查询。比如商品列表要按分类、关键词、价格区间组合筛选用注解拼 SQL 会很痛苦XML 的where和if标签就很顺手select idselectProductPage resultTypecom.seafood.vo.ProductListVO SELECT p.id, p.name, p.image, p.status, (SELECT MIN(ps.price) FROM product_sku ps WHERE ps.product_id p.id) AS min_price FROM product p where if testcategoryId ! null AND p.category_id #{categoryId} /if if testkeyword ! null and keyword ! AND p.name LIKE CONCAT(%, #{keyword}, %) /if if teststatus ! null AND p.status #{status} /if /where ORDER BY p.id DESC /select这里用子查询查最低价是为了在商品列表页直接展示“起价”让顾客一眼看到最便宜的那个规格。用 MyBatis 而不是 JPA 的理由也很明确电商项目里 SQL 优化是家常便饭MyBatis 让你对每条 SQL 都保持完全控制特别适合需要频繁调整查询逻辑的业务。3.3 JWT 登录鉴权实现前端是 H5用户每次请求都带着 token后端用一个拦截器统一校验Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { throw new BizException(401, 未登录); } Long userId JwtUtil.parseToken(token.substring(7)); UserContext.set(userId); return true; } }这个拦截器只拦截需要登录的接口路径像商品列表、Banner 这种公开接口不拦。用户上下文用ThreadLocal保存处理完请求后在afterCompletion里清掉避免线程池复用导致数据串用。4. Vue3 前端的落地组合式 API、Pinia 与接口对接4.1 工程初始化与目录结构前端工程用 Vite 搭建UI 组件库选 Element Plus。现在 Vite 是 Vue3 生态的主流选择冷启动快、热更新快开发体验比老一代的 Webpack 工程舒服很多。项目结构大概是这样src ├── api # 接口请求封装 ├── assets # 静态资源 ├── components # 公共组件 ├── router # 路由配置 ├── stores # Pinia 状态管理 ├── views # 页面组件 └── utils # 工具函数api目录单独出来很有必要。每个页面组件不直接写 axios 请求而是通过 api 层暴露的方法去调用。这样接口地址、请求参数、返回结构都集中管理后端接口一旦调整只需要改一个文件。4.2 Axios 封装与请求拦截axios 封装的核心逻辑很简单请求拦截器里带上 token响应拦截器里统一处理业务异常和登录失效。import axios from axios import { ElMessage } from element-plus import router from ../router const service axios.create({ baseURL: import.meta.env.VITE_API_BASE || /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) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res.data }, error { if (error.response?.status 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(error.response?.data?.message || 网络异常) return Promise.reject(error) } )这里一个容易被忽略的细节是响应拦截器里把res.data直接返回出去而不是返回整个response。这样业务代码里调用时不必要写两层res.data.data可以减少大量重复代码。4.3 购物车与称重交互的实现购物车是海鲜场景最特殊的地方。普通电商购物车操作的是“数量1/-1”这里操作的是“克重输入”。前端绑定一个输入框用户输入 750克单价是每斤 39.8 元那 750 克实际重量换算成斤是 1.5 斤显示金额约 59.7 元。我在前端用computed实时计算script setup import { ref, computed } from vue const skuPrice ref(39.8) // 每斤价格 const weight ref(500) // 克重默认一斤 const totalPrice computed(() { return (skuPrice.value * weight.value / 500).toFixed(2) }) /script后端接口传参时也传克重整数不在前端做任何舍入最终金额以服务端 BigDecimal 计算为准。前端展示只做参考防止用户改了小程序代码绕过校验造成金额误差。商品详情页的购买区域还有一个细节如果该商品下架了或者今日无货按钮要置灰并提示“今日售罄”这个状态是后端根据商品状态和今日库存算出来的而不是前端硬编码。5. 从源码到上线打包部署的完整流程5.1 环境准备与数据初始化我本地开发和后端部署用的环境如下版本尽量保持一致避免开发环境正常、生产环境报错JDK 1.8 以上项目用 JDK 8兼容性最好Maven 3.6MySQL 5.7 或 8.0Node.js 16数据库初始化时建议用项目自带的seafood.sql脚本里面除了建表语句还默认插入了几个测试商品和管理员账号。直接执行即可不用手动建库建表能省不少时间。5.2 后端打包与运行后端打包非常简单mvn clean package -DskipTests打包产物在target/目录下是一个可执行的 jar 包。启动命令java -jar seafood-server.jar --spring.profiles.activeprod生产中我建议把数据库连接信息、JWT 密钥等放到application-prod.yml里用--spring.profiles.activeprod切换环境不要把生产库密码写在开发配置里。这个项目里我特地把配置拆成了 dev 和 prod 两份避免误操作。5.3 前端构建与 Nginx 反向代理前端构建命令npm install npm run build构建产物在dist/目录把它上传到服务器后用 Nginx 托管静态文件并做反向代理server { listen 80; server_name your-domain.com; root /usr/share/nginx/html/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 / { try_files $uri $uri/ /index.html; } }这里最关键的是location /api/的反向代理前端请求/api/product/list会被转发到后端http://127.0.0.1:8080/api/product/list解决了跨域问题也避免了前端直接把后端端口暴露给用户。:8080端口本身用防火墙挡掉只对外开放 80。6. 一路踩过的坑与填坑方案6.1 金额精度BigDecimal 的正确用法金额精度问题我是在对接支付环节真正认识到严重性的。最开始图省事把价格字段设计成了double前几次测试没问题直到有一单单价 25.5 元/斤用户买了 1450 克按 2.9 斤算总价应该是 73.95 元结果前端算出来 73.95000000001。这要是上线了对账对不上用户看到的数字也很难看。后来把所有金额字段全部改为BigDecimal并且所有计算都通过BigDecimal方法完成字符串构造参数而不是直接传 double。这里分享一个经验new BigDecimal(19.9)和new BigDecimal(19.9)的结果完全不同前者会是 19.8999999...后者才是精确的 19.9。这个坑非常隐蔽一旦踩进去排查起来特别费劲。6.2 图片上传与静态资源映射海鲜商品的图片是运营日常操作里最高频的一部分客户每天要上传新到的货品照片。项目里做了图片上传接口后端把文件保存到本地目录同时做静态资源映射Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file: System.getProperty(user.dir) /upload/); } }部署时要注意jar 包运行所在目录要提前创建好upload/文件夹或者把路径配置成绝对路径否则上传时会报目录不存在。这个问题我第一次部署时就遇到过服务跑起来商品图片上传接口 500排查半天发现是目录权限问题。6.3 前后端联调字段命名和返回结构的统一联调阶段最容易打架的地方是字段命名。后端 Java 习惯用驼峰命名minPrice前端 JavaScript 也常用驼峰但如果哪次后端返回了下划线min_price前端没转换就会显示undefined。我的做法是在 SpringBoot 配置里统一开启驼峰映射mybatis: configuration: map-underscore-to-camel-case: true数据库下划线字段自动映射为 Java 驼峰属性前端拿到的 JSON 统一是驼峰风格整个链路口径一致。这个配置加和不加效果差别很大——不加的话MyBatis 查出来product_name字段无法映射到productName属性前端拿到的全是 null。6.4 关于这个项目后续的真正感悟如果你也想拿这套系统做类似的事情我的建议是不要只盯着代码跑通而是把它当作完整生意的数字化载体。客户实际用起来之后真正让我觉得有价值的功能不是花哨的 UI而是后台那个“今日改价”的需求响应速度——早市进完货手机拍张照传上去改个价格顾客在手机端马上能看到。这个系统现在的状态是能支撑日常售卖的代码结构上也预留了加接口的余地将来如果客户要做小程序或者对接第三方配送核心服务不需要重写。我在这个项目里的最大体会是做一个电商系统业务理解比技术炫技重要得多。你先想清楚海鲜按斤卖、价格随时变、库存到货才有这几个业务事实再看表结构和接口设计就顺理成章了。技术栈 SpringBoot Vue3 MyBatis 能解决这类系统的大多数问题剩下的都是产品细节和沟通问题。希望这篇复盘能帮你少走几步弯路。