☰
微信小程序多店铺上下文透传实战:SpringBoot租户隔离四层架构
2026/10/12 2:47:43 网站建设 项目流程

简介:dts-shop聚惠星商城是一套基于Java技术栈的商用级电商系统,面向Java初学者、全栈开发者及毕业设计学生,提供从微信小程序前端到SpringBoot+Vue后台的完整闭环解决方案,支持单店铺运营与多商户入驻两种商业模式。资源包为3.16MB的ZIP压缩文件,包含小程序源码、管理后台前后端代码及基础配置说明,其中Java后端实现商品、订单、用户、店铺审核等核心业务逻辑,Vue管理界面提供可视化操作,小程序端覆盖用户购物全流程。已有831人学习下载,项目已通过功能验证并达到上线标准,附带管理员(dtsadmin)与店铺账号(dtsdemo)双演示入口,便于快速部署调试与二次开发。读者可直接获取可运行的商用原型、清晰的模块分层结构、标准化接口设计范例,以及适配微信生态的前后端协同实践案例,是理解电商SaaS化架构与微服务过渡形态的优质学习样本。

1. 为什么“单店+多店入驻”在微信小程序商城里不是加个开关就能跑通?

你手头有个 SpringBoot + Vue + 微信小程序的商城项目,叫 dts-shop,标称“已功能闭环、达商用标准”。但真把它拉下来跑一遍,很快会发现:所谓“支持单店铺、多店铺入驻”,绝不是后台管理界面点两下“开启多店模式”就完事的——它是一整套贯穿用户身份、商品归属、订单路由、资金分账、权限隔离、数据视图的系统级重构。我去年在某高校实验室带学生复现这个项目时,三个组全卡在「商家入驻审核通过后,小程序端看不到自己上架的商品」这个环节超过48小时。根本原因不是代码写错了,而是没意识到:微信小程序的登录态(wx.login + code2Session)和 SpringBoot 后端的多租户标识(tenant_id / shop_id)之间,缺了一层动态绑定逻辑。这个项目真正价值不在“能跑”,而在它用一套可读性高、分层清晰的代码,把电商领域里最常被文档一笔带过、却让90%新手翻车的「多店铺上下文透传」问题,拆解成了可调试、可打断点、可逐层验证的链路。适合正在从单体商城转向平台型业务的 Java 工程师、想吃透微信生态与 SpringBoot 协同机制的全栈开发者,以及需要交付可扩展 SaaS 化商城的外包团队。


2. 搭建本地开发环境:三端联调前必须确认的5个关键锚点

dts-shop 不是单模块工程,它天然要求微信小程序、Vue 管理后台、SpringBoot 后端三端同时在线、互相识别。很多开发者 clone 下来直接npm run serve+mvn spring-boot:run,结果小程序报request:fail net::ERR_CONNECTION_REFUSED,后台日志却一片安静——问题往往出在“锚点没对齐”。下面这5个点,我建议你逐条核对,而不是跳过。

2.1 确认后端 API 网关地址是否被小程序合法请求

