简介:面向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 RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, 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)会对应多个规格(SKU),SKU 才是真正可卖的东西:颜色、尺码、库存、编码都挂在 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 扣减。两个并发请求同时读到 stock=1,各自判断“可以扣”,然后都执行 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 到 PAID,PAID 到 SHIPPED,PAID 不能直接跳到 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_type=authorization_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 放入 ThreadLocal:
public 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。如果不清理 ThreadLocal,B 的代码里读到的 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 的 N+1 查询
现象:订单列表接口日志里突然出现几十条 SQL,页面上一次查询拖出一串子查询。
原因:resultMap 里的一对多 collection 用了嵌套 select,主查询查出 N 条订单后,对每条订单再执行一次子查询,形成 N+1。
解决:改 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,因为它省掉了会话存储;订单状态机用枚举加条件更新,而不是引入工作流引擎。每一项设计都能在二十秒内解释清楚动机,这才是我认可的轻量级系统该有的样子。从那以后,我每次拿到新需求,都强制先问一遍那三个问题再动手写代码。希望帮到你。
本文还有配套的精品资源,点击获取