十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于SpringBoot+Vue3的垂直电商系统设计与实现——以海鲜商城为例

基于SpringBoot+Vue3的垂直电商系统设计与实现——以海鲜商城为例 做海鲜生意的人应该都懂这个场景凌晨两三点跑到码头进货拿个小本子记着带鱼15箱、梭子蟹20筐、蛏子5斤天亮了在摊位上一手算账一手接电话客人问价、订鱼、催发货忙起来连口热水都喝不上。这几年好几个做水产批发和社区团购的朋友找我说想把自己那套本子电话微信的生意搬到网上去让客户能自己看货下单不用天天打电话问价。于是就有了这个前后端分离的网络海鲜市场系统后端用Java SpringBoot做接口服务前端用Vue3做商城页面和管理后台数据持久层用MyBatis操作MySQL数据库。整套系统覆盖了用户注册登录、海鲜商品展示和搜索、购物车、下单支付、订单管理、后台商品上下架和库存维护这些电商核心链路。这篇文章我就把这套系统的完整设计思路、数据库表结构、后端关键业务实现、前端页面逻辑以及联调部署时踩过的坑全部梳理出来给准备做同类垂直电商或毕设项目的朋友一个可以直接参考的落地方案。1. 一个海鲜商城系统到底在解决什么问题1.1 海鲜品类的特殊业务模式我在做这套系统之前先跟着一个做海鲜配送的朋友跑了半个月现场发现海鲜电商跟卖衣服、卖数码产品完全是两码事。首先是非标品问题。一件T恤就是一个SKUM码和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 ViteVue3的组合式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字段类型说明idBIGINT(20)主键自增usernameVARCHAR(50)用户名唯一索引passwordVARCHAR(100)BCrypt加密后的密码phoneVARCHAR(20)手机号avatarVARCHAR(255)头像地址roleTINYINT(1)0-普通用户1-管理员statusTINYINT(1)账号状态0-启用created_atDATETIME注册时间商品表t_product字段类型说明idBIGINT(20)主键nameVARCHAR(100)商品名称category_idBIGINT(20)分类活鲜/冰鲜/冷冻/干货originVARCHAR(100)产地如舟山乳山unitVARCHAR(20)售卖单位500g/只/盒price_typeTINYINT(1)0-固定价1-每日时价priceDECIMAL(10,2)当前售价original_priceDECIMAL(10,2)划线原价stockINT(11)总库存余量salesINT(11)销量imageVARCHAR(255)主图imagesTEXT详情图JSON数组格式detailTEXT富文本详情statusTINYINT(1)1-上架0-下架created_atDATETIME创建时间商品规格表t_product_spec字段类型说明idBIGINT(20)主键product_idBIGINT(20)商品IDspec_nameVARCHAR(50)规格名如1斤装3-4两/只priceDECIMAL(10,2)该规格售价stockINT(11)该规格库存购物车表t_cart字段类型说明idBIGINT(20)主键user_idBIGINT(20)用户IDproduct_idBIGINT(20)商品IDspec_idBIGINT(20)规格IDquantityINT(11)数量checkedTINYINT(1)是否选中结算user_id product_id spec_id建联合唯一索引防止同一用户重复添加同一规格的商品。订单表t_order字段类型说明idBIGINT(20)主键order_noVARCHAR(32)订单号唯一索引user_idBIGINT(20)下单用户total_amountDECIMAL(10,2)订单总金额statusTINYINT(1)0-待付款 1-待发货 2-待收货 3-已完成 4-已取消receiver_nameVARCHAR(50)收货人receiver_phoneVARCHAR(20)收货电话receiver_addressVARCHAR(255)收货地址remarkVARCHAR(255)备注created_atDATETIME下单时间pay_timeDATETIME支付时间订单明细表t_order_item字段类型说明idBIGINT(20)主键order_idBIGINT(20)订单IDproduct_idBIGINT(20)商品IDproduct_nameVARCHAR(100)商品名称快照spec_nameVARCHAR(50)规格快照priceDECIMAL(10,2)成交单价快照quantityINT(11)数量subtotalDECIMAL(10,2)小计金额这里体现了一个电商系统的重要原则订单明细必须做商品快照。商品名称、价格、规格都是下单那一刻的拷贝之后后台改价、下架、改名都不能影响历史订单的展示。很多初学做商城的人忽略这一点导致订单页面显示的数据跟着商品表一起变这是很严重的业务错误。2.2 MyBatis动态SQL与自定义映射MyBatis在这个项目里的核心价值是动态SQL。海鲜商品的列表筛选非常灵活按分类、按价格区间、按产地、按关键字搜索、按销量或价格排序这些条件在一个查询接口里组合原生SQL写起来极其痛苦动态SQL就跟搭积木一样。来看一个典型的商品分页查询Mapperselect idselectProductPage resultMapProductResultMap SELECT p.* FROM t_product p where if testcategoryId ! null AND p.category_id #{categoryId} /if if testkeyword ! null and keyword ! AND (p.name LIKE CONCAT(%, #{keyword}, %) OR p.origin LIKE CONCAT(%, #{keyword}, %)) /if if testminPrice ! null AND p.price gt; #{minPrice} /if if testmaxPrice ! null AND p.price lt; #{maxPrice} /if if testpriceType ! null AND p.price_type #{priceType} /if AND p.status 1 /where choose when testsortField price_asc ORDER BY p.price ASC /when when testsortField price_desc ORDER BY p.price DESC /when when testsortField sales ORDER BY p.sales DESC /when otherwise ORDER BY p.id DESC /otherwise /choose /selectchoose标签解决多分支排序where标签自动剔除第一个多余的AND配合PageHelper分页插件一个方法就能覆盖商城首页和搜索页的所有查询场景。购物车勾选商品后批量提交订单涉及订单明细的批量插入用foreach一次性插入insert idbatchInsertOrderItems INSERT INTO t_order_item (order_id, product_id, product_name, spec_name, price, quantity, subtotal) VALUES foreach collectionitems itemitem separator, (#{item.orderId}, #{item.productId}, #{item.productName}, #{item.specName}, #{item.price}, #{item.quantity}, #{item.subtotal}) /foreach /insert批量插入比循环单条插入性能高一个数量级数据量小的时候看不出区别订单量大之后这个优化非常明显。2.3 字段类型和存储上的几个坑这块是我踩坑最多的环节写出来希望你们直接避开。金额一律用DECIMAL绝对不用DOUBLE。这个不用多解释二进制浮点数算0.10.2都会出错金额精度失真的后果在电商系统里是不可接受的。DECIMAL(10,2)表示最多1亿元、两位小数海鲜零售场景完全够用。MySQL中int的坑。有个热词叫mysql中int5说的是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. 参数校验 ListCartCheckoutDTO items dto.getItems(); if (items null || items.isEmpty()) { throw new BusinessException(购物车为空无法下单); } // 2. 生成订单号 String orderNo generateOrderNo(); // 3. 遍历购物车勾选商品累加金额 扣库存 BigDecimal totalAmount BigDecimal.ZERO; ListOrderItem 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 vitelatest seafood-web -- --template vue cd seafood-web npm install npm install vue-router4 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:contentform.detail themesnow :toolbartoolbarOptions contentTypehtml / /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: GMT8 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_PASSWORDyour_password - MYSQL_DATABASEseafood_db - TZAsia/Shanghai volumes: - ./mysql/data:/var/lib/mysql - ./mysql/init:/docker-entrypoint-initdb.d command: - --character-set-serverutf8mb4 - --collation-serverutf8mb4_unicode_ci这里有几个在生产环境踩过坑的细节utf8mb4字符集必须显式指定不然后端存入的表情符号和生僻字会变成问号。TZAsia/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组件不然改起来你会怀疑人生。购物车合并结算、订单状态机、库存批次管理这几个模块是我个人认为整个项目里最值得反复打磨的地方。把这三块想透了就算以后不做海鲜换到水果、生鲜、烘焙任何垂直电商都能快速迁移过去。这也是我写这篇文章的初衷——不只是给一套能跑的代码而是给你一套能举一反三的设计思路。
返回列表