分页插件到底怎么拦截 SQL?MyBatis-Plus 插件机制原理一次讲透

写分页接口时一行 PageHelper.startPage() 搞定,但你有没有想过:分页插件是怎么在 SQL 执行前自动加上 LIMITCOUNT 的?MyBatis 的拦截器链到底怎么工作的?MyBatis-Plus 的 InnerInterceptor 和原生 MyBatis Interceptor 是什么关系?今天从底层机制到实战自定义,一次讲透。


一、从问题出发:分页插件做了什么?

sql 复制代码
-- 你写的 SQL
SELECT * FROM t_order WHERE status = 'PAID' ORDER BY create_time DESC

-- 分页插件自动改写的 SQL
SELECT * FROM t_order WHERE status = 'PAID' ORDER BY create_time DESC LIMIT 10 OFFSET 0

-- 同时自动生成了 count 查询
SELECT COUNT(*) FROM t_order WHERE status = 'PAID'

一句话:分页插件在 SQL 执行前,拦截并改写了 Statement,加上了分页语法和 count 查询。

那它是怎么拦截的?


二、MyBatis 拦截器机制:一切的基础

2.1 四大可拦截对象

MyBatis 只允许拦截四种对象的方法:

scss 复制代码
MyBatis 四大可拦截对象
│
├── 1. Executor
│   ├── update()      ← INSERT/UPDATE/DELETE
│   ├── query()       ← SELECT
│   ├── commit()
│   └── rollback()
│
├── 2. StatementHandler
│   ├── prepare()     ← 创建 Statement(SQL 编译前)
│   ├── parameterize()← 设置参数
│   ├── query()       ← 执行查询
│   └── update()      ← 执行更新
│
├── 3. ParameterHandler
│   ├── setParameters() ← 设置 PreparedStatement 参数
│   └── getParameterObject()
│
└── 4. ResultSetHandler
    ├── handleResultSets()   ← 处理结果集
    └── handleOutputParameters()
对象 拦截时机 典型用途
Executor 执行前后 二级缓存、分页、慢 SQL 监控
StatementHandler SQL 编译前后 SQL 改写、分页插件
ParameterHandler 参数设置 参数加密脱敏
ResultSetHandler 结果处理 结果映射增强、脱敏

2.2 自定义拦截器的本质

java 复制代码
@Intercepts({
    @Signature(type = StatementHandler.class, method = "prepare",
               args = {Connection.class, Integer.class})
})
public class MyPlugin implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 拦截逻辑:在原方法执行前做处理
        StatementHandler handler = (StatementHandler) invocation.getTarget();
        String sql = handler.getBoundSql().getSql();

        // 改写 SQL
        String newSql = rewriteSql(sql);

        // 通过反射设置新的 SQL
        Field field = handler.getBoundSql().getClass()
            .getDeclaredField("sql");
        field.setAccessible(true);
        field.set(handler.getBoundSql(), newSql);

        // 继续执行原方法
        return invocation.proceed();
    }
}

2.3 拦截器链:责任链模式

scss 复制代码
SQL 执行流程(经过拦截器链)
│
├── 1. MyBatis 创建 Executor
│
├── 2. 依次应用所有 Interceptor(Plugin.wrap 生成代理)
│   ├── Interceptor A (动态代理)
│   │   ├── Interceptor B (动态代理)
│   │   │   ├── Interceptor C (动态代理)
│   │   │   │   └── 真实 Executor
│   │   │   └── C.intercept()
│   │   └── B.intercept()
│   └── A.intercept()
│
└── 3. 最终执行 SQL

关键细节 :MyBatis 用 JDK 动态代理包装拦截器。每次 Plugin.wrap() 都创建一个新的代理对象,层层嵌套。

java 复制代码
// MyBatis Plugin.java 源码简化版
public class Plugin implements InvocationHandler {

    public static Object wrap(Object target, Interceptor interceptor) {
        Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
        if (signatureMap.containsKey(target.getClass())) {
            return Proxy.newProxyInstance(
                target.getClass().getClassLoader(),
                target.getClass().getInterfaces(),
                new Plugin(target, interceptor, signatureMap)
            );
        }
        return target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        // 如果当前方法在拦截范围内,调用 intercept()
        // 否则直接调用原方法
        if (signatureMap.get(method.getDeclaringClass()).contains(method)) {
            return interceptor.intercept(new Invocation(target, method, args));
        }
        return method.invoke(target, args);
    }
}

