原创

Mybatis-代码检测是否缺表,缺字段,缺索引-并自动补齐-添加-补表-补字段-补索引-InputStream输入流中读取文本-split忽略引号中的分隔符


    @RestController
    @RequestMapping
    @Slf4j
    public class TestController{

    @Autowired
    private HandleTableService handleTableService;


    @GetMapping("/isLackOf")
    public Object isLackOf() {

        //示例1
         handleTableService.checkTableOrSyncData202210();
        //示例2
        handleTableService.checkTableOrSyncData202211();

        return 1;
    }


}
@Service
@Slf4j
public class HandleTableService extends DataBaseCheckService {

    //示例1
    public void checkTableOrSyncData202210() {
      String addIndexSql = "ALTER TABLE `edu_user_learning_analysis` ADD INDEX `u_idx_updatetime`(`update_time`) USING BTREE";
      addLackOfIndex("edu_user_learning_analysis", "u_idx_updatetime", addIndexSql);
    }

    //示例2
    //版本一旦发布(包括项目上),就不要修改这里的内容,直接补充新方法
     public void checkTableOrSyncData202211() {
     //;分号分割sql列表
        execSqlFromResource(isLackOfTable("edu_matrix_manage"), "/db/v2.1.11.sql");
    }
package cn.jiangjiesheng.edu.service.common;

import cn.jiangjiesheng.edu.mapper.BaseMapper;
import cn.jiangjiesheng.edu.utils.StreamUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.io.InputStream;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

@Service
@Slf4j
public class DataBaseCheckService {

    @Autowired
    protected BaseMapper baseMapper;

    //todo 这里不同的项目,这个配置的key应该是不同的,尤其是edu部分
    @Value("${spring.datasource.edu.one.url}")
    private String databaseUrl;

    private static String INVALID_SYMBOL = "[\r\n\t]";

    public boolean isLackOfTable(String tableId) {
        try {
            baseMapper.isLackOfTable(tableId);
        } catch (Exception e) {
            String message = e.getMessage();
            if (message != null
                    && message.toLowerCase().contains("sqlsyntaxerrorexception")
                    && message.contains("exist")
                    && message.contains(tableId)) {
                log.warn("缺少{}表", tableId);
                return true;
            }
        }
        return false;
    }

    public boolean isLackOfField(String tableId, String field) {
        try {
            baseMapper.isLackOfField(tableId, field);
        } catch (Exception e) {
            String message = e.getMessage();
            if (message != null
                    && message.toLowerCase().contains("sqlsyntaxerrorexception")
//                    && message.contains("Unknown column")
                    && message.contains(field)) {
                log.warn("{}表缺少{}字段", tableId, field);
                return true;
            }
        }
        return false;
    }

    public boolean isLackOfIndex(String tableId, String idx) {
        try {
            List<String> result = baseMapper.isLackOfIndex(tableId, idx);
            if (CollectionUtils.isEmpty(result)) {
                log.warn("{}表缺少{}索引 ", tableId, idx);
                return true;
            }
        } catch (Exception e) {
            log.warn("检查{}是否缺少{}索引出现异常,请手动检查并添加索引:{},异常:{}", tableId, idx, idx, e);
        }
        return false;
    }

