springboot集成swagger,生成在線文檔及離線文檔(md/adoc)

代碼項目地址:springboot_html

集成swagger

1.pom.xml集成swagger,然後reimport maven依賴
<!-- 構建Restful API -->
<dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-swagger2</artifactId>
    <version>2.9.2</version>
</dependency>
<dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-swagger-ui</artifactId>
    <version>2.9.2</version>
</dependency>
2.使用註解來進行啓動swagger加載項目
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Contact;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

/**
 * @author jiaohongtao
 * @version 1.0
 * @since 2020年04月15日
 */
@Configuration
@EnableSwagger2
public class SwaggerConfiguration {
    @Bean
    public Docket createRestApi() {
        return new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(apiInfo())
                .select()
                //爲controller包路徑
                .apis(RequestHandlerSelectors.basePackage("com.example.springboot_html.controller"))
                .paths(PathSelectors.any())
                .build();
    }

    /**
     * 構建 api文檔的詳細信息函數,注意這裏的註解引用的是哪個
     */
    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                //頁面標題
                .title("Spring Boot 使用 Swagger2 構建RESTful API")
                //創建人
                .contact(new Contact("Jiao", "http://blog.jiaohongtao.com/", "[email protected]"))
                //版本號
                .version("1.0")
                //描述
                .description("此API由jiaohongtao提供")
                .build();
    }
}
3.配置Controller註解供API使用
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author jiaohongtao
 * @since 2019/8/22 16:43
 * @describtion 測試頁面
 */
@RestController
@Api
public class TestController {
	@RequestMapping("test")
	@ApiOperation(value = "test", httpMethod = "GET")
	public String test() {
		return "test";
	}

	@RequestMapping("hello1")
	@ApiOperation(value = "hello1", httpMethod = "GET")
	public String hello1() {
		return "hello1";
	}

	@RequestMapping("hello2")
	@ApiOperation(value = "hello2", httpMethod = "GET")
	public String hello2() {
		return "hello2";
	}
}

PS:更多參數請查看另一篇文章 swagger註解知識點

至此配置已經成功,現在可以通過url來訪問

3.訪問並測試使用
  • 訪問: http://ip:port/項目名/swagger-ui.html
    swagger在線文檔
  • 測試結果
    接口測試

生成離線文檔

1.pom.xml集成swagger離線文檔依賴,然後reimport maven
<!-- 生成swagger離線文檔 -->
<dependency>
    <groupId>org.apache.maven</groupId>
    <artifactId>maven-plugin-api</artifactId>
    <version>3.6.0</version>
    <scope>compile</scope>
</dependency>
<dependency>
    <groupId>org.apache.maven.plugin-tools</groupId>
    <artifactId>maven-plugin-annotations</artifactId>
    <version>3.5.2</version>
    <scope>compile</scope>
</dependency>
<!-- Plugin dependencies -->
<dependency>
    <groupId>io.github.swagger2markup</groupId>
    <artifactId>swagger2markup</artifactId>
    <version>1.3.3</version>
    <scope>compile</scope>
</dependency>
<!-- Testing dependencies -->
<dependency>
    <groupId>org.testng</groupId>
    <artifactId>testng</artifactId>
    <version>7.2.0</version>
    <scope>compile</scope>
</dependency>
<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.12</version>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.13.2</version>
    <scope>compile</scope>
</dependency>
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>3.1.0</version>
    <scope>compile</scope>
</dependency>

....

<!-- 生成swagger離線文檔 -->
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-plugin-plugin</artifactId>
    <version>3.4</version>
    <configuration>
        <skipErrorNoDescriptorsFound>false</skipErrorNoDescriptorsFound>
    </configuration>
    <executions>
        <execution>
            <id>default-descriptor</id>
            <goals>
                <goal>descriptor</goal>
            </goals>
            <phase>process-classes</phase>
        </execution>
        <execution>
            <id>help-descriptor</id>
            <goals>
                <goal>helpmojo</goal>
            </goals>
            <phase>process-classes</phase>
        </execution>
    </executions>
</plugin>
2.添加生成離線文檔類
  1. Swagger2MarkupMojoTest.java
/*
  使用方法:
    1.訪問swagger在線文檔,複製swagger.json
    2.將swagger.json文件放到/src/test/resources/docs/swagger下,手動創建
    3.執行需要的格式並可執行方法,已測:
        shouldConvertIntoFile,shouldConvertIntoDirectory,
        shouldConvertIntoMarkdown,shouldConvertFromUrl
 */

