广告
返回顶部
首页 > 资讯 > 精选 >如何解决Mybatis #foreach中相同的变量名导致值覆盖的问题
  • 366
分享到

如何解决Mybatis #foreach中相同的变量名导致值覆盖的问题

2023-06-20 18:06:34 366人浏览 独家记忆
摘要

这篇文章主要讲解了“如何解决mybatis #foreach中相同的变量名导致值覆盖的问题”,文中的讲解内容简单清晰,易于学习与理解,下面请大家跟着小编的思路慢慢深入,一起来研究和学习“如何解决Mybatis #foreach中相同的变量名

这篇文章主要讲解了“如何解决mybatis #foreach中相同的变量名导致值覆盖的问题”,文中的讲解内容简单清晰,易于学习与理解,下面请大家跟着小编的思路慢慢深入,一起来研究和学习“如何解决Mybatis #foreach中相同的变量名导致值覆盖的问题”吧!

目录
  • 背景

  • 问题原因(简略版)

  • Mybatis流程源码解析(长文警告,按需自取)

    • 一、获取sqlSessionFactory

    • 二、获取SqlSession

    • 三、执行SQL

背景

使用Mybatis中执行如下查询:

单元测试

@Testpublic void test1() {    String resource = "mybatis-config.xml";    InputStream inputStream = null;    try {        inputStream = Resources.getResourceAsStream(resource);    } catch (IOException e) {        e.printStackTrace();    }    SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);    try (SqlSession sqlSession = sqlSessionFactory.openSession()) {        CommonMapper mapper = sqlSession.getMapper(CommonMapper.class);        QueryCondition queryCondition = new QueryCondition();        List<Integer> list = new ArrayList<>();        list.add(1);        list.add(2);        list.add(3);        queryCondition.setWidthList(list);        System.out.println(mapper.findByCondition(queryCondition));    }}

XML

<select id="findByCondition" parameterType="cn.liupjie.pojo.QueryCondition" resultType="cn.liupjie.pojo.Test">    select * from test    <where>        <if test="id != null">            and id = #{id,jdbcType=INTEGER}        </if>        <if test="widthList != null and widthList.size > 0">            <foreach collection="widthList" open="and width in (" close=")" item="width" separator=",">                #{width,jdbcType=INTEGER}            </foreach>        </if>        <if test="width != null">            and width = #{width,jdbcType=INTEGER}        </if>    </where></select>

打印的SQL:
DEBUG [main] - ==>  Preparing: select * from test WHERE width in ( ? , ? , ? ) and width = ?
DEBUG [main] - ==> Parameters: 1(Integer), 2(Integer), 3(Integer), 3(Integer)

Mybatis版本

<dependency>    <groupId>org.mybatis</groupId>    <artifactId>mybatis</artifactId>    <version>3.4.1</version></dependency>

这是公司的老项目,在迭代的过程中遇到了此问题,以此记录!
PS: 此bug在mybatis-3.4.5版本中已经解决。并且Mybatis维护者也建议不要在item/index中使用重复的变量名。

如何解决Mybatis #foreach中相同的变量名导致值覆盖的问题

如何解决Mybatis #foreach中相同的变量名导致值覆盖的问题

问题原因(简略版)

  • 在获取到DefaultSqlSession之后,会获取到Mapper接口的代理类,通过调用代理类的方法来执行查询

  • 真正执行数据库查询之前,需要将可执行的SQL拼接好,此操作在DynamicSqlSource#getBoundSql方法中执行

  • 当解析到foreach标签时,每次循环都会缓存一个item属性值与变量值之间的映射(如:width:1),当foreach标签解析完成后,缓存的参数映射关系中就保留了一个(width:3)

  • 当解析到最后一个if标签时,由于width变量有值,因此if判断为true,正常执行拼接,导致出错

  • 5版本中,在foreach标签解析完成后,增加了两行代码来解决这个问题。

 //foreach标签解析完成后,从bindings中移除item  context.getBindings().remove(item);  context.getBindings().remove(index);

Mybatis流程源码解析(长文警告,按需自取)