    //todo 字段默认值 暂时只能让直接执行 或 放在一个迭代sql文件中 execSqlFromResource(true, "/db/v2.2.0.sql");
    //后期尝试代码解析:打印下 columnResultSet1.next() 对象,看看能不能再找到默认值
    //    private void printTableStructure(String driver, String url, String user, String password) throws Exception {
    //        Class.forName(driver);
    //        Connection connection = DriverManager.getConnection(url, user, password);
    //
    //        DatabaseMetaData metaData = connection.getMetaData();
    //        // 获取所有表 tableNamePattern 可以模糊查询 "order%"
    //        ResultSet columnResultSet1 = metaData.getColumns(null, "safetyeducationandtrainingsystem", "edu_train_manager", "train_hour");
    //
    //        //https://blog.51cto.com/u_15127538/4402434
    //
    //        while (columnResultSet1.next()) {
    //            // 字段名称
    //            String columnName = columnResultSet1.getString("COLUMN_NAME");
    //            // 数据类型
    //            String columnType = columnResultSet1.getString("TYPE_NAME");
    //            // 字段长度
    //            int datasize = columnResultSet1.getInt("COLUMN_SIZE");
    //            // 小数部分位数
    //            int digits = columnResultSet1.getInt("DECIMAL_DIGITS");
    //            // 是否可为空 1代表可空 0代表不可为空
    //            int nullable = columnResultSet1.getInt("NULLABLE");
    //            // 描述
    //            String remarks = columnResultSet1.getString("REMARKS");
    //            System.out.println("当前字段:"+columnName + " " + columnType + " " + datasize + " " + digits + " " + nullable + " " + remarks);
    //
    ////            当前字段:train_hour INT 10 0 0 培训课时
    ////            当前字段:train_hour FLOAT 11 1 0 培训课时  这个是最新的
    //        }
    //
    //    }
    /**
     *
     * @param databaseNameOrUrlOrDefaultUrl
     * @param tableId
     * @param field
     * @param columnType 带长度,但不要空格,例如varchar(13);如果tinyint(2) unsigned ,unsigned不能少;那如果是datetime(0),直接填写datetime
     * @param isNullable
     * @param columnComment
     * @return
     */
    public boolean isNeedModifyFieldProperty(String databaseNameOrUrlOrDefaultUrl, String tableId, String field,
                                             String columnType, Boolean isNullable, String columnComment) {
        try {
            if(StringUtils.isBlank(databaseNameOrUrlOrDefaultUrl)){
                databaseNameOrUrlOrDefaultUrl = databaseUrl;
            }
            if (databaseNameOrUrlOrDefaultUrl.contains("?")) {
                databaseNameOrUrlOrDefaultUrl = databaseNameOrUrlOrDefaultUrl.substring(0, databaseNameOrUrlOrDefaultUrl.indexOf("?"));
            }
            if (databaseNameOrUrlOrDefaultUrl.contains("/")) {
                databaseNameOrUrlOrDefaultUrl = databaseNameOrUrlOrDefaultUrl.substring(databaseNameOrUrlOrDefaultUrl.lastIndexOf("/") + 1);
            }
            Map<String, Object> modifyField = baseMapper.isNeedModifyFieldProperty(databaseNameOrUrlOrDefaultUrl, tableId, field);

            if (modifyField != null) {
                //float(11,1)
                Object column_type = modifyField.get("COLUMN_TYPE");
                //YES NO
                Object is_nullable = modifyField.get("IS_NULLABLE");
                Object column_comment = modifyField.get("COLUMN_COMMENT");

                if (!StringUtils.equals(getValueFromObject(columnType), getValueFromObject(column_type))) {
                    return true;
                }
                if (!StringUtils.equals(isNullable != null && isNullable ? "yes" : "no", getValueFromObject(is_nullable))) {
                    return true;
                }
                if (!StringUtils.equals(getValueFromObject(columnComment), getValueFromObject(column_comment))) {
                    return true;
                }
            }
            return false;

        } catch (Exception e) {
            log.warn("检查{}是否缺少需要修改{}字段属性出现异常,请手动检查并执行,异常:{}", tableId, field, e);
        }
        return false;
    }

    public boolean isNeedChangeFieldName(String tableId, String oldFieldName, String newFieldName) {
        return !isLackOfField(tableId, oldFieldName) && isLackOfField(tableId, newFieldName);
    }

    private String getValueFromObject(Object object){
        if(object != null){
         return  String.valueOf(object).toLowerCase().replaceAll(" ","");
        }
        return null;
    }

    private void addLackOfTable(String tableId, String addTableSql) {
        try {
            baseMapper.isLackOfTable(tableId);
        } catch (Exception e) {
            String message = e.getMessage();
            if (message != null
                    && message.toLowerCase().contains("sqlsyntaxerrorexception")
                    && message.contains("exist")
                    && message.contains(tableId)) {
                log.warn("缺少{}表,开始自动添加{}表,addTableSql:{},", tableId, tableId, addTableSql);
                try {
                    baseMapper.executeSql(addTableSql);
                } catch (Exception e1) {
                    log.warn("executeSql 出现异常:{}", e1);
                }
            }
        }
    }