三、MyBatis-Plus 插件体系:在拦截器之上再抽象

3.1 为什么要再抽象?

原生 MyBatis 拦截器的问题:

sql 复制代码
原生 Interceptor 的痛点
│
├── 1. 注册顺序 = 执行顺序,但多个插件间顺序很难控制
│
├── 2. 每个插件独立拦截,无法共享上下文
│   ├── 分页插件拦截了 query
│   ├── 乐观锁插件也拦截了 update
│   └── 两个插件互相不知道对方的存在
│
├── 3. 拦截点分散在四大对象上
│   └── 你需要在 Executor、StatementHandler 上各写一个 @Signature
│
└── 4. SQL 改写逻辑分散
    └── 不同功能的 SQL 改写逻辑混在一个 intercept() 方法里

MyBatis-Plus 的解决方案:统一拦截点 + 插件链

3.2 MybatisPlusInterceptor:统一入口

scss 复制代码
MybatisPlusInterceptor 架构
│
├── MybatisPlusInterceptor(实现 MyBatis Interceptor)
│   ├── 拦截 Executor.query()
│   ├── 拦截 Executor.update()
│   └── 内部维护 List<InnerInterceptor>
│
└── InnerInterceptor(插件接口)
    ├── PaginationInnerInterceptor    ← 分页
    ├── OptimisticLockerInnerInterceptor ← 乐观锁
    ├── BlockAttackInnerInterceptor   ← 防全表更新删除
    ├── IllegalSQLInnerInterceptor    ← SQL 注入防护
    ├── TenantLineInnerInterceptor    ← 多租户
    └── DataPermissionInterceptor     ← 数据权限
java 复制代码
// 注册示例
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();

    // 1. 多租户插件
    interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(tenantLineHandler));

    // 2. 数据权限插件
    interceptor.addInnerInterceptor(new DataPermissionInterceptor(dataPermissionHandler));

    // 3. 分页插件
    interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));

    // 4. 乐观锁插件
    interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());

    // 5. 防全表更新删除
    interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());

    return interceptor;
}

注册顺序 = 执行顺序,这一点和原生 MyBatis 一致。

3.3 InnerInterceptor 接口

java 复制代码
public interface InnerInterceptor {

    // 查询前
    default boolean beforeQuery(Executor executor, MappedStatement ms,
                                Object parameter, RowBounds rowBounds,
                                ResultHandler resultHandler, BoundSql boundSql) {
        return true;  // 返回 false 则跳过原查询
    }

    // 查询后(处理结果)
    default void afterQuery(Executor executor, MappedStatement ms,
                             Object parameter, RowBounds rowBounds,
                             ResultHandler resultHandler, BoundSql boundSql) {}

    // 更新前
    default boolean beforeUpdate(Executor executor, MappedStatement ms,
                                  Object parameter) {
        return true;
    }

    // 更新后
    default void afterUpdate(Executor executor, MappedStatement ms,
                              Object parameter) {}

    // 准备 Statement 前
    default void beforePrepare(StatementHandler sh, Connection connection,
                                Integer transactionTimeout) {}

    // 获取 BoundSql
    default void setProperties(Properties properties) {}
}

四、分页插件源码拆解

4.1 分页执行流程

ini 复制代码
用户调用
│
├── 1. mapper.selectPage(page, wrapper)
│
├── 2. MybatisPlusInterceptor 拦截 Executor.query()
│   └── 遍历 InnerInterceptor 链
│       ├── TenantLineInnerInterceptor.beforeQuery()  ← 加租户条件
│       ├── DataPermissionInterceptor.beforeQuery()    ← 加权限条件
│       └── PaginationInnerInterceptor.beforeQuery()  ← 分页处理
│
├── 3. PaginationInnerInterceptor 核心逻辑
│   ├── 3.1 判断是否需要分页(参数中有 IPage 对象?)
│   ├── 3.2 执行 COUNT 查询
│   │   ├── 改写 SQL 为 SELECT COUNT(*)
│   │   ├── 执行 count 查询
│   │   └── 设置 total 到 IPage
│   └── 3.3 改写原始 SQL 加 LIMIT/OFFSET
│
├── 4. 执行改写后的分页 SQL
│
└── 5. 将结果设置到 IPage 中
    ├── records = 查询结果列表
    ├── total = count 查询结果
    ├── size = 每页大小
    └── current = 当前页码

