资讯动态

@MapperScan 源码解析:Mapper 接口如何被注册进 Spring 容器

发布时间:2026/10/13 12:16:16 来源:尧图企业网站定制
在 Spring Boot 项目里启动类上写一行MapperScan所有 Mapper 接口就像变魔法一样能被注入了。我一开始只是照着模板抄直到某天遇到一个诡异的问题一个明明存在且包路径也正确的 Mapper 接口启动后怎么都注入不进来。排查时翻到 mybatis-spring 的源码才意识到自己连 Mapper 是怎么进 Spring 容器的都没搞明白。弄清楚这个问题之后再看MapperScan相关的报错和配置基本都是一眼定位。这篇文章只讲一件事MapperScan从注解生效到 Mapper 接口的 BeanDefinition 被注册进容器的完整过程。我会把 Spring 容器里发生了什么、BeanDefinition 是怎么被“偷梁换柱”成MapperFactoryBean的、最终又如何变成我们注入的代理对象一条链路讲透。适合正在用 MyBatis 做开发、对 Spring 底层机制感兴趣或者被 Mapper 注入问题困扰的同学。看懂这一篇后面再深入 MyBatis 的执行器、SQL 绑定等源码会轻松很多。1. 背景铺垫为什么 Spring 原生扫描对 Mapper 接口没辙在拆解MapperScan之前得先理解一个基础问题为什么 MyBatis 不能像普通 Bean 一样直接让 Spring 把 Mapper 接口扫成一个组件1.1 接口既不能实例化也不是组件Spring 最常用的组件扫描机制是扫描Component、Service、Repository、Controller这几个注解。一个普通的接口上面没有任何 Spring 注解就算它被扫描到了Spring 也不会把它注册成 BeanDefinition。这是第一层障碍。第二层障碍更关键接口本身是不能实例化的。Spring 拿到一个具体类可以通过Class.getDeclaredConstructor()找到无参构造器然后newInstance()创建对象。但接口没有构造器Spring 没法直接new出一个接口实现。MyBatis 的 Mapper 接口真正可用是因为框架在运行时给它创建了 JDK 动态代理对象。这个代理对象是MapperProxy它需要拿到SqlSession、Configuration等 MyBatis 内部组件才能工作。所以 Mapper 接口的创建逻辑必须由 MyBatis 自己接管而不是交给 Spring 默认的实例化策略。用一个生活化的类比Spring 像是食堂的厨师他只会炒现成的菜。普通 Bean 是已经洗好切好的食材下锅就行。但 Mapper 接口是一张菜谱菜谱本身不能吃需要有专门的面点师傅MyBatis按照菜谱上的流程先和面、发面、烘烤才能出成品。所以 MyBatis 必须找一种方式告诉 Spring“这个 Bean 你别直接 new让我的MapperFactoryBean来代劳。”1.2 整条链路先画个地图看源码最怕一头扎进细节出不来。我的习惯是先画宏观链路知道每个类大概扮演什么角色再逐段深入。MapperScan的完整执行链路大致是这样的启动类上标注MapperScan(com.example.mapper)。Spring 解析启动类上的Import(MapperScannerRegistrar.class)把注册逻辑交给MapperScannerRegistrar。MapperScannerRegistrar创建ClassPathMapperScanner传入包路径执行扫描。扫描器在指定包下找到所有符合条件的接口文件生成候选的 BeanDefinition。关键一步扫描器把每个候选 BeanDefinition 的beanClass从“原 Mapper 接口”替换成MapperFactoryBean同时把原接口类型作为构造参数存进去。Spring 正常实例化这个 BeanDefinition得到MapperFactoryBean对象。当其他代码注入 Mapper 接口时Spring 通过MapperFactoryBean.getObject()拿到 JDK 动态代理对象。整条链路上MapperScan只是入口真正的幕后核心是MapperScannerRegistrar和ClassPathMapperScanner。名字很像但职责不同前者处理注解元数据、设置扫描器参数后者负责真正的类路径扫描和 BeanDefinition 加工。1.3 读源码前必须认识的三个 Spring 扩展点这段代码涉及 Spring 的几个扩展接口。不懂它们MapperScan的源码很容易看晕。ImportBeanDefinitionRegistrar是 Spring 提供的一个回调接口。当某个Configuration类被处理时Spring 会把该类上Import引入的类做处理。如果引入的类实现了ImportBeanDefinitionRegistrarSpring 就会回调它的registerBeanDefinitions()方法让你有机会手动向容器注册 BeanDefinition。MapperScan走的就是这条路。BeanDefinitionRegistry是 Spring 内部的“户籍系统”。所有 Bean 的注册信息都记录在这里。BeanDefinition是“户籍信息”Bean才是后来根据户籍信息创建出来的“真人”。registerBeanDefinitions()方法的第二个参数就是BeanDefinitionRegistry拿到它就能手动把自定义的 BeanDefinition 塞进去。ClassPathBeanDefinitionScanner是 Spring 自带的类路径扫描器。它负责遍历指定包下所有.class文件读取类的元数据判断哪些符合候选条件然后生成 BeanDefinition。MyBatis 的ClassPathMapperScanner就是继承并扩展了这个类所以MapperScan没有另起炉灶是在 Spring 已有的扫描机制上做改造。这三个概念串起来后再看MapperScan它其实不是“扫描”本身而是一把钥匙打开的是“让 MyBatis 自定义扫描和注册逻辑”这扇门。2. 注解拆解MapperScan 每个参数到底管什么很多人用MapperScan的时候只写一个包路径但实际这个注解上有十多个属性每个属性都对应扫描器的一条配置。源码看多了就会发现很多诡异的问题不是扫描本身出了问题而是注解上的某个参数和实际预期不一致。2.1 注解属性逐个说清楚先贴一份比较完整的注解属性对照表后面再展开讲重点。属性作用典型使用场景value指定扫描的基础包路径和basePackages等价MapperScan(com.example.mapper)basePackages同value区别在于语义更明确多包时basePackages {pkg1, pkg2}basePackageClasses以指定类的包路径作为扫描根路径避免字符串写错用类引用更安全nameGenerator自定义 Bean 名称生成器需要控制 Mapper Bean 的命名规则时annotationClass只注册标注了指定注解的接口限制某个 Mapper 必须标有自定义注解markerInterface只注册实现了指定标记接口的 Mapper做 Mapper 分组区分时常用sqlSessionTemplateRef指定注入的SqlSessionTemplateBean 名称多数据源场景下必用sqlSessionFactoryRef指定注入的SqlSessionFactoryBean 名称多数据源场景下必用factoryBean自定义MapperFactoryBean实现类极少用有特殊扩展需求才动defaultScope设置 Mapper Bean 的默认作用域Spring 6 / Boot 3 时代控制单例或原型lazyInitialization设置 Mapper Bean 是否懒加载配合 Spring Boot 的懒加载机制使用这里最容易被忽略的是value和basePackages都没写的情况。源码里有一段逻辑如果两个都没配置就用MapperScan所在类通常是启动类的包路径作为默认扫描包。曾经有同事把启动类放在com.example但 Mapper 放在com.example.module.mapper结果MapperScan什么都不写也能扫到一部分但另一部分在更深层级包里的接口没扫到排查了半天才怀疑到默认包路径的问题上。2.2 Import 机制注解如何从定义变成行动MapperScan的上方有一个关键元注解Import(MapperScannerRegistrar.class)Import是 Spring 导入配置的“快速通道”。它可以导入三类东西类型行为典型例子普通Configuration类直接作为配置类加载业务配置类ImportSelector实现类回调后返回一组需要导入的类名Spring Boot 自动配置ImportBeanDefinitionRegistrar实现类回调方法里直接向容器注册 BeanDefinitionMapperScan走的就是这条路MapperScannerRegistrar属于第三种。ImportBeanDefinitionRegistrar的特点是“你完全控制注册逻辑”不依赖 Spring 自动推断。MyBatis 选择这种方式的原因很直接它需要把MapperScan上各种属性的值读出来动态决定扫描器怎么配、扫哪些包、过滤哪些接口。用ImportSelector只能决定“加载哪个类”做不了这么细的定制。2.3 没有 MapperScan 时的另一条路自动配置扫描器很多老项目并不用MapperScan而是在接口上标Mapper注解。这两种方式其实对应两套不同的注册逻辑。只标Mapper注解、不写MapperScan时MyBatis-Spring Boot 的自动配置类中会加入一个AutoConfiguredMapperScannerRegistrar。这个 registrar 创建的扫描器默认扫描启动类所在包及其子包但只认标注了Mapper注解的接口。MapperScan一旦出现自动配置的扫描器就会被“替换掉”。判断方式也很简单MapperScan引起的注册器已经注册了 Mapper BeanMyBatis 自动配置如果发现容器中已经有MapperFactoryBean或MapperScannerConfigurer之类的 Bean就会放弃执行自己的扫描逻辑。这也是很多项目里既标Mapper又加MapperScan依然正常工作的原因后者覆盖了前者重复扫描被框架内部规避了。3. 核心走读BeanDefinition 是怎么被扫描和替换的现在进入正题。看MapperScannerRegistrar.registerBeanDefinitions()和ClassPathMapperScanner的关键逻辑把“接口变成 BeanDefinition”这条主线的每个细节都过一遍。3.1 registerBeanDefinitions入口处的编排MapperScannerRegistrar的registerBeanDefinitions()方法逻辑非常直白就是读注解、建扫描器、配参数、开扫。我把核心流程简化成伪代码public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) { // 1. 从启动类的元数据里取出 MapperScan 上的所有属性 AnnotationAttributes mapperScanAttrs AnnotationAttributes.fromMap( importingClassMetadata.getAnnotationAttributes(MapperScan.class.getName())); if (mapperScanAttrs ! null) { registerBeanDefinitions(mapperScanAttrs, registry, importingClassMetadata.getClassName()); } } protected void registerBeanDefinitions(AnnotationAttributes annoAttrs, BeanDefinitionRegistry registry, String importBeanName) { // 2. 创建 MyBatis 自定义扫描器 ClassPathMapperScanner scanner new ClassPathMapperScanner(registry); // 3. 把注解上的配置逐项设置给扫描器 scanner.setAnnotationClass(annoAttrs.getClass(annotationClass)); scanner.setMarkerInterface(annoAttrs.getClass(markerInterface)); scanner.setSqlSessionFactoryBeanName(annoAttrs.getString(sqlSessionFactoryRef)); scanner.setSqlSessionTemplateBeanName(annoAttrs.getString(sqlSessionTemplateRef)); scanner.setResourceLoader(resourceLoader); scanner.setBeanNameGenerator(...); scanner.setMapperFactoryBeanClass(annoAttrs.getClass(factoryBean)); // 4. 注册过滤器决定哪些接口可以被扫描到 scanner.registerFilters(); // 5. 执行真正的扫描 scanner.doScan(StringUtils.toStringArray(basePackages)); }这段代码有个值得注意的细节registerFilters()必须在doScan()之前执行。ClassPathMapperScanner扫描时能不能选中 Mapper 接口完全依赖于过滤器。如果漏掉registerFilters()扫描器会沿用 Spring 父类默认的过滤规则只认Component一族注解自然一个 Mapper 都扫不到。源码阅读时这种调用顺序往往比方法内部逻辑更重要。3.2 registerFilters扫描器怎么筛选出“接口”ClassPathMapperScanner的registerFilters()方法决定了一个.class文件经过类路径扫描后能不能成为候选。它的逻辑值得细看public void registerFilters() { // 场景一配置了 annotationClass选中所有标注了该注解的接口 if (this.annotationClass ! null) { addIncludeFilter(new AnnotationTypeFilter(this.annotationClass)); } // 场景二配置了 markerInterface选中所有实现了该接口的 Mapper if (this.markerInterface ! null) { addIncludeFilter(new AssignableTypeFilter(this.markerInterface) { Override protected boolean matchClassName(String className) { return false; } }); } // 场景三兜底过滤器所有接口都作为候选 addIncludeFilter((metadataReader, metadataReaderFactory) - metadataReader .getClassMetadata().isInterface()); }默认情况下annotationClass是Annotation.class的占位值markerInterface是Class.class的占位值。所以实际操作中绝大多数项目走的是第三种情况包下所有接口都成为候选。但光有 includeFilter 还不够。扫描器在生成 BeanDefinition 之前还会调用isCandidateComponent()再做一次资格校验。ClassPathMapperScanner重写了这个方法Override protected boolean isCandidateComponent(AnnotatedBeanDefinition beanDefinition) { boolean isCandidate false; if (beanDefinition.getMetadata().isIndependent()) { if (beanDefinition.getMetadata().isInterface()) { isCandidate true; } } return isCandidate; }这个重写非常关键。isIndependent()过滤掉了内部类、匿名类isInterface()确保只处理接口。而 Spring 父类的默认实现里对接口的态度更加保守需要配合其他条件才可能放行。MyBatis 重写之后意思就变成了只要是独立的接口就收进来。所以如果你的 Mapper 定义成了某个类的内部接口或者不是 public 访问级别就很可能在这里被悄悄过滤掉。3.3 doScan 与 processBeanDefinitions偷梁换柱的魔术时刻筛选完成后扫描器拿到了一批候选 BeanDefinition。这时它们的beanClassName都还指向原始接口。接下来processBeanDefinitions()开始做真正的“换芯”操作。public SetBeanDefinitionHolder doScan(String... basePackages) { SetBeanDefinitionHolder beanDefinitions super.doScan(basePackages); if (beanDefinitions.isEmpty()) { logger.warn(...); // 扫描不到时的警告 } else { processBeanDefinitions(beanDefinitions); } return beanDefinitions; }super.doScan()是 Spring 提供的标准流程负责遍历包路径下的 class 文件、读取元数据、套用过滤器。真正的 MyBatis 逻辑集中在processBeanDefinitions()里我用关键代码演示private void processBeanDefinitions(SetBeanDefinitionHolder beanDefinitions) { GenericBeanDefinition definition; for (BeanDefinitionHolder holder : beanDefinitions) { definition (GenericBeanDefinition) holder.getBeanDefinition(); // 拿到原本的接口全限定名 String beanClassName definition.getBeanClassName(); // 把接口类型存进构造参数后面MapperFactoryBean要用 definition.getConstructorArgumentValues() .addGenericArgumentValue(beanClassName); // 核心操作把beanClass替换成MapperFactoryBean definition.setBeanClassName(this.mapperFactoryBeanClass.getName()); // 构造器参数不用XML指定Spring按类型自动注入 definition.setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE); // 处理懒加载、默认作用域等扩展属性 ... } }这里面的每一行都有明确意图我来逐个解释。第一把接口类型记录到构造参数里。MapperFactoryBean有一个构造函数接收ClassT类型Spring 通过 BeanDefinition 的构造参数列表最终会调用new MapperFactoryBean(UserMapper.class)这样的代码。接口类型是后面创建代理的前提。第二把beanClass替换成MapperFactoryBean。这是一次典型的“李代桃僵”。Spring 原本以为自己要创建的是UserMapper接口的实例但接口没法实例化。MyBatis 的做法是把目标换成一个能生产代理的工厂类。Spring 的实例化流程并不关心这个工厂类叫不叫UserMapper它只负责按 BeanDefinition 的描述把对象创建出来。第三AUTOWIRE_BY_TYPE自动装配模式。MapperFactoryBean内部依赖SqlSessionFactory或SqlSessionTemplate但这些依赖不是通过 BeanDefinition 显式指定的而是让 Spring 在创建工厂 Bean 时按类型自动从容器中找。这样设计的好处是解耦MapperFactoryBean不需要关心具体数据源配置Spring 容器里有哪个SqlSessionFactory就注入哪个。多数据源环境下MapperScan里的sqlSessionFactoryRef也会在这里转成具体名称的限定。到这一步一个普通的接口 BeanDefinition就变成了“构造函数参数是接口类型、beanClass 是工厂类”的特殊 BeanDefinition。这个过程可以类比成食堂菜谱上原本写着“清蒸鱼”后厨拿到手后把菜谱替换成了“请找专门做鱼的师傅以这条鱼为原料重新做一份清蒸鱼”。Spring 不认识鱼但它认识师傅。4. 后续接力MapperFactoryBean 如何把接口变成可注入对象BeanDefinition 注册完成后Spring 容器开始创建 Bean。MapperFactoryBean作为整个流程的接力者它的实例化过程决定了最终拿到的是什么。4.1 MapperFactoryBean 的双重身份MapperFactoryBean继承了SqlSessionDaoSupport实现了FactoryBeanT接口。FactoryBeanT是 Spring 里一类特殊的 Bean容器里注册的虽然是你定义的那个对象但别人注入时实际拿到的是getObject()方法的返回值。MapperFactoryBean的getObject()方法内容很简单Override public T getObject() throws Exception { return getSqlSession().getMapper(this.mapperInterface); }也就是从SqlSession里取出 Mapper 接口对应的动态代理对象。这一步封装得很薄真正的逻辑在 MyBatis 内部。但这里有个前置条件getSqlSession()返回的SqlSession必须已经初始化好而且Configuration里必须已经注册过这个 Mapper 接口。谁来完成这个注册答案是afterPropertiesSet()。4.2 afterPropertiesSet 里的隐藏动作因为MapperFactoryBean实现了InitializingBean所以 Spring 在创建完对象、完成属性填充后会调用afterPropertiesSet()方法。它的核心逻辑是checkDaoConfig()。Override protected void checkDaoConfig() { // 从SqlSessionTemplate里拿到MyBatis的全局配置 Configuration configuration getSqlSession().getConfiguration(); if (this.addToConfig !configuration.hasMapper(this.mapperInterface)) { try { // 关键动作把Mapper接口注册进MyBatis的MapperRegistry configuration.addMapper(this.mapperInterface); } catch (Exception e) { logger.error(...); throw new IllegalArgumentException(...); } } }这一步非常容易被忽略没有它后面什么都跑不起来。configuration.addMapper()会在 MyBatis 的MapperRegistry中建立“接口类型 → 代理工厂”的映射同时创建MapperAnnotationBuilder解析接口上的注解 SQL 和 XML SQL。换句话说MyBatis 是到这一步才把UserMapper接口真正变成自己的“内部资产”。所以MapperScan链路完整走完后容器里的流程是Spring 创建MapperFactoryBean→ 初始化时把接口注册进 MyBatis 的Configuration→ 后续调用getObject()时MyBatis 已经准备好一切直接返回代理对象。4.3 动态代理与最终调用当你在任意 Service 里写Autowired UserMapper userMapperSpring 按照类型寻找候选 Bean。因为MapperFactoryBean的getObjectType()返回了接口类型Spring 能从工厂中识别出它生产的是UserMapper。注入时调用getObject()得到MapperProxyFactory.newInstance()创建的代理对象。我来演示一下代理对象的结构public class MapperProxyFactoryT { public T newInstance(SqlSession sqlSession) { MapperProxyT mapperProxy new MapperProxy(sqlSession, mapperInterface, methodCache); return (T) Proxy.newProxyInstance(mapperInterface.getClassLoader(), new Class[]{mapperInterface}, mapperProxy); } }MapperProxy实现了InvocationHandler。当你调用userMapper.selectById(1)时代理对象把这次调用交给invoke()方法Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // equals/hashCode/toString直接透传不经过SQL if (Object.class.equals(method.getDeclaringClass())) { return method.invoke(this, args); } // 拿到或创建MapperMethod最终按SQL指令选择执行方式 MapperMethod mapperMethod cachedMapperMethod(method); return mapperMethod.execute(sqlSession, args); }MapperMethod.execute()根据 SQL 类型调用sqlSession.selectOne()、insert()、update()、delete()等对应方法往下走就是 MyBatis 的Executor、JDBC 驱动最终完成数据库操作。从接口定义到数据库查询这条链路全部打通。5. 自定义验证与踩坑排查实战光看源码不验证印象总是浅的。我在实际排查问题时会自己写几个小工具把这条链路“看”出来。这里分享一些实测可行的验证方法和踩过的坑。5.1 写个 BeanFactoryPostProcessor 偷看 BeanDefinition想要确认某个 Mapper 接口有没有被扫描成预期的 BeanDefinition最直接的方法是注册一个BeanFactoryPostProcessor在 Spring 完成所有 BeanDefinition 注册之后、开始实例化之前把注册表翻一遍。Component public class MyBeanFactoryPostProcessor implements BeanFactoryPostProcessor { Override public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { String[] names beanFactory.getBeanDefinitionNames(); Arrays.stream(names) .filter(name - name.contains(UserMapper)) .forEach(name - { BeanDefinition bd beanFactory.getBeanDefinition(name); System.out.println(beanName name); System.out.println(beanClass bd.getBeanClassName()); System.out.println(constructor bd.getConstructorArgumentValues()); }); } }启动项目时输出结果会让链路一目了然beanName userMapper beanClass org.mybatis.spring.mapper.MapperFactoryBean constructor ConstructorArgumentValues: [UserMapper]beanClass是MapperFactoryBean构造参数是UserMapper说明扫描器的“偷梁换柱”已经生效。如果这里显示的beanClass还是UserMapper说明根本没有走到processBeanDefinitions()扫描逻辑有问题。5.2 常见问题一扫描包路径正确但接口没有注册这种情况最隐蔽。某个 Mapper 接口明明在扫描包范围内项目也能启动但注入时始终报找不到 Bean。我在生产环境排查过一次最后定位到原因是接口被定义成了内部接口。假设有一个类public class Foo { public interface UserMapper { User selectById(Integer id); } }ClassPathMapperScanner.isCandidateComponent()判断这个内部接口时isIndependent()返回 false于是直接排除。解决办法很简单把接口从内部类中挪出来独立成文件保证它是 top-level 接口访问权限是 public。另一个可能原因是包名写错级别。MapperScan(com.example.mapper)只扫描该包及其直接子包。如果接口在com.example.mapper.sub下那没问题但如果接口在com.example.dao下就扫不到。使用basePackageClasses用类引用来代替字符串可以从根本上避免字符串笔误。5.3 常见问题二多个扫描源导致 Bean 定义冲突MapperScan与Mapper同时使用、或者多个MapperScan扫描到同一批接口时可能出现 BeanDefinition 冲突。MyBatis 在classPathMapperScanner中做了checkCandidate()判断如果已经存在一个同名的 BeanDefinition且和新的不一致就直接抛出Invalid bean definition异常。多次扫描本身不一定有问题。如果两次扫描注册的 BeanDefinition 完全一致MyBatis 会选择“已经有合适定义就跳过”。真正会报错的是扫描后生成的 BeanDefinition 细节不同比如设置了不同的sqlSessionTemplateRef、不同的nameGenerator。解决方案是收敛扫描入口尽量只保留一个MapperScan或者统一用Mapper注解配合自动配置避免两套扫描机制互相干扰。5.4 常见问题三MapperFactoryBean 类型对不上还有一个场景很值得提项目引入 MyBatis 后手写了一个自定义的factoryBean或者改了MapperScan的factoryBean属性但新类没有正确实现FactoryBeanT。这种情况下 Spring 在解析 Bean 类型时拿不到正确的getObjectType()Autowired UserMapper会直接报No qualifying bean of type UserMapper。排查时可以先用BeanFactoryPostProcessor打印出所有 Mapper 接口的 BeanDefinition如果beanClass显示的不是MapperFactoryBean而是某个自定义类马上就能意识到是这个属性配置的问题。多数据源场景下如果同时存在多个SqlSessionFactory但MapperScan没有指定sqlSessionFactoryRefAUTOWIRE_BY_TYPE会因找不到唯一候选而报错。配置多数据源时每个MapperScan最好都显式指定对应的工厂引用。5.5 源码阅读与排查的经验心得在翻这些源码的时候我个人最大的收获不是背下了类名和行号而是养成了一套“从现象反推代码”的习惯。遇到 Mapper 注入问题先通过BeanFactoryPostProcessor看 BeanDefinition 是否存在再看 BeanDefinition 的beanClass和构造参数是否符合预期最后才检查 MyBatis 的Configuration中hasMapper的状态。三步定位基本不会走偏。断点也有讲究。ClassPathMapperScanner.processBeanDefinitions()是一个很好的断点位置能观察扫描结果和 BeanDefinition 的修改过程。另一个关键断点是MapperFactoryBean.checkDaoConfig()能确认接口注册进 MyBatis 的时机。我会在启动的主线程上打断点不走到业务代码只看容器初始化这一段。最后留一句体会读这种框架源码不要试图一次性理解所有分支先抓住“接口如何被 Spring 接纳”这条主干再看各属性如何被消费最后才是异常和边界情况。主干通了剩下的细节都只是在这条主线上加装饰而已。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