    public void addLackOfField(String tableId, String field, String addFieldSql) {
        if (isLackOfField(tableId, field)) {
            log.warn("{}缺少{}字段,开始自动添加字段,addFieldSql:{},", tableId, field, addFieldSql);
            try {
                baseMapper.executeSql(addFieldSql);;
            } catch (Exception e) {
                log.warn("executeSql 出现异常:{}", e);
            }
        }
    }

    public void addLackOfIndex(String tableId, String idx, String addIdxSql) {
        if (isLackOfIndex(tableId, idx)) {
            log.warn("{}缺少{}索引,开始自动添加索引,addIdxSql:{}", tableId, idx, addIdxSql);
            try {
                baseMapper.executeSql(addIdxSql);
            } catch (Exception e) {
                log.warn("executeSql 出现异常:{}", e);
            }
        }
    }

    public void modifyFieldProperty(String databaseNameOrUrlOrDefaultUrl, String tableId, String field,
                               String columnType, Boolean isNullable, String columnComment, String modifyFieldSql) {
        //columnType: float(11,1)
        //TODO 后期从 modifyFieldSql 中解析出 columnType isNullable columnComment,但是isNeedModifyFieldProperty()方法的参数必传
        if (isNeedModifyFieldProperty(databaseNameOrUrlOrDefaultUrl, tableId, field, columnType, isNullable, columnComment)) {
            log.warn("{}修改{}字段属性,开始自动修改字段属性,modifyFieldSql:{}", tableId, field, modifyFieldSql);
            try {
                baseMapper.executeSql(columnComment);
            } catch (Exception e) {
                log.warn("executeSql 出现异常:{}", e);
            }
        }
    }

    public void changeFieldName(String tableId,String oldFieldName, String newFieldName, String changeFieldNameSql) {
        //旧的字段必须存在
        //TODO 后期从 changeFieldNameSql 中解析出 oldFieldName newFieldName
        if (isNeedChangeFieldName(tableId, oldFieldName, newFieldName)) {
            log.warn("{}修改{}字段名称,开始自动改变字段名称,newFieldName:{},changeFieldNameSql:{}", tableId, oldFieldName, newFieldName, changeFieldNameSql);
            //  ALTER TABLE `edu_train_manager `CHANGE COLUMN `test` `test2` float(3, 2) NOT NULL DEFAULT 1.10 COMMENT '11222' AFTER `handle_user_orgs_status`;
            try {
                baseMapper.executeSql(changeFieldNameSql);
            } catch (Exception e) {
                log.warn("executeSql 出现异常:{}", e);
            }
        }
    }

    //注意:如果是一条sql修改多个字段的话要拆分出来,避免一个字段修改失败,其他的也没执行
    public void execSqlFromResource(boolean isNeedExec, String path) {
        if (isNeedExec) {
            try {
                String[] sqlList = getSqlFromResource(path);
                for (String sql : sqlList) {
                    try {
                        if(StringUtils.isBlank(sql)){
                            log.warn("execSqlFromResource解析到空sql");
                            continue;
                        }
                        baseMapper.executeSql(sql);
                    } catch (Exception e) {
                        String message = e.getMessage();
                        if (message == null) {
                            return;
                        }
                        message = message.toLowerCase();
                        if (!(message.contains("duplicate") || message.contains("exists"))) {
                            log.info("execSqlFromResource执行表结构修改出现异常,sql来源路径:{},当前sql:{},异常:{}", path, sql, e.getMessage());
                        }
                    }
                }
            } catch (Exception ignore) {
            }
        }
    }

    public String[] getSqlFromResource(String path) {
        try {
            InputStream inputStream = this.getClass().getResourceAsStream(path);
            String sqlStr = StreamUtil.readText(inputStream, "utf-8").replaceAll(INVALID_SYMBOL,"");

            //sqlStr.length()/1024
            //int kb = sqlStr.getBytes().length / 1024;
            //String[] sqlList = null;
           // if (kb > 200) {
            //    sqlList = splitIgnoreQuotaNotUsingRegex(sqlStr, ";");
            //} else {
            //    sqlList = splitIgnoreQuotaUsingRegex(sqlStr, ";");
           // }
           // 实测上面还是内存溢出
            String[] sqlList = splitIgnoreQuotaNotUsingRegex(sqlStr, ";");
            return sqlList;
        } catch (Exception e) {
            log.error("读取刷表结构变更的sql出现异常:sql名称:{},异常:{}", path, e);
            return new String[0];
        }
    }