4.2 SQL 改写核心

java 复制代码
// 简化版分页 SQL 改写逻辑
public class PaginationInnerInterceptor extends JsqlParserSupport
        implements InnerInterceptor {

    @Override
    public boolean beforeQuery(Executor executor, MappedStatement ms,
                                Object parameter, RowBounds rowBounds,
                                ResultHandler resultHandler, BoundSql boundSql) {
        // 1. 检查参数中是否有 IPage
        IPage<?> page = findPage(parameter);
        if (page == null) {
            return true;  // 不需要分页
        }

        // 2. 执行 COUNT 查询
        if (page.isSearchCount()) {
            long total = executeCount(executor, ms, parameter, boundSql);
            page.setTotal(total);
            if (total == 0) {
                return false;  // 没有数据,跳过查询
            }
        }

        // 3. 改写 SQL 加 LIMIT
        String originalSql = boundSql.getSql();
        String paginationSql = originalSql + " LIMIT " + page.size()
            + " OFFSET " + (page.current() - 1) * page.size();

        // 4. 替换 BoundSql 中的 SQL
        resetBoundSql(boundSql, paginationSql);

        return true;
    }
}

实际实现比这复杂得多:MyBatis-Plus 使用 JSqlParser 解析 SQL,根据不同数据库方言生成不同的分页语法。

4.3 不同数据库的分页语法

sql 复制代码
-- MySQL
SELECT * FROM t_order LIMIT 10 OFFSET 0

-- Oracle(12c 之前用 ROWNUM)
SELECT * FROM (
    SELECT t.*, ROWNUM rn FROM t_order t WHERE ROWNUM <= 10
) WHERE rn > 0

-- PostgreSQL
SELECT * FROM t_order LIMIT 10 OFFSET 0

-- SQL Server
SELECT * FROM t_order ORDER BY id OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY

MyBatis-Plus 通过 DbType 枚举处理不同数据库方言:

java 复制代码
PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL);
// DbType.ORACLE, DbType.POSTGRE_SQL, DbType.SQL_SERVER2005 ...

五、自定义 InnerInterceptor:实战多租户数据隔离

5.1 需求

所有查询自动加上 tenant_id = ? 条件,业务代码完全不感知。

5.2 实现

java 复制代码
public class TenantLineInnerInterceptor extends JsqlParserSupport
        implements InnerInterceptor {

    private TenantLineHandler handler;

    @Override
    public void beforeQuery(Executor executor, MappedStatement ms,
                             Object parameter, RowBounds rowBounds,
                             ResultHandler resultHandler, BoundSql boundSql) {
        // 使用 JSqlParser 解析 SQL,给所有表加上租户条件
        Statement statement = CCJSqlParserUtil.parse(boundSql.getSql());
        Select select = (Select) statement;
        SelectBody selectBody = select.getSelectBody();

        // 给 WHERE 条件追加 tenant_id = ?
        if (selectBody instanceof PlainSelect) {
            PlainSelect plainSelect = (PlainSelect) selectBody;
            Expression tenantCondition = new EqualsTo(
                new Column("tenant_id"),
                new LongValue(handler.getTenantId())
            );

            if (plainSelect.getWhere() != null) {
                plainSelect.setWhere(
                    new AndExpression(plainSelect.getWhere(), tenantCondition)
                );
            } else {
                plainSelect.setWhere(tenantCondition);
            }
        }

        // 替换 SQL
        resetBoundSql(boundSql, select.toString());
    }
}

效果:

sql 复制代码
-- 你写的 SQL
SELECT * FROM t_order WHERE status = 'PAID'

