简介:一份面向计算机、软件工程等专业学生的毕业设计参考文档,围绕农产品网上销售系统的完整设计与实现过程展开。文档以实际运用为开发背景,基于Java技术体系(JSP结合MySQL数据库),从系统分析、需求分析、设计分析到功能分析进行总体规划,并采用模块化设计方法划分功能模块,便于程序扩展与后期维护。内容还涵盖了摘要、关键词、绪论、数据库设计、系统维护等章节,并阐述了界面简洁、操作方便、功能齐全等特点,适合需要完成电商类课程设计或毕业设计的读者借鉴。文件包仅含1个docx格式的完整设计文档,压缩包大小为1.27MB,目录结构完整,可直接用于论文结构参考、功能模块梳理和开发流程复盘。已有72人学习下载,对于希望快速建立农产品网上销售系统设计框架、学习JSP+MySQL开发思路的读者具有实用价值。
1. 基于java的农产品网上销售系统:为什么有人三天跑通,有人三周还在配环境
这个标题每年毕业季都会出现在一批人的任务书里,看着像标准的商城项目,实际上比想象中多出不少隐藏坑。农产品网上销售系统要解决的是种植基地或批发商把货卖到零售端这件事,涉及商品多规格、斤两计价、库存并发扣减和订单状态流转,它和普通二手交易平台最大的区别在订单模型和库存策略上。对于Java基础已经入门、需要一个完整项目撑住简历或者毕业答辩的人来说,这是性价比很高的一条路线——技术栈主流、业务边界清晰、扩展点现成。能不能做扎实,取决于你从哪一层开始动手。
2. 技术栈与数据库设计:先定好骨架,后面每一步才不用返工
2.1 Spring Boot还是SSM:这个选择决定你后面三个月的节奏
现在能看到的Java网上商城代码,一半是SSM,也就是Spring + SpringMVC + MyBatis,另一半是Spring Boot + MyBatis Plus。如果你刚把Java基础过完,我一般会建议直接上Spring Boot 2.7.x + MyBatis Plus 3.5.x这套组合。原因不是SSM不能用,而是Spring Boot把配置收敛了,你能把省下来的时间拿去写业务逻辑和准备答辩问题,而不是在web.xml和Spring XML配置文件里来回折腾,光是那个applicationContext.xml里扫包扫漏了就能卡你一整天。
这套组合里,Spring Boot负责把项目跑起来,MyBatis Plus负责把单表CRUD从几十行XML缩减到几行代码,农产品销售系统的核心业务都在多表关联和状态流转上,不该把精力耗在重复的增删改查里。数据库层面配合MySQL 5.7或8.0,ORM就这一套,不要再叠JPA,两个一起用容易在事务和懒加载上翻车。下面是最小可运行的数据源配置,直接放到application.yml里。
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/agri_market?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 servlet: multipart: max-file-size: 5MB max-request-size: 20MB mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这个配置里有几个参数值得单独说。url里的serverTimezone=Asia/Shanghai必须加,MySQL 8.x的驱动默认时区处理会让你查出来的时间比实际晚8个小时,后文避坑章节会专门讲。map-underscore-to-camel-case打开之后,数据库字段create_time能自动映射到Java属性createTime,不用写一堆resultMap。logic-delete相关的三个配置是MyBatis Plus的逻辑删除开关,deleted字段值为0表示正常,1表示已删除,所有查询都会自动带上deleted = 0的条件。注意这个配置只对MyBatis Plus自带方法生效,手写SQL时依然要自己处理deleted条件,很多人在自定义Mapper方法里忘了加,导致删掉的数据又冒出来。
2.2 商品表设计:农产品按斤卖还是按份卖,字段级决策很关键
农产品和普通标品不一样,同一款苹果往往同时存在整箱批发和散称零售两种卖法,用户买3斤和买1箱的价格、库存单位完全不同。所以商品表不建议只放一个product表加一个price字段,而是把商品SPU和规格SKU拆开。SPU管商品本身的信息,比如名称、主图、分类;SKU管具体怎么卖,比如规格名、价格、库存、计价单位。用户在商品详情页看到的是SPU,加入购物车和下单的是SKU。
CREATE TABLE `product` ( `id` bigint NOT NULL AUTO_INCREMENT, `category_id` bigint NOT NULL COMMENT '归属分类', `product_name` varchar(128) NOT NULL COMMENT '商品名', `main_image` varchar(255) DEFAULT NULL COMMENT '主图URL', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1上架 0下架', `deleted` tinyint NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品SPU表'; CREATE TABLE `product_sku` ( `id` bigint NOT NULL AUTO_INCREMENT, `product_id` bigint NOT NULL COMMENT '所属商品ID', `spec_name` varchar(64) NOT NULL COMMENT '规格名,如3斤装/散称', `price` bigint NOT NULL COMMENT '单价,单位分', `stock` int NOT NULL DEFAULT 0 COMMENT '库存', `unit` varchar(16) NOT NULL COMMENT '计价单位:斤/份/件', `version` int NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品SKU表';这里有两个细节是踩过坑才明白的。第一,price字段用bigint存"分",不要用decimal存"元"。浮点数在金额计算上会有0.1 + 0.2不等于0.3的问题,虽然decimal精度比float好,但涉及订单金额累加、折扣计算时,统一用整数分最省心,展示时再除以100转成元。第二,product_sku表预留了version字段给乐观锁用,后面下单扣库存时靠它防止超卖,建表时加上比后期加字段容易得多。
另外要注意,inventory字段的默认值不要设成NULL,否则在SQL里做stock - count运算时,NULL参与计算的结果还是NULL,扣库存直接被吞掉。商品分类单独建一张category表,product表用category_id关联,分类的层级不要做得太深,两级就够了:一级是蔬菜、水果、粮油,二级是叶菜类、根茎类这种。做太深查询麻烦,答辩也讲不清楚。
2.3 订单表与地址快照:为什么不能直接关联用户地址表
订单表的设计决定了订单模块要返工几次。很多新手把订单表做成user_id关联用户表,收货地址也直接关联用户地址表,看起来没问题,但用户下单之后改了地址或者删了地址,历史订单上的收货信息就被牵动,这在电商系统里是不能接受的。订单必须冗余收货人信息,也就是把姓名、电话、地址原样复制一份到订单表里,这叫地址快照。
CREATE TABLE `order_main` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '对外单号', `user_id` bigint NOT NULL COMMENT '下单用户ID', `total_amount` bigint NOT NULL COMMENT '订单总金额,单位分', `status` tinyint NOT NULL COMMENT '10待支付 20已支付 30已发货 40已完成 50已取消', `receiver_name` varchar(64) NOT NULL COMMENT '收货人姓名快照', `receiver_phone` varchar(20) NOT NULL COMMENT '收货人电话快照', `receiver_address` varchar(255) NOT NULL COMMENT '收货地址快照', `create_time` datetime DEFAULT NULL, `pay_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';订单状态用tinyint而不是字符串,原因很简单:10到50的数值可以直接做比较运算,比如"大于等于20就是已支付",字符串判断一旦拼错一个字母就查不出数据。order_no是给用户看的单号,不能把自增id直接暴露出去,否则别人通过订单号差值就能推断出你的订单量。主表里还要冗余total_amount字段,不要在查询时用明细表实时sum汇总,订单一旦生成,金额就固化,明细被改也要以主表为准。
订单明细表order_item独立存储,记录每个SKU在订单里的商品名、单价、数量、小计金额。这里同样要冗余商品名和单价快照,因为商家改了商品名或价格之后,历史订单的展示不能跟着变。order_item表我一般不加逻辑删除字段,订单明细是流水记录,只增不改,加了deleted反而增加出错概率。需要关联查询时用order_main.id关联order_item.order_id就可以了。
数据库表之间不要建物理外键,逻辑关联就够了。物理外键在删除商品、批量导入数据时非常容易报约束错误,而且在分页查询里还会拖慢性能。答辩时如果老师问为什么不用外键,从性能和维护成本两个角度解释就行。
3. 核心功能实现:从商品列表到购物车再到下单
3.1 商品分页查询:用MyBatis Plus写最小可运行代码
商品列表是商城前端访问量最大的接口,需要分页、按分类筛选、按上架状态过滤。MyBatis Plus的LambdaQueryWrapper能把查询条件写得非常紧凑,对比传统XML里的动态SQL,代码量能省一半以上。
@RestController @RequestMapping("/api/product") public class ProductController { @Resource private ProductService productService; /** * 分页查询上架商品 * pageNum 从1开始 * pageSize 默认10 * categoryId 可空,传了则按分类过滤 */ @GetMapping("/list") public Result page(@RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize, @RequestParam(required = false) Long categoryId) { LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Product::getStatus, 1) .eq(categoryId != null, Product::getCategoryId, categoryId) .orderByDesc(Product::getCreateTime); Page<Product> page = productService.page(new Page<>(pageNum, pageSize), wrapper); return Result.ok(page); } }这段代码里最值得说的是eq方法的第一个参数是boolean condition,categoryId等于null时这个条件会被自动跳过,不用手写if判断。MyBatis Plus的page方法接收一个Page对象和查询条件,返回结果里封装了records、total、current、size等分页数据,前端展示页码全靠这些字段。orderByDesc按创建时间倒序,让新上架的农产品排在前面,这是电商列表页的默认习惯。
这里有个前置条件不能漏,否则分页会变成一个隐蔽的坑:必须注册MybatisPlusInterceptor分页插件。没有这个拦截器,page方法不会执行真正的LIMIT语句,而是把全表数据查出来再在内存里截断,total永远不对,商品少时看不出来,数据量一上来接口就明显变慢。
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这段配置类要在启动类的同级或子包下,Spring Boot的组件扫描才扫得到。DbType.MYSQL指定方言是必须的,不指定的话分页SQL在MySQL上可能生成出LIMIT ?,?和实际参数不匹配的语句来。我用的是MyBatis Plus 3.5.x,如果你在旧教程里看到PaginationInterceptor这个类,那是3.4之前的老写法,新版本已经移除了,别照搬。
3.2 购物车实现:先判断项目里是否已经有Redis再用
购物车这个模块,很多教程一上来就让你引入Redis。对毕业设计级别的系统来说,Redis不是必需品,判断依据就一条:你的项目里有没有其他地方已经在用Redis。如果没有,用一张cart表就够了,逻辑直观、事务好控制、答辩也更好解释。如果项目里刚好有登录token缓存或其他模块依赖了Redis,那购物车放Redis也合理,只是要额外处理Redis宕机和数据持久化的问题。
用数据库表实现购物车,先建一张简单的购物车表,核心字段就是user_id、sku_id、quantity三个。同一个用户加同一个SKU时,记录应该累加数量而不是插两行,所以需要唯一索引兜底,实现时才不用边查边担心并发重复。
@Service public class CartService { @Resource private CartMapper cartMapper; @Transactional public void addToCart(Long userId, Long skuId, Integer quantity) { LambdaQueryWrapper<Cart> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Cart::getUserId, userId).eq(Cart::getSkuId, skuId); Cart exist = cartMapper.selectOne(wrapper); if (exist == null) { Cart cart = new Cart(); cart.setUserId(userId); cart.setSkuId(skuId); cart.setQuantity(quantity); cartMapper.insert(cart); } else { exist.setQuantity(exist.getQuantity() + quantity); cartMapper.updateById(exist); } } }这里@Transactional保证的是insert或update的原子性,但如果两个请求同时走到selectOne都查到不存在,两个线程都会走insert分支,就会插入两条相同user_id和sku_id的记录。要彻底防住,必须在cart表上加唯一索引,这样重复插入的线程会抛DuplicateKeyException,事务回滚后由前端重新请求。这个属于基础事务和并发控制的知识点,面试时经常被问到。
购物车表是否有必要存价格快照,我建议不存。购物车里的价格随时可能被商家调整,用户结算时应该以实时价格为准,这和订单里存价格快照是两回事。真正下单那一刻才锁库存、锁价格,购物车只是一个购物意向的暂存区。
3.3 图片上传与回显:本地存储路径的配置决定了部署时会不会404
农产品系统的商品图片是必备功能,没有图片,商品列表页和详情页完全没有说服力。图片上传最常见的翻车方式是文件写到target/classes目录或static目录里,本地开发没问题,重新打包部署后图片全丢,或者换个环境路径不对就404。最稳妥的做法是把图片存到项目外的一个固定目录,比如D:/agri_upload,然后配置一个静态资源虚拟映射,把外部目录映射到URL前缀上。
spring: web: resources: static-locations: classpath:/static/,file:D:/agri_upload/这个配置的意思是说,访问/upload/xxx.jpg时,Spring Boot会到D:/agri_upload/xxx.jpg去找文件,同时原有的classpath:/static/仍然生效。注意file:后面的路径是本地绝对路径,部署到Linux服务器时改成/home/ubuntu/agri_upload这种路径,这个路径用一个自定义配置项来维护会更好,不要硬编码在yml里。
@PostMapping("/upload") public Result upload(@RequestParam("file") MultipartFile file) throws IOException { if (file.isEmpty()) { return Result.error("文件为空"); } String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); // 重命名,避免中文名和重名覆盖 String newName = UUID.randomUUID().toString().replace("-", "") + ext; File dir = new File("D:/agri_upload"); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dir, newName)); return Result.ok("/upload/" + newName); }这段代码只处理了图片名的随机化,但还缺一个关键校验:文件类型。原文件名拿到的后缀是可控的,但伪造ContentType很容易,我一般会额外校验文件的ContentType是否为image/jpeg、image/png、image/webp中的一个,再配合后缀判断。另外MultipartFile的默认大小限制是1MB,yml里的spring.servlet.multipart.max-file-size必须显式调大,否则上传超过1MB的商品实拍图直接抛MaxUploadSizeExceededException,前端完全没有友好提示。
4. 订单与库存扣减:并发和事务是答辩最容易追问的硬骨头
4.1 订单状态机与下单事务:从待支付到已完成,不允许跳转
订单模块是整个系统的核心,也是答辩时老师最爱追问的地方。订单状态必须是一个明确的状态机,而不是随手定义几个字符串。我设计的流转关系是:10待支付可以支付变成20已支付,后台发货变成30已发货,用户确认收货变成40已完成;10待支付也可以超时或主动取消变成50已取消。不允许出现已取消变成已支付,也不允许待支付直接变成已发货。
public enum OrderStatus { WAIT_PAY(10, "待支付"), PAID(20, "已支付"), SHIPPED(30, "已发货"), FINISHED(40, "已完成"), CANCELED(50, "已取消"); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code = code; this.desc = desc; } }用枚举的好处是把状态码定义在代码里,比对时直接OrderStatus.WAIT_PAY.getCode(),不会出现魔法数字满天飞的情况。状态流转我一般用Service层方法封装,比如cancelOrder内部先校验status必须等于10待支付才允许改成50已取消,否则抛业务异常。前端不能直接调update接口改状态,所有状态变更必须走后端Service方法,这是订单安全的基本要求。
下单是订单模块里事务最集中的操作,一个createOrder方法同时涉及查SKU、扣库存、插入订单主表、插入订单明细表四件事,必须在同一个事务里。下面是一个简化的核心实现,注意价格校验逻辑。
@Transactional(rollbackFor = Exception.class) public OrderMain createOrder(Long userId, Long skuId, Integer count, OrderAddress address) { // 1. 查SKU,价格以数据库为准,不信任前端传参 ProductSku sku = skuMapper.selectById(skuId); if (sku == null || sku.getStock() < count) { throw new BizException("库存不足"); } // 2. 乐观锁扣库存 int rows = skuMapper.deductStock(skuId, count, sku.getVersion()); if (rows == 0) { throw new BizException("手慢了,库存已被扣完"); } // 3. 生成订单主表和明细表 OrderMain order = buildOrder(userId, sku, count, address); orderMapper.insert(order); OrderItem item = buildOrderItem(order.getId(), sku, count); orderItemMapper.insert(item); return order; }这里最关键的是第2步,deductStock返回0表示更新失败,说明库存不够或者version不匹配,直接抛异常让整个事务回滚。注意第1步的库存检查只是预校验,真正拦截超卖的是第2步SQL里的stock >= #{count}条件,如果没有这个条件,两个并发请求同时读到stock=1再分别扣1,就会出现库存变成-1的情况。deductStock对应的SQL长这样:
UPDATE product_sku SET stock = stock - #{count}, version = version + 1 WHERE id = #{skuId} AND version = #{version} AND stock >= #{count}这条SQL只影响一行就成功,影响零行就说明条件不满足。乐观锁的version字段在更新时加1,下一次扣库存必须用最新的version值,这个设计能防止两个请求都基于旧值覆盖更新。在毕业设计的并发量级下,这个方案简单够用,答辩时也容易讲清楚为什么不用悲观锁。
4.2 扣库存方案怎么选:悲观锁、乐观锁、Redis预扣的对比
做订单模块时,扣库存的方案选择会直接影响代码复杂度和答辩深度。三种常见方案各有适用场景,我在实际项目里是按这个逻辑取舍的。
悲观锁用SELECT ... FOR UPDATE把SKU行锁住,事务提交后其他事务才能继续操作,实现最简单,任何并发量下都不会超卖。但缺点是锁等待时间长,高并发下数据库连接容易被占满。Redis预扣是先扣Redis里的库存,异步同步到数据库,性能最好,但要处理Redis宕机、数据库不一致、订单取消回补库存这一堆问题,复杂度对毕业设计来说过高。
我推荐的组合是乐观锁加数据库库存字段。它比悲观锁并发能力好,比Redis方案简单得多,在每秒几百请求的量级下完全够用,而且不需要引入额外组件。直接对比的话,可以用下面表格来理解:
| 方案 | 超卖风险 | 实现复杂度 | 适用场景 |
|---|---|---|---|
| 悲观锁 for update | 低 | 低 | 并发要求不高,代码简单优先 |
| 乐观锁 version | 低 | 中 | 中小并发,项目里没有Redis |
| Redis 预扣 | 低 | 高 | 高并发,已具备Redis基础设施 |
乐观锁方案里还有一个细节值得注意:如果同时下单的商品有多种SKU,比如用户购物车里有两件农产品,那要在同一个事务里依次扣减多个SKU的库存。只要有任何一个SKU扣减失败,整个事务回滚,已经扣成功的库存也会回滚,所以不用担心扣了一半的问题。这也是为什么我把扣库存和生成订单放在同一个@Transactional方法里。
4.3 超时未支付订单:定时扫描关单与库存回补
用户下单后不支付,库存一直被占用,会导致其他想买的人买不到,所以必须有超时关单机制。网上方案很多:延时队列、消息中间件、定时任务,对毕业设计来说,定时任务扫描是最容易理解和实现的,一个@Scheduled注解就能解决,不用引入RabbitMQ或DelayQueue。
@Component public class OrderTimeoutTask { @Resource private OrderMapper orderMapper; @Resource private SkuMapper skuMapper; /** * 每分钟执行一次,关闭超过30分钟未支付的订单并回补库存 */ @Scheduled(cron = "0 * * * * ?") @Transactional(rollbackFor = Exception.class) public void closeExpiredOrders() { List<OrderMain> expired = orderMapper.selectWaitPayExpired(30); for (OrderMain order : expired) { order.setStatus(OrderStatus.CANCELED.getCode()); orderMapper.updateById(order); skuMapper.restoreStock(order.getId()); } } }cron表达式"0 * * * * ?"表示每分钟的第0秒执行一次。selectWaitPayExpired这个SQL要在数据库层面过滤条件,核心是WHERE status = 10 AND create_time < DATE_SUB(NOW(), INTERVAL 30 MINUTE),不要把所有待支付订单查出来再在Java里一个个判断时间,数据量上来后接口会拖垮。
库存回补的逻辑要小心,不能直接把SKU的stock加回固定值,而要根据订单明细里的实际数量来加。写SQL时用order_item表去关联product_sku表:先查出所有已取消订单的明细,再按sku_id汇总数量批量更新库存。还有一点,selectWaitPayExpired查询出来的订单可能在执行回补之前刚好被用户支付了,所以更新status时要带条件WHERE status = 10,影响行数为0就跳过回补,避免把已支付订单的库存重复加回去。这个动作虽然概率很低,但处理了就能在答辩时多讲一个并发细节。
5. 避坑指南:五个让项目当场翻车的细节
5.1 前端传price改库存:价格必须以数据库为准
现象:用户通过抓包工具修改下单请求里的price参数,把商品价格改成1分钱,订单金额变成0.01元。
原因:后端下单接口直接信任了前端传过来的价格字段,用前端金额参与订单金额计算。农产品价格经常变动,前端展示的价格本来就应该只做展示用,真正的金额必须以后端查到的数据库值为准。
解决:下单接口只接收skuId和quantity,后端从product_sku表查实时价格和最新库存,订单金额完全由后端计算,前端传的任何price字段一律忽略。这条不仅防恶意修改,也防正常的前后端数据不一致。我验收订单模块时,第一步就是抓包改价格,能挡住这个才算过关。
5.2 MySQL驱动时区导致时间差8小时
现象:订单创建时间create_time查出来比实际时间晚8个小时,或者按时间筛选订单时边界不对。
原因:MySQL Connector/J 8.x的驱动默认使用服务器时区,而数据库连接串里没有显式指定serverTimezone,导致Java程序和MySQL之间换算时间时差了8小时。这个问题在本地可能是对的,部署到云服务器后突然出现,非常隐蔽。
解决:在JDBC URL里加serverTimezone=Asia/Shanghai,同时检查MySQL的全局时区配置,执行SET GLOBAL time_zone = '+08:00'。连接串上的时区配置是最直接的,改了立刻生效,不用重启数据库。
5.3 图片上传成功但页面刷新404
现象:上传接口返回了/upload/xxx.jpg这样的路径,浏览器访问这个地址却报404,刷新也没用。
原因:Spring Boot默认只把classpath:/static/目录映射成静态资源路径,上传接口把文件写到了D:/agri_upload目录里,这个外部目录并没有被Spring Boot识别。还有种常见做法是把图片写到target/classes/static下面,IDE里调试能访问,重新打包后target目录被清空,图片就全没了。
解决:用spring.web.resources.static-locations配置外部目录的虚拟映射,这是2.x版本的标准写法;如果是Spring Boot 3.x,配置项迁移到spring.web.resources.static-locations同样适用,但要注意文件路径写法仍然是file:绝对路径。部署到Linux时路径改为/home/user/upload这类目录,别再用Windows的D盘路径。
5.4 逻辑删除与唯一索引打架
现象:商品表中同一个商品被删除后再次上架,插入数据库时报Duplicate entry错误。
原因:逻辑删除方案里deleted字段被设置为0和1,而数据库在product_name或order_no上建了唯一索引。MySQL的唯一索引对多个NULL值不去重,但对0和1这种值是去重的。商品第一次删掉时deleted=1,再插入一条同名商品deleted=0,唯一索引判断两条记录值不同所以放行;但第二次删除时deleted又变成1,第三次插入deleted=0就会和上一次的冲突。
解决:把deleted字段改成可空类型,默认NULL,删除时填入主键id值。这样每条逻辑删除的记录deleted值都不同,唯一索引因为NULL不参与判断就能避开冲突。或者干脆删除商品时把deleted置为NULL,查询条件改成IS NULL判断,和MyBatis Plus的logic-delete配置配合时需要在yml里调整逻辑删除的未删除值,这个方案需要统一约定,两个地方同时改。
5.5 MyBatis Plus分页total不对,第一页永远只返回一条
现象:分页查询返回的total是1,records里只有一条数据,但数据库里其实有几十条上架商品。
原因:MyBatis Plus的分页插件没有注册。3.4版本之后,旧的分页PaginationInterceptor被移除,改成了MybatisPlusInterceptor内部添加PaginationInnerInterceptor。没有注册这个拦截器时,page方法不会执行COUNT查询,也不会生成LIMIT语句,而是把相当于全部数据查出来之后在应用层截断,total自然不对。
解决:注册MybatisPlusInterceptor这个Bean,并把PaginationInnerInterceptor指定DbType.MYSQL。配置类要放在Spring Boot启动类能被扫描到的地方。另外一个连带问题是前端如果依赖total做总页数计算,total错了整个分页器都会错乱,所以这个配置必须在搭建项目时就加上。排查时看控制台SQL日志,如果打印出的SQL没有LIMIT关键词,基本就是分页插件没生效。
6. 答辩前夜:三个值得花时间验证的升级点
6.1 用并发请求验证不超卖
下单和扣库存的逻辑写完,先不要急着往下做,用JMeter或者Postman的Runner模式模拟并发:把某个SKU库存改成10,拿50个线程同时发起下单请求,断言最终数据库中该SKU的库存不小于0,且成功订单数量不超过10。这个验证通过了,说明乐观锁和事务逻辑基本稳了,这也是一段能直接写进项目报告的实验数据。
6.2 验证前端价格篡改被后端校正
用浏览器开发者工具或者抓包软件把下单请求里的价格字段改成1,提交后去数据库里看订单金额。正确的结果是订单金额依然是数据库里的原价,而不是1分钱。这个点不用写进代码,但要在答辩时主动提,一句话就能说明你在安全边界上做过思考,比被老师追问出来再解释效果好得多。
6.3 给商品列表接口加一层Redis缓存
如果本地已经装了Redis,给商品详情接口加一个简单缓存:第一次查询后把商品对象放入Redis,设置5分钟过期时间,管理员修改商品价格后主动删除对应缓存。这一步能展示两件事:你对缓存淘汰策略有概念,你知道缓存一致性怎么处理。按照我的习惯,答辩前一天晚上再做这三个验证,哪个挂了还有时间修。能跑通,第二天答辩就从容了。希望帮到你。
本文还有配套的精品资源,点击获取