    ///////////////////////////////////////////////下面都是私有方法/////////////////////////////////////////////
//    public static void main(String[] args) {
//        String str = "数据分析师,14k-25k,1-3年,北京,花生米富,150-500人,朝阳区,B轮,\'移动互联网,金融\',数据分析,2,100";
//        // str = "数据分析师,14k-25k,1-3年,北京,花生米富,150-500人,朝阳区,B轮,\'移动互联网,金融\',数据分析,2,100";
//        System.out.println("通过正则的结果");
//        // 使用正则表达式匹配
//        String[] fields = splitIgnoreQuotaUsingRegex(str,",");
//        for (String s:fields){
//            System.out.println(s);
//        }
//
//        fields =  splitIgnoreQuotaNotUsingRegex(str,",");
//
//        // 遍历输出拆分的结果
//        for (String s:fields){
//            System.out.println(s);
//        }
//    }

    /**
     * 使用正则表达式的方法来实现 `根据指定分隔符分割字符串---忽略在[单双(配对出现)]引号里面的分隔符`
     * 相关参考:
     * https://blog.csdn.net/qq_45797116/article/details/115445901
     * https://www.cnblogs.com/zhazhaacmer/p/12357949.html
     * @param text
     * @param delimiter 分隔符
     * @return
     */
    public static String[] splitIgnoreQuotaUsingRegex(String text, String delimiter) {
        //带-1参数最后会有一个空字符串
        //return text.split(delimiter + "(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)(?=(?:[^']*'[^']*')*[^']*$)", -1);
        return text.split(delimiter + "(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)(?=(?:[^']*'[^']*')*[^']*$)");
    }

    /**
     * 使用非正则表达式的方法来实现 `根据指定分隔符分割字符串---忽略在[单双(配对出现)]引号里面的分隔符`
     * 数据量超过200k就使用这个代码
     * 相关参考:
     * https://blog.csdn.net/qq_45797116/article/details/115445901
     * https://www.cnblogs.com/zhazhaacmer/p/12357949.html
     * @param text
     * @param delimiter 分隔符
     * @return
     */
    public static String[] splitIgnoreQuotaNotUsingRegex(String text, String delimiter) {
        //trim
        text = text.trim();
        // 遍历出成对的双引号的位置区间,排除转义的双引号
        List<CopyPair<Integer, Integer>> doubleQuotas = getQuotaIndexPairs(text, '\"');
        // 遍历出成对的单引号的位置区间,排除转义的单引号
        List<CopyPair<Integer, Integer>> singleQuotas = getQuotaIndexPairs(text, '\'');

        // 遍历出所有的delimiter的位置,排除掉在上述两个区间中的,排除掉转义的,按该delimiter位置拆分字符串
        List<String> splitList = new ArrayList<>(128);
        // index 表示目前搜索指针下标
        // beforeIndex 表示目前已经成功匹配到的指针下标
        int index = 0, beforeIndex = -1;
        while ((index = text.indexOf(delimiter, Math.max(beforeIndex + 1, index))) != -1) {
            // 排除转义
            if (index == 0 || text.charAt(index - 1) != '\\') {
                boolean flag = false;
                // 排除双引号内的
                for (CopyPair<Integer, Integer> p : doubleQuotas) {
                    if (p.getKey() <= index && p.getValue() >= index) {
                        flag = true;
                        break;
                    }
                }
                // 排除单引号内的
                for (int i = 0; !flag && i < singleQuotas.size(); i++) {
                    CopyPair<Integer, Integer> p = singleQuotas.get(i);
                    if (p.getKey() <= index && p.getValue() >= index) {
                        flag = true;
                        break;
                    }
                }
                // flag = true, 表示该字符串在匹配的成对引号,跳过
                if(flag){
                    index++;
                    continue;
                }
                // 这里的substring只取到分隔符的前一位,分隔符不加进来
                splitList.add(text.substring(beforeIndex + 1, index));
                beforeIndex = index;
            } else {
                index++;
            }
        }
        // 收尾串【这个会返回一个空字符串】,和text.split()的第2个参数相同
        if (beforeIndex != text.length()) {
            String lastOne = text.substring(beforeIndex + 1);
            if (StringUtils.isNotBlank(lastOne)) {
                splitList.add(lastOne);
            }
        }
        return splitList.toArray(new String[0]);
    }

