基于Spring Boot的城乡商城协作系统:从需求到部署的实战解析
我自己做过好几个类似的商城类项目前后端分离、单体架构、微服务都碰过。这次想重点聊聊一个比较特殊的场景基于Spring Boot的城乡商城协作系统。很多人一听“商城系统”就觉得是老掉牙的CRUD其实一旦把“城乡协作”这个业务属性加进去整个系统的设计思路、功能边界、数据流转都会变得有意思很多。如果你是准备做毕业设计、课设或者想接一个偏电商类的私活这篇文章应该能帮你少踩不少坑。我会从需求拆解、技术选型、数据库设计、核心功能实现到常见问题排查把整个项目的关键点都过一遍尽量做到拿来就能用。1. 项目概述与整体设计思路1.1 “城乡商城协作”到底是什么先把这个概念说清楚。普通商城系统核心是“用户逛→下单→支付→收货”逻辑相对直白。但城乡商城协作系统多了一个关键词是“协作”。这个协作体现在几个层面货源协作乡村农户/合作社能发布农产品、土特产城市商家或者平台本身可以代销、分销形成“产地→销地”的供应链。物流协作乡村往往没有密集的快递网点系统需要支持“村级服务站/自提点”之类的协作节点订单可以跨区域流转、定点集配。信息协作城市消费者关心食材新鲜度、产地溯源乡村商户关心市场需求和价格行情。系统内需要有供需信息发布、数据看板这类功能而不是单纯卖货。履约协作订单可能由城市商家发货也可能由乡村直发也可能是平台统一调度仓储需要灵活的多方角色参与。所以在设计上这个系统不能只做普通B2C那一套而是要把“多方角色多级库存灵活履约”这几点吃透。1.2 系统面向的核心用户角色我梳理下来这套系统至少需要五类角色角色核心诉求典型功能城市消费者买到正宗、可溯源的农产品配送体验好浏览商品、下单、支付、订单跟踪、评价乡村商户/农户快速上架农货管理库存和订单收款方便店铺管理、商品管理、订单处理、提现城市分销商需要一件代发、订单同步、分销利润核算分销商品、订单同步、佣金管理平台运营需要保证交易合规处理纠纷看经营数据审核、公告、数据统计、用户管理乡村服务站承担集配中转、自提核销等工作入库、出库、自提核销、物流交接角色一变多Spring Boot里最经典的做法就是用Spring Security或者Sa-Token做RBAC权限管理给不同角色分配不同的菜单和数据权限。这个我在后面功能实现部分会详细说。1.3 关键需求优先级排序需求不能一把抓否则项目会失控。我按实际开发的优先级做了个排序基础电商链路商品、购物车、订单、支付回调。这一层是地基做不好其他都白搭。角色权限体系多角色登录、权限控制。这是协作的基础没有它就谈不上角色分工。协作机制包括商品分销关系绑定、跨区域配送模板、站点核销。这是这个项目的差异化亮点也是评委会追问的地方。信息可视化乡村供需数据看板、订单流转跟踪。用于提升系统价值感。扩展性预留比如秒杀、优惠券、直播带货入口应该在设计上预留接口但不一定全部实现。2. 技术选型与方案设计2.1 后端技术栈选型Spring Boot的版本选择我得专门说一下。如果是一个需要稳定交付的项目我个人推荐用Spring Boot 2.7.x而不是3.x。原因是Spring Boot 3.x基于Jakarta EE和Spring Framework 6最低要求Java 17。很多教学视频、现成轮子、老项目代码都还是基于javax命名空间写的。你做一个以快速交付为导向的项目2.7.13配合JDK 8或JDK 11生态最稳遇到的坑最少。我建议的技术栈清单如下JDK1.8如果你不介意稍微新一点JDK 11也行框架Spring Boot 2.7.13持久层MyBatis-Plus 3.5.x不为别的就为那个BaseMapper和分页插件能省一大堆重复劳动数据库MySQL 8.0缓存Redis做验证码、Token缓存、热点数据如果本地环境装不了Redis先随便用一个嵌入式替代方案但生产必须上Redis安全认证Sa-Token或者Spring Security JWT。我更倾向Sa-Token语法简单、文档友好适合快速开发接口文档Knife4j基于Swagger前后端联调痛点全靠它解决工具库Hutool里面封装了各种日常工具能省不少事2.2 前端技术方案我做过几次单体模板方案也做过前后端分离方案。这个项目如果要兼顾展示效果和开发效率推荐Vue 3 Element Plus Vite确实是大势所趋。但这里有个很现实的问题如果你前端基础一般直接用Vue全家桶反而会被Node版本、Vite配置、打包发布整崩溃。所以我的建议是两条路快速版用服务端渲染模板比如Thymeleaf Bootstrap JQuery一套Spring Boot应用全搞定部署简单对新手友好。进阶版Vue 3 Element Plus Axios通过RESTful API对接界面现代化但联调成本高一些。两者并不冲突。Article里我会按前后端分离方案来讲因为这样更能体现Spring Boot作为后端服务的本质。2.3 数据库设计核心要点数据库设计是整个项目最需要花时间的地方。我梳理了核心表结构给你一个可落地的参考。用户与角色相关表得有一张user表再加一张role表和user_role关联表。用户表基本字段id, username, password, phone, real_name, role_type, status, create_time。role_type我建议直接冗余到user表里因为一个用户在这个系统中注册时就已经明确了身份虽然规范化角度看应该走关联表但实际开发时直接字段更好用——按角色查询的时候少一次join。商品与店铺相关表商品表product字段比较多我挑几个容易踩坑的price用DECIMAL(10,2)不要用float/double不然金额计算会出怪问题stock库存字段用int但要配合乐观锁版本号version一起使用product_source标记是“产地直供”还是“城市仓”这个字段就是城乡协作的一个体现点shop_id归属店铺audit_status上架审核状态乡村商户发布的商品需要平台审核店铺表shop需要区分店铺类型1乡村直营店2城市分销店3平台自营。订单与物流相关表订单主表order_master建议包含order_sn订单编号要唯一建议用“日期随机数”生成user_id下单用户shop_id发货店铺/供应商receiver_info收货人信息可以冗余存储防止用户修改默认地址后历史订单地址变化order_status状态流转0待付款1待发货2待收货3已完成4售后中fulfil_type履约方式1快递配送2村级自提3城市自提collaboration_type协作类型1产地直发2城市仓代发3分销一件代发订单明细表order_item就是常规的快照设计order_id, product_id, product_name, product_image, price, quantity, total_amount。注意商品信息要冗余商品改名或下架不影响历史订单展示。协作与分销相关表这是这个系统的特色部分。需要一张distributor_product表记录分销关系product_id被分销的商品source_shop_id货源方乡村dist_shop_id分销方城市dist_price分销价settle_price结算价commission_rate佣金比例还要有一张logistics_station表维护乡村服务站/自提点信息关联订单后实现集配中转。数据字典与系统表再用一张dict_data表维护商品单位、订单来源、审核状态等枚举值。这个不是必须的但如果后面想接管理系统有字典表会方便很多。3. 核心功能模块设计与实现3.1 用IDEA创建Spring Boot项目IDEA创建Spring Boot项目我建议直接从https://start.spring.io或者IDEA内置的Spring Initializr生成。但这里有个坑如果你用的是IDEA内置初始化器有时候拉取的是Spring Boot 3.x版本Group那一栏还是com.example整个包名默认值改起来很麻烦。我的步骤是这样的打开IDEANew Project→Spring Initializr。如果默认选的Spring Boot版本是3.x手动改成2.7.13。Java Version选择8或者11。依赖勾选Spring Web、MySQL Driver、Spring Data Redis可选、Lombok。生成后手动在pom.xml里加入MyBatis-Plus、Knife4j、Hutool、Sa-Token依赖。一个标准的pom.xml关键依赖大概是这样的parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.13/version relativePath/ /parent properties java.version1.8/java.version mybatis-plus.version3.5.3/mybatis-plus.version knife4j.version4.3.0/knife4j.version sa-token.version1.36.0/sa-token.version hutool.version5.8.18/hutool.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version${mybatis-plus.version}/version /dependency dependency groupIdcom.github.xiaoymin/groupId artifactIdknife4j-openapi2-spring-boot-starter/artifactId version${knife4j.version}/version /dependency dependency groupIdcn.dev33/groupId artifactIdsa-token-spring-boot-starter/artifactId version${sa-token.version}/version /dependency dependency groupIdcn.hutool/groupId artifactIdhutool-all/artifactId version${hutool.version}/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies3.2 工程目录与分层架构我做这种单体项目习惯按功能模块分包而不是按技术层次分包。对比一下你就明白差异了。按技术层次分包新手常见com.example.mall ├── controller ├── service ├── mapper ├── entity └── config按功能模块分包我更推荐com.example.mall ├── common # 通用类Result、异常处理、常量 ├── config # 配置类MyBatisPlus、Knife4j、CORS ├── module │ ├── user # 用户模块 │ │ ├── UserController.java │ │ ├── UserService.java │ │ ├── User.java │ │ └── UserMapper.java │ ├── product # 商品模块 │ ├── order # 订单模块 │ ├── cart # 购物车模块 │ └── collaborate # 协作模块 └── framework # Sa-Token、日志、全局异常处理按模块分包的好处很明显改一个功能时只需要关注这个包下的文件代码聚集性强。等系统复杂了还可以直接把某个module拆成独立微服务重构成本低。3.3 配置文件的编写application.yml是Spring Boot项目最核心的配置文件。我贴一个基本配置里面注释都标好了。server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/rural_city_mall?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password redis: host: localhost port: 6379 database: 0 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 sa-token: token-name: satoken timeout: 2592000 active-timeout: -1 is-concurrent: true token-style: uuid这里特别注意三个点serverTimezoneAsia/Shanghai一定要加MySQL 8.x不加这个会报时区错误。map-underscore-to-camel-case开启后数据库product_name字段能自动映射到实体类的productName属性不用手写一堆TableField。MyBatis-Plus的逻辑删除配置这样删除操作走的是update deleted 1而不是物理delete对数据留痕很重要。3.4 实体类设计与BaseEntity抽取所有表都会有id, create_time, update_time, deleted这些公共字段我建议抽出一个BaseEntityData public class BaseEntity { TableId(type IdType.ASSIGN_ID) private Long id; TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; TableLogic private Integer deleted; }然后配合MyBatis-Plus的MetaObjectHandler实现字段自动填充Component public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, createTime, LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } }这样你在业务代码里就不用每次手动setCreateTime很省事。唯一的坑是MetaObjectHandler的包名容易写错注意是com.baomidou.mybatisplus.core.handlers.MetaObjectHandler。3.5 用户登录与权限控制实践Sa-Token的使用非常直白。登录成功后StpUtil.login(userId); String token StpUtil.getTokenValue();然后拦截器配置Configuration public class SaTokenConfigure implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new SaInterceptor(handle - StpUtil.checkLogin())) .addPathPatterns(/**) .excludePathPatterns(/user/login, /user/register, /product/list, /doc.html, /webjars/**, /v3/api-docs/**); } }角色权限控制直接用注解SaCheckRole(merchant) PostMapping(/product) public Result addProduct(RequestBody Product product) { // 只有乡村商户才能上架商品 }Sa-Token默认的注解拦截你需要在配置里开一下Configuration public class SaTokenConfig { Bean public SaInterceptor saInterceptor() { return new SaInterceptor(handle - StpUtil.checkLogin(), new SaAnnotationStrategy()); } }不过实际开发中我更推荐在service层做数据权限校验比如当前登录的商户只能修改自己店铺的商品这个不能只靠注解得在代码里显式判断product.getShopId().equals(currentUser.getShopId())。3.6 商品模块乡村直供与城市分销的上架逻辑商品上架是核心流程。对乡村商户来说操作路径是发布商品 → 平台审核 → 上架展示 → 城市分销商可申请分销。我在ProductService里实现的上架逻辑大致是Transactional(rollbackFor Exception.class) public Long addProduct(Product product, User loginUser) { // 1. 校验商户身份 if (!merchant.equals(loginUser.getRoleType())) { throw new BusinessException(只有商户才能发布商品); } // 2. 初始化商品数据 product.setShopId(loginUser.getShopId()); product.setAuditStatus(0); // 待审核 product.setSales(0); product.setVersion(0L); // 3. 如果是产地直供标记来源 if (product.getProductSource() null) { product.setProductSource(rural); } productMapper.insert(product); // 4. 记录商品操作日志留痕 productLogMapper.insert(new ProductLog(product.getId(), CREATE, loginUser.getId())); return product.getId(); }这里有个操作细节值得注意Transactional必须加在public方法上而且不能同类内调用。如果你在同一个类里调用this.addProduct()事务注解会失效。新手经常在这上面栽跟头。要避免这个问题要么把方法拆到不同Service要么用AopContext.currentProxy()获取代理对象再调用。商品列表查询城乡协作的特色是支持多维度筛选public PageResultProductVO listProducts(ProductQuery query) { LambdaQueryWrapperProduct wrapper new LambdaQueryWrapper(); wrapper.eq(Product::getStatus, 1) .eq(query.getSource() ! null, Product::getProductSource, query.getSource()) .like(StrUtil.isNotBlank(query.getKeyword()), Product::getProductName, query.getKeyword()) .ge(query.getMinPrice() ! null, Product::getPrice, query.getMinPrice()) .le(query.getMaxPrice() ! null, Product::getPrice, query.getMaxPrice()) .orderByDesc(Product::getCreateTime); PageProduct page new Page(query.getPageNum(), query.getPageSize()); PageProduct result productMapper.selectPage(page, wrapper); // 转VO填充店铺信息、销量、评分等 return convertToPageResult(result); }MyBatis-Plus的LambdaQueryWrapper条件构造器看着方便但有个坑eq(condition, column, value)里的condition参数为false时不生效这个特性要熟练使用不然只能手写一堆if。3.7 订单模块支持多种履约方式的下单流程订单模块是电商系统的核心一个完整的下单流程牵扯到库存扣减、订单生成、购物车清理、支付等多个环节。我先说库存扣减的两把锁第一把是数据库层面的乐观锁UPDATE product SET stock stock - 1, version version 1 WHERE id #{productId} AND stock 1对应MyBatis-Plus的写法需要手动写SQLUpdate(UPDATE product SET stock stock - #{count}, version version 1 WHERE id #{productId} AND stock #{count}) int deductStock(Param(productId) Long productId, Param(count) Integer count);如果返回的int为0说明库存不足或并发冲突这时候抛异常让事务回滚。第二把是Redis分布式锁用于防止同一用户重复下单String lockKey order:create: userId; boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, Duration.ofSeconds(10)); if (!locked) { throw new BusinessException(操作太频繁请稍后再试); } try { // 执行下单逻辑 } finally { redisTemplate.delete(lockKey); }下单核心伪代码如下Transactional(rollbackFor Exception.class) public OrderVO createOrder(OrderCreateDTO dto, User loginUser) { // 1. 获取购物车选中的商品列表 ListCartItem cartItems cartMapper.selectBatchIds(dto.getCartItemIds()); // 2. 按店铺维度拆分订单一家店一张订单方便结算和发货 MapLong, ListCartItem shopGroupMap cartItems.stream() .collect(Collectors.groupingBy(CartItem::getShopId)); ListString orderSns new ArrayList(); BigDecimal totalAmount BigDecimal.ZERO; for (Map.EntryLong, ListCartItem entry : shopGroupMap.entrySet()) { Long shopId entry.getKey(); ListCartItem items entry.getValue(); // 3. 生成订单主表 OrderMaster order new OrderMaster(); String orderSn generateOrderSn(); order.setOrderSn(orderSn); order.setUserId(loginUser.getId()); order.setShopId(shopId); order.setOrderStatus(0); // 待付款 order.setTotalAmount(calculateTotal(items)); order.setFulfilType(dto.getFulfilType()); order.setReceiverInfo(dto.getReceiverInfo()); // 4. 插入订单明细 for (CartItem item : items) { OrderItem orderItem new OrderItem(); orderItem.setOrderSn(orderSn); orderItem.setProductId(item.getProductId()); orderItem.setProductName(item.getProductName()); orderItem.setPrice(item.getPrice()); orderItem.setQuantity(item.getQuantity()); orderItem.setTotalAmount(item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); orderItemMapper.insert(orderItem); } orderMasterMapper.insert(order); // 5. 扣减库存 for (CartItem item : items) { int result productMapper.deductStock(item.getProductId(), item.getQuantity()); if (result 0) { throw new BusinessException(商品库存不足 item.getProductName()); } } orderSns.add(orderSn); } // 6. 清理购物车已下单商品 cartMapper.deleteBatchIds(dto.getCartItemIds()); return orderResult; }这段代码有两个注意点拆分订单因为一个购物车可能同时包含乡村直发和城市分销的商品这两类商品的发货方和仓库都不一样所以必须按店铺/发货维度拆分。这也是城乡协作和普通商城在订单处理上的核心差异。事务粒度所有订单在一个事务里要么全成功要么全失败。如果后续要优化性能可以改成先扣库存预占然后异步创建订单但那是高并发项目的玩法单体项目不必。3.8 协作模块分销绑定与佣金结算城市分销商申请分销一件商品后系统要记录绑定关系。后续该分销商店铺产生这笔商品的订单系统自动计算佣金。设计思路如下Transactional(rollbackFor Exception.class) public Long bindDistributor(BindRequest request) { // 校验分销商身份 User user userService.getById(request.getDistShopId()); if (!distributor.equals(user.getRoleType())) { throw new BusinessException(只有分销商才能申请分销); } // 检查商品是否存在 Product product productMapper.selectById(request.getProductId()); if (product null) { throw new BusinessException(商品不存在); } // 插入分销关系 DistributorProduct dp new DistributorProduct(); dp.setProductId(product.getId()); dp.setSourceShopId(product.getShopId()); dp.setDistShopId(request.getDistShopId()); dp.setDistPrice(request.getDistPrice()); dp.setCommissionRate(request.getCommissionRate()); dp.setStatus(1); distributorProductMapper.insert(dp); return dp.getId(); }佣金结算的时机建议放在订单确认收货之后而不是下单时。因为确认收货意味着交易完成这时候才会把佣金从货款里划给分销商。具体实现就是监听订单状态变更消息然后生成佣金记录。这个模块在设计时可以接一个Spring的EventListener事件监听机制订单状态变更时发布事件佣金模块监听事件去结算实现模块间解耦。// 订单状态变更时发布事件 applicationEventPublisher.publishEvent(new OrderStatusChangeEvent(order)); // 佣金模块监听事件 EventListener public void onOrderStatusChange(OrderStatusChangeEvent event) { if (event.getOrder().getOrderStatus() 3) { // 已确认收货 commissionService.settle(event.getOrder()); } }3.9 数据看板供需信息与销售统计城乡协作需要一个数据看板让农户能看到什么好卖让平台看到什么缺货。数据大屏不一定是实时推送可以用定时任务或者查询时聚合。我的实现方式是维护一张daily_stats表每天凌晨用Spring Boot自带的Scheduled定时任务跑汇总。看板接口只查这张聚合表查询速度快不会拖垮主库。Component public class StatsTask { Scheduled(cron 0 0 2 * * ?) // 每天凌晨2点执行 public void collectDailyStats() { // 统计前一天的订单量、销售额、热销Top10商品 // 统计各品类供需比 // 写入daily_stats表 } }定时任务记得在启动类加EnableScheduling注解不然不生效。这个坑我踩过找了半天才发现是没加启动注解。4. 关键业务场景与完整实现4.1 场景复现一个城市用户购买乡村土鸡我用一个完整场景把上面的模块串起来让整个系统是怎么跑的更有画面感。第一步乡村商户账号登录系统上架“散养土鸡”一把鸡卖128元产地直供发货地是某村级服务站。平台审核通过后商品在商城前端可见。第二步城市分销商看到这个商品觉得有市场申请分销设置分销价138元赚10元差价外加5%平台佣金。平台审核通过后城市店铺里也出现了同一个土鸡商品价格为138元。第三步城市用户搜索“土鸡”结果列表里有两条数据一条128元产地直发一条138元城市次日达。用户选择138元的城市次日达下单付款。第四步系统生成订单shop_id是分销商的店铺collaboration_type3分销一件代发。分销商看到订单后不需要自己发货而是系统生成一个“代发指令”通知乡村商户发货收货人写的是终端用户的地址。第五步乡村商户发货系统更新物流信息城市用户收到货后确认收货。第六步系统自动计算138元中128元给乡村商户5元作为分销商差价实际比例按配置剩下的作为平台服务费。佣金记录写入commission_record表。整个过程很流畅。关键是通过订单上的collaboration_type字段系统知道这笔订单是哪种协作模式然后走不同的后续处理流程。4.2 场景复现乡村振兴版“集单自提”再设想一个模式平台对接了某个村的草莓基地但单笔订单量太少直接发快递不划算。系统支持“社区集单”功能。平台在系统里发起一个“团购活动”积累到20单统一从村里发货到城市的一个自提点再由自提点分配给各个用户。这个功能的技术实现关键点是活动预热、成团判断、支付截止、批量采购单生成。我建议单独建一张group_buy_activity表和group_buy_order表用定时任务扫描“已成团但未下单”的活动自动生成批发订单。这块功能量不小如果时间紧可以不做但是如果你想要项目有亮点做一个简化版的成团自提流程成本不高但面试和答辩时可以讲的故事就丰富多了。4.3 代码实现乡村服务站自提核销自提模式的最后一个环节是核销。用户在站点报手机号站务员输入后核销订单。SaCheckRole(station) PostMapping(/verify) public Result verifyOrder(RequestBody VerifyDTO dto) { // 1. 根据订单号手机号查询自提订单 LambdaQueryWrapperOrderMaster wrapper new LambdaQueryWrapper(); wrapper.eq(OrderMaster::getOrderSn, dto.getOrderSn()) .eq(OrderMaster::getFulfilType, 2) // 自提 .eq(OrderMaster::getOrderStatus, 2); // 待收货 OrderMaster order orderMasterMapper.selectOne(wrapper); if (order null) { throw new BusinessException(未找到待核销的自提订单); } // 2. 校验核销站点是否匹配 if (!order.getStationId().equals(dto.getStationId())) { throw new BusinessException(该订单不属于当前站点); } // 3. 更新订单状态为已完成 order.setOrderStatus(3); order.setVerifyTime(LocalDateTime.now()); orderMasterMapper.updateById(order); // 4. 触发后续事件佣金结算、评价提醒 applicationEventPublisher.publishEvent(new OrderStatusChangeEvent(order)); return Result.ok(); }5. 常见问题与排查技巧实录5.1 启动报错数据库连接失败或时区问题现象启动Spring Boot应用报Cannot create PoolableConnectionFactory。排查先ping数据库地址然后确认MySQL服务是否启动最后看application.yml里的url配置。比较隐蔽的问题是MySQL连接时区报错解决方法是在JDBC URL上加serverTimezoneAsia/Shanghai。我第一次用MySQL 8.0时遇到的就是时区问题报错信息里还带一串英文不看不懂看了才发现是时区。所以这里再强调一次这个参数一定要加上。5.2 跨域问题前后端分离联调报错现象前端Vue项目请求后端接口报CORS error或者请求发不出、返回不了。原因浏览器同源策略限制了前端localhost:5173请求后端localhost:8080的接口。解决后端写一个全局CORS配置Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }如果用了Sa-Token还要注意CORS配置和Sa-Token拦截器的顺序否则带Token的请求可能先被跨域拦截。这个顺序问题我调了很久后来把CORS配置和Sa-Token拦截器都实现了WebMvcConfigurer并且确保CORS配置的优先级高一点才解决。5.3 MyBatis-Plus分页查询不生效现象调用selectPage后返回的总条数是0数据也不对。原因MyBatis-Plus的分页插件需要手动配置PaginationInnerInterceptor。解决Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }没配这个拦截器之前selectPage其实是假分页会查全表数据量一大就把数据库打崩了。配置完分页插件它会自动在SQL尾部拼接LIMIT性能提升明显。5.4 Java 8日期格式化问题现象后端返回的LocalDateTime字段在前端显示成一段很长的数组格式。原因Jackson序列化LocalDateTime时没有指定格式。解决在application.yml里已经配置了spring.jackson.date-format但有时候不生效特别是字段类型是LocalDateTime时。更稳妥的方式是用JsonFormat注解public class BaseEntity { JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8) private LocalDateTime createTime; }或者在配置类里自定义一个Jackson2ObjectMapperBuilderCustomizer统一对LocalDateTime做格式化。我个人推荐后者一劳永逸不用每个字段都加注解。5.5 热门商品查询慢数据库压力大现象商品列表加上热门排序后查询响应时间越来越长。一开始数据量小看不出问题数据量过10万后就会很明显。排查EXPLAIN看一下SQL执行计划是不是没走索引。在product表的category_id、status、sales字段上建联合索引。热点数据商品详情放入Redis缓存设置合理的过期时间。冷热数据分离历史订单归档独立表主表只保留最近3个月订单。我在项目里给商品列表接口加了Redis缓存Cacheable(cacheNames product:list, key #query.hashCode()) public PageResultProductVO listProducts(ProductQuery query) { // 查询逻辑 }这样在并发访问高峰期大部分流量能直接命中缓存数据库压力小很多。6. 项目部署与上线总结6.1 打包与部署Spring Boot项目打包部署很简单但有几个细节要注意用mvn clean package打包或者IDEA右侧Maven面板直接双击package。打出来的jar包在target目录下部署时写个start.sh脚本。生产环境的配置要外置不要打包在jar里面。我的习惯是用--spring.profiles.activeprod指定profile配合application-prod.yml读取外部配置文件。最简单的部署脚本#!/bin/bash APP_NAMErural-city-mall.jar nohup java -Xms512m -Xmx512m -jar $APP_NAME --spring.profiles.activeprod app.log 21 echo App started, pid: $!6.2 项目中踩过的坑与实战心得我最后总结几点个人经验这些都是做完项目之后才真正理解的第一数据库设计一定要想清楚“协作”的业务语义。城乡商城协作系统不是给每个角色各做一个CRUD就叫协作而是要通过数据流把角色串起来。我从一开始就在订单表里加了collaboration_type和station_id这样的字段后面实现分销、核销、结算的时候顺理成章基本没有返工。第二权限模型越简单越好。我的权限模型一开始设计了五张表用户表、角色表、菜单表、用户角色关联表、角色菜单关联表。后来发现要做前端动态菜单又要做后端接口权限工作量陡增。最后我砍成了“用户表带角色字段”“注解权限”的组合开发效率高了一大截功能也完全够用。第三缓存是双刃剑。商品列表加了Redis缓存后确实扛住了并发压力但也出现了商品下架后缓存未及时清理、用户看到已下架商品的问题。后面我总结出原则状态变更类操作上下架、改价、库存变动一定要主动删除对应缓存可以基于Spring的CacheEvict实现。第四接口返回格式要提前统一。我踩过最大的坑之一就是接口返回类型不统一。早期有些接口直接返回Map有些返回实体类前端对接起来非常痛苦。后来我封装了一个ResultT响应类所有接口都返回它包括异常情况也统一封装前端直接按统一结构解析。这个习惯越早养成越好。第五前后端分离项目的文档和注释一定要写清楚。这是很多人忽略的点。前后端分离联调的时候如果没有接口文档前端会一直来问你“这个字段啥意思”、“这个接口传什么”效率极低。用Knife4j自动生成文档加上在代码里写清晰的注释联调能少掉一半的沟通成本。7. 给新手的几点实操启发如果你准备动手做类似项目我有几条具体建议按优先级排先用半个月把Spring Boot的基础搞透特别是自动装配、starter机制、常用注解这几个点。不然后面代码写得不明白出了问题也不知道去哪里找。数据库设计阶段多花时间表结构一旦定下来后面改动的成本指数级上升。设计时多想想各角色之间的业务关系怎么用数据表达。从一个最小可用版本开始先把电商主链路走通商品上架→浏览→下单→支付→发货→收货在此基础上再逐步加“城乡协作”的特色功能。遇到报错不要慌先看报错堆栈的第一行那是问题的源头。大部分启动失败都能从最后几行日志中找到原因。这个项目做下来我对Spring Boot的理解提升了一个层次不只是CRUD还涉及事务、并发、缓存、事件驱动、权限控制这些真实业务中躲不掉的问题。城乡协作这个主题又给了业务层足够的复杂度作为练手项目来说含金量是很高的。希望这篇文章能给你的项目开发带来一些参考哪怕只是帮你少踩一个坑也值了。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →