Spring Boot单体商城源码:从扣库存到JWT鉴权的电商后端实战解析
简介面向Java中高级开发者与商城系统学习者的单商户商城小程序项目源码包基于Spring Boot、MyBatis、Thymeleaf、Redis及Maven构建并融入微服务拆分、小程序接口对接、数据库设计、OAuth2安全认证、Docker部署与监控测试等电商开发核心知识点适合用于理解从后端服务到小程序联动的完整业务链路。资源包为zip压缩格式整体大小约13.2MB由于上传方暂未提供文件总数与明细清单包内目录结构有待下载后自行查看目前已有166人学习/下载。项目本身定位为轻量级商城系统源码中通常包含商品、订单、用户、支付等典型模块结合小程序端API可帮助读者快速搭建原型并掌握现代化Java项目在配置、缓存、持久层与安全方面的实践思路是一份可用于二次开发或教学演练的参考资料。1. 轻量级单商户商城为什么单体 Java 应用能撑住完整的交易链路这份 java 商城小程序源码数据表加起来不到二十张却要把商品、库存、订单、支付回调、小程序登录全部串起来。反直觉的地方在于很多开发者的第一反应是“商城就得上微服务、上消息队列”但真正稳定跑起来的恰恰是这种 Spring Boot MyBatis Redis 的单体结构。原因不难理解——单商户场景的并发峰值有限瓶颈从来不在服务拆分而在数据一致性和会话状态的正确性。这套项目正好把这两件事封装在最容易被阅读的地方MyBatis 的 SQL 映射、Redis 的缓存拦截层、订单状态机、小程序鉴权拦截器。搞懂它等于把电商后端最关键的几块底层拼图按顺序装了一遍。2. Spring Boot 自动装配与 MyBatis 会话机制一个注解背后藏了多少配置2.1 SpringBootApplication 三合一条件装配如何决定这次启动加载谁SpringBootConfiguration EnableAutoConfiguration ComponentScan(excludeFilters { Filter(type FilterType.CUSTOM, classes TypeExcludeFilter.class), Filter(type FilterType.CUSTOM, classes AutoConfigurationExcludeFilter.class) }) public interface SpringBootApplication { }把这三个注解拆开看SpringBootConfiguration 就是 Configuration 的占位标记“此类是配置源”EnableAutoConfiguration 通过 Import(AutoConfigurationImportSelector.class) 把 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 里列出的几百个自动配置类全部加载到候选池ComponentScan 不加任何 basePackages 时默认以启动类所在包为根路径扫描。这三者的协作顺序是先扫描候选配置再逐个套用 ConditionalOnClass、ConditionalOnMissingBean 这类条件注解决定哪些 Bean 真正被实例化。我一般把自动配置看成一张“条件清单”而不是固定清单。比如 MybatisAutoConfiguration 标了 ConditionalOnClass(SqlSessionFactory.class)只有当工程里确实引入了 mybatis 相关依赖、类路径上存在 SqlSessionFactory 时它才会继续往下执行再比如 RedisAutoConfiguration 标了 ConditionalOnClass(RedisOperations.class)缺少 spring-data-redis 时它直接跳过。所以你在 pom.xml 里每加一个 starter相当于把一组条件改为“可满足”下次启动时自动配置就会多落一个分支。这种设计的好处是框架级配置不需要显式硬编码缺点是当两个 starter 的自动配置产生交叉依赖时Bean 冲突会变得极其隐蔽通常得翻启动日志才能定位是哪个条件分支先执行了。2.2 SqlSessionFactory 注入链MyBatis 的映射解析和代理机制在 mybatis-spring-boot-autoconfigure 的 MybatisAutoConfiguration 里核心方法是这样一段逻辑Bean ConditionalOnMissingBean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { SqlSessionFactoryBean factoryBean new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); factoryBean.setMapperLocations(new PathMatchingResourcePatternResolver() .getResources(classpath:mapper/*.xml)); return factoryBean.getObject(); }SqlSessionFactoryBean 实现了 FactoryBean 接口它不是在 Bean 构建时就完成全部解析而是把 XML 里的mapper、select、update等节点逐个解析成 MappedStatement 对象再注册进 Configuration。这也是为什么修改 XML 后必须重启应用解析发生在启动阶段运行时只做查找而不做重读。ConditionalOnMissingBean 的意思是只有当容器里没有用户自定义的 SqlSessionFactory 时这套自动装配才会生效给开发者留了一个随时覆盖的出口。真正把链路串起来的运行时流程是Mapper 接口被 MapperScannerConfigurer 扫描并注册为 MapperFactoryBean调用方法时进入 MapperProxy 的 invoke 方法MyBatis 用方法签名namespace 加方法名在 Configuration 中定位 MappedStatement然后创建 Executor、StatementHandler把 SQL、参数和结果映射执行完。观察这个过程最直接的方式是把日志打开将 mybatis.configuration.log-impl 设为 StdOutImpl每次查询都会输出完整预编译语句和参数占位。提示改完 XML 里的 SQL 后一定要重启应用光刷新浏览器不会触发 MappedStatement 重解析这个点在实际联调里经常被忽略。开发中最常被误解的地方是 Mapper 的类型。Mapper 接口被注册后实际注入的是 MapperFactoryBean 返回的 JDK 动态代理对象所以 Autowired 注入 Mapper 时断点里看到的类型永远带着 $Proxy 后缀。这不是异常而是 MyBatis 的设计本意业务代码只依赖接口运行时由代理转发到 SqlSession。2.3 RedisTemplate 的序列化缓存值为什么会变成乱码Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); template.setKeySerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); template.afterPropertiesSet(); return template; }这段配置几乎是每个项目都会抄一遍的东西。为什么默认不行因为 Spring Data Redis 的 RedisTemplate 在没有指定序列化器时key 和 value 都用 JdkSerializationRedisSerializer存入的对象必须实现 Serializable否则直接抛 NotSerializableException就算能序列化Redis 里的 key 也是\xac\xed\x00\x05t\x00...这种二进制前缀排查问题时肉眼完全对不上业务含义。换成 StringRedisSerializer 处理 key、JSON 处理 value 之后缓存内容变得可读但 JSON 序列化又带来新的边界问题比如 LocalDateTime 字段反序列化失败。常见做法是在 value 序列化器上额外注册 JavaTimeModule或者直接传给 Jackson 一个自定义 ObjectMapper。这也是我建议初学者先跑一次默认配置、亲眼看乱码再替换序列化器的原因——只有对比过“坏”的样子才知道现在这套配置到底解决了什么。缓存机制的核心是 Cache Aside 模式先读缓存命中直接返回未命中查数据库再回填缓存并设置过期时间。手动写这段逻辑比 Cacheable 注解更可控因为可以在回填路径上加空值保护、打日志、做数据修复。3. 商品-库存-订单的数据一致性SQL 条件更新和状态机怎么守住交易底线3.1 商品与 SKU 的数据建模DECIMAL 和库存字段的硬约束在电商表设计里一个商品SPU会对应多个规格SKUSKU 才是真正可卖的东西颜色、尺码、库存、编码都挂在 SKU 上。这个轻量级单商户项目为了控制复杂度把商品和规格的层级简化成一张 goods 表规格属性塞进一个 JSON 字段。简化带来的直接收益是查询少两次 join代价是改规格时要整体更新 JSON。表结构属于这类项目的典型取舍CREATE TABLE goods ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, price DECIMAL(10, 2) NOT NULL, stock INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1, detail TEXT, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL );price 用 DECIMAL(10,2) 而不是 double这个选择很多人一开始不以为然直到对账时看到 0.30000000000000004 这类浮点噪声。DECIMAL 是定点数在 MySQL 内部按整数存储和计算精度有严格保证。stock 用 INT 并给默认值 0配合后面的条件更新才能做并发扣减。create_time 用 DATETIME 而不用 TIMESTAMP是为了避开 2038 年问题同时 DATETIME 不受数据库时区影响前后端展示更稳定。订单表里通常还有一个业务字段order_no 作为对外单号与支付平台的 out_trade_no 保持一致并且在这个字段上建唯一索引。这为支付回调幂等打好底。有些项目偷懒直接用自增主键当外部单号一旦对接第三方支付对方要求单号全局唯一自增主键在不同环境会撞号排查时非常被动。3.2 扣库存的正确姿势为什么“先查再改”会必然超卖最经典的错误做法是业务代码里先 SELECT stock 判断库存大于零再 UPDATE 扣减。两个并发请求同时读到 stock1各自判断“可以扣”然后都执行 update最终库存变成 -1。这个问题的根源是把判断逻辑放在事务之外靠应用层的 if 根本拦不住并发。正确做法是把判断放进 SQL 条件本身UPDATE goods SET stock stock - 1, update_time NOW() WHERE id #{goodsId} AND stock 1;这条 UPDATE 自带捕获条件。InnoDB 在执行行级更新时会对命中行加锁stock 1 的判断在锁保护下完成。如果影响行数是 0说明库存不足或商品已下架业务层直接返回“库存不足”即可。这是一种典型的乐观锁策略不提前加锁冲突发生时才通过条件更新放弃操作。相比 SELECT ... FOR UPDATE 的悲观锁它把锁的持有时间压缩到一条 UPDATE 内在高并发下不阻塞读请求代价是失败时需要重试或直接反馈给用户。单商户商城的秒杀场景用这个方案就足够挡住绝大多数超卖风险。再补一个事务细节如果扣库存的方法和订单状态更新在同一个事务里后者失败会导致前者回滚这通常不是我们想要的——用户可能已经完成支付库存却被回滚会造成可售库存和真实库存不一致。更合理的顺序是先把状态更新提交再扣库存库存扣减失败时走人工补偿而不是让已支付订单回滚。源码里 Transactional 只加在状态更新入口而扣库存放在同一方法内但允许局部失败正是这个取舍的体现。3.3 订单状态机支付回调的幂等和状态流转public enum OrderStatus { CREATED(0, 待支付), PAID(1, 已支付), SHIPPED(2, 已发货), COMPLETED(3, 已完成), CANCELLED(-1, 已取消); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code code; this.desc desc; } }状态机的核心约束是状态只能沿合法路径迁移比如 CREATED 到 PAIDPAID 到 SHIPPEDPAID 不能直接跳到 COMPLETED。把这个约束落到 SQL 上的做法是条件更新状态字段作为更新条件的一部分Transactional public boolean handlePayNotify(String outTradeNo, String transactionId) { Order order orderMapper.selectByOutTradeNo(outTradeNo); if (order null) { return false; } if (order.getStatus() ! OrderStatus.CREATED.getCode()) { // 重复回调直接返回成功保证幂等 return true; } int rows orderMapper.updateStatusByOutTradeNo(outTradeNo, OrderStatus.CREATED.getCode(), OrderStatus.PAID.getCode()); if (rows 1) { goodsMapper.deductStock(order.getGoodsId(), order.getGoodsQuantity()); payLogMapper.insert(new PayLog(transactionId, order.getId())); } return rows 1; }这段逻辑里有三个幂等关键点。第一个是“先查状态再判断”重复回调不会重复执行业务。第二个是条件更新靠 UPDATE ... WHERE status 0 保证两个线程同时回调时只有一个能把状态从待支付改成已支付。第三个是支付流水表的 out_trade_no 唯一索引就算更新状态成功但插入流水失败导致事务回滚前面的 update 也会跟着回滚不会出现“状态已改但流水缺失”的中间态。这三个点缺一个支付对账时就会冒出无法解释的差异单。4. 小程序登录鉴权链路从 code 换 openid 到 JWT 的完整流程4.1 小程序端登录态临时 code 的一次性语义小程序端的登录态第一步是调用 wx.login() 拿到一个临时 code。这个 code 的有效期只有几分钟并且只能用一次。客户端把它传给后端后端用 appid、secret 和 code 调小程序官方接口换回 openid 和 session_key。openid 是用户在当前小程序下的唯一标识session_key 用于解密手机号或敏感数据。这个设计的精妙之处在于 code 是一次性的就算被中间人截获也无法重放。但它也有连带约束——code 失效很快必须在短时间内完成交换所以后端在换到 openid 后要立刻签发自己的 token后续请求都不再依赖平台侧。public String decodeOpenId(String code) { String url https://api.weixin.qq.com/sns/jscode2session ?appid appId secret appSecret js_code code grant_typeauthorization_code; String json restTemplate.getForObject(url, String.class); JSONObject obj JSON.parseObject(json); if (obj.getInteger(errcode) ! null) { throw new BusinessException(code 无效或已过期: obj.getString(errmsg)); } return obj.getString(openid); }这段代码里 appId 和 appSecret 必须从配置中心或环境变量读取不能硬编码进源码。不要小看这一点——很多商城项目泄漏过敏感配置就是因为把密钥写死在 application.yml 里然后整个仓库被拉取。拿到 openid 后业务表通常以 openid 作为用户表的唯一键第一次登录自动注册后续登录直接返回已有 userId。4.2 后端签发 JWT 并用拦截器维护用户上下文后端统一签一个 JWT 而不是把 openid 直接透传原因有两个一是 JWT 可以附带过期时间和自定义 claim服务端无需保存会话状态二是后续接口校验只需要验签不查询数据库性能消耗低。拦截器的 preHandle 里完成 token 解析后把用户 ID 放入 ThreadLocalpublic class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return false; } try { Claims claims Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(token.substring(7)) .getBody(); UserContext.set(claims.get(userId, Integer.class)); return true; } catch (JwtException e) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return false; } } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }这里最容易被忽视的是 afterCompletion 里的 UserContext.clear()。Tomcat 的工作线程是池化复用的同一个线程在处理完请求 A 后会接着处理请求 B。如果不清理 ThreadLocalB 的代码里读到的 userId 可能是 A 留下的出现用户串号这种间歇性玄学 bug。它不经常出现但并发一上来就会随机触发非常难排查。所以 ThreadLocal 的正确姿势是入口写入、出口必清理最好用 try-finally 保证异常路径上也会执行 clear。登录链路还有一个容易忽略的约束session_key 不能下发给前端。所有需要解密的请求都由后端拿到 openid 后自行处理不能信任前端传来的任何用户标识。常见错误是前端把 openid 放进请求参数攻击者换一个 openid 就变成了另一个用户。正确做法是 openid 只出现在 JWT 的 claim 里由服务端解析后写进 ThreadLocal业务层永远不从请求参数里取用户身份。4.3 参数校验与接口防刷在 RestController 的入参对象上加校验注解是最简单的方式NotNull(message 商品ID不能为空) Min(value 1, message 商品ID不合法) private Integer goodsId;前端传参不可信服务端校验是最后一道防线。防刷这类风险通常用 Redis 的 INCR 加 EXPIRE 配合做一个滑动窗口计数对于商品列表或搜索接口同一个用户或同一个 IP 在 60 秒内请求超过 N 次就拒绝处理。这个方案不需要引入网关层在拦截器里加一个计数逻辑就够用了。单商户场景没必要上 Sentinel 这类重限流组件但至少要理解“计数放在 Redis 里、过期时间做窗口”的模型流量起来后平滑切换到网关层也容易。5. 避坑指南跑通商城的常见几处翻车现场5.1 缓存穿透现象商品详情接口偶发变慢数据库 SELECT 数量飙升Redis 命中率骤降。原因大量并发请求查一个根本不存在的商品 ID缓存里没有这个 key每次请求都穿透到数据库。解决简单做法是把空结果也缓存 60 秒key 固定为goods:{id}value 用空占位标记更严谨的是在缓存前加布隆过滤器把所有合法 ID 过滤进去查询前先判断。单商户流量不大空值缓存足够。注意空值缓存的过期时间不要设太长60 秒足够太长时间会掩盖商品重新上架后的时效问题。5.2 MyBatis 的 N1 查询现象订单列表接口日志里突然出现几十条 SQL页面上一次查询拖出一串子查询。原因resultMap 里的一对多 collection 用了嵌套 select主查询查出 N 条订单后对每条订单再执行一次子查询形成 N1。解决改 JOIN 把订单和订单项一次查出来用 collection 的 ofType 批量映射或者分两次查询后内存组装。判断标准是看 SQL 日志里相同语句是否反复出现如果一条主查询后面跟着 N 条同结构子查询基本就是这个病。5.3 Transactional 自调用失效现象类内部方法调用另一个 Transactional 方法抛异常后数据没有回滚库存已经被扣了。原因Spring 的声明式事务基于代理外部调用方法走代理this.method()调用走的是原始对象代理完全没参与。解决把事务方法和调用方拆到两个 Bean或者在类内注入自己Resource注入 self再通过 self 调用让代理生效。这个翻车现场在代码评审里极其常见而且一旦发生对账时几乎无法从日志里发现。5.4 金额用 double 计算现象支付对账时发现订单金额与实际金额差几分钱人工重算依然对不上。原因double 是二进制浮点0.1 加 0.2 无法精确表示累计运算后误差被放大。解决数据库用 DECIMAL(10,2)Java 层入参用 BigDecimal把 double 在入口处统一转成 BigDecimal 或用 String 传参。更规范的团队会直接以“分”为单位的 BIGINT 存储金额展示时才做换算。5.5 Redis key 出现二进制乱码现象Redis 客户端里看到大量\xac\xed\x00\x05t\x00开头的 key按业务前缀批量删除也删不干净。原因没配置 RedisTemplate 的 key 序列化器默认 JdkSerializationRedisSerializer 把 String key 做了 Java 对象序列化。解决显式配置 StringRedisSerializer 作为 key 和 hash key 的序列化器value 用 GenericJackson2JsonRedisSerializer。配置改完后要清理旧数据因为 key 编码已经变了新旧数据无法共存残留的乱码 key 会一直占用内存。6. 设计取舍这个商城该不该微服务化以及单体下怎么保持优雅把商城从单体改成微服务的前提不是流量而是组织边界和故障隔离需求。单商户商城的核心是订单和库存一旦拆成订单服务、支付服务、商品服务跨服务的分布式事务必然出现支付回调触发的扣库存、改状态、记流水在单体里是一个本地事务拆开后就需要分布式事务方案兜底。微服务引入的服务发现、链路追踪、配置中心每一层都是运维成本。这份源码选择保持单体正是明白“能用一个本地事务解决的绝不引入两次网络调用”。我在看过一些盲目微服务化的项目之后养成了一个强制习惯每次设计新功能先问三个问题。第一这个功能是否真的需要独立伸缩第二失败后是否需要对用户隔离影响面第三引入消息队列后最终一致性是否能接受秒级延迟三个问题的答案如果都是否就留在单体里用缓存和索引解决性能用条件更新保持一致性。这个习惯帮我避开了很多“为了架构而架构”的过度设计。从这套源码里读到的取舍也都是同一个逻辑Redis 只承担缓存不抢数据库持久化的活扣库存不用乐观锁版本号而是条件更新因为它足够表达业务约束小程序鉴权用 JWT 而不是 session因为它省掉了会话存储订单状态机用枚举加条件更新而不是引入工作流引擎。每一项设计都能在二十秒内解释清楚动机这才是我认可的轻量级系统该有的样子。从那以后我每次拿到新需求都强制先问一遍那三个问题再动手写代码。希望帮到你。本文还有配套的精品资源点击获取