    /**
     * 遍历出成对的双/单引号的位置区间,排除转义的双引号
     * @param str
     * @param quotaChar
     * @return
     */
    private static List<CopyPair<Integer, Integer>> getQuotaIndexPairs(String str, char quotaChar) {
        List<CopyPair<Integer, Integer>> quotaPairs = new ArrayList<>(64);
        List<Integer> posList = new ArrayList<>(128);
        for (int idx = 0; idx < str.length(); idx++) {
            if (str.charAt(idx) == quotaChar) {
                if (idx == 0 || str.charAt(idx - 1) != '\\') {
                    posList.add(idx);
                }
            }
        }
        // 每两个装进Pair中,总数为奇数的话最后一个舍掉
        for (int idx = 0; idx <= posList.size() - 2; idx += 2) {
            quotaPairs.add(new CopyPair<>(posList.get(idx), posList.get(idx + 1)));
        }
        return quotaPairs;
    }

    /**
     * 原来 import javafx.util.Pair;
     */
    private static class CopyPair<K, V> implements Serializable {

        private K key;

        public K getKey() {
            return key;
        }

        private V value;

        public V getValue() {
            return value;
        }

        private CopyPair(K key, V value) {
            this.key = key;
            this.value = value;
        }

        @Override
        public String toString() {
            return key + "=" + value;
        }

        @Override
        public int hashCode() {
            // name's hashCode is multiplied by an arbitrary prime number (13)
            // in order to make sure there is a difference in the hashCode between
            // these two parameters:
            //  name: a  value: aa
            //  name: aa value: a
            return key.hashCode() * 13 + (value == null ? 0 : value.hashCode());
        }

        /**
         * <p>Test this <code>CopyPair</code> for equality with another
         * <code>Object</code>.</p>
         *
         * <p>If the <code>Object</code> to be tested is not a
         * <code>CopyPair</code> or is <code>null</code>, then this method
         * returns <code>false</code>.</p>
         *
         * <p>Two <code>CopyPair</code>s are considered equal if and only if
         * both the names and values are equal.</p>
         *
         * @param o the <code>Object</code> to test for
         *          equality with this <code>CopyPair</code>
         * @return <code>true</code> if the given <code>Object</code> is
         * equal to this <code>CopyPair</code> else <code>false</code>
         */
        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o instanceof CopyPair) {
                CopyPair pair = (CopyPair) o;
                if (key != null ? !key.equals(pair.key) : pair.key != null) return false;
                if (value != null ? !value.equals(pair.value) : pair.value != null) return false;
                return true;
            }
            return false;
        }
    }

}
BaseMapper.java
package cn.jiangjiesheng.edu.mapper;

import org.apache.ibatis.annotations.Param;

public interface BaseMapper {
    //或者引入版本的概念
    Object isLackOfTable(@Param("tableId") String tableId);

    Object isLackOfField(@Param("tableId") String tableId, @Param("field") String field);

    List<String> isLackOfIndex(@Param("tableId") String tableId, @Param("idx") String idx);

    //MODIFY COLUMN
    Map<String,Object> isNeedModifyFieldProperty(@Param("databaseName") String databaseName, @Param("tableId") String tableId, @Param("field") String field);

