微信小程序多店铺上下文透传实战:SpringBoot租户隔离四层架构
发布时间:2026/10/12 2:47:44 锦皓数字建站

简介dts-shop聚惠星商城是一套基于Java技术栈的商用级电商系统面向Java初学者、全栈开发者及毕业设计学生提供从微信小程序前端到SpringBootVue后台的完整闭环解决方案支持单店铺运营与多商户入驻两种商业模式。资源包为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 servemvn 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.port9090但忘了同步改这里 → 后台页面所有接口 504你在 IDEA 里用 Maven 插件启动但实际监听的是8080而用java -jar启动时指定了-Dserver.port9090→ 两端端口错位。验证方法浏览器直接访问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 使用 MySQLSQL 脚本位于sql/目录下包含dts_shop.sql建库语句含字符集utf8mb4dts_shop_table.sql建表语句含shop_id、tenant_type字段dts_shop_data.sql初始数据管理员账号、默认店铺、分类等。关键顺序先执行dts_shop.sql创建数据库再执行dts_shop_table.sql建表最后执行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 解析并绑定租户上下文ShopContextFilterSpringBoot 的ShopContextFilter是整个多店铺体系的基石。它在每次 HTTP 请求进入时做三件事从请求 HeaderX-Shop-ID或 Query ParamshopId中提取店铺 ID查询shop_info表验证该店铺是否存在、是否启用将ShopContext含 shopId、tenantType、authLevel存入ThreadLocalShopContext。// 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是自定义工具类封装了ThreadLocalShopContext。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); ListObject params new ArrayList(boundSql.getParameterObject() instanceof List ? (ListObject) 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 ResultListGoods 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 testshopId ! nullshop_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 idNotBlank校验更符合 REST 规范。4.5 现象启用多店铺后Swagger 文档无法加载页面空白原因ShopContextFilter在处理/swagger-ui.html请求时因ShopContextHolder.get()为 null尝试调用shopService.getById(null)导致 NPEFilter 链中断静态资源无法返回。解决在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 idlistByCategoryWithShop resultTypeGoods 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 PageResultGoods listByCategory(String categoryId, Integer pageNum, Integer pageSize) { ShopContext context ShopContextHolder.get(); if (context null) throw new BusinessException(店铺上下文丢失); int offset (pageNum - 1) * pageSize; ListGoods 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 查询 DSLJava 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 ResultListGoods list(...) { ... }这样权限控制和店铺上下文彻底解耦未来加「店铺角色」、「店铺菜单」都只需改ShopPermission类不用动业务代码。我带的最后一个项目就是靠这套「子查询分页 ES 聚合 Shiro 店铺权限」组合拳把原来 3 秒的店铺商品列表压到了 300ms 内且支持 500 店铺并发入驻。上线前我养成了一个习惯每次改完ShopContextFilter或TenantInterceptor必写一个单元测试用MockMvc模拟带X-Shop-ID的请求断言 SQL 日志里是否真的出现了AND shop_id ?。这招看似笨却是防止「玄学失效」最可靠的后悔药。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。