-- 插件自动改写后的 SQL
SELECT * FROM t_order WHERE status = 'PAID' AND tenant_id = 10086

5.3 插件执行顺序的重要性

ini 复制代码
场景:多租户 + 分页
│
├── 正确顺序:先租户 → 再分页
│   ├── 1. 加 tenant_id = 10086
│   ├── 2. 执行 COUNT(带租户条件)
│   ├── 3. 加 LIMIT(带租户条件)
│   └── 结果:分页正确,租户隔离正确
│
└── 错误顺序:先分页 → 再租户
    ├── 1. 执行 COUNT(不带租户条件!)
    ├── 2. 加 LIMIT(不带租户条件!)
    ├── 3. 加 tenant_id = 10086
    └── 结果:COUNT 数字不对,分页数据可能跨租户
java 复制代码
// 正确的注册顺序
interceptor.addInnerInterceptor(tenantInterceptor);   // 先租户
interceptor.addInnerInterceptor(paginationInterceptor); // 再分页

六、防全表更新删除插件

java 复制代码
// 防止全表 UPDATE / DELETE(没有 WHERE 条件)
interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());
sql 复制代码
被拦截的 SQL 示例
│
├── DELETE FROM t_order;                ← 无 WHERE,拦截!
├── UPDATE t_order SET status = 'X';    ← 无 WHERE,拦截!
└── DELETE FROM t_order WHERE id = 1;    ← 有 WHERE,放行 ✅

源码逻辑 :JSqlParser 解析 SQL,检查 DeleteUpdate 语句是否有 Where 子句。


七、乐观锁插件

java 复制代码
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
ini 复制代码
乐观锁执行流程
│
├── 1. 查询时获取 version 值
│   SELECT id, name, version FROM t_product WHERE id = 1
│   → version = 3
│
├── 2. 更新时自动加上 version 条件
│   UPDATE t_product SET name = '新名称', version = 4
│   WHERE id = 1 AND version = 3
│                         ↑ 自动加的
├── 3. 判断更新结果
│   ├── 影响行数 = 1 → 更新成功
│   └── 影响行数 = 0 → 版本冲突,更新失败
java 复制代码
// 实体类
@Version
private Integer version;

一行注解,插件自动处理。


八、自定义插件实战:慢 SQL 检测

java 复制代码
@Intercepts({
    @Signature(type = StatementHandler.class, method = "query",
               args = {Connection.class, Integer.class})
})
public class SlowSqlInterceptor implements Interceptor {

    private long slowThreshold;  // 慢 SQL 阈值(ms)

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        StatementHandler handler = (StatementHandler) invocation.getTarget();
        String sql = handler.getBoundSql().getSql();

        long start = System.currentTimeMillis();
        Object result = invocation.proceed();
        long cost = System.currentTimeMillis() - start;

        if (cost > slowThreshold) {
            log.warn("慢 SQL 检测:cost={}ms, sql={}", cost, sql);
            // 也可以发送到监控系统
        }

        return result;
    }
}

但这有个问题:如果项目已经用了 MybatisPlusInterceptor,自定义 Interceptor 和 InnerInterceptor 的执行顺序怎么控制?

markdown 复制代码
执行顺序规则
│
├── MyBatis 拦截器链的执行顺序 = Spring 容器中的注册顺序
│
├── MybatisPlusInterceptor 是一个 Interceptor
│   └── 它内部的 InnerInterceptor 链是自己的逻辑
│
└── 自定义 Interceptor 和 MybatisPlusInterceptor 是平级的
    ├── 如果你希望慢 SQL 检测在最外层
    │   └── 先注册 SlowSqlInterceptor,再注册 MybatisPlusInterceptor
    └── 如果你希望它在 MybatisPlusInterceptor 内部
        └── 应该实现 InnerInterceptor,加到 MybatisPlusInterceptor 中

推荐做法:实现 InnerInterceptor,而不是单独写 Interceptor。

java 复制代码
public class SlowSqlInnerInterceptor implements InnerInterceptor {

    private long slowThreshold;

