☰
Java商城小程序单体项目实战:Spring Boot+Redis+MyBatis拆解
2026/10/18 9:55:44 网站建设 项目流程

简介:这份代码包是一套基于 Java 的单商户商城小程序系统源码,面向中小商家和想系统学习电商后端开发的 Java 工程师。项目围绕 Spring Boot、MyBatis、Thymeleaf、Redis 等主流技术组织,涵盖商品、订单、用户、支付等电商核心模块,并预留微信小程序相关接口,适合用来理解从数据库设计到前后端联调、再延伸至微服务化改造的完整链路。资源以 zip 压缩包形式发布,包体约 13.2MB;平台暂未给出具体的文件总数与类型明细,但源码工程通常会包含后端 Java 文件、MyBatis 映射配置、前端模板及项目构建文件,便于直接导入 IDE 查阅。目前已有 166 人学习浏览。通过阅读项目结构和关键模块,可以收获一套可复用的商城业务表设计思路、接口分层写法,以及单体应用向微服务演进的实践参考。

1. 一个被 microapp 名字骗了的 Java 商城小程序单体项目

第一次拿到 microapp-linjiashop-master 这个源码包,我差点被名字带偏——看到 microapp 以为又是一套微服务全家桶,翻进去才发现是个非常务实的 Java 商城小程序单体项目。商品、购物车、订单、支付、后台管理全塞在一个 Spring Boot 工程里,小程序端走独立 HTTP API,管理后台用 Thymeleaf 服务端渲染,数据访问层是 MyBatis + MySQL,热点数据交给 Redis 缓存。这个定位很聪明:个人商家或小团队要搭在线商城,最缺的不是服务拆分,而是一个能改能跑、没有过度设计的完整模板。想学电商闭环的 Java 开发者可以拿它练手,外包团队也能直接基于它做二次开发,我把里面值得看的技术点逐个拆开讲。

2. 技术架构与请求链路:从 Thymeleaf 后台到小程序 API 的完整闭环

2.1 技术选型:单体优先,名字里的 microapp 只是伪装

这个项目名字容易误导人。实际工程是一个标准 Spring Boot 单体应用,没有服务注册中心,没有网关,没有拆开的独立部署单元。为什么这么选?因为单商户商城业务体量就摆在那里:一个用户表、一张商品表、一套订单流程,强行微服务化只会把所有简单问题复杂化。单体应用配合 Redis 缓存,在几千并发下完全够用,部署也省心。

背后的选型逻辑值得说一句:Spring Boot 负责把 Controller、Service、Mapper 串起来,MyBatis 让开发者手写 SQL 控制查询细节,Redis 缓存商品列表和登录态,Thymeleaf 负责渲染后台管理页面,Maven 统一管理依赖。这套组合在 Java 电商项目里非常典型,换个项目也是一样的配方。数据库用 MySQL,会话不依赖 Tomcat Session,而是把 token 写进 Redis,天然支持将来水平扩展。

技术选型在该项目中的职责
Spring Boot提供 REST API、定时任务、事务管理、内嵌 Tomcat 运行环境
MyBatisSQL 与 Java 对象映射,商品/订单等表操作全部走 XML 或注解
Redis缓存商品列表、购物车临时数据、小程序登录 token
Thymeleaf后台管理页面服务端渲染,模板直接访问后端数据
Maven依赖管理、生命周期构建、打包可执行 Jar

2.2 工程目录与数据库表设计:拿到源码先认路

解压源码包后,目录结构一眼能看明白。前端和后端混在一个仓库里,miniapp 目录装的是微信小程序前端,src 下面是 Java 后端,sql 目录放着建库脚本。

microapp-linjiashop-master/ ├── pom.xml # Maven 父工程配置 ├── sql/ │ └── linjia_shop.sql # 初始化表结构和默认数据 ├── src/main/java/ │ ├── common/ # 统一返回体、异常处理、工具类 │ ├── config/ # Redis、拦截器、WebMvc 配置 │ ├── controller/ │ │ ├── admin/ # 后台管理接口,返回 Thymeleaf 页面 │ │ └── api/ # 小程序端 REST 接口 │ ├── service/ # 业务逻辑层,事务都写在这层 │ ├── mapper/ # MyBatis 接口,对应 resources/mapper 下的 XML │ └── model/entity/ # 数据库实体类 ├── src/main/resources/ │ ├── mapper/ # SQL XML 文件 │ ├── templates/ # Thymeleaf 后台模板 │ └── application.yml # 数据库、Redis、接口配置 └── miniapp/ # 微信小程序前端源码