import io.github.swagger2markup.Swagger2MarkupProperties;
import io.github.swagger2markup.markup.builder.MarkupLanguage;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.apache.maven.plugin.MojoFailureException;
import org.junit.Before;
import org.junit.Test;
import org.mockito.Mockito;
import org.mockito.stubbing.Answer;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import static org.assertj.core.api.Assertions.assertThat;

/**
 * 生成文檔測試類
 * from: https://github.com/Swagger2Markup/swagger2markup-maven-plugin
 *
 * @author jiaohongtao
 * @date 2020/4/15
 */
public class Swagger2MarkupMojoTest {

    private static final String RESOURCES_DIR = "src/test/resources";
    private static final String SWAGGER_DIR = "/docs/swagger";
    private static final String INPUT_DIR = RESOURCES_DIR + SWAGGER_DIR;
    private static final String SWAGGER_OUTPUT_FILE = "swagger";
    private static final String SWAGGER_INPUT_FILE = "swagger.json";
    private static final String OUTPUT_DIR = "target/generated-docs";
    private File outputDir;

    @Before
    public void clearGeneratedData() throws Exception {
        outputDir = new File(OUTPUT_DIR);
        FileUtils.deleteQuietly(outputDir);
    }

    @Test
    public void shouldSkipExecution() throws Exception {
        //given
        Swagger2MarkupMojo mojo = new Swagger2MarkupMojo();
        mojo.outputFile = new File(OUTPUT_DIR, SWAGGER_OUTPUT_FILE).getAbsoluteFile();
        mojo.skip = true;

        //when
        mojo.execute();

        //then
        assertThat(mojo.outputFile).doesNotExist();
    }

    @Test
    public void shouldConvertIntoFile() throws Exception {
        //given
        Swagger2MarkupMojo mojo = new Swagger2MarkupMojo();
        mojo.swaggerInput = new File(INPUT_DIR, SWAGGER_INPUT_FILE).getAbsoluteFile().getAbsolutePath();
        mojo.outputFile = new File(OUTPUT_DIR, SWAGGER_OUTPUT_FILE).getAbsoluteFile();

        //when
        mojo.execute();

        //then
        Iterable<String> outputFiles = recursivelyListFileNames(outputDir);
        assertThat(outputFiles).containsOnly("swagger.adoc");
    }

    @Test
    public void shouldConvertIntoDirectory() throws Exception {
        //given
        Swagger2MarkupMojo mojo = new Swagger2MarkupMojo();
        mojo.swaggerInput = new File(INPUT_DIR, SWAGGER_INPUT_FILE).getAbsoluteFile().getAbsolutePath();
        mojo.outputDir = new File(OUTPUT_DIR).getAbsoluteFile();

        //when
        mojo.execute();

        //then
        Iterable<String> outputFiles = recursivelyListFileNames(mojo.outputDir);
        assertThat(outputFiles).containsOnly("definitions.adoc", "overview.adoc", "paths.adoc", "security.adoc");
    }

    @Test
    public void shouldConvertIntoDirectoryIfInputIsDirectory() throws Exception {
        //given that the input folder contains a nested structure with Swagger files
        Swagger2MarkupMojo mojo = new Swagger2MarkupMojo();
        mojo.swaggerInput = new File(RESOURCES_DIR).getAbsoluteFile().getAbsolutePath();
        mojo.outputDir = new File(OUTPUT_DIR).getAbsoluteFile();

        //when
        mojo.execute();

        //then
        Iterable<String> outputFiles = recursivelyListFileNames(new File(mojo.outputDir, SWAGGER_DIR));
        assertThat(outputFiles).containsOnly("definitions.adoc", "overview.adoc", "paths.adoc", "security.adoc");
        outputFiles = listFileNames(new File(mojo.outputDir, SWAGGER_DIR + "2"), false);
        assertThat(outputFiles).containsOnly("definitions.adoc", "overview.adoc", "paths.adoc", "security.adoc");
    }

    @Test
    public void shouldConvertIntoDirectoryIfInputIsDirectoryWithMixedSeparators() throws Exception {
        //given that the input folder contains a nested structure with Swagger files but path to it contains mixed file
        //separators on Windows (/ and \)
        Swagger2MarkupMojo mojo = new Swagger2MarkupMojo();
        String swaggerInputPath = new File(RESOURCES_DIR).getAbsoluteFile().getAbsolutePath();
        mojo.swaggerInput = replaceLast(swaggerInputPath, "\\", "/");
        mojo.outputDir = new File(OUTPUT_DIR).getAbsoluteFile();

        //when
        mojo.execute();

        //then
        Iterable<String> outputFiles = recursivelyListFileNames(new File(mojo.outputDir, SWAGGER_DIR));
        assertThat(outputFiles).containsOnly("definitions.adoc", "overview.adoc", "paths.adoc", "security.adoc");
        outputFiles = listFileNames(new File(mojo.outputDir, SWAGGER_DIR + "2"), false);
        assertThat(outputFiles).containsOnly("definitions.adoc", "overview.adoc", "paths.adoc", "security.adoc");
    }

    @Test
    public void shouldConvertIntoSubDirectoryIfMultipleSwaggerFilesInSameInput() throws Exception {
        //given that the input folder contains two Swagger files
        Swagger2MarkupMojo mojo = new Swagger2MarkupMojo();
        mojo.swaggerInput = new File(INPUT_DIR).getAbsoluteFile().getAbsolutePath();
        mojo.outputDir = new File(OUTPUT_DIR).getAbsoluteFile();

        //when
        mojo.execute();

        //then
        Iterable<String> outputFiles = recursivelyListFileNames(mojo.outputDir);
        List<String> directoryNames = Arrays.asList(mojo.outputDir.listFiles()).stream().map(File::getName)
                .collect(Collectors.toList());
        assertThat(outputFiles).containsOnly("definitions.adoc", "overview.adoc", "paths.adoc", "security.adoc");
        assertThat(outputFiles.spliterator().getExactSizeIfKnown()).isEqualTo(8); // same set of files twice
        assertThat(directoryNames).containsOnly("swagger", "swagger2");
    }

    @Test
    public void shouldConvertIntoSubDirectoryOneFileIfMultipleSwaggerFilesInSameInput() throws Exception {
        //given that the input folder contains two Swagger files
        Swagger2MarkupMojo mojo = new Swagger2MarkupMojo();
        mojo.swaggerInput = new File(INPUT_DIR).getAbsoluteFile().getAbsolutePath();
        mojo.outputDir = new File(OUTPUT_DIR).getAbsoluteFile();
        mojo.outputFile = new File(SWAGGER_OUTPUT_FILE);

        //when
        mojo.execute();

        //then
        Iterable<String> outputFiles = recursivelyListFileNames(mojo.outputDir);
        List<String> directoryNames = Arrays.asList(mojo.outputDir.listFiles()).stream().map(File::getName)
                .collect(Collectors.toList());
        assertThat(outputFiles).containsOnly("swagger.adoc");
        assertThat(outputFiles.spliterator().getExactSizeIfKnown()).isEqualTo(2); // same set of files twice
        assertThat(directoryNames).containsOnly("swagger", "swagger2");
    }

    @Test
    public void shouldConvertIntoMarkdown() throws Exception {
        //given
        Map<String, String> config = new HashMap<>();
        config.put(Swagger2MarkupProperties.MARKUP_LANGUAGE, MarkupLanguage.MARKDOWN.toString());

        Swagger2MarkupMojo mojo = new Swagger2MarkupMojo();
        mojo.swaggerInput = new File(INPUT_DIR, SWAGGER_INPUT_FILE).getAbsoluteFile().getAbsolutePath();
        mojo.outputDir = new File(OUTPUT_DIR).getAbsoluteFile();
        mojo.config = config;

        //when
        mojo.execute();

        //then
        Iterable<String> outputFiles = recursivelyListFileNames(mojo.outputDir);
        assertThat(outputFiles).containsOnly("definitions.md", "overview.md", "paths.md", "security.md");
    }

    @Test
    public void shouldConvertFromUrl() throws Exception {
        //given
        Swagger2MarkupMojo mojo = new Swagger2MarkupMojo();
        // addr: ip:port/項目名/v2/api-docs,如果沒有定義項目名,則爲addr: ip:port/v2/api-docs
        mojo.swaggerInput = "http://localhost:9090/spring_html/v2/api-docs";
        mojo.outputDir = new File(OUTPUT_DIR).getAbsoluteFile();

        //when
        mojo.execute();

        //then
        Iterable<String> outputFiles = recursivelyListFileNames(mojo.outputDir);
        assertThat(outputFiles).containsOnly("definitions.adoc", "overview.adoc", "paths.adoc", "security.adoc");
    }

    @Test(expected = MojoFailureException.class)
    public void testMissingInputDirectory() throws Exception {
        //given
        Swagger2MarkupMojo mojo = new Swagger2MarkupMojo();
        mojo.swaggerInput = new File(INPUT_DIR, "non-existent").getAbsoluteFile().getAbsolutePath();

        //when
        mojo.execute();
    }

