
如果你正打算用Java做一个同城网上甜品系统不管是当作毕业设计、课程设计还是单纯想练手一个完整的小型电商项目这篇内容应该能帮你在动手之前把思路理顺。我前阵子完整做过一个这样的演示项目从需求拆解、技术选型、数据库设计到核心代码实现全部走了一遍中间踩过的坑和最后沉淀下来的经验都在这篇里。需要说明的是文中项目和代码是我用来梳理思路的模拟项目所有门店、商品和订单数据都是虚构的大家正常参考即可。很多人拿到同城网上甜品系统这个题目上来就想把页面做得花哨一点结果做到订单环节才发现表结构和业务逻辑对不上返工成本非常高。这个系统听起来只是一个普通电商但同城两个字会让它跟普通电商出现本质区别。先把这个区别想明白后面所有设计才不会跑偏。1. 先搞清楚同城两个字意味着什么1.1 同城甜品系统与普通电商的本质差异普通电商的核心逻辑是把商品卖到全国系统最关心的是商品SKU、物流单号和售后流程。同城甜品系统不一样它服务的是一个城市甚至几个商圈里的用户核心是即时性和履约半径这两个概念。甜品这类商品对时间非常敏感。一个奶油蛋糕放两小时和放两小时半口感差异很大用户会直接给差评。所以系统里必须存在门店这个实体商品归属于门店而不是归属于一个中心化仓库。用户下单时系统要先判断用户收货地址在哪个门店的配送范围内再决定这个订单由谁履约。如果用户选择自提还需要展示门店地址、营业时间和自提码。同城系统还要处理门店库存的问题。同样是提拉米苏A门店有货B门店可能已经售罄。普通电商只需要扣一个中心仓库的库存同城系统必须按门店维度扣库存。这就让商品表、订单表和门店表之间的关系比普通电商更复杂一层。还有一个差异是配送状态流转。普通电商的物流状态由第三方物流商提供同城甜品系统的履约状态完全由商家自己控制接单、制作、出餐、配送中、已完成。这一套状态流转需要系统设计得足够清晰商家端和用户端看到的状态要实时同步这是整个项目中工作量最大的部分之一。1.2 三端功能边界用户端、商家端、管理端我在做这个演示项目时把系统拆成了三个端每个端的功能边界划分得很清楚。这个划分方式也直接对应了数据库里的角色字段。用户端面向消费者核心功能是注册登录、浏览门店和甜品、搜索分类、加入购物车、提交订单、选择配送或自提、使用优惠券、查看订单状态。不需要做得太复杂但界面要流畅下单流程的每一步反馈都要清晰。商家端面向门店店员或店长核心功能是商品上下架、修改库存、查看订单列表、接单、操作订单状态开始制作、开始配送、完成订单、查看当日营收。它是整个系统里操作频率最高的端按钮和状态提示必须非常明确不能出现这个按钮点了到底会发生什么的疑惑。管理端面向平台运营人员核心功能是门店管理新增门店、调整坐标、设置营业时间、用户管理、品类管理、优惠券规则配置、基础数据统计。管理端不直接参与交易但他控制的数据是所有业务运行的基础。三端对应三种角色我建议把角色直接设计成一张独立的权限模型后台通过role字段区分ROLE_USER、ROLE_MERCHANT、ROLE_ADMIN接口层用拦截器做角色校验。这样代码结构清晰后面扩展新角色也不至于推倒重写。端核心使用者重点功能操作频率用户端C端消费者浏览、下单、支付、查订单中商家端门店店员、店长接单、状态流转、库存管理高管理端平台运营人员门店、商品、优惠券、统计低1.3 这个系统不做什么做项目最怕的就是什么都想要。一个甜品系统的题目容易被加进去各种花哨需求比如骑手实时轨迹、聊天客服、社区团购、会员积分商城。如果只有一个人开发这些功能会把核心链路拖垮。我当时明确砍掉了几块不做骑手派单系统只做商家自配送和用户自提不做实时聊天用户如有问题直接通过订单备注和电话联系不做复杂的售后流程只做取消订单和退款标记不做积分商城保留优惠券就够了。砍掉这些不是为了偷懒而是为了让核心交易闭环跑得更牢。一个能稳定完成选店→加购→下单→支付→接单→制作→配送/自提→完成的系统比一个功能堆砌但订单状态会乱的半成品有价值得多。这个取舍思路写进论文的设计部分反而比只罗列功能更体现思考深度。2. 技术选型凭什么用Spring Boot Redis GEO2.1 主干技术栈一览技术选型部分我定的方案如下可以说是一个兼顾稳定性、学习成本和实现效率的组合层次技术选型说明后端框架Spring Boot 2.7生态成熟自动配置省去大量XML配置JDKJava 8稳定且资料多部署环境兼容性好ORMMyBatis-Plus单表CRUD无需手写SQL复杂查询用注解SQL数据库MySQL 8关系数据存储支持JSON字段做扩展缓存与定位Redis 6缓存热点数据同时用GEO功能做门店距离计算权限JWT Spring拦截器无状态登录三端角色校验前端Vue 3 Element Plus组件化开发后台管理界面搭建效率高构建部署Maven Docker统一构建容器化部署为什么选择这套组合因为Spring Boot解决的是配置繁琐的问题MyBatis-Plus解决的是CRUD样板代码多的问题Redis解决的是配送范围计算和热点缓存的问题。这三件事正好是本项目的三个主要痛点。新一代的框架比如Spring Boot 3和Java 17当然更新但对于毕业设计、课程设计这种规模的项目Spring Boot 2.7的社区资料最全遇到问题搜一下就能解决没必要在版本选择上给自己增加无谓的阻碍。如果你的环境条件允许用Spring Boot 3也没有问题核心代码基本兼容。2.2 Redis GEO为什么比手动计算距离更合适同城系统的核心场景之一是用户在下单时系统判断哪些门店能给他配送。最容易想到的方案是把所有门店经纬度存到MySQL用户提交地址后用Haversine公式遍历计算距离筛选出5公里内的门店。这个方案在门店数量只有几十个时完全可行但有两个问题一是距离计算在MySQL里做索引失效二是每次请求都要全表扫描门店坐标门店数量上来之后响应时间明显变差。Redis GEO是更优雅的方案。Redis从3.2版本开始支持GEO类型底层基于有序集合实现可以高效地完成给定一个坐标查询半径内所有门店的操作。门店数量和查询量上来之后性能依然稳定。我在项目里是这样用的// 初始化门店坐标门店ID与经纬度写入Redis GEO redisTemplate.opsForGeo().add( shop:geo:locations, new Point(shop.getLongitude(), shop.getLatitude()), shop.getId().toString() ); // 用户下单选店时查询当前位置3公里内的所有门店 ListGeoResultRedisGeoCommands.GeoLocationString results redisTemplate.opsForGeo() .search( shop:geo:locations, new Circle( new Point(userLng, userLat), new Distance(3, RedisGeoCommands.DistanceUnit.KILOMETERS) ) ); // 解析出门店ID列表再回MySQL查门店详情 ListLong shopIds results.stream() .map(result - Long.valueOf(result.getContent().getName())) .collect(Collectors.toList());实际开发中我会在管理端保存门店坐标时同步写入Redis GEO在门店下架或关闭时删除对应GEO记录保证缓存与数据库的一致性。这里的核心经验是GEO适合做粗筛找出3公里内有哪几家店门店的精准配送范围、营业状态、是否暂停接单这些信息还是要回数据库做二次校验不能只依赖Redis。2.3 登录与权限JWT怎么落地一个包含用户端、商家端、管理端的系统权限控制是绕不开的。我用的方案是JWT登录态 拦截器角色校验。用户登录成功后后端签发一个JWT把userId、role放到token的claim里。前端每次请求在Authorization头带上token后端拦截器解析token之后把用户信息放入ThreadLocal或请求上下文后续的业务代码就可以直接拿到登录用户。Component public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 放行登录、注册、商品浏览等公开接口 String uri request.getRequestURI(); if (checkPublicUri(uri)) { return true; } // 解析token校验合法性 String token request.getHeader(Authorization); LoginUser loginUser JwtUtil.parseToken(token); if (loginUser null) { throw new BizException(401, 登录状态已过期请重新登录); } // 按角色校验接口权限 if (!checkRole(uri, loginUser.getRole())) { throw new BizException(403, 无权限访问); } UserContext.set(loginUser); return true; } }权限规则我建议用一个小工具类维护而不是把角色判断散落在各个Controller里。比如/api/admin/**需要ROLE_ADMIN/api/merchant/**需要ROLE_MERCHANT/api/user/**需要登录即可。这样角色和URL的对应关系集中管理排查问题非常方便。3. 数据库建模让六张表撑起整个订单闭环3.1 核心表清单与整体关系数据库设计是整个项目的地基我在动手写代码之前花了两天时间把表结构反复推演最终确认了六张核心表用户表、门店表、甜品表、购物车表、订单表、订单明细表外加一个优惠券表。它们之间的关系可以这样理解用户通过购物车关联到甜品提交订单后购物车数据转化为订单主表加订单明细表订单主表记录整体信息订单明细表记录这个订单里每一个甜品和对应的数量门店表在整个流程中同时服务于商品归属和配送归属两个角色。这个模型不复杂但能覆盖同城甜品系统的全部核心业务。额外的评价表、退款记录表、管理员表可以在核心表跑通之后再逐步补充。3.2 关键表的字段设计用户表CREATE TABLE tb_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 用户ID, username VARCHAR(50) NOT NULL UNIQUE COMMENT 用户名, password VARCHAR(100) NOT NULL COMMENT BCrypt加密后的密码, phone VARCHAR(20) COMMENT 手机号, avatar VARCHAR(255) COMMENT 头像URL, role TINYINT NOT NULL DEFAULT 0 COMMENT 0普通用户 1商家 2管理员, status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用 0禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) COMMENT 用户表;门店表CREATE TABLE tb_shop ( id BIGINT PRIMARY KEY AUTO_INCREMENT, shop_name VARCHAR(100) NOT NULL COMMENT 门店名称, address VARCHAR(255) NOT NULL COMMENT 详细地址, longitude DECIMAL(10, 7) NOT NULL COMMENT 经度, latitude DECIMAL(10, 7) NOT NULL COMMENT 纬度, business_hours VARCHAR(100) COMMENT 营业时间如 09:00-21:00, contact_phone VARCHAR(20), status TINYINT NOT NULL DEFAULT 1 COMMENT 1营业中 0打烊, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT 门店表;这里有个细节经纬度我用的是DECIMAL(10,7)而不是FLOAT或DOUBLE。FLOAT在大量坐标计算时会产生精度误差DECIMAL(10,7)的精度约0.01米足够支撑同城配送距离计算同时还能避免浮点数存储带来的隐患。甜品表CREATE TABLE tb_cake ( id BIGINT PRIMARY KEY AUTO_INCREMENT, shop_id BIGINT NOT NULL COMMENT 所属门店ID, category_id BIGINT COMMENT 分类ID, name VARCHAR(100) NOT NULL, description VARCHAR(500), image VARCHAR(255) COMMENT 商品图片URL, price DECIMAL(10, 2) NOT NULL COMMENT 原价, discount_price DECIMAL(10, 2) COMMENT 售价, stock INT NOT NULL DEFAULT 0 COMMENT 门店库存, sales INT NOT NULL DEFAULT 0 COMMENT 销量, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT 甜品表;订单主表CREATE TABLE tb_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT 订单号, user_id BIGINT NOT NULL, shop_id BIGINT NOT NULL, total_amount DECIMAL(10, 2) NOT NULL COMMENT 商品总金额, discount_amount DECIMAL(10, 2) NOT NULL DEFAULT 0 COMMENT 优惠金额, pay_amount DECIMAL(10, 2) NOT NULL COMMENT 实付金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1待接单 2制作中 3配送中 4已完成 5已取消 6已退款, delivery_type TINYINT NOT NULL DEFAULT 0 COMMENT 0外送 1自提, receive_name VARCHAR(50), receive_phone VARCHAR(20), receive_address VARCHAR(255) COMMENT 外送地址, remark VARCHAR(255) COMMENT 订单备注, pay_time DATETIME COMMENT 支付时间, complete_time DATETIME COMMENT 完成时间, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT 订单主表;订单明细表CREATE TABLE tb_order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, cake_id BIGINT NOT NULL, cake_name VARCHAR(100) COMMENT 下单时的商品名称快照, cake_image VARCHAR(255) COMMENT 下单时的商品图片快照, price DECIMAL(10, 2) NOT NULL COMMENT 下单时的商品单价快照, quantity INT NOT NULL, subtotal DECIMAL(10, 2) NOT NULL COMMENT 小计 ) COMMENT 订单明细表;购物车表CREATE TABLE tb_cart ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, cake_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 1, selected TINYINT NOT NULL DEFAULT 1 COMMENT 是否勾选1勾选 0未勾选, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_cake (user_id, cake_id) ) COMMENT 购物车表;优惠券表CREATE TABLE tb_coupon ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, threshold DECIMAL(10, 2) NOT NULL COMMENT 满减门槛金额, amount DECIMAL(10, 2) NOT NULL COMMENT 抵扣金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 0未使用 1已使用 2已过期, expire_time DATETIME, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT 用户优惠券表;3.3 订单主表和明细表为什么要拆开我在很多代码里看到有人图省事把订单里的每个商品都当成一个订单记录存订单表里直接存商品名、价格、数量和收货地址。这样做用户查我的订单时会看到同一个订单拆成几条甚至几十条记录订单状态还要逐条维护根本是给自己挖坑。拆成主表和明细表之后订单主表描述一次交易订单明细表描述这次交易买了哪些商品。好处有三个一是查询订单列表只要查主表速度快展示清晰二是同一个订单里的多个商品可以在明细表里各自保留快照商品改价、下架都不影响历史订单三是做营收统计时按主表汇总实付金额按明细表分析商品销量两种口径互不干扰。特别要注意快照的意义。用户下单时商品单价是10元商家第二天改价到12元历史订单明细里必须还显示10元。所以我在明细表里冗余了cake_name、cake_image、price字段而不是下单时去联表查商品表。这是订单系统的基本素养宁可冗余不要出问题。3.4 金额、状态、时间字段的三个设计原则第一个原则是金额一律用DECIMAL(10,2)。甜品单价、优惠金额、实付金额都属于钱Java代码里我用BigDecimal与之对应绝对不能用double或float。这不是小题大做浮点数在二进制存储中本身存在误差0.1 0.2 用浮点数计算得到的结果是 0.30000000000000004金额误差积累到对账环节会变成大问题。第二个原则是状态字段用TINYINT加注释而不是直接用字符串。原因很简单int状态可比较、可索引、占用空间小配合代码里的枚举类可以保证只能写入合法状态。字符串状态PAID和paid如果大小写不统一就会莫名出现脏数据。第三个原则是时间字段统一存DATETIME。创建时间用DEFAULT CURRENT_TIMESTAMP更新时间用ON UPDATE CURRENT_TIMESTAMP这样数据库会自动维护不需要业务代码手动塞时间。后面做超时订单扫描时直接用create_time做条件查询即可。3.5 门店与商品的归属关系门店和商品是1对多关系这个不用多说。但有一个容易被忽略的点同一款甜品在不同门店可以有不同价格和不同库存。比如芒果慕斯在A店售价35元在B店售价32元两店的库存也互不影响。所以我设计甜品表时把shop_id作为强关联字段每个商品记录严格属于一个门店。用户浏览商品详情时先定位门店再基于门店查商品列表。这样商品管理天然符合实际业务查询时也避免了两店互相干扰的麻烦。如果想做得更细可以在甜品表里加base_price作为总部指导价再在门店维度做浮动价格但这会引入第三张关联表。对于这个项目规模甜品表直接带shop_id就够用了不必过度设计。4. 下单链路拆解从加购到完成的每一步4.1 购物车确认与金额计算整个系统的核心环节是下单。我梳理出的完整链路是用户确认购物车 → 选择门店 → 选择配送或自提 → 确认收货信息 → 选择优惠券 → 后端校验 → 生成订单和明细 → 扣减库存 → 清空购物车 → 支付 → 商家接单 → 制作 → 配送/自提 → 完成。购物车模块的逻辑相对直接。用户把甜点加入购物车时后端要校验商品是否上架、门店是否营业然后把userId和cakeId以唯一索引写入购物车表如果用户已经加过同一款甜点就累加数量而不是重复插入。金额计算需要在前端展示和后端校验两个层面都做一遍。前端实时显示总金额是为了给用户良好的交互体验但真正下单时的金额计算必须以后端为准。后端会根据订单明细表重新计算商品总金额再减去优惠金额得到实付金额。前端传来的金额只能作为展示参考绝对不能作为下单依据否则用户篡改价格就是一个严重漏洞。计算金额的代码大概是这样的逻辑BigDecimal totalAmount cartItems.stream() .map(item - item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal discountAmount BigDecimal.ZERO; // 如果有优惠券且满足门槛则计算优惠金额 if (coupon ! null totalAmount.compareTo(coupon.getThreshold()) 0) { discountAmount coupon.getAmount(); } BigDecimal payAmount totalAmount.subtract(discountAmount);4.2 订单生成、库存扣减与事务边界订单生成的数据库操作涉及多张表插入订单主表、插入订单明细表、扣减商品库存、清空购物车、修改优惠券状态。这些操作必须处于同一个数据库事务里任何一个环节失败都不能留下半成品数据。Spring里直接使用Transactional注解即可。要注意的是事务的边界要明确远程调用、支付回调这类外部操作不应该包含在事务里否则会长时间占用数据库连接。我的做法是事务内只做数据库操作和内存计算支付环节通过模拟支付接口在事务外完成支付成功后再更新订单状态。库存扣减是并发场景最容易出错的地方。用户在两个设备上同时购买同一款只剩最后一件的甜品如果代码先查库存、判断大于0、再更新库存两个请求都可能通过判断最终导致超卖。正确的做法是使用条件更新把库存足够作为更新条件的一部分update iddeductStock UPDATE tb_cake SET stock stock - #{quantity}, sales sales #{quantity} WHERE id #{cakeId} AND stock #{quantity} /update如果影响行数为0说明库存不足直接抛出业务异常整个事务回滚。这种方案在门店库存这种量级下完全够用不需要引入复杂的分布式锁。配合Redis预扣减当然可以做更高并发但对课程设计、毕业设计来说数据库条件更新是最简单可靠的方案。4.3 订单状态机与超时取消订单生成之后需要一个清晰的状态机否则业务越做越乱。我定义的状态流转如下状态值状态名称触发动作下游状态0待支付下单成功用户支付 → 1超时未支付 → 51待接单支付成功商家接单 → 2商家拒单 → 62制作中商家接单制作完成 → 33配送中/待自提商家开始配送或通知自提用户确认收货/自提完成 → 44已完成用户确认终态5已取消用户取消或超时未支付终态6已退款商家拒单后退款终态超时未支付取消是一个必须处理的核心逻辑。用户下了单不支付会一直占用库存商家看到的订单列表也会出现大量无效单。我采用了定时任务扫描的方式每隔一分钟扫描一次待支付且创建时间超过15分钟的订单将其状态改为已取消并回补库存。Scheduled(fixedDelay 60000) public void cancelTimeoutOrders() { // 查询超时待支付订单 ListOrder orders orderMapper.selectTimeoutOrders(15); for (Order order : orders) { orderMapper.updateStatus(order.getId(), OrderStatus.CANCELLED.getValue()); // 恢复库存 orderItemMapper.selectByOrderId(order.getId()) .forEach(item - cakeMapper.revertStock(item.getCakeId(), item.getQuantity())); } }订单状态流转这个环节是测试的重灾区。我强烈建议在代码里用枚举类约束状态值每次状态变更写一个独立方法不要允许任意状态跳到任意状态。否则订单从待支付直接变成已完成这种脏数据会让你查问题查到怀疑人生。4.4 优惠券的核销与过期优惠券的设计相对独立但有两个点特别容易出问题。第一个是门槛判断。用户可能选择了低于满减门槛的商品但前端还展示着优惠券抵扣金额这时后端校验不通过会导致下单失败。我的做法是优惠券选择之后前端实时根据商品总金额判断券是否可用不可用的券置灰并提示还差xx元可用。后端的校验逻辑再兜底一次防止前端绕过。第二个是防重复使用。优惠券状态有0未使用、1已使用、2已过期三种。下单事务里更新优惠券状态时必须加上WHERE status 0的条件保证同一张券只能被核销一次。如果更新影响行数为0说明这张券已经被用过了立即抛出异常。过期优惠券的处理可以在用户每次查询优惠券列表时把expire_time早于当前时间的券批量更新为已过期状态。这个小任务不需要单独开定时器查询时顺手完成即可。5. 开发期最容易翻车的四个细节5.1 金额计算double的锅你背不起我见过很多人在项目里用double存金额理由是数字也不大够用就行。这个坑我在早期也踩过直到需要对账时发现账永远对不平才算真正长记性。Java里的double采用二进制浮点表示很多十进制小数无法被精确表达。最简单的验证方式是在控制台打印0.1 0.2结果不是0.3而是0.30000000000000004。你卖一个8.88元的蛋糕用户买三个你算出来的金额是26.639999999999997展示给用户是不专业存到数据库再参与二次计算是灾难。解决方案是用BigDecimal包住所有金额计算并且在构造BigDecimal时永远传字符串或使用valueOf不要直接传double。BigDecimal price new BigDecimal(8.88); BigDecimal quantity BigDecimal.valueOf(3); BigDecimal total price.multiply(quantity);数据库层用DECIMAL(10,2)与之对应。金额比较用compareTo而不是equals因为BigDecimal的equals会比较精度new BigDecimal(1.0).equals(new BigDecimal(1.00))返回的是false而compareTo返回值是0。这个小细节很容易把你绕晕。5.2 库存超卖乐观锁与Redis预扣库存超卖问题在上文已经提到这里再强调一遍排查思路。如果你发现下单成功后库存变成了负数大概率是代码里用了先查后改的操作// 错误示范 Cake cake cakeMapper.selectById(cakeId); if (cake.getStock() quantity) { throw new BizException(库存不足); } cake.setStock(cake.getStock() - quantity); cakeMapper.updateById(cake);两个并发请求同时查到了stock1都判断库存够都执行了更新最终库存变成-1。解决思路有两个层面数据库条件更新是最直接的兜底Redis预扣减适合高并发场景。但对于这个项目数据库条件更新已经足够不要为了炫技引入无谓的复杂度。另一个容易忽略的点是下单失败、超时取消、用户取消订单时都要恢复库存。不要把恢复库存的逻辑散落在各个地方最好统一封装成一个RestoreStockService任何取消路径都调用同一个方法避免某条路径漏掉恢复。5.3 图片上传与静态资源映射商品图片上传是小项目里最容易出现环境差异的坑。在本地开发时图片路径没问题部署到服务器后图片全部裂开十有八九是路径配置问题。我的建议是图片上传到服务器的一个独立目录比如/data/upload然后通过Spring Boot的静态资源映射把/upload/**指向这个目录数据库里存相对路径而不是绝对路径。# application.yml spring: web: resources: static-locations: file:/data/upload/上传的逻辑是接收MultipartFile→ 校验文件类型和大小 → 按日期生成子目录 → 生成UUID文件名 → 保存到本目录 → 返回/upload/2025/06/xxx.jpg给前端拼接展示。这里必须限制上传文件类型和大小否则容易被上传恶意文件。图片类型用Content-Type判断不可靠最好在服务端用工具读取文件头做二次校验。对于课程设计限制后缀名加限制大小已经可以满足要求。生产级方案建议接对象存储但对于演示项目本地存储配合Nginx或Spring静态映射足够。5.4 时区与日期存储时区问题看起来小但一旦出错会让你摸不着头脑。我就遇到过前端显示的下单时间是2024-06-01 08:00:00数据库中存的却是2024-06-01 00:00:00时间整整差了8个小时。原因是MySQL连接串里没有指定时区或者JVM默认时区和MySQL时区不一致。最简单的解决办法是在数据库连接参数里明确配置时区jdbc:mysql://localhost:3306/sweet_system?serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf8同时在Java代码里使用LocalDateTime而不是Date。LocalDateTime不携带时区信息配合MySQL的DATETIME类型可以保证存入和读出完全一致。这是一个零成本但收益很高的规范越早统一越好。6. 联调、测试与部署让系统真正跑起来6.1 统一接口返回体与异常处理前后端分离的项目接口规范直接决定了联调效率。我写了一套统一的返回体所有Controller的返回都走这个封装Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(成功); result.setData(data); return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.setCode(code); result.setMessage(message); return result; } }配套的还有全局异常处理器。业务异常BizException统一返回400和中文提示信息参数校验异常返回400未登录返回401权限不足返回403系统未知异常返回500并打日志。这样前端只需要看code和message就能定位问题不需要后端逐字段排查。接口路径的命名也建议统一规范化。用户端接口用/api/user/**商家端用/api/merchant/**管理端用/api/admin/**。登录和商品浏览等公开接口放在/api/public/**。每个模块内部按资源命名比如/api/user/orders、/api/user/orders/{id}、/api/merchant/orders/{id}/accept。这种约定让接口自解释文档都不用写太多。6.2 功能测试、并发测试与回滚测试系统写完之后我习惯用Postman把所有接口按业务流程走一遍模拟真实的用户操作路径。测试用例建议覆盖下面这些场景测试类型具体用例预期结果正常流程注册 → 登录 → 加购 → 下单 → 支付 → 商家接单 → 完成所有环节状态正确库存边界库存只剩1件时两个并发订单同时提交只有一个订单成功扣库存超时取消下单后不支付等待15分钟订单自动取消且库存回补优惠券边界订单金额刚好达到优惠券门槛优惠券正常抵扣优惠券防重复同一张券在两个请求中同时使用只有一次核销成功配送范围收货地址在门店3公里外提示无法配送权限控制用户token访问商家端接口返回403并发测试可以用JMeter或者简单的多线程代码模拟。10个线程同时抢购同一种库存为5的商品最终生成的订单数量必须恰好是5库存变为0不多不少。这个测试能一次性验证数据库条件更新的正确性。回滚测试很多人容易忽略。比如在订单生成过程中故意在插入明细表之前抛出一个异常然后检查数据库订单主表和明细表应该都不存在库存没有被扣减购物车里的商品还在。这个测试能确认Transactional的边界没有设错。6.3 Docker部署与演示准备部署时我用的Docker Compose一次性拉起MySQL、Redis和后端服务。前端打包出来的静态文件放到Nginx容器里做反向代理把/api转发到后端服务。version: 3 services: mysql: image: mysql:8.0 container_name: sweet-mysql environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: sweet_system ports: - 3306:3306 volumes: - ./mysql-data:/var/lib/mysql redis: image: redis:6.2 container_name: sweet-redis ports: - 6379:6379 backend: build: ./backend container_name: sweet-backend ports: - 8080:8080 depends_on: - mysql - redis environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/sweet_system?serverTimezoneAsia/Shanghai SPRING_REDIS_HOST: redis如果你需要现场演示建议提前准备三套测试账号一个用户账号购物车里存好商品、一个商家账号订单列表里有一单待接单、一个管理员账号能看到门店和统计图表。演示的时候按照用户下单 → 商家接单 → 状态流转 → 用户确认完成这条主线走比临时注册新账号演示流畅得多。有一点特别重要演示环境要使用独立的数据库提前导入一套有真实感的演示数据比如十几个甜品商品、三五个门店、几个优惠券。数据太少会显得系统很空数据太多会让页面加载变慢保持在足够丰富但又不臃肿的度上。写在最后一个人做完整系统最值得花时间的部分如果让我重新把这个项目做一遍我最想说的体会是这个系统的核心难点不在页面也不在某个炫酷的技术点而在于把订单状态管理和业务边界想清楚。只要订单状态机设计对了优惠券逻辑写对了库存扣减没有超卖这个系统就立得住反过来哪怕界面做得再好看下单链路稍微乱一点就会被打回原形。所以动手之前我建议先花时间把表结构和订单状态流转图画清楚哪怕手写也行。数据库设计得合理后面写代码会非常顺畅。我开始做这个系统时就是在E-R图和状态流转上反复推演了两天最终写代码的时间反而压缩了不少。这个系统后续可以扩展的方向其实很多给门店增加独立的子账号让店员只处理本店的订单增加一个小程序端让用户不用下载App也能下单给管理端加一个销售统计看板用ECharts展示每日营业额和热销榜单。每一步扩展都建立在这个已经跑通的闭环基础上不会推倒重来。最后再分享一个小技巧开发过程中每完成一个模块就顺手在文档里记录一段设计思路和踩坑记录。这不仅能帮你理清自己的项目逻辑当你需要把这些内容整理成论文或者答辩材料时你会发现所有素材都已经准备好了完全不需要临时抱佛脚。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。