数据库表设计覆盖了单商户电商的最小闭环:用户表存 openid 和昵称,商品表存标题、价格、库存、主图,订单表和订单明细表是一对多关系,购物车数据在 Redis 里但也会落一份到表里,另外还有 banner 轮播图、收货地址、优惠券这些周边表。商品表里的stock字段是重点,后面下单扣库存全靠它做条件更新。

2.3 一次小程序请求从进入到返回的完整链路

把链路走一遍就明白整项目的骨架了。小程序端wx.request发起请求,先到 Nginx 或者直接打到 Spring Boot 的 Controller。Controller 不做业务,只接收参数、调 Service、包装返回结果。Service 层先查 Redis 缓存,命中就直接返回,没命中就去查 MySQL,查完再回填缓存。购物车这类高频操作整个放在 Redis 里,用 Hash 结构按用户维度存储。

后台管理端的链路走的是另一条路:浏览器访问/admin/**的 URL,Controller 返回 Thymeleaf 模板名,模板引擎渲染成 HTML 回给浏览器。因为后台请求频率低,没有接 Redis 缓存,直接查库拿最新数据。这种双链路设计在中小型项目里很常见,小程序端是接口驱动,后台是页面驱动,互不干扰。

3. 核心模块源码拆解:登录、商品缓存、购物车到支付回调

3.1 小程序登录:code2session 换 openid,Redis 存会话

小程序登录和传统账号密码登录不一样,微信把用户身份绑定在 openid 上,前端拿wx.login()返回的 code 换 session。核心逻辑看登录接口:

@RestController @RequestMapping("/api/user") public class AuthController { @PostMapping("/login") public Result login(@RequestBody LoginRequest req) { // 1. 小程序端 wx.login 拿到的 code,5 分钟内有效,且只能使用一次 JSONObject session = wxApi.code2Session(req.getCode()); String openid = session.getString("openid"); if (openid == null) { return Result.fail("登录凭证无效"); } // 2. 用 openid 作为唯一标识查用户,不存在则自动注册 User user = userMapper.selectByOpenid(openid); if (user == null) { user = new User(); user.setOpenid(openid); user.setNickname("微信用户_" + openid.substring(0, 8)); userMapper.insert(user); } // 3. 生成随机 token 写入 Redis,设置 7 天过期时间 String token = UUID.randomUUID().toString().replace("-", ""); redisTemplate.opsForValue().set( "login_token:" + token, openid, 7, TimeUnit.DAYS); return Result.ok(new LoginVO(token, user)); } }

code2Session是微信服务端接口,把 code 换成 openid 和 session_key。openid 是用户维度唯一 ID,同一个用户在小程序里永远是同一个 openid,所以拿它当主键天然合适。token 是自定义会话凭证,每次登录都生成新的,旧 token 会在 Redis 里留着直到过期,一个用户同时存在多个有效 token 是正常情况。后续所有需要登录的接口,请求头带上Authorization: Bearer <token>,拦截器去 Redis 里查一次即可。

3.2 商品列表缓存:分页数据缓存容易翻车,注意脏数据

商品首页是流量最大的地方,每次请求都打 MySQL 显然扛不住。商品列表做了分页缓存,Key 里带分类和页码,缓存 30 分钟:

public PageInfo<Goods> listGoods(int page, int size, Long categoryId) { // 缓存 key 由分类和页码组成,不同分页数据互不干扰 String key = "goods:list:" + categoryId + ":" + page + ":" + size; String cached = redisTemplate.opsForValue().get(key); if (cached != null) { return JSON.parseObject(cached, PageInfo.class); } PageHelper.startPage(page, size); GoodsExample example = new GoodsExample(); if (categoryId != null) { example.createCriteria().andCategoryIdEqualTo(categoryId); } List<Goods> list = goodsMapper.selectByExample(example); PageInfo<Goods> info = new PageInfo<>(list); // 设置过期时间,避免缓存永远不更新 redisTemplate.opsForValue().set(key, JSON.toJSONString(info), 30, TimeUnit.MINUTES); return info; }

这套写法的坑在于:后台改了商品价格后,小程序首页还可能展示goods:list:*老缓存。库存和价格的实时性要求不高,30 分钟过期还能接受,但秒杀这类强实时场景不能这么干。常见做法是在后台管理端商品变更接口里,用 Redisscan命令扫出goods:list:*前缀的 key 全部删掉,或者给缓存 key 加一个版本号,改价时递增版本号,查询时拼上版本号,旧缓存自然失效。分页缓存每次请求约 10 到 20 个商品,串行 JSON 存储和读取都没问题。

3.3 Redis 购物车与下单扣库存:条件更新防止超卖

购物车用 Redis Hash 存储,每个用户一个 key,field 是 skuId,value 是商品数量,加购直接increment原子自增:

public void addCart(Long userId, Long skuId, Integer count) { String key = "cart:" + userId; // Hash 结构:field 是 skuId,value 是购买数量,原子自增 redisTemplate.opsForHash().increment(key, String.valueOf(skuId), count); // 设置 30 天过期时间,用户长时间不登录购物车自然清理 redisTemplate.expire(key, 30, TimeUnit.DAYS); }

Hash 选得好,一个 key 下面挂多个商品,查购物车一次entries全拿出来,不用循环查库。下单是事务操作,核心在扣库存那条 SQL,把stock >= count写进 UPDATE 条件,数据库层面保证不会扣成负数:

@Transactional(rollbackFor = Exception.class) public Long submitOrder(Long userId, List<CartItem> items) { Order order = new Order(); order.setUserId(userId); order.setOrderNo(DateTimeFormatter.ofPattern("yyyyMMddHHmmss").format(now) + userId); order.setStatus(0); // 0 待支付,1 已支付,2 已发货,3 已完成 orderMapper.insert(order); long total = 0; for (CartItem item : items) { // 关键:把库存条件写进 SQL,数据库自己判断,避免先查再改的超卖问题 int rows = goodsMapper.reduceStock(item.getSkuId(), item.getCount()); if (rows == 0) { throw new BizException("商品库存不足:" + item.getSkuId()); } // 插入订单明细,累加金额 OrderItem orderItem = new OrderItem(); orderItem.setOrderId(order.getId()); orderItem.setSkuId(item.getSkuId()); orderItem.setCount(item.getCount()); orderItem.setPrice(goodsMapper.selectPrice(item.getSkuId())); orderItemMapper.insert(orderItem); total += orderItem.getPrice() * item.getCount(); } order.setTotalAmount(total); orderMapper.updateByPrimaryKey(order); return order.getId(); }

对应的 Mapper SQL 是精髓:

UPDATE goods SET stock = stock - #{count} WHERE id = #{skuId} AND stock >= #{count}

reduceStock返回的影响行数只有 0 和 1,0 表示库存不足,直接抛异常回滚整个事务。这套逻辑比先select再update安全得多,在高并发下也不会出现两个请求同时把库存扣成负数。Redis 里的购物车在下单成功后要清掉对应的商品 field,否则会出现购物车残留,我在清购物车时用redisTemplate.opsForHash().delete(key, skuIds)批量删除,一次网络开销搞定。

3.4 支付回调:验签、幂等、更新订单状态

支付成功后微信服务器会回调商户接口,通知支付结果。回调接口必须处理三个问题:验签确认请求真的来自微信、幂等防止重复通知、只更新一次订单状态:

@RequestMapping("/pay/notify") public String notify(@RequestBody String xml, @RequestHeader("sign") String sign) { // 1. 验签:用商户密钥对回调参数重新签名,不一致直接拒绝 if (!WxPayUtil.verifySign(xml, sign)) { return "fail"; } // 2. 幂等判断:订单状态不是待支付,说明已经处理过,不再重复处理 String orderNo = WxPayUtil.parseOrderNo(xml); Order order = orderMapper.selectByOrderNo(orderNo); if (order.getStatus() != 0) { return "success"; } // 3. 更新订单状态为已支付,记录支付时间 order.setStatus(1); order.setPayTime(new Date()); orderMapper.updateByPrimaryKey(order); return "success"; }

回调只做状态更新,不发消息推送、不调物流接口,保持逻辑轻量。如果业务上需要通知用户支付成功,我一般会在回调里加一个 MQ 消息或者直接查库主动轮询。验签的具体算法是商户私钥对关键参数做 HMAC-SHA256 签名,项目里用工具类封装了,改的时候只需要换商户号和密钥。回调返回值固定是字符串success或fail,微信收到success才停止回调,收到其他值会重试多次,所以幂等判断那一行绝对不能漏。

4. 本地运行:改配置、建库、启动、挂上小程序

4.1 环境核对:JDK、Maven、MySQL、Redis 一个都不能少

跑这个项目之前先核对环境。后端是标准 Java 应用,JDK 8 或 11 都可以,Maven 3.6 以上用于构建。数据库用 MySQL 5.7 或 8.0,Redis 2.8 以上都行,项目里用的 Redis 命令都是基础操作,没有依赖高级特性。小程序端需要微信开发者工具,建议用稳定版。Windows 和 macOS 都能跑通,唯一需要注意的是 MySQL 和 Redis 必须在本机装好并且能连上,不然启动阶段就会报连接失败。

组件版本建议用途
JDK8 或 11编译并运行 Spring Boot 应用
Maven3.6+拉取依赖、打包 Jar
MySQL5.7+持久化存储商品、订单、用户数据
Redis3.0+缓存、购物车、登录 token
微信开发者工具最新稳定版运行小程序前端代码

4.2 初始化数据库与修改 application.yml

导入数据库脚本之前先看一眼 sql 目录,找到linjia_shop.sql,用命令行导入比在图形工具里点鼠标更不容易出错:

mysql -uroot -p < sql/linjia_shop.sql

导入成功后,修改src/main/resources/application.yml里的数据源和 Redis 配置,这是跑通项目最关键的一步:

spring: datasource: url: jdbc:mysql://localhost:3306/linjia_shop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 redis: host: localhost port: 6379 password: thymeleaf: cache: false mybatis: configuration: map-underscore-to-camel-case: true

数据库连接串里的serverTimezone=Asia/Shanghai必须配,否则 MySQL 8.0 默认时区偏差会让时间字段凭空多 8 小时。map-underscore-to-camel-case: true也很关键,它让数据库的create_time自动映射成 Java 的createTime,代码里不用每个字段写@JsonProperty。Redis 如果本机装的时候设置了密码,把password填上,没有就留空。Thymeleaf 的cache: false是开发模式标配,不然改了模板要重启才能生效。

4.3 后台管理端启动与验证

配置改好后,切到项目根目录执行打包命令。第一次打包会下载一堆 Maven 依赖,几分钟到十几分钟正常,耐心等:

mvn clean package -DskipTests

打包成功后 target 目录下会生成可执行 Jar,直接启动:

java -jar target/microapp-linjiashop-1.0.0.jar

看到Started MicroappLinjiaShopApplication之类的日志就说明启动成功了。浏览器访问http://localhost:8080/admin/login,会进入一个 Thymeleaf 渲染的登录页,账号密码在 sql 初始化脚本里,默认一般是admin / admin123,如果不对就打开数据库的sys_user表看初始化记录。后台管理端能登录、商品列表能展示、编辑商品能保存,说明整个后台链路通了。日志里如果出现Redis connection refused,去 Redis 服务端确认redis-server进程是否活着。

4.4 微信开发者工具接入:本地调试先跳过域名校验

后端跑通后,用微信开发者工具导入miniapp目录,会提示 AppID 不匹配,改成测试号或者自己的小程序 AppID 都行。关键一步是修改小程序里的接口地址,全局搜索baseUrl或者http://127.0.0.1:8080,把它换成http://localhost:8080/api,后端所有接口都挂在/api前缀下。

本地联调时,在微信开发者工具右上角点击"详情",勾选"不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书",否则wx.request到http://localhost会被拦截。真机预览时不能用 localhost,要改成电脑的局域网 IP,手机和电脑连同一个 WiFi 才能访问到后端。调试登录功能时,先在工具里调用wx.login,再把 code 传到登录接口,能返回 token 并在 Redis 里查到login_token:*就说明链路全部打通。我第一次跑这个项目时在域名校验这一步卡了半小时,后来才发现本地开发根本不需要 HTTPS,这个开关就是给开发者省事的。

5. 避坑指南:时区、Redis 序列化、登录态与超卖——5 次翻车记录

5.1 订单创建时间偏移 8 小时:MySQL 时区与 JDBC 连接的坑

现象是后台管理端看到订单时间是昨天,小程序端显示的时间却正常。原因是 MySQL 8.0 默认时区是 UTC,而 JDBC 连接串里没指定时区,数据库存的是系统当前时间减去 8 小时的结果。解决方法是连接串末尾加serverTimezone=Asia/Shanghai,同时 MySQL 服务端启动参数加上--default-time-zone=+8:00。这个坑在 Linux 服务器上特别容易出现,本机跑 Windows 反而少见。从那以后我建连接串直接固定写Asia/Shanghai,不为环境差异留任何玄学空间。

5.2 Redis key 出现\xac\xed乱码前缀:序列化器没配置

现象非常显眼:命令行连 Redis 去看 key,发现存的不是login_token:xxx,而是一长串\xac\xed\x00\x05t...的二进制乱码。原因是用JdkSerializationRedisSerializer作为默认序列化器,Java 对象序列化时把类型信息都写进了 key 和 value 里。解决方法是给 RedisTemplate 显式配置 key 用StringRedisSerializer,value 用GenericJackson2JsonRedisSerializer,这样 key 干净可读,跨语言访问也没有障碍。我见过不少项目在这上面吃了亏,缓存数据两边序列化方式不一致,查不到值又不敢删,最后只能清空 Redis 重启服务。

5.3 小程序登录态突然失效:Redis 缓存被清空

现象是用户用着用着,接口突然返回未登录,重新打开小程序又恢复了。排查下来是 Redis 配置了allkeys-lru淘汰策略,内存达到上限时把登录 token 当作冷数据淘汰了。token 是用户身份凭证,不能被 LRU 淘汰,解决办法是 Redis 配置里改成volatile-lru,只淘汰设置了过期时间的 key,或者给login_token:前缀的 key 单独建一个 Redis 实例。改完配置重启 Redis,问题就不再出现。另外,上线前最好加一层拦截器兜底,token 查不到时返回明确的业务码,小程序端可以静默重新登录,用户体验不至于断掉。

5.4 秒杀场景库存超卖:默认的乐观锁在极端并发下失效

如果只是在代码里用UPDATE ... WHERE stock >= #{count},普通秒杀量级下没问题,但极端并发下两个事务同时读到相同版本号,后面的更新会一直失败重试,最后用户看到的是库存还有但提交就报错。解决思路是把 Redis 的原子自减作为第一道闸门,先拿 Redis 扣减,扣成功再落订单表,数据库只做最终一致性校验。更稳妥的做法是在秒杀接口上用 Lua 脚本保证 Redis 里 check 和 decr 是一个原子操作。防超卖的底线是数据库那层stock >= count条件不能丢,缓存只能挡流量,不能兜底。

5.5 小程序真机连不上本地接口:域名白名单和局域网 IP

现象是开发者工具里一切正常,手机扫码预览就一片白或者接口全部超时。原因有两个:一是真机预览时不校验合法域名的开关失效,wx.request只能发到 HTTPS 域名;二是就算关了校验,localhost在手机上指向手机自己,根本连不到电脑。解决方法是开发阶段真机预览时,把接口地址改成电脑局域网 IP,例如http://192.168.1.5:8080/api,并保持勾选跳过域名校验。上生产就必须有备案过的 HTTPS 域名,Nginx 把/api反代到后端 8080 端口。这条属于上线前必经之路,早改早踏实。

6. 二次开发实战:给商城加一个限时秒杀模块

6.1 秒杀表与接口设计

秒杀业务和普通商品最大的区别在库存控制。给项目加秒杀功能,我先加一张seckill_goods表,字段包括商品 ID、秒杀价格、秒杀库存、开始时间和结束时间,然后秒杀接口只做一件事:Redis 预扣库存,扣成功才走下单逻辑。

6.2 Lua 原子扣减与验证流程

核心扣减逻辑放在 Lua 脚本里,因为 Redis 执行 Lua 是单线程原子操作,不会出现"检查库存和扣减之间被别的请求插队"的问题:

-- KEYS[1] 是秒杀库存 key,ARGV[1] 是用户 id if redis.call('exists', KEYS[1]) == 1 then local stock = tonumber(redis.call('get', KEYS[1])) if stock <= 0 then return -1 end -- 防止同一用户重复秒杀,用 set 记录已参与用户 if redis.call('sismember', KEYS[1] .. ':users', ARGV[1]) == 1 then return -2 end redis.call('decr', KEYS[1]) redis.call('sadd', KEYS[1] .. ':users', ARGV[1]) return 1 else return -3 -- key 不存在说明活动未开始或已结束 end

改造步骤就三步:秒杀接口先执行 Lua 脚本,返回 1 才调用现有submitOrder;活动开始前用定时任务把秒杀库存预热到 Redis;小程序端新增一个秒杀 tab,接口只展示活动期间的商品。验证方法很简单,本地起一个简单的循环请求脚本,同时发 1000 个请求打秒杀接口,看返回成功数是否等于预热库存数,再查数据库订单数是否一致。我在某个项目里上线秒杀就吃过亏:只做了 Redis 扣减,数据库下单失败后没有回滚 Redis,导致用户秒杀成功却永远收不到货。从那以后我每次都强制走一遍完整链路验证,缓存扣减、数据库落单、失败补偿三个环节逐一核对,数据对不上就直接 fail 整个请求。秒杀这类功能宁可多花时间做补偿,也不留给线上一个黑匣子,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询