大概去年年中,我接了一个项目:帮本地一位做海鲜批发的客户,把线下门店搬到线上。客户的要求很朴素——不用花哨,但得能真实用起来:早上进货入库,顾客在线下单,后台能打印订单、改库存、管理海鲜的规格和价格。做下来就是这套 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") List<ProductSku> 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 id="selectProductPage" resultType="com.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 test="categoryId != null"> AND p.category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND p.name LIKE CONCAT('%', #{keyword}, '%') </if> <if test="status != 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.6+
- MySQL 5.7 或 8.0
- Node.js 16+
数据库初始化时,建议用项目自带的seafood.sql脚本,里面除了建表语句,还默认插入了几个测试商品和管理员账号。直接执行即可,不用手动建库建表,能省不少时间。
5.2 后端打包与运行
后端打包非常简单:
mvn clean package -DskipTests打包产物在target/目录下,是一个可执行的 jar 包。启动命令:
java -jar seafood-server.jar --spring.profiles.active=prod生产中我建议把数据库连接信息、JWT 密钥等放到application-prod.yml里,用--spring.profiles.active=prod切换环境,不要把生产库密码写在开发配置里。这个项目里我特地把配置拆成了 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 能解决这类系统的大多数问题,剩下的都是产品细节和沟通问题。希望这篇复盘能帮你少走几步弯路。