做海鲜生意的人应该都懂这个场景:凌晨两三点跑到码头进货,拿个小本子记着"带鱼15箱、梭子蟹20筐、蛏子5斤",天亮了在摊位上一手算账一手接电话,客人问价、订鱼、催发货,忙起来连口热水都喝不上。这几年好几个做水产批发和社区团购的朋友找我,说想把自己那套"本子+电话+微信"的生意搬到网上去,让客户能自己看货下单,不用天天打电话问价。
于是就有了这个前后端分离的网络海鲜市场系统:后端用Java SpringBoot做接口服务,前端用Vue3做商城页面和管理后台,数据持久层用MyBatis操作MySQL数据库。整套系统覆盖了用户注册登录、海鲜商品展示和搜索、购物车、下单支付、订单管理、后台商品上下架和库存维护这些电商核心链路。这篇文章我就把这套系统的完整设计思路、数据库表结构、后端关键业务实现、前端页面逻辑以及联调部署时踩过的坑全部梳理出来,给准备做同类垂直电商或毕设项目的朋友一个可以直接参考的落地方案。
1. 一个海鲜商城系统到底在解决什么问题
1.1 海鲜品类的特殊业务模式
我在做这套系统之前,先跟着一个做海鲜配送的朋友跑了半个月现场,发现海鲜电商跟卖衣服、卖数码产品完全是两码事。
首先是非标品问题。一件T恤就是一个SKU,M码和L码是不同的规格;但一条带鱼可以是1斤装的、2斤装的,梭子蟹可以按只卖、也可以按斤卖,还有冰鲜和冷冻的区别,不同批次进货的价格也不一样。这意味着商品表不能简单地一条记录对应一个商品,必须支持规格和批次的概念。
其次是时价波动。海鲜的价格受出海天气影响极大,今天梭子蟹批发价30一斤,明天台风来了可能直接翻倍。所以商品价格不能像普通电商那样长期稳定,系统里必须区分"固定价"和"每日时价"两种定价模式,后台每天可以快速调整价格。
第三个是保质期和鲜活状态。活鲜、冰鲜、冷冻这三类商品的库存逻辑完全不同。活鲜死了就不能卖,冰鲜两三天之内要清掉,冷冻可以囤一个月。所以在设计库存表的时候,我加了一个批次维度,每次进货一条记录,可以追踪是哪一批货、什么时候进的、还剩多少。
这些业务特点最终都反映到了数据库设计和接口设计上,后面我会逐一展开。
1.2 技术选型:为什么刚好是这套组合
这套系统用的是 SpringBoot + Vue3 + MyBatis + MySQL 的组合,先说清楚每个技术选型背后的理由。
- SpringBoot 2.7.x:我没有直接用SpringBoot 3.x。这两年"springboot版本太高"的坑在开发者社区里特别常见,SpringBoot 3.x强制要求JDK17,很多云服务器和生产环境还在用JDK8,升级成本很高。2.7.x是兼容JDK8的最后一个稳定版本,生态成熟,资料也多,作为项目落地最稳妥。
- Vue3 + Vite:Vue3的组合式API写业务确实比Options API清爽,
computed、reactive这些响应式API在做购物车这种高频状态更新页面时性能更好。Vite冷启动快到几乎无感,开发体验比Webpack好太多。 - MyBatis原生:我知道现在很多人直接用MyBatis Plus,但在这个项目里我坚持用了原生MyBatis。原因有两个:一是海鲜商品的筛选查询条件非常灵活,MyBatis动态SQL在
<where>和<if>的组合下特别好用;二是MyBatis Plus的逻辑删除和批量插入在多表联查时其实挺容易出问题的(后面有一个专门的坑位讲这个),原生SQL反而直观可控。 - MySQL 8.0:事务支持、行级锁、JSON字段类型,对电商系统来说完全够用,而且部署成本低,Docker一条命令就能跑起来。
1.3 项目目录与模块划分
整个工程分为seafood-server(后端)和seafood-web(前端)两个独立目录,前后端完全分离,通过RESTful API通信。
后端目录(基于Maven分层):
com.seafood ├── common // 通用类:Result封装、异常处理、工具类 ├── config // 配置类:CORS、Jackson、拦截器注册 ├── controller // 接口层 │ ├── admin // 管理端接口 │ └── api // 用户端接口 ├── service // 业务逻辑层 ├── mapper // MyBatis Mapper接口 ├── entity // 数据库实体类 ├── dto // 请求/响应数据传输对象 └── interceptor // 登录鉴权拦截器前端目录(基于Vite创建):
src ├── api // axios接口封装 ├── assets // 静态资源 ├── components // 通用组件 ├── router // 路由配置 ├── stores // Pinia状态管理 ├── views │ ├── home // 商城首页、商品列表、商品详情 │ ├── cart // 购物车 │ ├── order // 下单、订单列表、订单详情 │ ├── login // 登录注册 │ └── admin // 管理后台 └── utils // 工具函数如果你打算远程部署跑通整个项目,这个结构基本可以直接作为蓝本,模块边界清晰,后续加功能也方便。
2. MySQL表结构设计与MyBatis持久层实战
2.1 核心业务表设计
表结构是整个系统的地基,海鲜电商的特殊性在这里体现得淋漓尽致。我用以下几张核心表支撑所有业务链路:
用户表t_user
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT(20) | 主键,自增 |
| username | VARCHAR(50) | 用户名,唯一索引 |
| password | VARCHAR(100) | BCrypt加密后的密码 |
| phone | VARCHAR(20) | 手机号 |
| avatar | VARCHAR(255) | 头像地址 |
| role | TINYINT(1) | 0-普通用户,1-管理员 |
| status | TINYINT(1) | 账号状态,0-启用 |
| created_at | DATETIME | 注册时间 |
商品表t_product
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT(20) | 主键 |
| name | VARCHAR(100) | 商品名称 |
| category_id | BIGINT(20) | 分类:活鲜/冰鲜/冷冻/干货 |
| origin | VARCHAR(100) | 产地,如"舟山""乳山" |
| unit | VARCHAR(20) | 售卖单位:500g/只/盒 |
| price_type | TINYINT(1) | 0-固定价,1-每日时价 |
| price | DECIMAL(10,2) | 当前售价 |
| original_price | DECIMAL(10,2) | 划线原价 |
| stock | INT(11) | 总库存余量 |
| sales | INT(11) | 销量 |
| image | VARCHAR(255) | 主图 |
| images | TEXT | 详情图,JSON数组格式 |
| detail | TEXT | 富文本详情 |
| status | TINYINT(1) | 1-上架,0-下架 |
| created_at | DATETIME | 创建时间 |
商品规格表t_product_spec
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT(20) | 主键 |
| product_id | BIGINT(20) | 商品ID |
| spec_name | VARCHAR(50) | 规格名,如"1斤装""3-4两/只" |
| price | DECIMAL(10,2) | 该规格售价 |
| stock | INT(11) | 该规格库存 |
购物车表t_cart
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT(20) | 主键 |
| user_id | BIGINT(20) | 用户ID |
| product_id | BIGINT(20) | 商品ID |
| spec_id | BIGINT(20) | 规格ID |
| quantity | INT(11) | 数量 |
| checked | TINYINT(1) | 是否选中结算 |
user_id + product_id + spec_id建联合唯一索引,防止同一用户重复添加同一规格的商品。
订单表t_order
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT(20) | 主键 |
| order_no | VARCHAR(32) | 订单号,唯一索引 |
| user_id | BIGINT(20) | 下单用户 |
| total_amount | DECIMAL(10,2) | 订单总金额 |
| status | TINYINT(1) | 0-待付款 1-待发货 2-待收货 3-已完成 4-已取消 |
| receiver_name | VARCHAR(50) | 收货人 |
| receiver_phone | VARCHAR(20) | 收货电话 |
| receiver_address | VARCHAR(255) | 收货地址 |
| remark | VARCHAR(255) | 备注 |
| created_at | DATETIME | 下单时间 |
| pay_time | DATETIME | 支付时间 |
订单明细表t_order_item
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT(20) | 主键 |
| order_id | BIGINT(20) | 订单ID |
| product_id | BIGINT(20) | 商品ID |
| product_name | VARCHAR(100) | 商品名称快照 |
| spec_name | VARCHAR(50) | 规格快照 |
| price | DECIMAL(10,2) | 成交单价快照 |
| quantity | INT(11) | 数量 |
| subtotal | DECIMAL(10,2) | 小计金额 |
这里体现了一个电商系统的重要原则:订单明细必须做商品快照。商品名称、价格、规格都是下单那一刻的拷贝,之后后台改价、下架、改名都不能影响历史订单的展示。很多初学做商城的人忽略这一点,导致订单页面显示的数据跟着商品表一起变,这是很严重的业务错误。
2.2 MyBatis动态SQL与自定义映射
MyBatis在这个项目里的核心价值是动态SQL。海鲜商品的列表筛选非常灵活:按分类、按价格区间、按产地、按关键字搜索、按销量或价格排序,这些条件在一个查询接口里组合,原生SQL写起来极其痛苦,动态SQL就跟搭积木一样。
来看一个典型的商品分页查询Mapper:
<select id="selectProductPage" resultMap="ProductResultMap"> SELECT p.* FROM t_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}, '%') OR p.origin LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="minPrice != null"> AND p.price >= #{minPrice} </if> <if test="maxPrice != null"> AND p.price <= #{maxPrice} </if> <if test="priceType != null"> AND p.price_type = #{priceType} </if> AND p.status = 1 </where> <choose> <when test="sortField == 'price_asc'"> ORDER BY p.price ASC </when> <when test="sortField == 'price_desc'"> ORDER BY p.price DESC </when> <when test="sortField == 'sales'"> ORDER BY p.sales DESC </when> <otherwise> ORDER BY p.id DESC </otherwise> </choose> </select><choose>标签解决多分支排序,<where>标签自动剔除第一个多余的AND,配合PageHelper分页插件,一个方法就能覆盖商城首页和搜索页的所有查询场景。
购物车勾选商品后批量提交订单,涉及订单明细的批量插入,用foreach一次性插入:
<insert id="batchInsertOrderItems"> INSERT INTO t_order_item (order_id, product_id, product_name, spec_name, price, quantity, subtotal) VALUES <foreach collection="items" item="item" separator=","> (#{item.orderId}, #{item.productId}, #{item.productName}, #{item.specName}, #{item.price}, #{item.quantity}, #{item.subtotal}) </foreach> </insert>批量插入比循环单条插入性能高一个数量级,数据量小的时候看不出区别,订单量大之后这个优化非常明显。
2.3 字段类型和存储上的几个坑
这块是我踩坑最多的环节,写出来希望你们直接避开。
金额一律用DECIMAL,绝对不用DOUBLE。这个不用多解释,二进制浮点数算0.1+0.2都会出错,金额精度失真的后果在电商系统里是不可接受的。DECIMAL(10,2)表示最多1亿元、两位小数,海鲜零售场景完全够用。
MySQL中int的坑。有个热词叫"mysql中int+5",说的是int类型字段做加法运算,结果超过21亿上限就会溢出报错。订单号、库存、销量这些数值字段,设计表时全部用BIGINT或者INT但要想清楚量级。库存用INT没问题,订单号我直接用了BIGINT,别给自己留隐患。
保存订单号不要用自增主键。订单号我从一开始就单独设计,格式类似20250115120000001,用日期+随机数生成,对用户来说方便查看下单时间,对系统来说避免通过订单号猜测订单总量。
MyBatis逻辑删除的坑。如果你用MyBatis Plus的逻辑删除,默认会在所有查询上自动拼接WHERE deleted = 0条件。订单表这种纯流水数据,如果也做逻辑删除,历史统计时经常忘了带条件,导致各种数据对不上。我这个项目里,用户和订单都用物理删除或软删除字段按需查询,而不是一刀切地逻辑删除。尤其是订单表,我压根没加deleted字段,订单就是流水,只能加状态,不能删。
3. SpringBoot后端:接口设计、事务与并发库存
3.1 JWT登录与权限拦截
用户认证我用的是经典的JWT方案,SpringBoot后端没有做Session存储,前端拿到token后每次请求放在请求头里,服务端通过拦截器统一解析校验。
JWT的引入依赖就一个jjwt,核心逻辑在拦截器里:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录注册接口 if (request.getRequestURI().contains("/api/login")) { return true; } String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { throw new BusinessException(401, "未登录"); } try { // 去掉"Bearer "前缀 token = token.replace("Bearer ", ""); Claims claims = Jwts.parserBuilder() .setSigningKey(SECRET_KEY) .build() .parseClaimsJws(token) .getBody(); // 将用户ID和角色写入请求上下文,控制器直接读取 UserContext.setUserId(claims.get("userId", Long.class)); UserContext.setRole(claims.get("role", Integer.class)); return true; } catch (Exception e) { throw new BusinessException(401, "登录状态已失效,请重新登录"); } } }管理端接口要单独校验角色,我加了一个@RequireAdmin注解,拦截器里读出来检查是不是管理员,防止普通用户直接调后台接口。这种注解方式比在业务代码里到处写if (user.getRole() != 1)干净很多。
密码存储用BCryptPasswordEncoder,这是Spring Security里的独立工具类,不引入完整Security框架也能直接用。密码加盐哈希之后落库,永远不存明文。
3.2 下单流程与事务边界
下单是电商系统里最核心的业务,也是并发和事务最复杂的环节。
我梳理的业务流程是:
- 用户从购物车勾选商品
- 后端校验商品是否上架、规格是否存在
- 锁定库存(关键步骤)
- 计算订单金额,生成订单号和订单明细快照
- 清空对应的购物车记录
- 返回订单信息,前端跳转支付页
第3步的扣库存是整个下单流程的并发安全核心。传统写法是先 SELECT 查库存,再判断库存够不够,最后 UPDATE 扣减——这个流程在并发场景下会因为读到旧库存而超卖。
我用的是乐观扣减,一条UPDATE语句搞定:
UPDATE t_product_spec SET stock = stock - #{quantity} WHERE id = #{specId} AND stock >= #{quantity}执行这条语句后再selectRowCount(),如果返回0,说明库存不足或者库存已经被其他并发请求扣完,直接抛异常回滚事务。这种方式靠数据库行锁保证原子性,不会超卖,也不用在应用层加锁,性能最好。
整个下单流程一个方法搞定,@Transactional保证原子性:
@Transactional(rollbackFor = Exception.class) public OrderVO createOrder(CreateOrderDTO dto) { // 1. 参数校验 List<CartCheckoutDTO> items = dto.getItems(); if (items == null || items.isEmpty()) { throw new BusinessException("购物车为空,无法下单"); } // 2. 生成订单号 String orderNo = generateOrderNo(); // 3. 遍历购物车勾选商品,累加金额 + 扣库存 BigDecimal totalAmount = BigDecimal.ZERO; List<OrderItem> orderItems = new ArrayList<>(); for (CartCheckoutDTO item : items) { ProductSpec spec = productSpecMapper.selectByIdForUpdate(item.getSpecId()); if (spec == null) { throw new BusinessException("商品规格不存在,请刷新后重试"); } // 乐观锁扣库存,防超卖 int rows = productSpecMapper.deductStock(spec.getId(), item.getQuantity()); if (rows == 0) { throw new BusinessException("商品库存不足:" + spec.getName()); } // 取商品最新信息做快照 Product product = productMapper.selectById(spec.getProductId()); OrderItem orderItem = new OrderItem(); orderItem.setProductId(product.getId()); orderItem.setProductName(product.getName()); orderItem.setSpecName(spec.getSpecName()); orderItem.setPrice(spec.getPrice()); orderItem.setQuantity(item.getQuantity()); orderItem.setSubtotal(spec.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); orderItems.add(orderItem); totalAmount = totalAmount.add(orderItem.getSubtotal()); } // 4. 创建订单主表 + 明细 Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(UserContext.getUserId()); order.setTotalAmount(totalAmount); order.setStatus(0); order.setReceiverName(dto.getReceiverName()); order.setReceiverPhone(dto.getReceiverPhone()); order.setReceiverAddress(dto.getReceiverAddress()); order.setRemark(dto.getRemark()); orderMapper.insert(order); // 批量插入明细 orderItems.forEach(item -> item.setOrderId(order.getId())); orderItemMapper.batchInsert(orderItems); // 5. 清空购物车已选商品 cartMapper.deleteCheckedItems(UserContext.getUserId()); // 6. 返回 OrderVO vo = new OrderVO(); vo.setOrderId(order.getId()); vo.setOrderNo(orderNo); vo.setTotalAmount(totalAmount); return vo; }这段代码有几个细节值得注意:selectByIdForUpdate用了FOR UPDATE行锁,这是悲观兜底策略,防止两个请求同时读同一行规格数据。但真正防超卖靠的还是乐观扣减,FOR UPDATE确保的是在计算出subtotal之前,这个规格的记录不会被修改导致价格不一致。
事务边界上,我把整个下单流程放在一个事务里。之前有同事提出把扣库存单独做预扣、支付完成后再真正扣减,这样设计更灵活,但复杂度高了一个量级。对于海鲜商城的业务量,一个事务搞定下单最可靠,宁可多锁一秒钟。
3.3 缓存策略与MyBatis二级缓存的坑
商品列表是访问最频繁的接口,肯定要加缓存。但这里有个很多项目都会踩的坑:不要无脑用MyBatis二级缓存。
MyBatis二级缓存的粒度是整个Mapper namespace,如果商品表和库存表彼此关联,一张表更新了,所有关联查询的缓存都必须失效。实际开发中,商品表每次上下架、调价都要清缓存,非常容易漏。而且多个查询条件拼装的列表缓存,key的设计也容易出问题。
我的方案是接口层手动控制缓存:Redis缓存商品详情,key是product:detail:{id},商品上下架或改价时主动删除对应key。列表查询因为有分页和筛选条件,缓存命中率不一定高,干脆不做缓存,靠数据库索引解决。MySQL单表几千条商品,加上索引,MySQL的查询性能完全够用,不需要在这个层面为缓存而缓存。
真正需要Redis的地方是购物车和用户会话的分布式一致性。当然,单体应用部署时Redis不是必需组件,如果只是校内演示或者小规模试运行,购物车直接存MySQL,登录状态用JWT无状态验证,甚至可以不引入Redis,少一个组件少一套运维包袱。我这个项目为了演示完整,引入了Redis并实现了商品缓存的主动失效逻辑,这部分代码在后续可以无缝切换到Redis集群。
4. Vue3前端:从零搭建商城界面
4.1 工程化初始化与目录划分
前端工程是标准的Vite + Vue3 + Pinia + Vue Router组合,用Vite创建项目很简单:
npm create vite@latest seafood-web -- --template vue cd seafood-web npm install npm install vue-router@4 pinia axios element-plus @element-plus/icons-vueUI组件库我选了Element Plus,理由很实在:组件全、对Vue3支持好、文档丰富、中文社区活跃,商城后台管理页面几乎不需要额外开发组件,表格、表单、弹窗、分页都是现成的。
前后端联调阶段,所有接口请求都经过axios实例统一封装。我在src/api/request.js里做了三件基础工作:
- baseURL指向后端服务地址
- 请求拦截器自动从Pinia里取token并放到Authorization头
- 响应拦截器统一处理HTTP错误和业务错误码,401时自动跳转登录页
import axios from 'axios' import { ElMessage } from 'element-plus' import { useUserStore } from '@/stores/user' import router from '@/router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.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) { const userStore = useUserStore() userStore.logout() router.push('/login') } ElMessage.error(error.response?.data?.message || '网络异常') return Promise.reject(error) } ) export default request4.2 购物车中的computed与响应式设计
Vue3的computed在购物车页面是重头戏。购物车列表是从后端接口拉的数据,但"勾选商品的总价"是用户每次点击checkbox都要立即计算的,这个计算必须在前端完成,不能每次勾选都请求后端。
购物车的Pinia store里维护一个items数组,每个item包含checked布尔值。计算已选总价用computed再合适不过:
import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { getCartList } from '@/api/cart' export const useCartStore = defineStore('cart', () => { const items = ref([]) const checkedItems = computed(() => items.value.filter(item => item.checked)) const checkedCount = computed(() => checkedItems.value.reduce((sum, item) => sum + item.quantity, 0) ) const checkedTotal = computed(() => checkedItems.value.reduce( (sum, item) => sum + item.price * item.quantity, 0 ) ) async function fetchCart() { items.value = await getCartList() } function updateChecked(id, checked) { const item = items.value.find(item => item.id === id) if (item) { item.checked = checked } } return { items, checkedItems, checkedCount, checkedTotal, fetchCart, updateChecked } })computed在这里和普通方法的区别在于缓存,只有items或checked变化时才重新计算,其他时候读的是缓存值。购物车页面上几十个商品,每次点击checkbox如果都重新遍历一次数组,体验还行;但订单确认页还要再计算一次运费、满减、优惠券,用computed的缓存优势就很明显了。
购物车页面的全选功能,也用一个computed带setter实现:
const allChecked = computed({ get() { return items.value.length > 0 && items.value.every(item => item.checked) }, set(value) { items.value.forEach(item => { item.checked = value }) } })这种带setter的computed写法,双向绑定全选checkbox的v-model时特别优雅,一次把全选/取消全选都搞定了。
4.3 管理端与富文本编辑器的实战集成
管理后台的商品编辑页需要富文本编辑器,用来编辑海鲜商品的详情描述。在热词里看到很多人搜"vue-quill-editor vue3",这个组件确实是Vue2时代王者,Vue3兼容性很差,官方维护基本停滞了。
我实测下来,Vue3项目用富文本编辑器,最省心的是@vueup/vue-quill-editor这个fork版本,API和原版几乎一样:
npm install @vueup/vue-quill-editor qusill注册组件后在商品编辑页直接用:
<template> <QuillEditor v-model:content="form.detail" theme="snow" :toolbar="toolbarOptions" contentType="html" /> </template> <script setup> import { QuillEditor } from '@vueup/vue-quill-editor' import '@vueup/vue-quill-editor/dist/vue-quill-editor.css' const toolbarOptions = [ ['bold', 'italic', 'underline', 'strike'], [{ list: 'ordered' }, { list: 'bullet' }], [{ header: [1, 2, 3, 4, 5, 6, false] }], ['link', 'image'], ['clean'] ] </script>注意富文本里的图片处理。本地开发时图片可以通过编辑器的图片按钮上传到后端,生产环境建议直接接OSS或者图床,否则服务器存储压力大,而且图片请求会拖慢商品详情页加载速度。
5. 前后端联调、部署上线与避坑实录
5.1 跨域问题处理
前后端分离项目,联调第一关就是跨域。我在开发和生产环境用了两种不同的方案。
开发环境用Vite的proxy代理,前端请求/api开头的接口都转发到后端的http://localhost:8080,浏览器看到的还是同源请求,天然避开了CORS:
// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })生产环境直接前后端同源部署,Nginx把域名根路径指向后端,/api路径也指向后端。但如果是前后端分别部署在不同域名/IP,就需要后端开启CORS。SpringBoot里用@Configuration方式配置一个全局CORS过滤器:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }一个小提醒:addAllowedOrigin("*")和setAllowCredentials(true)不能同时使用,浏览器规范不允许带凭证的跨域请求同时用通配符Origin,必须用addAllowedOriginPattern明确指定。
5.2 时间、金额与JSON序列化的细节
联调过程中,时间字段和金额字段是最容易出幺蛾子的。
后端实体类用LocalDateTime,如果直接Jaskson序列化,前端拿到的是全数字格式,可读性差。我统一在application.yml里配置了全局序列化规则:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 serialization: write-dates-as-timestamps: false但LocalDateTime类型不会走date-format配置,需要单独加一个全局配置类:
@Configuration public class JacksonConfig { @Bean public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { return builder -> { builder.serializers(new LocalDateTimeSerializer( DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); builder.deserializers(new LocalDateTimeDeserializer( DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); }; } }金额字段必须注意后端返回的是字符串还是数字。DECIMAL类型在Jaskson里默认序列化为数字,JavaScript浮点运算精度问题就会冒出来,前端0.1 + 0.2的经典问题。稳妥做法是金额字段序列化为字符串,或者干脆后端计算好所有小计、总额,前端只做展示不运算。我在项目里两种都做了:展示字段全部由后端计算好返回,购物车的临时试算是前端算的,但提交订单时会重新用后端金额校验。
5.3 Docker部署MySQL与后端服务
部署阶段我强烈推荐 Docker Compose,一条命令拉起MySQL、Redis和后端服务,不用在Linux服务器上手动装环境。
先看MySQL的docker-compose配置:
version: '3.8' services: mysql: image: mysql:8.0 container_name: seafood-mysql restart: always ports: - "3306:3306" environment: - MYSQL_ROOT_PASSWORD=your_password - MYSQL_DATABASE=seafood_db - TZ=Asia/Shanghai volumes: - ./mysql/data:/var/lib/mysql - ./mysql/init:/docker-entrypoint-initdb.d command: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_unicode_ci这里有几个在生产环境踩过坑的细节:
utf8mb4字符集必须显式指定,不然后端存入的表情符号和生僻字会变成问号。TZ=Asia/Shanghai指定时区非常重要,不然MySQL默认UTC时间,所有DATETIME字段查出来都比北京时间慢8小时,我第一次部署时看订单时间全是乱的,排查半天才发现是时区配置问题。
后端服务镜像构建好之后,在application.yml里把数据库地址从 localhost改成mysql,这是Docker Compose内部服务名的DNS解析。如果不在同一网络里,后端的数据库连接就找不到主机。
5.4 关于SpringBoot版本选择的最后提醒
开头我提过"springboot版本太高"的热搜词,这里展开说下选型建议。
SpringBoot 3.x是当前新项目的主流方向,但如果你手里是一个以快速落地为前提的项目,尤其团队还停留在JDK8阶段,那SpringBoot 2.7.x绝对是最省事的选择。SpringBoot 2.7和3.x的API差异不大,但依赖坐标变化(javax包名变成jakarta)足以让老项目迁移时焦头烂额。
我给朋友做的海鲜商城,后端就是JDK8 + SpringBoot 2.7.18,跑了一年多,稳定得很。后续如果真要升级到3.x,把项目里的javax.servlet替换成jakarta.servlet,再检查一下其他第三方库的兼容性,工作量在可控范围内。但没必要在项目初期就扛着版本负担做新功能。
6. 写在最后:关于这套系统的一些真实体会
项目做下来,我最深的感受是一个垂直品类电商系统的复杂度并不低,但SpringBoot + Vue3 + MyBatis这套技术栈的成熟度足够应对。海鲜商品的时价、规格、批次这些业务特性,让我在数据库设计和接口抽象上想了很多,这些思考比单纯写一堆CRUD代码有价值得多。
如果你打算参考这个项目做自己的版本,我建议你按这个顺序动手:先把数据库表结构建好,确认所有业务链路的数据落点;再写后端接口,用Postman把每个接口调试通;最后写前端页面,接到后端数据。不要一上来就写页面,更不要数据表还没建就开始写Vue组件,不然改起来你会怀疑人生。
购物车合并结算、订单状态机、库存批次管理这几个模块,是我个人认为整个项目里最值得反复打磨的地方。把这三块想透了,就算以后不做海鲜,换到水果、生鲜、烘焙任何垂直电商,都能快速迁移过去。这也是我写这篇文章的初衷——不只是给一套能跑的代码,而是给你一套能举一反三的设计思路。