    void executeSql(@Param("sql") String sql);
}
BaseMapper.xml
<select id="isLackOfTable" resultType="integer">
      select 1 from ${tableId} limit 1
    </select>
    <select id="isLackOfField" resultType="object" >
        select ${field} from ${tableId} limit 1
    </select>
    <select id="isLackOfIndex" resultType="string">
        show index from ${tableId} WHERE key_name = #{idx}
    </select>
    <!--https://www.jb51.net/article/206936.htm -->
    <!--修改字段属性,不包括字段本身 -->
    <select id="isNeedModifyFieldProperty" resultType="java.util.HashMap">
        SELECT
            *
        FROM
            INFORMATION_SCHEMA.COLUMNS
        WHERE
            TABLE_SCHEMA = #{databaseName}
          AND TABLE_NAME = #{tableId}
          AND COLUMN_NAME = #{field}
        LIMIT 1
    </select>
    <update id="executeSql">
        <![CDATA[ ${sql} ]]>
    </update>
 // 一次性初始化基础版本的代码
    @Transactional(timeout = 50)
    public void checkTableOrSyncData_V_1_0_1_202210() {
        if (isLackOfTable("eduinp_attachment")) {
            try {
                InputStream inputStream = this.getClass().getResourceAsStream("/database/eduinp_v1_0_1.sql");
                String sql = StreamUtil.readText(inputStream, "utf-8");
                String[] tables = sql.split(";");
                for (String table : tables) {
                    if (!table.toLowerCase().replaceAll(" ", "").contains("createtable")) {
                        continue;
                    }
                    //允许报错后回滚
                    baseMapper.executeSql(table);
                }
            } catch (Exception ignore) {
            }
        }
    }
import com.alibaba.fastjson.util.IOUtils;
import lombok.extern.slf4j.Slf4j;
import lombok.var;

import java.io.*;

/**
*
* InputStream输入流中读取文本
*
*/
@Slf4j
public class StreamUtil {
    /**
     * @deprecated IOUtils.readAll
     */
    static String readText_ali(InputStream inputStream) {
        try {
            InputStreamReader dataReader = new InputStreamReader(inputStream);
            String result = IOUtils.readAll(dataReader);
            dataReader.close();
            inputStream.close();
            return result;
        } catch (Exception ex) {
            log.error("Stream2Text error! readText_ali,ex:{}", ex);
            return ex.toString();
        }
    }

    /**
     * @deprecated java.io.EOFException
     */
    static String readText_readUtf(InputStream inputStream) {
        try {
            var dataInputStream = new DataInputStream(inputStream);
            var result = dataInputStream.readUTF();
            dataInputStream.close();
            inputStream.close();
            return result;
        } catch (Exception ex) {
            log.error("Stream2Text error! readText_readUtf,ex:{}", ex);
            return ex.toString();
        }
    }

    /**
     * @deprecated 这个是有缺陷的,如果换行符是\n不是\r\n那么文本就变了
     */
    static String readText_bufferedReader(InputStream inputStream) {
        try {
            var inputStreamReader = new InputStreamReader(inputStream);
            var bufferedReader = new BufferedReader(inputStreamReader);
            String line = null;
            StringBuilder stringBuilder = new StringBuilder();
            while ((line = bufferedReader.readLine()) != null)
                stringBuilder.append(line + "\r\n");
            bufferedReader.close();
            inputStreamReader.close();
            inputStream.close();
            return stringBuilder.toString();
        } catch (Exception ex) {
            log.error("Stream2Text error! getText_bufferedReader,ex:{}", ex);
            return ex.toString();
        }
    }

    static String readText_bytesOut(InputStream inputStream, String charset) {
        try {
            var byteArrayOutputStream = new ByteArrayOutputStream();
            int _byte;
            while ((_byte = inputStream.read()) != -1)
                byteArrayOutputStream.write(_byte);
            var result = byteArrayOutputStream.toString(charset);
            byteArrayOutputStream.close();
            inputStream.close();
            return result;
        } catch (Exception ex) {
            log.error("Stream2Text error! readText_bytesOut,ex:{}", ex);
            return ex.toString();
        }
    }

    public static String readText(InputStream inputStream, String charset) {
        return readText_bytesOut(inputStream, charset);
    }

    public static byte[] readBytes(InputStream inputStream) {
        try {
            var byteArrayOutputStream = new ByteArrayOutputStream();
            int _byte;
            while ((_byte = inputStream.read()) != -1)
                byteArrayOutputStream.write(_byte);
            var result = byteArrayOutputStream.toByteArray();
            byteArrayOutputStream.close();
            inputStream.close();
            return result;
        } catch (Exception ex) {
            ex.printStackTrace();
            return null;
        }
    }
}
正文到此结束
本文目录