    @Test(expected = MojoFailureException.class)
    public void testUnreadableOutputDirectory() throws Exception {
        //given
        Swagger2MarkupMojo mojo = new Swagger2MarkupMojo();
        mojo.swaggerInput = new File(INPUT_DIR, SWAGGER_INPUT_FILE).getAbsoluteFile().getAbsolutePath();
        mojo.outputDir = Mockito.mock(File.class, (Answer) invocationOnMock -> {
            if (!invocationOnMock.getMethod().getName().contains("toString")) {
                throw new IOException("test exception");
            }
            return null;
        });

        //when
        mojo.execute();
    }

    private static Iterable<String> recursivelyListFileNames(File dir) throws Exception {
        return listFileNames(dir, true);
    }

    private static Iterable<String> listFileNames(File dir, boolean recursive) {
        return FileUtils.listFiles(dir, null, recursive).stream().map(File::getName).collect(Collectors.toList());
    }

    private static void verifyFileContains(File file, String value) throws IOException {
        assertThat(IOUtils.toString(file.toURI(), StandardCharsets.UTF_8)).contains(value);
    }

    private static String replaceLast(String input, String search, String replace) {
        int lastIndex = input.lastIndexOf(search);
        if (lastIndex > -1) {
            return input.substring(0, lastIndex)
                    + replace
                    + input.substring(lastIndex + search.length(), input.length());
        } else {
            return input;
        }
    }
}
  1. Swagger2MarkupMojo.java
/*
 * Copyright 2016 Robert Winkler
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *        http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import io.github.swagger2markup.Swagger2MarkupConfig;
import io.github.swagger2markup.Swagger2MarkupConverter;
import io.github.swagger2markup.builder.Swagger2MarkupConfigBuilder;
import io.github.swagger2markup.utils.URIUtils;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugin.MojoFailureException;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;

import java.io.File;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;

/**
 * Basic mojo to invoke the {@link Swagger2MarkupConverter}
 * during the maven build cycle
 */
@Mojo(name = "convertSwagger2markup")
public class Swagger2MarkupMojo extends AbstractMojo {

    @Parameter(property = "swaggerInput", required = true)
    protected String swaggerInput;

    @Parameter(property = "outputDir")
    protected File outputDir;

    @Parameter(property = "outputFile")
    protected File outputFile;

    @Parameter
    protected Map<String, String> config = new HashMap<>();

    @Parameter(property = "skip")
    protected boolean skip;

    @Override
    public void execute() throws MojoExecutionException, MojoFailureException {

        if (skip) {
            getLog().info("convertSwagger2markup is skipped.");
            return;
        }

        if (getLog().isDebugEnabled()) {
            getLog().debug("convertSwagger2markup goal started");
            getLog().debug("swaggerInput: " + swaggerInput);
            getLog().debug("outputDir: " + outputDir);
            getLog().debug("outputFile: " + outputFile);
            for (Map.Entry<String, String> entry : this.config.entrySet()) {
                getLog().debug(entry.getKey() + ": " + entry.getValue());
            }
        }

        try {
            Swagger2MarkupConfig swagger2MarkupConfig = new Swagger2MarkupConfigBuilder(config).build();
            if (isLocalFolder(swaggerInput)) {
                getSwaggerFiles(new File(swaggerInput), true).forEach(f -> {
                    Swagger2MarkupConverter converter = Swagger2MarkupConverter.from(f.toURI())
                            .withConfig(swagger2MarkupConfig)
                            .build();
                    swaggerToMarkup(converter, true);
                });
            } else {
                Swagger2MarkupConverter converter = Swagger2MarkupConverter.from(URIUtils.create(swaggerInput))
                        .withConfig(swagger2MarkupConfig).build();
                swaggerToMarkup(converter, false);
            }
        } catch (Exception e) {
            throw new MojoFailureException("Failed to execute goal 'convertSwagger2markup'", e);
        }
        getLog().debug("convertSwagger2markup goal finished");
    }

    private boolean isLocalFolder(String swaggerInput) {
        return !swaggerInput.toLowerCase().startsWith("http") && new File(swaggerInput).isDirectory();
    }