一、获取SqlSessionFactory

入口,跟着build方法走

//获取SqlSessionFactory, 解析完成后,将XML中的内容封装到一个Configuration对象中,//使用此对象构造一个DefaultSqlSessionFactory对象,并返回SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);

来到SqlSessionFactoryBuilder#build方法

public SqlSessionFactory build(InputStream inputStream, String environment, Properties properties) {  try {    //获取XMLConfigBuilder,在XMLConfigBuilder的构造方法中,会创建XPathParser对象    //在创建XPathParser对象时,会将mybatis-config.xml文件转换成Document对象    XMLConfigBuilder parser = new XMLConfigBuilder(inputStream, environment, properties);    //调用XMLConfigBuilder#parse方法开始解析Mybatis的配置文件    return build(parser.parse());  } catch (Exception e) {    throw ExceptionFactory.wrapException("Error building SqlSession.", e);  } finally {    ErrorContext.instance().reset();    try {      inputStream.close();    } catch (IOException e) {      // Intentionally ignore. Prefer previous error.    }  }}

跟着parse方法走,来到XMLConfigBuilder#parseConfiguration方法

private void parseConfiguration(Xnode root) {  try {    Properties settings = settingsAsPropertiess(root.evalNode("settings"));    //issue #117 read properties first    propertiesElement(root.evalNode("properties"));    loadCustomVfs(settings);    typeAliasesElement(root.evalNode("typeAliases"));    pluginElement(root.evalNode("plugins"));    objectFactoryElement(root.evalNode("objectFactory"));    objectWrapperFactoryElement(root.evalNode("objectWrapperFactory"));    reflectorFactoryElement(root.evalNode("reflectorFactory"));    settingsElement(settings);    // read it after objectFactory and objectWrapperFactory issue #631    environmentsElement(root.evalNode("environments"));    databaseIdProviderElement(root.evalNode("databaseIdProvider"));    typeHandlerElement(root.evalNode("typeHandlers"));    //这里解析mapper    mapperElement(root.evalNode("mappers"));  } catch (Exception e) {    throw new BuilderException("Error parsing SQL Mapper Configuration. Cause: " + e, e);  }}

来到mapperElement方法

//本次mappers配置:<mapper resource="xml/CommomMapper.xml"/>private void mapperElement(XNode parent) throws Exception {  if (parent != null) {    for (XNode child : parent.getChildren()) {      if ("package".equals(child.getName())) {        String mapperPackage = child.getStringAttribute("name");        configuration.addMappers(mapperPackage);      } else {        String resource = child.getStringAttribute("resource");        String url = child.getStringAttribute("url");        String mapperClass = child.getStringAttribute("class");        if (resource != null && url == null && mapperClass == null) {          //因此走这里,读取xml文件,并开始解析          ErrorContext.instance().resource(resource);          InputStream inputStream = Resources.getResourceAsStream(resource);          //这里同上文创建XMLConfigBuilder对象一样,在内部构造时,也将xml文件转换为了一个Document对象          XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments());          //解析          mapperParser.parse();        } else if (resource == null && url != null && mapperClass == null) {          ErrorContext.instance().resource(url);          InputStream inputStream = Resources.getUrlAsStream(url);          XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, url, configuration.getSqlFragments());          mapperParser.parse();        } else if (resource == null && url == null && mapperClass != null) {          Class<?> mapperInterface = Resources.classForName(mapperClass);          configuration.addMapper(mapperInterface);        } else {          throw new BuilderException("A mapper element may only specify a url, resource or class, but not more than one.");        }      }    }  }}

XMLMapperBuilder类,负责解析SQL语句所在XML中的内容

//parse方法public void parse() {  if (!configuration.isResourceLoaded(resource)) {    //解析mapper标签    configurationElement(parser.evalNode("/mapper"));    configuration.addLoadedResource(resource);    bindMapperForNamespace();  }  parsePendingResultMaps();  parsePendinGChacheRefs();  parsePendingStatements();}//configurationElement方法private void configurationElement(XNode context) {  try {    String namespace = context.getStringAttribute("namespace");    if (namespace == null || namespace.equals("")) {      throw new BuilderException("Mapper's namespace cannot be empty");    }    builderAssistant.setCurrentNamespace(namespace);    cacheRefElement(context.evalNode("cache-ref"));    cacheElement(context.evalNode("cache"));    parameterMapElement(context.evalNodes("/mapper/parameterMap"));    resultMapElements(context.evalNodes("/mapper/resultMap"));    sqlElement(context.evalNodes("/mapper/sql"));    //解析各种类型的SQL语句:select|insert|update|delete    buildStatementFromContext(context.evalNodes("select|insert|update|delete"));  } catch (Exception e) {    throw new BuilderException("Error parsing Mapper XML. Cause: " + e, e);  }}private void buildStatementFromContext(List<XNode> list, String requiredDatabaseId) {  for (XNode context : list) {    //创建XMLStatementBuilder对象    final XMLStatementBuilder statementParser = new XMLStatementBuilder(configuration, builderAssistant, context, requiredDatabaseId);    try {      //解析      statementParser.parseStatementNode();    } catch (IncompleteElementException e) {      configuration.addIncompleteStatement(statementParser);    }  }}

XMLStatementBuilder负责解析单个select|insert|update|delete节点

public void parseStatementNode() {  String id = context.getStringAttribute("id");  String databaseId = context.getStringAttribute("databaseId");  //判断databaseId是否匹配,将namespace+'.'+id拼接,判断是否已经存在此id  if (!databaseIdMatchesCurrent(id, databaseId, this.requiredDatabaseId)) {    return;  }  Integer fetchSize = context.getIntAttribute("fetchSize");  Integer timeout = context.getIntAttribute("timeout");  String parameterMap = context.getStringAttribute("parameterMap");  //获取参数类型  String parameterType = context.getStringAttribute("parameterType");  //获取参数类型的class对象  Class<?> parameterTypeClass = resolveClass(parameterType);  String resultMap = context.getStringAttribute("resultMap");  String resultType = context.getStringAttribute("resultType");  String lang = context.getStringAttribute("lang");  LanguageDriver langDriver = getLanguageDriver(lang);  //获取resultType的class对象  Class<?> resultTypeClass = resolveClass(resultType);  String resultSetType = context.getStringAttribute("resultSetType");  StatementType statementType = StatementType.valueOf(context.getStringAttribute("statementType", StatementType.PREPARED.toString()));  ResultSetType resultSetTypeEnum = resolveResultSetType(resultSetType);  //获取select|insert|update|delete类型  String nodeName = context.getNode().getNodeName();  SqlCommandType sqlCommandType = SqlCommandType.valueOf(nodeName.toUpperCase(Locale.ENGLISH));  boolean isSelect = sqlCommandType == SqlCommandType.SELECT;  boolean flushCache = context.getBooleanAttribute("flushCache", !isSelect);  boolean useCache = context.getBooleanAttribute("useCache", isSelect);  boolean resultOrdered = context.getBooleanAttribute("resultOrdered", false);  // Include Fragments before parsing  XMLIncludeTransfORMer includeParser = new XMLIncludeTransformer(configuration, builderAssistant);  includeParser.applyIncludes(context.getNode());  // Parse selecTKEy after includes and remove them.  processSelectKeyNodes(id, parameterTypeClass, langDriver);  // Parse the SQL (pre: <selectKey> and <include> were parsed and removed)  //获取SqlSource对象,langDriver为默认的XMLLanguageDriver,在new Configuration时设置  //若sql中包含元素节点或$,则返回DynamicSqlSource,否则返回RawSqlSource  SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass);  String resultSets = context.getStringAttribute("resultSets");  String keyProperty = context.getStringAttribute("keyProperty");  String keyColumn = context.getStringAttribute("keyColumn");  KeyGenerator keyGenerator;  String keyStatementId = id + SelectKeyGenerator.SELECT_KEY_SUFFIX;  keyStatementId = builderAssistant.applyCurrentNamespace(keyStatementId, true);  if (configuration.hasKeyGenerator(keyStatementId)) {    keyGenerator = configuration.getKeyGenerator(keyStatementId);  } else {    keyGenerator = context.getBooleanAttribute("useGeneratedKeys",        configuration.isUseGeneratedKeys() && SqlCommandType.INSERT.equals(sqlCommandType))        ? new Jdbc3KeyGenerator() : new NoKeyGenerator();  }  builderAssistant.addMappedStatement(id, sqlSource, statementType, sqlCommandType,      fetchSize, timeout, parameterMap, parameterTypeClass, resultMap, resultTypeClass,      resultSetTypeEnum, flushCache, useCache, resultOrdered,      keyGenerator, keyProperty, keyColumn, databaseId, langDriver, resultSets);}

二、获取SqlSession

由上文可知,此处的SqlSessionFactory使用的是DefaultSqlSessionFactory

private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) {  Transaction tx = null;  try {    final Environment environment = configuration.getEnvironment();    final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment);    tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit);    //创建执行器,默认是SimpleExecutor    //如果在配置文件中开启了缓存(默认开启),则是CachingExecutor    final Executor executor = configuration.newExecutor(tx, execType);    //返回DefaultSqlSession对象    return new DefaultSqlSession(configuration, executor, autoCommit);  } catch (Exception e) {    closeTransaction(tx); // may have fetched a connection so lets call close()    throw ExceptionFactory.wrapException("Error opening session.  Cause: " + e, e);  } finally {    ErrorContext.instance().reset();  }}

这里获取到了一个DefaultSqlSession对象

三、执行SQL

获取CommonMapper的对象,这里CommonMapper是一个接口,因此是一个代理对象,代理类是MapperProxy

org.apache.ibatis.binding.MapperProxy@72cde7cc

执行Query方法,来到MapperProxy的invoke方法

@Overridepublic Object invoke(Object proxy, Method method, Object[] args) throws Throwable {  if (Object.class.equals(method.getDeclaringClass())) {    try {      return method.invoke(this, args);    } catch (Throwable t) {      throw ExceptionUtil.unwrapThrowable(t);    }  }  //缓存  final MapperMethod mapperMethod = cachedMapperMethod(method);  //执行操作:select|insert|update|delete  return mapperMethod.execute(sqlSession, args);}

执行操作时,根据SELECT操作,以及返回值类型(反射方法获取)确定executeForMany方法

caseSELECT:  if (method.returnsVoid() && method.hasResultHandler()) {    executeWithResultHandler(sqlSession, args);    result = null;  } else if (method.returnsMany()) {    result = executeForMany(sqlSession, args);  } else if (method.returnsMap()) {    result = executeForMap(sqlSession, args);  } else if (method.returnsCursor()) {    result = executeForCursor(sqlSession, args);  } else {    Object param = method.convertArgsToSqlCommandParam(args);    result = sqlSession.selectOne(command.getName(), param);  }  break;

来到executeForMany方法中,就可以看到执行查询的操作,由于这里没有进行分页查询,因此走else

if (method.hasRowBounds()) {  RowBounds rowBounds = method.extractRowBounds(args);  result = sqlSession.<E>selectList(command.getName(), param, rowBounds);} else {  result = sqlSession.<E>selectList(command.getName(), param);}

来到DefaultSqlSession#selectList方法中

@Overridepublic <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {  try {    //根据key(namespace+"."+id)来获取MappedStatement对象    //MappedStatement对象中封装了解析好的SQL信息    MappedStatement ms = configuration.getMappedStatement(statement);    //通过CachingExecutor#query执行查询    return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);  } catch (Exception e) {    throw ExceptionFactory.wrapException("Error querying database.  Cause: " + e, e);  } finally {    ErrorContext.instance().reset();  }}

CachingExecutor#query

@Overridepublic <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException { //解析SQL为可执行的SQL BoundSql boundSql = ms.getBoundSql(parameter); //获取缓存的key CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql); //执行查询 return query(ms, parameter, rowBounds, resultHandler, key, boundSql);}

MappedStatement#getBoundSql

public BoundSql getBoundSql(Object parameterObject) { //解析SQL  BoundSql boundSql = sqlSource.getBoundSql(parameterObject);  List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();  if (parameterMappings == null || parameterMappings.isEmpty()) {    boundSql = new BoundSql(configuration, boundSql.getSql(), parameterMap.getParameterMappings(), parameterObject);  }  //检查是否有嵌套的ResultMap  // check for nested result maps in parameter mappings (issue #30)  for (ParameterMapping pm : boundSql.getParameterMappings()) {    String rmId = pm.getResultMapid();    if (rmId != null) {      ResultMap rm = configuration.getResultMap(rmId);      if (rm != null) {        hasNestedResultMaps |= rm.hasNestedResultMaps();      }    }  }  return boundSql;}

由上文,此次语句由于SQL中包含元素节点,因此是DynamicSqlSource。由此来到DynamicSqlSource#getBoundSql。
rootSqlNode.apply(context);这段代码便是在执行SQL解析。

@Overridepublic BoundSql getBoundSql(Object parameterObject) {  DynamicContext context = new DynamicContext(configuration, parameterObject);  //执行SQL解析  rootSqlNode.apply(context);  SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);  Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass();  SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), parameterType, context.getBindings());  BoundSql boundSql = sqlSource.getBoundSql(parameterObject);  for (Map.Entry<String, Object> entry : context.getBindings().entrySet()) {    boundSql.setAdditionalParameter(entry.getKey(), entry.getValue());  }  return boundSql;}

打上断点,跟着解析流程,来到解析foreach标签的代码,ForEachSqlNode#apply

@Overridepublic boolean apply(DynamicContext context) {  Map<String, Object> bindings = context.getBindings();  final Iterable<?> iterable = evaluator.evaluateIterable(collectionExpression, bindings);  if (!iterable.iterator().hasNext()) {    return true;  }  boolean first = true;  //解析open属性  applyOpen(context);  int i = 0;  for (Object o : iterable) {    DynamicContext oldContext = context;    if (first) {      context = new PrefixedContext(context, "");    } else if (separator != null) {      context = new PrefixedContext(context, separator);    } else {        context = new PrefixedContext(context, "");    }    int uniqueNumber = context.getUniqueNumber();    // Issue #709    //集合中的元素是Integer,走else    if (o instanceof Map.Entry) {      @SuppressWarnings("unchecked")      Map.Entry<Object, Object> mapEntry = (Map.Entry<Object, Object>) o;      applyIndex(context, mapEntry.getKey(), uniqueNumber);      applyItem(context, mapEntry.getValue(), uniqueNumber);    } else {      //使用index属性      applyIndex(context, i, uniqueNumber);      //使用item属性      applyItem(context, o, uniqueNumber);    }    //当foreach中使用#号时,会将变量替换为占位符(类似__frch_width_0)(StaticTextSqlNode)    //当使用$符号时,会将值直接拼接到SQL中(TextSqlNode)    contents.apply(new FilteredDynamicContext(configuration, context, index, item, uniqueNumber));    if (first) {      first = !((PrefixedContext) context).isPrefixApplied();    }    context = oldContext;    i++;  }  applyClose(context);  return true;}private void applyItem(DynamicContext context, Object o, int i) {    if (item != null) {        //在参数映射中绑定item属性值与集合值的关系        //第一次:(width:1)        //第二次:(width:2)        //第三次:(width:3)        context.bind(item, o);        //在参数映射中绑定处理后的item属性值与集合值的关系        //第一次:(__frch_width_0:1)        //第二次:(__frch_width_1:2)        //第三次:(__frch_width_2:3)        context.bind(itemizeItem(item, i), o);    }  }

到这里,结果就清晰了,在解析foreach标签时,每次循环都会将item属性值与参数集合中的值进行绑定,到最后就会保留(width:3)的映射关系,而在解析完foreach标签后,会解析最后一个if标签,此时在判断if标签是否成立时,答案是true,因此最终拼接出来一个错误的SQL。

在3.4.5版本中,代码中增加了context.getBindings().remove(item);在foreach标签解析完成后移除bindings中的参数映射。以下是源码:

@Overridepublic boolean apply(DynamicContext context) {  Map<String, Object> bindings = context.getBindings();  final Iterable<?> iterable = evaluator.evaluateIterable(collectionExpression, bindings);  if (!iterable.iterator().hasNext()) {    return true;  }  boolean first = true;  applyOpen(context);  int i = 0;  for (Object o : iterable) {    DynamicContext oldContext = context;    if (first || separator == null) {      context = new PrefixedContext(context, "");    } else {      context = new PrefixedContext(context, separator);    }    int uniqueNumber = context.getUniqueNumber();    // Issue #709    if (o instanceof Map.Entry) {      @SuppressWarnings("unchecked")      Map.Entry<Object, Object> mapEntry = (Map.Entry<Object, Object>) o;      applyIndex(context, mapEntry.getKey(), uniqueNumber);      applyItem(context, mapEntry.getValue(), uniqueNumber);    } else {      applyIndex(context, i, uniqueNumber);      applyItem(context, o, uniqueNumber);    }    contents.apply(new FilteredDynamicContext(configuration, context, index, item, uniqueNumber));    if (first) {      first = !((PrefixedContext) context).isPrefixApplied();    }    context = oldContext;    i++;  }  applyClose(context);  //foreach标签解析完成后,从bindings中移除item  context.getBindings().remove(item);  context.getBindings().remove(index);  return true;}

感谢各位的阅读,以上就是“如何解决Mybatis #foreach中相同的变量名导致值覆盖的问题”的内容了,经过本文的学习后,相信大家对如何解决Mybatis #foreach中相同的变量名导致值覆盖的问题这一问题有了更深刻的体会,具体使用情况还需要大家实践验证。这里是编程网,小编将为大家推送更多相关知识点的文章,欢迎关注!

--结束END--

本文标题: 如何解决Mybatis #foreach中相同的变量名导致值覆盖的问题

本文链接: https://www.lsjlt.com/news/299124.html(转载时请注明来源链接)

有问题或投稿请发送至: 邮箱/279061341@qq.com    QQ/279061341

本篇文章演示代码以及资料文档资料下载

下载Word文档到电脑,方便收藏和打印~

下载Word文档
猜你喜欢
  • Mybatis #foreach中相同的变量名导致值覆盖的问题解决
    目录背景问题原因(简略版)Mybatis流程源码解析(长文警告,按需自取)一、获取SqlSessionFactory二、获取SqlSession三、执行SQL背景 使用Mybati...
    99+
    2022-11-12
  • 如何解决Mybatis #foreach中相同的变量名导致值覆盖的问题
    这篇文章主要讲解了“如何解决Mybatis #foreach中相同的变量名导致值覆盖的问题”,文中的讲解内容简单清晰,易于学习与理解,下面请大家跟着小编的思路慢慢深入,一起来研究和学习“如何解决Mybatis #foreach中相同的变量名...
    99+
    2023-06-20
  • 如何解决css文件中的样式类被覆盖和js文件中的变量未定义问题
    这篇文章给大家介绍如何解决css文件中的样式类被覆盖和js文件中的变量未定义问题,内容非常详细,感兴趣的小伙伴们可以参考借鉴,希望对大家能有所帮助。问题原因:为什么呢?因为在调用组件W的css样式时,我们自...
    99+
    2022-10-19
  • 如何解决Mybatis中foreach嵌套使用if标签对象取值的问题
    今天小编给大家分享一下如何解决Mybatis中foreach嵌套使用if标签对象取值的问题的相关知识点,内容详细,逻辑清晰,相信大部分人都还太了解这方面的知识,所以分享这篇文章给大家参考一下,希望大家阅读完这篇文章后有所收获,下面我们一起来...
    99+
    2023-06-29
软考高级职称资格查询
编程网,编程工程师的家园,是目前国内优秀的开源技术社区之一,形成了由开源软件库、代码分享、资讯、协作翻译、讨论区和博客等几大频道内容,为IT开发者提供了一个发现、使用、并交流开源技术的平台。
  • 官方手机版

  • 微信公众号

  • 商务合作