微信小程序对 request 域名有强校验:必须在「小程序管理后台 → 开发管理 → 开发者工具 → 服务器域名」中白名单注册。dts-shop 默认配置的后端地址是http://localhost:8080,但这是无效的——小程序不认localhost,也不认http(必须https)。
正确做法是:

  • 本地开发时,用ngrok或localtunnel将localhost:8080映射为公网 https 地址(如https://abc123.ngrok.io);
  • 将该地址填入小程序后台的「request 合法域名」;
  • 修改小程序源码中utils/request.js的baseURL:
// utils/request.js const baseURL = 'https://abc123.ngrok.io'; // ← 替换为你自己的 ngrok 地址

提示:ngrok http 8080启动后,控制台第一行显示的就是你的 https 地址。别复制错成 http 版本,否则小程序会静默失败。

2.2 Vue 后台管理系统的跨域代理必须指向真实后端端口

Vue CLI 的vue.config.js中配置了 devServer 代理,但 dts-shop 的默认配置是:

// vue.config.js devServer: { proxy: { '/api': { target: 'http://localhost:8080', // ← 这里必须和你 SpringBoot 实际启动端口一致 changeOrigin: true, pathRewrite: { '^/api': '' } } } }

常见翻车点:

  • 你改了 SpringBoot 的server.port=9090,但忘了同步改这里 → 后台页面所有接口 504;
  • 你在 IDEA 里用 Maven 插件启动,但实际监听的是8080,而用java -jar启动时指定了-Dserver.port=9090→ 两端端口错位。
    验证方法:浏览器直接访问http://localhost:8080/swagger-ui.html,能打开 Swagger 页面即说明后端已就位且端口匹配。

2.3 微信小程序的 AppID 和 Secret 必须替换为自有账号凭证

项目源码中project.config.json和后端application.yml里的微信配置是占位符:

# application.yml wechat: appid: wx1234567890abcdef # ← 必须替换成你小程序的 AppID secret: 1234567890abcdef1234567890abcdef # ← 必须替换成你小程序的 AppSecret mch-id: 1234567890 # ← 微信支付商户号(若启用支付)

注意:appid和secret是小程序唯一身份凭证,不可共用。如果你用的是测试号,需在「微信公众平台 → 开发管理 → 开发设置」中找到对应值。填错会导致code2Session接口返回{"errcode":40013,"errmsg":"invalid appid"}。

2.4 数据库初始化脚本必须按顺序执行,且区分环境

dts-shop 使用 MySQL,SQL 脚本位于sql/目录下,包含:

  • dts_shop.sql:建库语句(含字符集utf8mb4);
  • dts_shop_table.sql:建表语句(含shop_id、tenant_type字段);
  • dts_shop_data.sql:初始数据(管理员账号、默认店铺、分类等)。

关键顺序:

  1. 先执行dts_shop.sql创建数据库;
  2. 再执行dts_shop_table.sql建表;
  3. 最后执行dts_shop_data.sql插入基础数据。

若跳过第1步直接执行第2步,MySQL 报错Unknown database 'dts_shop';若先执行第3步,因表不存在导致插入失败且无提示。
血泪经验:用 Navicat 执行时,勾选「遇到错误时继续执行」,否则一个 INSERT 失败,后续全停。

2.5 SpringBoot 配置文件必须激活 profile,且多店铺开关显式开启

dts-shop 通过application-multi.yml支持多店铺模式,但默认未激活。必须在application.yml中显式指定:

# application.yml spring: profiles: active: multi # ← 关键!不加这行,永远走单店逻辑

同时检查application-multi.yml中是否开启多租户开关:

# application-multi.yml shop: multi-tenant: true # ← 必须为 true,否则 ShopContextFilter 不生效

这个开关控制着核心过滤器ShopContextFilter是否注入 Spring 容器——它是整个多店铺体系的“总闸门”。


3. 多店铺核心链路:从用户登录到商品展示的 4 层上下文透传

dts-shop 的多店铺能力不是靠“if (multiTenant) { … }”硬编码实现的,而是通过ThreadLocal + Filter + 注解 + 动态 SQL四层协同完成上下文透传。理解这四层,才能改得动、调得通、扩得开。

3.1 第一层:小程序端登录态携带 shop_id(前端埋点)

微信小程序用户登录后,获取code并调用后端/auth/login接口。但 dts-shop 的设计是:同一个微信用户,可以同时是 A 店铺的顾客、B 店铺的店主、C 店铺的供应商。因此,登录请求必须明确告诉后端:“这次我要以什么身份进入哪个店铺”。

小程序在调用登录接口时,需在 body 中传入shopId(非必填,但多店铺场景下强烈建议传):

// pages/login/login.js wx.login({ success: res => { const code = res.code; wx.request({ url: `${baseURL}/auth/login`, method: 'POST', data: { code: code, shopId: wx.getStorageSync('currentShopId') || null // ← 关键:从缓存读当前店铺ID }, success: res => { if (res.data.code === 200) { wx.setStorageSync('token', res.data.data.token); } } }); } });

逻辑说明:currentShopId通常来自首页店铺列表点击事件,或从分享链接中解析。不传则默认进入“平台视角”(如平台自营店),传了则锁定为该店铺上下文。

3.2 第二层:后端 Filter 解析并绑定租户上下文(ShopContextFilter)

SpringBoot 的ShopContextFilter是整个多店铺体系的基石。它在每次 HTTP 请求进入时,做三件事:

  1. 从请求 Header(X-Shop-ID)或 Query Param(shopId)中提取店铺 ID;
  2. 查询shop_info表验证该店铺是否存在、是否启用;
  3. 将ShopContext(含 shopId、tenantType、authLevel)存入ThreadLocal<ShopContext>。
// filter/ShopContextFilter.java public class ShopContextFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) { HttpServletRequest httpRequest = (HttpServletRequest) request; String shopId = getShopIdFromRequest(httpRequest); // 优先取 header,再取 param if (StringUtils.isNotBlank(shopId)) { ShopInfo shop = shopService.getById(shopId); if (shop != null && shop.getStatus() == 1) { // 状态启用 ShopContext context = new ShopContext(shop.getId(), shop.getTenantType()); ShopContextHolder.set(context); // ← 绑定到当前线程 } } chain.doFilter(request, response); ShopContextHolder.remove(); // ← 必须清理,防 ThreadLocal 内存泄漏 } }

参数说明:ShopContextHolder是自定义工具类,封装了ThreadLocal<ShopContext>。remove()调用是硬性要求,否则 Tomcat 线程复用时,下次请求会拿到上一次的shopId,造成严重数据错乱。

3.3 第三层:MyBatis 拦截器动态注入 tenant_id 条件(TenantInterceptor)

当ShopContext绑定成功后,所有 DAO 层查询都应自动带上AND shop_id = ?。dts-shop 用 MyBatis 插件实现:

// interceptor/TenantInterceptor.java @Intercepts(@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})) public class TenantInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler statementHandler = (StatementHandler) invocation.getTarget(); MetaObject metaObject = SystemMetaObject.forObject(statementHandler); BoundSql boundSql = statementHandler.getBoundSql(); String sql = boundSql.getSql(); ShopContext context = ShopContextHolder.get(); if (context != null && isNeedTenant(sql)) { // isNeedTenant 判断是否为 shop_info 以外的表 String newSql = sql + " AND shop_id = ?"; metaObject.setValue("boundSql.sql", newSql); List<Object> params = new ArrayList<>(boundSql.getParameterObject() instanceof List ? (List<Object>) boundSql.getParameterObject() : Collections.singletonList(boundSql.getParameterObject())); params.add(context.getShopId()); metaObject.setValue("boundSql.parameterObject", params); } return invocation.proceed(); } }

逻辑说明:该拦截器只对非shop_info表生效(因为店铺信息表本身就要查所有店铺)。它修改 SQL 字符串,并追加参数。注意params的构造方式——必须兼容原参数是 List 或单对象两种情况,否则批量更新会 NPE。

3.4 第四层:Controller 方法级租户校验(@RequireShop)

并非所有接口都需要店铺上下文。dts-shop 用自定义注解@RequireShop标记必须校验的接口:

// controller/GoodsController.java @GetMapping("/list") @RequireShop // ← 加了这个注解,就会触发 ShopAuthAspect public Result<List<Goods>> list(@RequestParam String categoryId) { return Result.success(goodsService.listByCategory(categoryId)); }

对应的切面ShopAuthAspect在执行前检查ShopContextHolder.get()是否为空,为空则抛出ShopNotSelectedException,由全局异常处理器返回{"code":400, "msg":"请先选择店铺"}。

这种“声明式租户校验”比在每个方法里写if (ShopContextHolder.get() == null)更优雅,也更易维护。你可以根据业务需要,在@RequireShop上加属性,比如level = SHOP_OWNER,实现角色级控制。


4. 多店铺避坑指南:5 个真实踩过的坑,附现象、根因与解法

部署 dts-shop 多店铺模式时,以下 5 个问题出现频率最高,且排查路径隐蔽。我把它们整理成「现象 → 原因 → 解决」结构,避免你再花半天时间抓包、断点、查日志。

4.1 现象:小程序端切换店铺后,商品列表仍是上一家的,刷新也不变

原因:小程序端未清除旧token,且新登录未覆盖token缓存,导致后续请求仍携带旧 token 对应的shop_id上下文。
解决:

  • 切换店铺时,强制调用wx.removeStorageSync('token');
  • 登录成功后,将shopId一并存入缓存:wx.setStorageSync('currentShopId', shopId);
  • 后端AuthController.login()方法中,生成 token 前,将shopId写入 JWT payload(如claims.put("shop_id", shopId)),确保 token 与店铺强绑定。

4.2 现象:后台管理端「店铺入驻审核」通过后,商家小程序看不到自己上架的商品

原因:商品表goods的shop_id字段在商家上架时未正确赋值,仍为NULL或平台默认值0。
解决:

  • 检查GoodsService.save()方法,确认在保存前执行了goods.setShopId(ShopContextHolder.get().getShopId());
  • 若商家是通过「入驻流程」新创建的,其shop_id可能尚未写入ShopContextHolder,需在入驻成功回调中手动 set;
  • 在GoodsMapper.xml的<insert>语句中,添加<if test="shopId != null">shop_id = #{shopId},</if>,避免空值覆盖。

4.3 现象:同一微信用户,在 A 店铺下单后,B 店铺的订单列表里也出现了该订单

原因:订单表order_info的shop_id字段未被TenantInterceptor拦截(因 SQL 中用了INSERT INTO order_info (...) VALUES (...),未带AND shop_id = ?条件)。
解决:

  • TenantInterceptor.isNeedTenant()方法中,将order_info表名加入白名单(默认可能只加了goods,category);
  • 或更稳妥:在OrderService.createOrder()中,显式设置order.setShopId(ShopContextHolder.get().getShopId()),而非依赖拦截器。

4.4 现象:后台管理端「店铺列表」能查出所有店铺,但「店铺详情」打不开,报 404

原因:ShopController.detail()方法使用了@PathVariable获取shopId,但前端路由/shop/{id}中的{id}是字符串,而后端@PathVariable Long id强转失败,抛出TypeMismatchException,被全局异常处理器吞掉,返回 404。
解决:

  • 将@PathVariable Long id改为@PathVariable String id;
  • 在方法内手动Long.parseLong(id)并 try-catch;
  • 或统一用@PathVariable("id") String id+@NotBlank校验,更符合 REST 规范。

4.5 现象:启用多店铺后,Swagger 文档无法加载,页面空白

原因:ShopContextFilter在处理/swagger-ui.html请求时,因ShopContextHolder.get()为 null,尝试调用shopService.getById(null)导致 NPE,Filter 链中断,静态资源无法返回。
解决:

  • 在ShopContextFilter.doFilter()开头添加放行逻辑:
String uri = httpRequest.getRequestURI(); if (uri.startsWith("/swagger") || uri.startsWith("/webjars") || uri.startsWith("/doc.html")) { chain.doFilter(request, response); return; }
  • 或更彻底:将ShopContextFilter的urlPatterns从/*改为/api/*,避免拦截静态资源路径。

5. 进阶技巧:用「店铺维度」重写分页与搜索,绕过 MyBatis 分页插件的租户陷阱

MyBatis-Plus 的Page对象或 PageHelper 的分页插件,在多租户场景下极易翻车——因为它们的LIMIT ?, ?是加在最终 SQL 末尾的,而TenantInterceptor插入的AND shop_id = ?在WHERE子句里。如果原始 SQL 没有WHERE,拦截器追加的条件会被忽略,导致分页查出全量数据。我见过最玄学的一次:PageHelper.startPage(1,10)查出 127 条,PageHelper.startPage(2,10)又查出 127 条,完全没分页。

5.1 根本解法:放弃通用分页插件,改用「店铺维度子查询分页」

dts-shop 的GoodsMapper.xml中,商品列表分页不走PageHelper,而是用原生 SQL 子查询:

<!-- GoodsMapper.xml --> <select id="listByCategoryWithShop" resultType="Goods"> SELECT * FROM ( SELECT g.id, g.name, g.price, g.cover_img, ROW_NUMBER() OVER (ORDER BY g.create_time DESC) AS rn FROM goods g WHERE g.category_id = #{categoryId} AND g.shop_id = #{shopId} <!-- ← 显式传 shopId,不依赖拦截器 --> AND g.status = 1 ) t WHERE t.rn BETWEEN #{offset} AND #{limit} </select>

对应的 Service 方法:

// GoodsService.java public PageResult<Goods> listByCategory(String categoryId, Integer pageNum, Integer pageSize) { ShopContext context = ShopContextHolder.get(); if (context == null) throw new BusinessException("店铺上下文丢失"); int offset = (pageNum - 1) * pageSize; List<Goods> list = goodsMapper.listByCategoryWithShop(categoryId, context.getShopId(), offset, pageSize); long total = goodsMapper.countByCategoryAndShop(categoryId, context.getShopId()); return new PageResult<>(list, total, pageNum, pageSize); }

优势:shop_id作为参数显式传入,不受拦截器失效影响;ROW_NUMBER()确保排序稳定;countByCategoryAndShop单独查总数,避免COUNT(*) OVER()性能问题。

5.2 搜索增强:用 Elasticsearch 实现跨店铺商品聚合搜索

当商品量超 10 万,MySQLLIKE '%keyword%'会拖垮数据库。dts-shop 的进阶方案是接入 ES,但必须支持「按店铺聚合」:

// ES 查询 DSL(Java High Level Client 构建) { "query": { "bool": { "must": [ { "match": { "name": "手机" } }, { "term": { "shop_id": "shop_123" } } // ← 店铺精准匹配 ] } }, "aggs": { "by_shop": { "terms": { "field": "shop_id" }, // ← 聚合各店铺命中数 "aggs": { "top_hits": { "size": 3 } } } } }

在GoodsSearchService中,将ShopContext.get().getShopId()作为term查询条件传入,确保搜索结果严格限定在当前店铺内。若要做「平台级搜索」,则去掉term条件,保留aggs做店铺维度统计。

5.3 权限兜底:用 Shiro 的@RequiresPermissions("shop:goods:list")替代硬编码判断

dts-shop 的权限模型是 RBAC + 店铺维度。不要在 Controller 里写if (!user.getShopId().equals(ShopContextHolder.get().getShopId()))。正确姿势是:

// ShiroConfig.java @Bean public ModularRealmAuthorizer modularRealmAuthorizer() { ModularRealmAuthorizer authorizer = new ModularRealmAuthorizer(); authorizer.setPermissionResolver(new ShopPermissionResolver()); // ← 自定义解析器 return authorizer; } // ShopPermissionResolver.java public class ShopPermissionResolver implements PermissionResolver { @Override public Permission resolvePermission(String permissionStr) { if (permissionStr.startsWith("shop:")) { return new ShopPermission(permissionStr); // ← 解析出 shopId } return new WildcardPermission(permissionStr); } }

然后 Controller 方法上直接写:

@RequiresPermissions("shop:goods:list") // ← Shiro 自动校验当前店铺是否有此权限 @GetMapping("/list") public Result<List<Goods>> list(...) { ... }

这样,权限控制和店铺上下文彻底解耦,未来加「店铺角色」、「店铺菜单」都只需改ShopPermission类,不用动业务代码。

我带的最后一个项目,就是靠这套「子查询分页 + ES 聚合 + Shiro 店铺权限」组合拳,把原来 3 秒的店铺商品列表压到了 300ms 内,且支持 500+ 店铺并发入驻。上线前,我养成了一个习惯:每次改完ShopContextFilter或TenantInterceptor,必写一个单元测试,用MockMvc模拟带X-Shop-ID的请求,断言 SQL 日志里是否真的出现了AND shop_id = ?。这招看似笨,却是防止「玄学失效」最可靠的后悔药。希望帮到你。

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

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

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

立即咨询