    private void swaggerToMarkup(Swagger2MarkupConverter converter, boolean inputIsLocalFolder) {
        if (outputFile != null) {
            Path useFile = outputFile.toPath();
            /*
             * If user has specified input folder with multiple files to convert,
             * and has specified a single output file, then route all conversions
             * into one file under each 'new' sub-directory, which corresponds to
             * each input file.
             * Otherwise, specifying the output file with an input DIRECTORY means
             * last file converted wins.
             */
            if (inputIsLocalFolder) {
                if (outputDir != null) {
                    File effectiveOutputDir = outputDir;
                    effectiveOutputDir = getEffectiveOutputDirWhenInputIsAFolder(converter);
                    converter.getContext().setOutputPath(effectiveOutputDir.toPath());
                    useFile = Paths.get(effectiveOutputDir.getPath(), useFile.getFileName().toString());
                }
            }
            if (getLog().isInfoEnabled()) {
                getLog().info("Converting input to one file: " + useFile);
            }
            converter.toFile(useFile);
        } else if (outputDir != null) {
            File effectiveOutputDir = outputDir;
            if (inputIsLocalFolder) {
                effectiveOutputDir = getEffectiveOutputDirWhenInputIsAFolder(converter);
            }
            if (getLog().isInfoEnabled()) {
                getLog().info("Converting input to multiple files in folder: '" + effectiveOutputDir + "'");
            }
            converter.toFolder(effectiveOutputDir.toPath());
        } else {
            throw new IllegalArgumentException("Either outputFile or outputDir parameter must be used");
        }
    }

    private File getEffectiveOutputDirWhenInputIsAFolder(Swagger2MarkupConverter converter) {
        String outputDirAddendum = getInputDirStructurePath(converter);
        if (multipleSwaggerFilesInSwaggerLocationFolder(converter)) {
            /*
             * If the folder the current Swagger file resides in contains at least one other Swagger file then the
             * output dir must have an extra subdir per file to avoid markdown files getting overwritten.
             */
            outputDirAddendum += File.separator + extractSwaggerFileNameWithoutExtension(converter);
        }
        return new File(outputDir, outputDirAddendum);
    }

    private String getInputDirStructurePath(Swagger2MarkupConverter converter) {
        /*
         * When the Swagger input is a local folder (e.g. /Users/foo/) you'll want to group the generated output in the
         * configured output directory. The most obvious approach is to replicate the folder structure from the input
         * folder to the output folder. Example:
         * - swaggerInput is set to /Users/foo
         * - there's a single Swagger file at /Users/foo/bar-service/v1/bar.yaml
         * - outputDir is set to /tmp/asciidoc
         * -> markdown files from bar.yaml are generated to /tmp/asciidoc/bar-service/v1
         */
        String swaggerFilePath = new File(converter.getContext().getSwaggerLocation()).getAbsolutePath(); // /Users/foo/bar-service/v1/bar.yaml
        String swaggerFileFolder = StringUtils.substringBeforeLast(swaggerFilePath, File.separator); // /Users/foo/bar-service/v1
        return StringUtils.remove(swaggerFileFolder, getSwaggerInputAbsolutePath()); // /bar-service/v1
    }

    private boolean multipleSwaggerFilesInSwaggerLocationFolder(Swagger2MarkupConverter converter) {
        Collection<File> swaggerFiles = getSwaggerFiles(new File(converter.getContext().getSwaggerLocation())
                .getParentFile(), false);
        return swaggerFiles != null && swaggerFiles.size() > 1;
    }

    private String extractSwaggerFileNameWithoutExtension(Swagger2MarkupConverter converter) {
        return FilenameUtils.removeExtension(new File(converter.getContext().getSwaggerLocation()).getName());
    }

    private Collection<File> getSwaggerFiles(File directory, boolean recursive) {
        return FileUtils.listFiles(directory, new String[]{"yaml", "yml", "json"}, recursive);
    }

    /**
     * The 'swaggerInput' provided by the user can be anything; it's just a string. Hence, it could by Unix-style,
     * Windows-style or even a mix thereof. This methods turns the input into a File and returns its absolute path. It
     * will be platform dependent as far as file separators go but at least the separators will be consistent.
     */
    private String getSwaggerInputAbsolutePath() {
        return new File(swaggerInput).getAbsolutePath();
    }
}
3.使用方法
  • 使用方法:
    • 1.訪問swagger在線文檔地址 http://ip:port/spring_html/v2/api-docs,創建swagger.json文件並將json複製到文件中

    • 2.將swagger.json文件放到/src/test/resources/docs/swagger下,手動創建

    • 3.執行需要的格式並可執行方法,已測:
      shouldConvertIntoFile,shouldConvertIntoDirectory,
      shouldConvertIntoMarkdown,shouldConvertFromUrl

    • 4.執行需要執行的方法,eg:shouldConvertIntoMarkdown,結果:

      • 生成所有文檔生成文檔
      • 文檔內容
        文檔內容

終於總結好了,一個還在路上的猿。

參考文獻:
Springboot集成Swagger操作步驟
A Swagger2Markup Maven Plugin
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章