    @Override
    public void beforeQuery(Executor executor, MappedStatement ms,
                             Object parameter, RowBounds rowBounds,
                             ResultHandler resultHandler, BoundSql boundSql) {
        // 记录开始时间到 ThreadLocal
        SlowSqlHolder.setStart(System.currentTimeMillis());
    }

    @Override
    public void afterQuery(Executor executor, MappedStatement ms,
                            Object parameter, RowBounds rowBounds,
                            ResultHandler resultHandler, BoundSql boundSql) {
        long cost = System.currentTimeMillis() - SlowSqlHolder.getStart();
        if (cost > slowThreshold) {
            log.warn("慢 SQL:cost={}ms, sql={}", cost, boundSql.getSql());
        }
        SlowSqlHolder.clear();
    }
}

九、InnerInterceptor 执行原理:委托模式

scss 复制代码
MybatisPlusInterceptor.intercept() 源码简化
│
├── 拦截 Executor.query()
│   │
│   ├── for (InnerInterceptor inner : interceptors) {
│   │       if (!inner.beforeQuery(...)) {
│   │           return Collections.emptyList();  ← 某插件阻止查询
│   │       }
│   │   }
│   │
│   ├── result = invocation.proceed();  ← 执行真实查询
│   │
│   └── for (InnerInterceptor inner : interceptors) {
│           inner.afterQuery(...);  ← 后置处理
│       }
│
└── 拦截 Executor.update() 类似

这就是"委托模式":MybatisPlusInterceptor 是唯一的外部拦截器,它把拦截逻辑委托给内部的一组 InnerInterceptor。

scss 复制代码
对比
│
├── 原生 MyBatis
│   ├── 每个 Interceptor 都是独立的动态代理
│   ├── 代理嵌套层数 = Interceptor 数量
│   ├── 调试困难(代理套代理)
│   └── 顺序靠 Spring 容器注册顺序
│
└── MyBatis-Plus
    ├── 只有一个外部 Interceptor(MybatisPlusInterceptor)
    ├── 内部维护 InnerInterceptor 链
    ├── 前置/后置逻辑清晰
    └── 顺序靠 addInnerInterceptor() 调用顺序

十、常见坑与最佳实践

坑 1:分页插件不生效

sql 复制代码
原因排查清单
│
├── 1. 没注册 MybatisPlusInterceptor
│   └── 检查是否有 @Bean MybatisPlusInterceptor
│
├── 2. 没传 IPage 参数
│   ├── mapper.selectList(wrapper)         ← 不分页
│   └── mapper.selectPage(page, wrapper)   ← 分页
│
├── 3. 多数据源场景下 DbType 配置错误
│   └── PaginationInnerInterceptor(DbType.MYSQL)
│       如果实际是 PostgreSQL,分页语法会出错
│
└── 4. 自定义 SQL(XML)中用了分页方言
    └── XML 里手写了 LIMIT,插件又加了一层 → 双重 LIMIT

坑 2:多租户插件影响系统表

复制代码
问题:租户条件加到了 sys_config 等系统表上
│
├── 原因:TenantLineInnerInterceptor 默认对所有表生效
│
└── 解决:配置忽略表
java 复制代码
TenantLineInnerInterceptor tenantInterceptor =
    new TenantLineInnerInterceptor(new TenantLineHandler() {
        @Override
        public Expression getTenantId() {
            return new LongValue(10086);
        }

        @Override
        public boolean ignoreTable(String tableName) {
            // 系统表不加租户条件
            return "sys_config".equals(tableName)
                || "sys_dict".equals(tableName);
        }
    });

坑 3:乐观锁和批量更新冲突

objectivec 复制代码
问题:updateBatchById 批量更新时乐观锁不生效
│
├── 原因:批量更新是逐条执行,每条独立事务
│   但版本号检查在每条 SQL 中独立完成
│
└── 解决:批量更新时手动处理版本号,或使用乐观锁 + CAS 重试

最佳实践 Checklist

# 实践 说明
1 插件注册顺序 租户 → 权限 → 分页 → 乐观锁 → 防全表
2 指定 DbType 分页插件必须指定正确的数据库类型
3 配置忽略表 多租户插件要忽略系统表
4 优先 InnerInterceptor 自定义插件实现 Inner 而非原生 Interceptor
5 避免多重 LIMIT 自定义 SQL 和分页插件别同时加 LIMIT
6 监控慢 SQL 用 InnerInterceptor 做 before/after 计时
7 分页查询合理设 limit 避免深分页(offset 过大),考虑游标分页

十一、总结:插件机制全景图

bash 复制代码
MyBatis-Plus 插件机制全景
│
├── 底层:MyBatis Interceptor
│   ├── JDK 动态代理
│   ├── 四大可拦截对象
│   └── 责任链模式
│
├── 中间层:MybatisPlusInterceptor
│   ├── 统一拦截入口
│   ├── InnerInterceptor 链
│   └── before/after 钩子
│
├── 内置插件
│   ├── PaginationInnerInterceptor    ← 分页
│   ├── TenantLineInnerInterceptor    ← 多租户
│   ├── DataPermissionInterceptor     ← 数据权限
│   ├── OptimisticLockerInnerInterceptor ← 乐观锁
│   ├── BlockAttackInnerInterceptor   ← 防全表
│   └── IllegalSQLInnerInterceptor    ← SQL 安全
│
└── 自定义插件
    ├── 实现 InnerInterceptor
    ├── 注册到 MybatisPlusInterceptor
    └── 注意顺序和边界条件

下一篇我们聊 Spring Boot 自动配置到底怎么做到的 ------不对,那个已经发了。下一篇是 Spring 事件机制与监听器模式:ApplicationEvent 到底怎么传播的?@EventListener 和 ApplicationListener 有什么区别?异步事件怎么配?------把 Spring 的"观察者模式"拆清楚。


本文是 Java 技术系列第 6 篇,系列目录:

  1. Spring 循环依赖到底怎么解的?三级缓存源码拆解
  2. @Transactional 注了等于没用?Spring 事务失效的 7 种场景
  3. Spring Boot 自动配置到底怎么做到的?源码拆解 @EnableAutoConfiguration 全流程
  4. @Autowired 注入的是代理对象?Spring AOP 代理选择原理一次讲透
  5. 线上 Full GC 频发怎么救?JVM 内存模型与 GC 调优一次讲透
  6. 本文:分页插件到底怎么拦截 SQL?MyBatis-Plus 插件机制原理一次讲透
相关推荐
马优晨5 小时前
pring Boot + MyBatis + Redis 项目分层架构详解 —— 各层职责、关系与工作流程
架构·mybatis·spring项目分层架构详解·spring项目架构详解·spring各层职责·spring关系与工作流程
Listen·Rain8 小时前
用AI开发出一个AI
java·人工智能·spring boot·tomcat·intellij-idea·mybatis·visual studio
马优晨11 小时前
Spring Boot + MyBatis + Redis 整合实战 —— 项目源码深度解析
spring boot·redis·mybatis·mybatis + redis·redis实战·spring实战·mybatis实战
阿里云云原生1 天前
AI Agent 上线容易稳定难?阿里云 AgentLoop 推出“经验自进化”闭环治理方案
人工智能·阿里云·mybatis·agentscope
CodeStats2 天前
【Spring事务】Spring事务注解 @Transactional 完整体系:从 MySQL 隔离级别到 MyBatis 原理详解
java·spring·mybatis·事务·transactional
Nuanyt2 天前
SSM 学习记录 第二部分 Spring整合Mybatis&Junit AOP核心概念 Spring事务管理 SpringMVC 请求与响应 Rest风格
java·spring·junit·mybatis·restful
米码收割机2 天前
【SSM】Spring MVC_MyBatis SSM商城系统(源码+论文)【独一无二】
spring·mvc·mybatis
Devin~Y3 天前
互联网大厂 Java 面试实录:Spring Boot、MyBatis、Redis、Kafka、Spring Security、RAG 与 MCP 全链路问答
java·redis·kafka·mybatis·spring security·spring mvc·sprint boot
VX_bysjlw9854 天前
基于微信小程序的宠物用品商城系统-后端74346-计算机毕设原创(免费领源码+带部署教程)
java·redis·微信小程序·eclipse·mybatis·idea·微信开发者工具